Eu tenho uma tabela de relatórios (cerca de 1 bilhão de linhas) e uma pequena tabela de dimensões:
CREATE TABLE dbo.Sales_unpartitioned (
BusinessUnit int NOT NULL,
[Date] date NOT NULL,
SKU varchar(8) NOT NULL,
Quantity numeric(10, 2) NOT NULL,
Amount numeric(10, 2) NOT NULL,
CONSTRAINT PK_Sales_unpartitioned PRIMARY KEY CLUSTERED (BusinessUnit, [Date], SKU)
);
--- Demo data:
INSERT INTO dbo.Sales_unpartitioned
SELECT severity AS BusinessUnit,
DATEADD(day, message_id, '2000-01-01') AS [Date],
LEFT([text], 3) AS SKU,
1000.*RAND(CHECKSUM(NEWID())) AS Quantity,
10000.*RAND(CHECKSUM(NEWID())) AS Amount
FROM sys.messages
WHERE [language_id]=1033;
--- Artificially inflate statistics of demo data:
UPDATE STATISTICS dbo.Sales_unpartitioned WITH ROWCOUNT=1000000000;
--- Dimension table:
CREATE TABLE dbo.BusinessUnits (
BusinessUnit int NOT NULL,
SalesManager nvarchar(250) NULL,
PRIMARY KEY CLUSTERED (BusinessUnit)
);
INSERT INTO dbo.BusinessUnits (BusinessUnit)
SELECT DISTINCT BusinessUnit FROM dbo.Sales;
... ao qual adicionei uma exibição de relatórios usada por um aplicativo para relatórios no estilo OLTP.
CREATE OR ALTER VIEW dbo.SalesReport_unpartitioned
AS
SELECT bu.BusinessUnit,
s.[Date],
s.SKU,
s.Quantity,
s.Amount
FROM dbo.BusinessUnits AS bu
CROSS APPLY (
--- Regular sales
SELECT t.BusinessUnit, t.[Date], t.SKU, t.Quantity, t.Amount
FROM dbo.Sales_unpartitioned AS t
WHERE t.BusinessUnit=bu.BusinessUnit
AND t.SKU LIKE 'T%'
UNION ALL
--- This is a special reporting entry. We only
--- want to see today's row. In case of duplicates,
--- get the row with the first "SKU".
SELECT TOP (1) s.BusinessUnit, s.[Date], s.SKU, s.Quantity, s.Amount
FROM dbo.Sales_unpartitioned AS s
WHERE s.BusinessUnit=bu.BusinessUnit
AND s.[Date]=CAST(SYSDATETIME() AS date)
AND s.SKU LIKE 'S%'
ORDER BY s.BusinessUnit, s.[Date], s.SKU
) AS s
A ideia é que o aplicativo do usuário consulte essa exibição com uma consulta SELECT que filtra um intervalo de datas e uma ou mais Unidades de Negócios. Para isso, escolhi um CROSS APPLY
padrão, para que a consulta possa fazer um "loop" em cada Unidade de Negócios, buscar um intervalo de Datas e aplicar um filtro residual no SKU.
Exemplo de consulta de aplicativo:
DECLARE @from date='2021-01-01', @to date='2021-12-31';
SELECT *
FROM dbo.SalesReport_unpartitioned
WHERE BusinessUnit=16
AND [Date] BETWEEN @from AND @to
ORDER BY BusinessUnit, [Date], SKU;
Eu esperaria um plano de consulta parecido com este: Plano desejado
No entanto, o plano fica assim: Plano real
Eu esperava que o SQL Server fizesse um "empilhamento de predicado" na coluna Data, permitindo que o Clustered Index Seek procurasse uma única unidade de negócios e um intervalo de datas e, em seguida, aplicasse um predicado residual no SKU. Isso funciona no Seek na ramificação "s" (aquele com TOP
) - provavelmente porque tem um predicado Date codificado na consulta - mas não na ramificação "t".
No entanto, na ramificação "t" o SQL Server busca apenas a BusinessUnit específica com um predicado residual no SKU, recuperando efetivamente todas as datas . Somente no final do plano é aplicado um operador Filtro que filtra na coluna Data.
Em uma tabela grande, isso tem uma penalidade de desempenho muito significativa - você pode acabar lendo 20 anos de dados do disco quando tudo o que você procura é uma semana.
Coisas que eu tentei
Soluções alternativas:
- Converter a exibição em uma função com valor de tabela embutida com os parâmetros @fromDate e @toDate que filtram as consultas "s" e "t" habilitará uma Busca em (BusinessUnit, Date) conforme desejado, mas requer a reescrita do código do aplicativo.
- Mover a
UNION ALL
saída deCROSS APPLY
(deCROSS APPLY (UNION)
paraCROSS APPLY() UNION CROSS APPLY()
) habilitará o pushdown de predicado. Faz mais uma busca na mesa da BusinessUnit, o que é perfeitamente aceitável.
Corrige o Seek, mas altera os resultados:
- Surpreendentemente, remover o
TOP (1)
andORDER BY
para a consulta "s" faz o empilhamento de predicado funcionar em "t", mas pode retornar muitas linhas de "s". - A eliminação
UNION ALL
removendo a consulta "s" ou "t" habilitará o empilhamento de predicado, mas gerará resultados incorretos.
Sem alteração ou inviável:
- Substituir
TOP (1)
por umROW_NUMBER()
padrão não altera a Busca. - Alterar o
CROSS APPLY
para uma correção forçadaINNER LOOP JOIN
do Seek em "t", mas na verdade altera "s" para um Scan, o que é ainda pior. - Adicionar o sinalizador de rastreamento 8780 para permitir que o otimizador trabalhe em um plano por mais tempo não altera nada. O plano já está otimizado FULL sem rescisão antecipada.
Um tópico comum parece ser que alterar/simplificar a consulta "s" (remover TOP
, ORDER BY
) corrige o problema na consulta "t", o que parece contra-intuitivo para mim.
O que estou olhando
Estou tentando entender se isso é uma falha do otimizador, se é o resultado de um mecanismo de otimização/custo deliberado, ou se eu simplesmente ignorei algo.
É um pouco de tudo isso.
Há muita coisa acontecendo na consulta apresentada - demais na verdade - então, para evitar escrever meio livro sobre isso, vou resumir ao elemento principal que está fazendo com que você não consiga o plano que procura:
O otimizador não empurra predicados para o lado interno de uma aplicação.
A regra que opera em seleções relacionais (filtros, predicados) acima de um apply chama-se, naturalmente,
SELonApply
. Ela realiza a seguinte substituição lógica:Ele toma parte(s) de uma seleção potencialmente complexa envolvendo A e B, e empurra as partes que puder para a mesa de acionamento A. Nenhuma parte da seleção é empurrada para B. A(s) parte(s) da seleção que não pode(m) ser empurrado para baixo ficam para trás.
Isso pode soar como um descuido chocante e contrário à experiência. Isso porque não é a história completa.
O otimizador tenta converter uma aplicação para a junção equivalente no início do processo de compilação (durante a simplificação, antes do plano trivial e da otimização baseada em custo). Ele é capaz de empurrar seleções para baixo em ambos os lados de uma junção , onde é seguro. Essa junção pode, por sua vez, ser transformada em uma aplicação física durante a otimização baseada em custo.
O efeito de tudo isso é fazer parecer que o otimizador empurrou um predicado para o lado interno de uma aplicação:
Deixe-me mostrar-lhe um exemplo:
Se você observar cuidadosamente o plano, verá o predicado em T2 enviado para a busca do lado interno e a junção de loop aninhada é uma aplicação (tem referências externas ). Isso só foi possível porque o otimizador foi capaz de reescrever a aplicação como uma junção inicialmente, enviar os predicados e, em seguida, transformar novamente em uma aplicação posteriormente.
Podemos desabilitar a reescrita de aplicação para junção usando o sinalizador de rastreamento não documentado 9114:
Isso significa que só
SELonApply
pode ser usado, o que só empurra para a mesa de acionamento A:Observe que a parte da seleção em T2.c2 está 'presa' acima do apply, em um filtro. (A busca do lado interno está apenas na igualdade fk/pk especificada dentro do apply.)
O otimizador é construído com base em princípios relacionais. Ele aprecia um design de esquema relacional e consultas que usam construções relacionais. Aplicar (junção lateral) é uma extensão relativamente nova. O otimizador conhece muito mais truques com join do que com apply, daí o esforço inicial para reescrever.
Quando você usa coisas como aplicar ou o Top não relacional, está implicitamente assumindo mais responsabilidade pela forma final do plano. Em outras palavras, com mais frequência você terá que expressar sua consulta de maneira diferente (como em sua solução alternativa) para obter um bom resultado.
Para constar, minha preferência seria usar a função com valor de tabela embutida com posicionamento de predicado explícito. Se eu fosse reescrever a visão, eu poderia usar:
Para a consulta de teste fornecida:
O plano de execução é:
A seção laranja é de vendas regulares. A seção amarela é para a entrada de relatório especial.
Este é apenas um daqueles casos em que o otimizador não foi escrito para lidar com esse requisito complicado específico, pois não pode dizer que o envio do
date
predicado não está afetando nada natop
subconsulta.Existe uma maneira, use uma junção e reescreva
top 1
como um filtro em umarow_number
análise particionada. Incluir oBusinessUnit
norow_number
particionamento significa que é legal empurrar para baixo.Colar o plano