Chave primária composta no banco de dados do SQL Server com vários inquilinos

16

Estou criando um aplicativo multilocatário (banco de dados único, esquema único) usando a API da Web ASP, o Entity Framework e o banco de dados SQL Server / Azure. Este aplicativo será usado por 1000-5000 clientes. Todas as tabelas terão o campo TenantId(Guid / UNIQUEIDENTIFIER). No momento, eu uso a Chave Primária de campo único, que é Id (Guid). Mas, usando apenas o campo Id, tenho que verificar se os dados fornecidos pelo usuário são de / para o inquilino certo. Por exemplo, eu tenho uma SalesOrdertabela que possui um CustomerIdcampo. Sempre que os usuários postam / atualizam um pedido de vendas, tenho que verificar se o mesmo CustomerIdé do mesmo inquilino. Fica pior porque cada inquilino pode ter várias saídas. Então eu tenho que verificar TenantIde OutletId. É realmente um pesadelo de manutenção e ruim para o desempenho.

Estou pensando em adicionar TenantIdà chave primária junto com Id. E, possivelmente OutletId, adicione também. Portanto, a chave primária na SalesOrdertabela será: Id, TenantId, e OutletId. Qual é a desvantagem dessa abordagem? O desempenho seria prejudicial ao usar uma chave composta? A ordem das chaves compostas é importante? Existem melhores soluções para o meu problema?

Reynaldi
fonte

Respostas:

34

