Como é uma resposta longa, decidi adicionar um resumo aqui.
- A princípio, apresento uma solução que produz exatamente o mesmo resultado na mesma ordem que na pergunta. Ele varre a tabela principal três vezes: para obter uma lista
ProductIDs
com o intervalo de datas de cada Produto, resumir os custos de cada dia (porque existem várias transações com as mesmas datas), para associar o resultado às linhas originais.
- Em seguida, comparo duas abordagens que simplificam a tarefa e evitam uma última varredura da tabela principal. O resultado é um resumo diário, ou seja, se várias transações em um Produto tiverem a mesma data, elas serão roladas em uma única linha. Minha abordagem da etapa anterior varre a tabela duas vezes. A abordagem de Geoff Patterson examina a tabela uma vez, porque ele usa conhecimento externo sobre o intervalo de datas e a lista de produtos.
- Por fim, apresento uma solução de passe único que retorna novamente um resumo diário, mas não requer conhecimento externo sobre o intervalo de datas ou a lista de
ProductIDs
.
Vou usar o banco de dados AdventureWorks2014 e o SQL Server Express 2014.
Alterações no banco de dados original:
- Tipo alterado de
[Production].[TransactionHistory].[TransactionDate]
de datetime
para date
. O componente de tempo era zero de qualquer maneira.
- Tabela de calendário adicionada
[dbo].[Calendar]
- Índice adicionado a
[Production].[TransactionHistory]
.
CREATE TABLE [dbo].[Calendar]
(
[dt] [date] NOT NULL,
CONSTRAINT [PK_Calendar] PRIMARY KEY CLUSTERED
(
[dt] ASC
))
CREATE UNIQUE NONCLUSTERED INDEX [i] ON [Production].[TransactionHistory]
(
[ProductID] ASC,
[TransactionDate] ASC,
[ReferenceOrderID] ASC
)
INCLUDE ([ActualCost])
-- Init calendar table
INSERT INTO dbo.Calendar (dt)
SELECT TOP (50000)
DATEADD(day, ROW_NUMBER() OVER (ORDER BY s1.[object_id])-1, '2000-01-01') AS dt
FROM sys.all_objects AS s1 CROSS JOIN sys.all_objects AS s2
OPTION (MAXDOP 1);
O artigo da MSDN sobre OVER
cláusula tem um link para uma excelente postagem no blog sobre as funções da janela por Itzik Ben-Gan. Nesse cargo ele explica como OVER
funciona, a diferença entre ROWS
e RANGE
opções e menciona este mesmo problema de cálculo de uma soma rolando sobre um intervalo de datas. Ele menciona que a versão atual do SQL Server não implementa RANGE
na íntegra e não implementa tipos de dados de intervalo temporal. Sua explicação da diferença entre ROWS
e RANGE
me deu uma ideia.
Datas sem intervalos e duplicatas
Se a TransactionHistory
tabela contiver datas sem intervalos e sem duplicatas, a consulta a seguir produzirá resultados corretos:
SELECT
TH.ProductID,
TH.TransactionDate,
TH.ActualCost,
RollingSum45 = SUM(TH.ActualCost) OVER (
PARTITION BY TH.ProductID
ORDER BY TH.TransactionDate
ROWS BETWEEN
45 PRECEDING
AND CURRENT ROW)
FROM Production.TransactionHistory AS TH
ORDER BY
TH.ProductID,
TH.TransactionDate,
TH.ReferenceOrderID;
De fato, uma janela de 45 linhas cobriria exatamente 45 dias.
Datas com intervalos sem duplicatas
Infelizmente, nossos dados têm intervalos de datas. Para resolver esse problema, podemos usar uma Calendar
tabela para gerar um conjunto de datas sem intervalos, depois LEFT JOIN
dados originais para esse conjunto e usar a mesma consulta ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
. Isso produziria resultados corretos apenas se as datas não se repetissem (dentro da mesma ProductID
).
Datas com intervalos com duplicatas
Infelizmente, nossos dados têm lacunas nas datas e as datas podem se repetir na mesma ProductID
. Para resolver esse problema, podemos obter GROUP
dados originais ProductID, TransactionDate
para gerar um conjunto de datas sem duplicatas. Em seguida, use a Calendar
tabela para gerar um conjunto de datas sem intervalos. Em seguida, podemos usar a consulta com ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
para calcular o rolamento SUM
. Isso produziria resultados corretos. Veja os comentários na consulta abaixo.
WITH
-- calculate Start/End dates for each product
CTE_Products
AS
(
SELECT TH.ProductID
,MIN(TH.TransactionDate) AS MinDate
,MAX(TH.TransactionDate) AS MaxDate
FROM [Production].[TransactionHistory] AS TH
GROUP BY TH.ProductID
)
-- generate set of dates without gaps for each product
,CTE_ProductsWithDates
AS
(
SELECT CTE_Products.ProductID, C.dt
FROM
CTE_Products
INNER JOIN dbo.Calendar AS C ON
C.dt >= CTE_Products.MinDate AND
C.dt <= CTE_Products.MaxDate
)
-- generate set of dates without duplicates for each product
-- calculate daily cost as well
,CTE_DailyCosts
AS
(
SELECT TH.ProductID, TH.TransactionDate, SUM(ActualCost) AS DailyActualCost
FROM [Production].[TransactionHistory] AS TH
GROUP BY TH.ProductID, TH.TransactionDate
)
-- calculate rolling sum over 45 days
,CTE_Sum
AS
(
SELECT
CTE_ProductsWithDates.ProductID
,CTE_ProductsWithDates.dt
,CTE_DailyCosts.DailyActualCost
,SUM(CTE_DailyCosts.DailyActualCost) OVER (
PARTITION BY CTE_ProductsWithDates.ProductID
ORDER BY CTE_ProductsWithDates.dt
ROWS BETWEEN 45 PRECEDING AND CURRENT ROW) AS RollingSum45
FROM
CTE_ProductsWithDates
LEFT JOIN CTE_DailyCosts ON
CTE_DailyCosts.ProductID = CTE_ProductsWithDates.ProductID AND
CTE_DailyCosts.TransactionDate = CTE_ProductsWithDates.dt
)
-- remove rows that were added by Calendar, which fill the gaps in dates
-- add back duplicate dates that were removed by GROUP BY
SELECT
TH.ProductID
,TH.TransactionDate
,TH.ActualCost
,CTE_Sum.RollingSum45
FROM
[Production].[TransactionHistory] AS TH
INNER JOIN CTE_Sum ON
CTE_Sum.ProductID = TH.ProductID AND
CTE_Sum.dt = TH.TransactionDate
ORDER BY
TH.ProductID
,TH.TransactionDate
,TH.ReferenceOrderID
;
Confirmei que esta consulta produz os mesmos resultados que a abordagem da pergunta que usa subconsulta.
Planos de execução
Primeira consulta usa subconsulta, segundo - esta abordagem. Você pode ver que a duração e o número de leituras são muito menos nessa abordagem. A maioria do custo estimado nessa abordagem é a final ORDER BY
, veja abaixo.
A abordagem de subconsulta possui um plano simples com loops e O(n*n)
complexidade aninhados .
O plano para essa abordagem varre TransactionHistory
várias vezes, mas não há loops. Como você pode ver, mais de 70% do custo estimado é o Sort
da final ORDER BY
.
Resultado superior - subquery
inferior - OVER
.
Evitando verificações extras
A última verificação de índice, mesclar junção e classificação no plano acima é causada pela final INNER JOIN
com a tabela original para tornar o resultado final exatamente o mesmo que uma abordagem lenta com subconsulta. O número de linhas retornadas é o mesmo da TransactionHistory
tabela. Existem linhas em TransactionHistory
que várias transações ocorreram no mesmo dia para o mesmo produto. Se não houver problema em mostrar apenas o resumo diário no resultado, essa final JOIN
poderá ser removida e a consulta se tornará um pouco mais simples e um pouco mais rápida. A última Verificação de índice, Mesclar associação e Classificação do plano anterior são substituídas por Filtro, que remove as linhas adicionadas por Calendar
.
WITH
-- two scans
-- calculate Start/End dates for each product
CTE_Products
AS
(
SELECT TH.ProductID
,MIN(TH.TransactionDate) AS MinDate
,MAX(TH.TransactionDate) AS MaxDate
FROM [Production].[TransactionHistory] AS TH
GROUP BY TH.ProductID
)
-- generate set of dates without gaps for each product
,CTE_ProductsWithDates
AS
(
SELECT CTE_Products.ProductID, C.dt
FROM
CTE_Products
INNER JOIN dbo.Calendar AS C ON
C.dt >= CTE_Products.MinDate AND
C.dt <= CTE_Products.MaxDate
)
-- generate set of dates without duplicates for each product
-- calculate daily cost as well
,CTE_DailyCosts
AS
(
SELECT TH.ProductID, TH.TransactionDate, SUM(ActualCost) AS DailyActualCost
FROM [Production].[TransactionHistory] AS TH
GROUP BY TH.ProductID, TH.TransactionDate
)
-- calculate rolling sum over 45 days
,CTE_Sum
AS
(
SELECT
CTE_ProductsWithDates.ProductID
,CTE_ProductsWithDates.dt
,CTE_DailyCosts.DailyActualCost
,SUM(CTE_DailyCosts.DailyActualCost) OVER (
PARTITION BY CTE_ProductsWithDates.ProductID
ORDER BY CTE_ProductsWithDates.dt
ROWS BETWEEN 45 PRECEDING AND CURRENT ROW) AS RollingSum45
FROM
CTE_ProductsWithDates
LEFT JOIN CTE_DailyCosts ON
CTE_DailyCosts.ProductID = CTE_ProductsWithDates.ProductID AND
CTE_DailyCosts.TransactionDate = CTE_ProductsWithDates.dt
)
-- remove rows that were added by Calendar, which fill the gaps in dates
SELECT
CTE_Sum.ProductID
,CTE_Sum.dt AS TransactionDate
,CTE_Sum.DailyActualCost
,CTE_Sum.RollingSum45
FROM CTE_Sum
WHERE CTE_Sum.DailyActualCost IS NOT NULL
ORDER BY
CTE_Sum.ProductID
,CTE_Sum.dt
;
Ainda assim, TransactionHistory
é digitalizado duas vezes. É necessária uma varredura extra para obter o intervalo de datas para cada produto. Eu estava interessado em ver como ele se compara a outra abordagem, na qual usamos conhecimento externo sobre o intervalo global de datas TransactionHistory
, além de uma tabela extra Product
que tem tudo ProductIDs
para evitar essa verificação extra. Eu removi o cálculo do número de transações por dia desta consulta para validar a comparação. Ele pode ser adicionado nas duas consultas, mas eu gostaria de simplificar a comparação. Eu também tive que usar outras datas, porque eu uso a versão 2014 do banco de dados.
DECLARE @minAnalysisDate DATE = '2013-07-31',
-- Customizable start date depending on business needs
@maxAnalysisDate DATE = '2014-08-03'
-- Customizable end date depending on business needs
SELECT
-- one scan
ProductID, TransactionDate, ActualCost, RollingSum45
--, NumOrders
FROM (
SELECT ProductID, TransactionDate,
--NumOrders,
ActualCost,
SUM(ActualCost) OVER (
PARTITION BY ProductId ORDER BY TransactionDate
ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
) AS RollingSum45
FROM (
-- The full cross-product of products and dates,
-- combined with actual cost information for that product/date
SELECT p.ProductID, c.dt AS TransactionDate,
--COUNT(TH.ProductId) AS NumOrders,
SUM(TH.ActualCost) AS ActualCost
FROM Production.Product p
JOIN dbo.calendar c
ON c.dt BETWEEN @minAnalysisDate AND @maxAnalysisDate
LEFT OUTER JOIN Production.TransactionHistory TH
ON TH.ProductId = p.productId
AND TH.TransactionDate = c.dt
GROUP BY P.ProductID, c.dt
) aggsByDay
) rollingSums
--WHERE NumOrders > 0
WHERE ActualCost IS NOT NULL
ORDER BY ProductID, TransactionDate
-- MAXDOP 1 to avoid parallel scan inflating the scan count
OPTION (MAXDOP 1);
Ambas as consultas retornam o mesmo resultado na mesma ordem.
Comparação
Aqui estão as estatísticas de tempo e IO.
A variante de duas varreduras é um pouco mais rápida e tem menos leituras, porque a variante de uma varredura precisa usar muito o Worktable. Além disso, a variante de uma varredura gera mais linhas do que o necessário, como você pode ver nos planos. Ele gera datas para cada um ProductID
que está na Product
tabela, mesmo se um ProductID
não tiver nenhuma transação. Existem 504 linhas na Product
tabela, mas apenas 441 produtos possuem transações TransactionHistory
. Além disso, gera o mesmo intervalo de datas para cada produto, mais do que o necessário. Se TransactionHistory
tivesse um histórico geral mais longo, com cada produto individual tendo um histórico relativamente curto, o número de linhas desnecessárias extras seria ainda maior.
Por outro lado, é possível otimizar um pouco mais a variante de duas varreduras criando outro índice mais estreito apenas (ProductID, TransactionDate)
. Esse índice seria usado para calcular as datas de início / término de cada produto ( CTE_Products
) e teria menos páginas do que o índice de cobertura e, como resultado, causaria menos leituras.
Assim, podemos escolher, ter uma verificação simples explícita extra ou ter uma tabela de trabalho implícita.
BTW, se não há problema em obter resultados apenas com resumos diários, é melhor criar um índice que não inclua ReferenceOrderID
. Usaria menos páginas => menos IO.
CREATE NONCLUSTERED INDEX [i2] ON [Production].[TransactionHistory]
(
[ProductID] ASC,
[TransactionDate] ASC
)
INCLUDE ([ActualCost])
Solução de passagem única usando o CROSS APPLY
Torna-se uma resposta realmente longa, mas aqui está mais uma variante que retorna apenas resumo diário novamente, mas faz apenas uma varredura dos dados e não requer conhecimento externo sobre o intervalo de datas ou a lista de IDs do produto. Também não faz classificações intermediárias. O desempenho geral é semelhante às variantes anteriores, embora pareça um pouco pior.
A idéia principal é usar uma tabela de números para gerar linhas que preencham as lacunas nas datas. Para cada data existente, use LEAD
para calcular o tamanho do intervalo em dias e, em seguida, use CROSS APPLY
para adicionar o número necessário de linhas no conjunto de resultados. No começo, tentei com uma tabela permanente de números. O plano mostrava um grande número de leituras nesta tabela, embora a duração real fosse praticamente a mesma, como quando eu gerava números em tempo real usando CTE
.
WITH
e1(n) AS
(
SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL
SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL
SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1
) -- 10
,e2(n) AS (SELECT 1 FROM e1 CROSS JOIN e1 AS b) -- 10*10
,e3(n) AS (SELECT 1 FROM e1 CROSS JOIN e2) -- 10*100
,CTE_Numbers
AS
(
SELECT ROW_NUMBER() OVER (ORDER BY n) AS Number
FROM e3
)
,CTE_DailyCosts
AS
(
SELECT
TH.ProductID
,TH.TransactionDate
,SUM(ActualCost) AS DailyActualCost
,ISNULL(DATEDIFF(day,
TH.TransactionDate,
LEAD(TH.TransactionDate)
OVER(PARTITION BY TH.ProductID ORDER BY TH.TransactionDate)), 1) AS DiffDays
FROM [Production].[TransactionHistory] AS TH
GROUP BY TH.ProductID, TH.TransactionDate
)
,CTE_NoGaps
AS
(
SELECT
CTE_DailyCosts.ProductID
,CTE_DailyCosts.TransactionDate
,CASE WHEN CA.Number = 1
THEN CTE_DailyCosts.DailyActualCost
ELSE NULL END AS DailyCost
FROM
CTE_DailyCosts
CROSS APPLY
(
SELECT TOP(CTE_DailyCosts.DiffDays) CTE_Numbers.Number
FROM CTE_Numbers
ORDER BY CTE_Numbers.Number
) AS CA
)
,CTE_Sum
AS
(
SELECT
ProductID
,TransactionDate
,DailyCost
,SUM(DailyCost) OVER (
PARTITION BY ProductID
ORDER BY TransactionDate
ROWS BETWEEN 45 PRECEDING AND CURRENT ROW) AS RollingSum45
FROM CTE_NoGaps
)
SELECT
ProductID
,TransactionDate
,DailyCost
,RollingSum45
FROM CTE_Sum
WHERE DailyCost IS NOT NULL
ORDER BY
ProductID
,TransactionDate
;
Este plano é "mais longo", porque a consulta usa duas funções de janela ( LEAD
e SUM
).
RunningTotal.TBE IS NOT NULL
condição (e, consequentemente, aTBE
coluna) é desnecessária. Você não receberá linhas redundantes se a soltar, porque sua condição de junção interna inclui a coluna de data - portanto, o conjunto de resultados não pode ter datas que não estavam originalmente na origem.Eu tenho algumas soluções alternativas que não usam índices ou tabelas de referência. Talvez eles possam ser úteis em situações nas quais você não tem acesso a nenhuma tabela adicional e não pode criar índices. Parece ser possível obter resultados corretos ao agrupar
TransactionDate
com apenas uma única passagem dos dados e apenas uma função de janela. No entanto, não consegui descobrir uma maneira de fazer isso com apenas uma função da janela quando você não pode agrupar porTransactionDate
.Para fornecer um quadro de referência, na minha máquina, a solução original postada na pergunta tem um tempo de CPU de 2808 ms sem o índice de cobertura e 1950 ms com o índice de cobertura. Estou testando com o banco de dados AdventureWorks2014 e o SQL Server Express 2014.
Vamos começar com uma solução para quando podemos agrupar
TransactionDate
. Uma soma contínua nos últimos X dias também pode ser expressa da seguinte maneira:No SQL, uma maneira de expressar isso é fazendo duas cópias dos seus dados e para a segunda cópia, multiplicando o custo por -1 e adicionando X + 1 dias à coluna da data. A computação de uma soma contínua em todos os dados implementará a fórmula acima. Vou mostrar isso para alguns dados de exemplo. Abaixo está uma data de amostra para um single
ProductID
. Eu represento datas como números para facilitar os cálculos. Dados iniciais:Adicione uma segunda cópia dos dados. A segunda cópia tem 46 dias adicionados à data e o custo multiplicado por -1:
Tome a soma corrente ordenada por
Date
ascendente eCopiedRow
descendente:Filtre as linhas copiadas para obter o resultado desejado:
O SQL a seguir é uma maneira de implementar o algoritmo acima:
Na minha máquina, isso levou 702 ms de tempo de CPU com o índice de cobertura e 734 ms de tempo de CPU sem o índice. O plano de consulta pode ser encontrado aqui: https://www.brentozar.com/pastetheplan/?id=SJdCsGVSl
Uma desvantagem desta solução é que parece haver uma classificação inevitável ao fazer o pedido pela nova
TransactionDate
coluna. Não acho que esse tipo possa ser resolvido adicionando índices, pois precisamos combinar duas cópias dos dados antes de fazer o pedido. Consegui me livrar de uma classificação no final da consulta adicionando uma coluna diferente ao ORDER BY. Se eu pedisse,FilterFlag
descobri que o SQL Server otimizaria essa coluna da classificação e executaria uma classificação explícita.As soluções para quando precisamos retornar um conjunto de resultados com
TransactionDate
valores duplicados para o mesmoProductId
eram muito mais complicadas. Eu resumiria o problema como simultaneamente necessário particionar e ordenar pela mesma coluna. A sintaxe fornecida por Paul resolve esse problema, não surpreende que seja tão difícil expressar com as funções atuais da janela disponíveis no SQL Server (se não fosse difícil expressar, não haveria necessidade de expandir a sintaxe).Se eu usar a consulta acima sem agrupar, obtenho valores diferentes para a soma rolante quando houver várias linhas com o mesmo
ProductId
eTransactionDate
. Uma maneira de resolver isso é fazer o mesmo cálculo da soma de execução acima, mas também sinalizar a última linha da partição. Isso pode ser feito comLEAD
(supondo queProductID
nunca seja NULL) sem uma classificação adicional. Para o valor final da soma em execução, eu usoMAX
como função de janela para aplicar o valor na última linha da partição a todas as linhas na partição.Na minha máquina, isso levou 2464ms de tempo de CPU sem o índice de cobertura. Como antes, parece haver um tipo inevitável. O plano de consulta pode ser encontrado aqui: https://www.brentozar.com/pastetheplan/?id=HyWxhGVBl
Eu acho que há espaço para melhorias na consulta acima. Certamente, existem outras maneiras de usar as funções do Windows para obter o resultado desejado.
fonte