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.