Tendo trabalhado em um sistema multilocatário em larga escala (abordagem federada com clientes espalhados por mais de 18 servidores, cada servidor com esquema idêntico, apenas clientes diferentes e milhares de transações por segundo por cada servidor), posso dizer:

  1. Existem algumas pessoas (algumas, pelo menos) que concordam com a sua escolha de GUID como os IDs para "TenantID" e para qualquer entidade "ID". Mas não, não é uma boa escolha. Todas as outras considerações à parte, essa escolha sozinha prejudicará de algumas maneiras: fragmentação para começar, vastas quantidades de espaço desperdiçado (não diga que o disco é barato quando se pensa em armazenamento corporativo - SAN - ou consultas demorando mais devido a cada página de dados mantendo menos linhas do que poderia com um INTou BIGINTmais), suporte e manutenção mais difíceis etc. GUIDs são ótimos para portabilidade. Os dados são gerados em algum sistema e depois transferidos para outro? Se não, então mudar para um tipo mais compacto de dados (por exemplo TINYINT, SMALLINT, INT, ou mesmo BIGINT), e sequencialmente através de incremento IDENTITYouSEQUENCE.

  2. Com o item 1 fora do caminho, você realmente precisa ter o campo TenantID em TODAS as tabelas que possuem dados do usuário. Dessa forma, você pode filtrar qualquer coisa sem precisar de um JOIN extra. Isso também significa que TODAS as consultas em tabelas de dados do cliente precisam ter a TenantIDcondição JOIN e / ou a cláusula WHERE. Isso também ajuda a garantir que você não misture acidentalmente dados de diferentes clientes ou mostre dados do inquilino A do inquilino B.

  3. Estou pensando em adicionar TenantId como chave primária junto com o Id. E, possivelmente, adicione OutletId também. Portanto, as chaves primárias na tabela de pedidos de vendas serão Id, TenantId, OutletId.

    Sim, você deve ter seus índices agrupados nas tabelas de dados do cliente como chaves compostas, inclusive TenantIDe ID ** . Isso também garante que TenantIDesteja em todos os índices Não Clusterizados (já que incluem as Chaves de Índice Clusterizado) de que você precisaria, já que 98,45% das consultas nas tabelas de dados do cliente precisarão da TenantID(a principal exceção é quando a coleta de lixo é baseada em dados antigos on CreatedDatee não sobre como cuidar TenantID).

    Não, você não incluiria FKs como OutletIDo PK. O PK precisa identificar exclusivamente a linha, e adicionar FKs não ajudaria nisso. De fato, aumentaria as chances de dados duplicados, assumindo que o OrderID fosse único para cada um TenantID, em oposição ao exclusivo para cada um OutletIDdentro de cada um TenantID.

    Além disso, não é necessário adicionar OutletIDao PK para garantir que os Outlets do inquilino A não se misturem com o inquilino B. Como todas as tabelas de dados do usuário terão TenantIDno PK, isso significa TenantIDque também estarão nos FKs . Por exemplo, a Outlettabela tem um PK de (TenantID, OutletID)e a Ordertabela tem um PK de (TenantID, OrderID) e um FK do (TenantID, OutletID)qual faz referência ao PK na Outlettabela. Os FKs definidos corretamente impedirão que os dados do inquilino sejam misturados.

  4. A ordem das chaves compostas é importante?

    Bem, aqui é onde fica divertido. Há algum debate sobre qual campo deve vir primeiro. A regra "típica" para projetar bons índices é escolher o campo mais seletivo para ser o campo principal. TenantID, por sua própria natureza, não será o campo mais seletivo; o IDcampo é o campo mais seletivo. Aqui estão alguns pensamentos:

    • ID primeiro: este é o campo mais seletivo (ou seja, o mais exclusivo). Porém, por ser um campo de incremento automático (ou aleatório se ainda estiver usando GUIDs), os dados de cada cliente são espalhados por cada tabela. Isso significa que há momentos em que um cliente precisa de 100 linhas e que exige quase 100 páginas de dados lidas do disco (não rápido) no Buffer Pool (ocupando mais espaço que 10 páginas de dados). Também aumenta a contenção nas páginas de dados, pois será mais frequente que vários clientes precisem atualizar a mesma página de dados.

      No entanto, normalmente você não encontra tantos problemas de sniffing de parâmetro / plano de cache ruim, pois as estatísticas entre os diferentes valores de ID são razoavelmente consistentes. Você pode não ter os melhores planos, mas é menos provável que tenha planos horríveis. Esse método sacrifica essencialmente o desempenho (ligeiramente) em todos os clientes para obter o benefício de problemas menos frequentes.

    • TenantID primeiro:Isso não é muito seletivo. Pode haver muito pouca variação em 1 milhão de linhas se você tiver apenas 100 TenantIDs. Mas as estatísticas para essas consultas são mais precisas, pois o SQL Server saberá que uma consulta para o inquilino A recuará 500.000 linhas, mas a mesma consulta para o inquilino B é de apenas 50 linhas. É aqui que está o principal ponto de dor. Esse método aumenta muito as chances de ocorrer problemas de detecção de parâmetros nos casos em que a primeira execução de um Procedimento armazenado é para o inquilino A e atua adequadamente com base no Query Optimizer, visualizando essas estatísticas e sabendo que precisa ser eficiente para obter 500 mil linhas. Mas quando o inquilino B, com apenas 50 linhas, é executado, esse plano de execução não é mais apropriado e, de fato, é bastante inadequado. E, como os dados não estão sendo inseridos na ordem do campo principal,

      No entanto, para o primeiro TenantID executar um Procedimento Armazenado, o desempenho deve ser melhor do que na outra abordagem, pois os dados (pelo menos depois de fazer a manutenção do índice) serão física e logicamente organizados de forma que sejam necessárias muito menos páginas de dados para satisfazer o consultas. Isso significa menos E / S física, menos leituras lógicas, menos disputas entre os inquilinos pelas mesmas páginas de dados, menos espaço desperdiçado ocupado no Buffer Pool (daí a expectativa de vida útil da página melhorada) etc.

      Existem dois custos principais para obter esse desempenho aprimorado. O primeiro não é tão difícil: você deve fazer a manutenção regular do índice para neutralizar o aumento da fragmentação. O segundo é um pouco menos divertido.

      Para combater o aumento dos problemas de detecção de parâmetros, é necessário separar os planos de execução entre os inquilinos. A abordagem simplista é usar WITH RECOMPILEem procs ou a OPTION (RECOMPILE)dica de consulta, mas isso é um impacto no desempenho que pode acabar com todos os ganhos obtidos com a colocação em TenantIDprimeiro lugar. O método que eu achei que funcionou melhor é usar o SQL dinâmico parametrizado via sp_executesql. O motivo para a necessidade do SQL dinâmico é permitir concatenar o TenantID no texto da consulta, enquanto todos os outros predicados que normalmente seriam parâmetros ainda são parâmetros. Por exemplo, se você estivesse procurando um pedido específico, faria algo como:

      DECLARE @GetOrderSQL NVARCHAR(MAX);
      SET @GetOrderSQL = N'
        SELECT ord.field1, ord.field2, etc.
        FROM   dbo.Orders ord
        WHERE  ord.TenantID = ' + CONVERT(NVARCHAR(10), @TenantID) + N'
        AND    ord.OrderID = @OrderID_dyn;
      ';
      
      EXEC sp_executesql
         @GetOrderSQL,
         N'@OrderID_dyn INT',
         @OrderID_dyn = @OrderID;

      O efeito disso é criar um plano de consulta reutilizável apenas para esse TenantID que corresponda ao volume de dados desse inquilino específico. Se o mesmo inquilino A executar o procedimento armazenado novamente para outro @OrderID, ele reutilizará esse plano de consulta em cache. Um inquilino diferente executando o mesmo procedimento armazenado geraria um texto de consulta diferente apenas no valor do TenantID, mas qualquer diferença no texto da consulta é suficiente para gerar um plano diferente. E o plano gerado para o inquilino B não corresponderá apenas ao volume de dados do inquilino B, mas também será reutilizável para o inquilino B para diferentes valores de @OrderID(já que esse predicado ainda está parametrizado).

      As desvantagens dessa abordagem são:

      • É um pouco mais trabalhoso do que apenas digitar uma consulta simples (mas nem todas as consultas precisam ser de SQL Dinâmico, apenas aquelas que acabam tendo o problema de cheirar o parâmetro).
      • Dependendo de quantos inquilinos estão em um sistema, ele aumenta o tamanho do cache do plano, já que cada consulta agora exige 1 plano por TenantID que está chamando. Isso pode não ser um problema, mas é pelo menos algo para estar ciente.
      • O SQL dinâmico interrompe a cadeia de propriedade, o que significa que o acesso de leitura / gravação a tabelas não pode ser assumido com EXECUTEpermissão no Procedimento Armazenado. A correção fácil, mas menos segura, é apenas para fornecer ao usuário acesso direto às tabelas. Certamente isso não é o ideal, mas esse é geralmente o trade-off para rápido e fácil. A abordagem mais segura é usar a segurança baseada em certificado. Ou seja, criar um certificado, em seguida, criar um usuário de que Certificate, conceder que usuário as permissões desejadas (um usuário com base em certificado ou login não pode se conectar ao SQL Server por conta própria), e depois assinar os procedimentos armazenados que usam SQL dinâmico com que mesmo certificado via ADICIONAR ASSINATURA .

        Para obter mais informações sobre assinatura e certificados de módulos, consulte: ModuleSigning.Info
         

    Consulte a seção ATUALIZAÇÃO no final para obter tópicos adicionais relacionados ao problema de lidar com problemas estatísticos atenuantes resultantes desta decisão.


