A junção de hash entre tabelas mestre / detalhe produz uma estimativa de cardinalidade muito baixa

9

Ao ingressar em uma tabela mestre em uma tabela de detalhes, como incentivar o SQL Server 2014 a usar a estimativa de cardinalidade da tabela maior (detalhes) como a estimativa de cardinalidade da saída da associação?

Por exemplo, ao associar 10 mil linhas mestras a 100 mil linhas de detalhes, desejo que o SQL Server estime a associação em 100 mil linhas - o mesmo que o número estimado de linhas de detalhes. Como devo estruturar minhas consultas e / ou tabelas e / ou índices para ajudar o estimador do SQL Server a aproveitar o fato de que cada linha de detalhes sempre tem uma linha principal correspondente? (Significando que uma união entre eles nunca deve reduzir a estimativa de cardinalidade.)

Aqui estão mais detalhes. Nosso banco de dados possui um par de tabelas mestre / detalhes: VisitTargetpossui uma linha para cada transação de vendas e VisitSaleuma linha para cada produto em cada transação. É um relacionamento um para muitos: uma linha VisitTarget para uma média de 10 linhas VisitSale.

As tabelas ficam assim: (Estou simplificando apenas as colunas relevantes para esta pergunta)

-- "master" table
CREATE TABLE VisitTarget
(
  VisitTargetId int IDENTITY(1,1) NOT NULL PRIMARY KEY CLUSTERED,
  SaleDate date NOT NULL,
  StoreId int NOT NULL
  -- other columns omitted for clarity  
);
-- covering index for date-scoped queries
CREATE NONCLUSTERED INDEX IX_VisitTarget_SaleDate 
    ON VisitTarget (SaleDate) INCLUDE (StoreId /*, ...more columns */);

-- "detail" table
CREATE TABLE VisitSale
(
  VisitSaleId int IDENTITY(1,1) NOT NULL PRIMARY KEY CLUSTERED,
  VisitTargetId int NOT NULL,
  SaleDate date NOT NULL, -- denormalized; copied from VisitTarget
  StoreId int NOT NULL, -- denormalized; copied from VisitTarget
  ItemId int NOT NULL,
  SaleQty int NOT NULL,
  SalePrice decimal(9,2) NOT NULL
  -- other columns omitted for clarity  
);
-- covering index for date-scoped queries
CREATE NONCLUSTERED INDEX IX_VisitSale_SaleDate 
  ON VisitSale (SaleDate)
  INCLUDE (VisitTargetId, StoreId, ItemId, SaleQty, TotalSalePrice decimal(9,2) /*, ...more columns */
);
ALTER TABLE VisitSale 
  WITH CHECK ADD CONSTRAINT FK_VisitSale_VisitTargetId 
  FOREIGN KEY (VisitTargetId)
  REFERENCES VisitTarget (VisitTargetId);
ALTER TABLE VisitSale
  CHECK CONSTRAINT FK_VisitSale_VisitTargetId;

Por motivos de desempenho, desnormalizamos parcialmente copiando as colunas de filtragem mais comuns (por exemplo SaleDate) da tabela mestre nas linhas de cada tabela de detalhes e adicionamos índices de cobertura em ambas as tabelas para melhor suportar as consultas filtradas por data. Isso funciona muito bem para reduzir a E / S ao executar consultas filtradas por data, mas acho que essa abordagem está causando problemas de estimativa de cardinalidade ao unir as tabelas mestre e de detalhes.

Quando juntamos essas duas tabelas, as consultas ficam assim:

SELECT vt.StoreId, vt.SomeOtherColumn, Sales = sum(vs.SalePrice*vs.SaleQty)
FROM VisitTarget vt 
    JOIN VisitSale vs on vt.VisitTargetId = vs.VisitTargetId
WHERE
    vs.SaleDate BETWEEN '20170101' and '20171231'
    and vt.SaleDate BETWEEN '20170101' and '20171231'
    -- more filtering goes here, e.g. by store, by product, etc. 

O filtro de data na tabela de detalhes ( VisitSale) é redundante. Está lá para ativar a E / S sequencial (também conhecida como operador Index Seek) na tabela de detalhes para consultas filtradas por um período.

O plano para esses tipos de consultas é assim:

insira a descrição da imagem aqui

Um plano real de uma consulta com o mesmo problema pode ser encontrado aqui .