** Pessoalmente, eu realmente não gosto de usar apenas "ID" para o nome do campo PK em todas as tabelas, pois não é significativo e não é consistente entre os FKs, pois o PK é sempre "ID" e o campo na tabela filho precisa ser inclua o nome da tabela pai. Por exemplo: Orders.ID-> OrderItems.OrderID. Acho muito mais fácil lidar com um modelo de dados que possui: Orders.OrderID-> OrderItems.OrderID. É mais legível e reduz o número de vezes que você receberá o erro "referência de coluna ambígua" :-).


ATUALIZAR

  • Será que a OPTIMIZE FOR UNKNOWN dica de consulta (introduzido no SQL Server 2008) ajuda com qualquer ordenação do PK composta?

    Na verdade não. Esta opção contorna problemas de detecção de parâmetros, mas apenas substitui um problema por outro. Nesse caso, em vez de lembrar as informações estatísticas dos valores dos parâmetros da execução inicial do procedimento armazenado ou da consulta parametrizada (o que é definitivamente ótimo para alguns, mas potencialmente medíocre para alguns e potencialmente horrível para alguns), ele usa um método geral. estatística de distribuição de dados para estimar a contagem de linhas. É um acerto ou acerto de quantas (e em que grau) consultas serão afetadas positivamente, negativamente ou não. Pelo menos com o sniffing de parâmetros, algumas consultas foram garantidas. Se o seu sistema tiver inquilinos com volumes de dados muito variados, isso poderá prejudicar o desempenho de todas as consultas.

    Esta opção realiza o mesmo que copiar parâmetros de entrada para variáveis ​​locais e, em seguida, usar as variáveis ​​locais na consulta (testei isso, mas não há espaço para isso aqui). Informações adicionais podem ser encontradas nesta postagem do blog: http://www.brentozar.com/archive/2013/06/optimize-for-unknown-sql-server-parameter-sniffing/ . Lendo os comentários, Daniel Pepermans chegou a uma conclusão semelhante à minha em relação ao uso do Dynamic SQL que tem variação limitada.

  • Se o ID for o campo principal no Índice de Cluster, ajudaria / seria suficiente ter um Índice Não de Cluster em (TenantID, ID) ou apenas (TenantID) para ter estatísticas precisas para consultas que processam muitas linhas de um único inquilino?

    Sim, isso ajudaria. O grande sistema em que mencionei trabalhar por anos foi baseado em um design de índice de ter o IDENTITYcampo como o principal, porque era mais seletivo e reduzia os problemas de detecção de parâmetros. No entanto, quando precisávamos operar contra uma boa parte dos dados de um inquilino em particular, o desempenho não se sustentava. De fato, um projeto para migrar todos os dados para novos bancos de dados teve que ser suspenso porque os controladores da SAN foram atingidos no máximo em termos de taxa de transferência. A correção foi adicionar índices não clusterizados a todas as tabelas de dados do inquilino para serem justas (TenantID). Não é necessário fazer (TenantID, ID), pois o ID já está no Índice de Cluster, portanto a estrutura interna do Índice Não de Cluster foi naturalmente (TenantID, ID).

    Embora isso tenha resolvido o problema imediato de poder fazer consultas com base no TenantID de maneira muito mais eficiente, elas ainda não eram tão eficientes quanto poderiam ter sido se fosse o Índice de Cluster na mesma ordem. E agora tínhamos mais um índice em todas as tabelas. Isso aumentou a quantidade de espaço da SAN que estávamos usando, aumentou o tamanho de nossos backups, fez com que os backups demorassem mais para serem concluídos, aumentou o potencial de bloqueio e conflitos, diminuiu o desempenho INSERTe as DELETEoperações, etc.

    E ainda ficamos com a ineficiência geral de ter os dados de um inquilino espalhados por muitas páginas de dados, misturados com muitos outros dados do inquilino. Como mencionei acima, isso aumenta a quantidade de contenção nessas páginas e enche o Buffer Pool de muitas páginas de dados que têm 1 ou 2 linhas úteis, especialmente quando algumas das linhas nessas páginas eram para clientes que estavam inativos, mas ainda não haviam sido coletados. Há muito menos potencial para reutilizar as páginas de dados no buffer pool nessa abordagem, portanto nossa Expectativa de vida útil da página foi bastante baixa. E isso significa mais tempo voltando ao disco para carregar mais páginas.