Como você pode ver, a estimativa de cardinalidade para a junção (a dica de ferramenta na parte inferior esquerda da imagem) é mais de 4x muito baixa: 2,1 milhões reais versus 0,5 milhões estimados. Isso causa problemas de desempenho (por exemplo, espalhar para tempdb), especialmente quando essa consulta é uma subconsulta usada em uma consulta mais complexa.

Mas as estimativas de contagem de linhas para cada ramificação da junção estão próximas das contagens de linhas reais. A metade superior da junção é 100K real versus 164K estimada. A metade inferior da junção tem 2,1 milhões de linhas reais versus 3,7 milhões estimados. A distribuição de hash bucket também parece boa. Essas observações sugerem que as estatísticas são válidas para cada tabela e que o problema é a estimativa da cardinalidade da junção.

No começo, pensei que o problema era o SQL Server esperando que as colunas SaleDate em cada tabela fossem independentes, enquanto na verdade elas eram idênticas. Então, tentei adicionar uma comparação de igualdade para as datas de venda na condição de junção ou na cláusula WHERE, por exemplo

ON vt.VisitTargetId = vs.VisitTargetId and vt.SaleDate = vs.SaleDate

ou

WHERE vt.SaleDate = vs.SaleDate

Isso não funcionou. Até piorou as estimativas de cardinalidade! Portanto, o SQL Server não está usando essa dica de igualdade ou outra coisa é a causa raiz do problema.

Tem alguma idéia de como solucionar problemas e, esperamos, corrigir esse problema de estimativa de cardinalidade? Meu objetivo é que a cardinalidade da junção mestre / detalhe seja estimada da mesma forma que a estimativa para a entrada maior ("tabela de detalhes") da junção.

Se isso importa, estamos executando o SQL Server 2014 Enterprise SP2 CU8 build 12.0.5557.0 no Windows Server. Não há sinalizadores de rastreamento ativados. O nível de compatibilidade do banco de dados é o SQL Server 2014. Vemos o mesmo comportamento em vários servidores SQL diferentes, portanto, parece improvável que seja um problema específico do servidor.

Há uma otimização no SQL Server 2014 Cardinality Estimator que é exatamente o comportamento que estou procurando:

O novo CE, no entanto, usa um algoritmo mais simples, que pressupõe que haja uma associação de junção um para muitos entre uma tabela grande e uma tabela pequena. Isso pressupõe que cada linha na tabela grande corresponde exatamente a uma linha na tabela pequena. Esse algoritmo retorna o tamanho estimado da entrada maior como a cardinalidade da junção.

Idealmente, eu poderia obter esse comportamento, onde a estimativa de cardinalidade para a junção seria a mesma da tabela grande, mesmo que minha tabela "pequena" ainda retorne mais de 100 mil linhas!

Justin Grant
fonte

Respostas:

6

Supondo que nenhuma melhoria possa ser obtida fazendo algo com as estatísticas ou usando o CE herdado, a maneira mais direta de contornar o seu problema é alterá-lo INNER JOINpara LEFT OUTER JOIN:

SELECT vt.StoreId, vt.SomeOtherColumn, Sales = sum(vs.SalePrice*vs.SaleQty)
FROM VisitSale vs
    LEFT OUTER JOIN VisitTarget vt on vt.VisitTargetId = vs.VisitTargetId
            AND vt.SaleDate BETWEEN '20170101' and '20171231'
WHERE vs.SaleDate BETWEEN '20170101' and '20171231'

Se você possui uma chave estrangeira entre as tabelas, sempre filtra no mesmo SaleDateintervalo para ambas as tabelas e SaleDatesempre corresponde entre as tabelas; os resultados da sua consulta não devem ser alterados. Pode parecer estranho usar uma junção externa como essa, mas pense nisso como informar ao otimizador de consultas que a junção à VisitTargettabela nunca reduzirá o número de linhas retornadas pela consulta. Infelizmente, chaves estrangeiras não alteram as estimativas de cardinalidade; portanto, às vezes você precisa recorrer a truques como esse. (Sugestão do Microsoft Connect: torne as estimativas do otimizador mais precisas usando metadados ).

É possível que a escrita da consulta neste formulário não funcione bem, dependendo do que mais acontecer na consulta após a associação. Você pode tentar usar uma tabela temporária para manter os resultados intermediários do conjunto de resultados com a estimativa de cardinalidade mais importante.

Joe Obbish
fonte