Solomon Rutzky
fonte
2
Você considerou ou testou OPTIMIZE FOR UNKNOWN neste espaço problemático? Apenas curioso.
RLF
1
@RLF Sim, nós pesquisamos essa opção, e ela deveria ser pelo menos não melhor e possivelmente pior do que o desempenho abaixo do ideal que estávamos obtendo ao ter o campo IDENTITY primeiro. Não me lembro de onde li isso, mas supostamente fornece as mesmas estatísticas "médias" como reatribuir um parâmetro de entrada a uma variável local. Mas este artigo explica por que essa opção realmente não resolve o problema: brentozar.com/archive/2013/06/… Lendo os comentários, Daniel Pepermans chegou a uma conclusão semelhante sobre: ​​SQL dinâmico com variação limitada :)
Solomon Rutzky
3
E se o índice clusterizado estiver ativado (ID, TenantID)e você também criar um índice não clusterizado (TenantID, ID)ou simplesmente (TenantID)possuir estatísticas precisas para consultas que processam a maioria das linhas de um único inquilino?
Vladimir Baranov
1
@VladimirBaranov Excellent question. Eu o abordei em uma nova seção UPDATE no final da resposta :-).
Solomon Rutzky 01/11
4
ponto interessante sobre o sql dinâmico para gerar planos para cada cliente.
Max Vernon