Questa è una risposta lunga, quindi ho deciso di aggiungere un riepilogo qui.
- All'inizio presento una soluzione che produce esattamente lo stesso risultato nello stesso ordine della domanda. Esegue la scansione della tabella principale 3 volte: per ottenere un elenco
ProductIDs
con l'intervallo di date per ciascun prodotto, per sommare i costi per ogni giorno (perché ci sono diverse transazioni con le stesse date), per unire il risultato con le righe originali.
- Quindi confronto due approcci che semplificano l'attività ed evitano un'ultima scansione della tabella principale. Il loro risultato è un riepilogo giornaliero, ovvero se più transazioni su un Prodotto hanno la stessa data, vengono raggruppate in un'unica riga. Il mio approccio dal passaggio precedente analizza due volte la tabella. L'approccio di Geoff Patterson esegue una scansione del tavolo una volta, poiché utilizza conoscenze esterne sull'intervallo di date e sull'elenco dei prodotti.
- Alla fine presento una soluzione a singolo passaggio che restituisce nuovamente un riepilogo giornaliero, ma non richiede conoscenze esterne sull'intervallo di date o sull'elenco di
ProductIDs
.
Userò AdventureWorks2014 database e SQL Server Express 2014.
Modifiche al database originale:
- Tipo modificato da
[Production].[TransactionHistory].[TransactionDate]
da datetime
a date
. La componente temporale era comunque zero.
- Tabella del calendario aggiunta
[dbo].[Calendar]
- Indice aggiunto 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);
L'articolo di MSDN sulla OVER
clausola contiene un collegamento a un eccellente post sul blog sulle funzioni delle finestre di Itzik Ben-Gan. In quel post spiega come OVER
funziona, la differenza tra ROWS
e le RANGE
opzioni e menziona proprio questo problema del calcolo di una somma variabile su un intervallo di date. Egli menziona che l'attuale versione di SQL Server non implementa RANGE
completamente e non implementa i tipi di dati di intervallo temporale. La sua spiegazione della differenza tra ROWS
e RANGE
mi ha dato un'idea.
Date senza lacune e duplicati
Se la TransactionHistory
tabella contenesse date senza spazi vuoti e senza duplicati, la query seguente produrrebbe risultati corretti:
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;
In effetti, una finestra di 45 file coprirebbe esattamente 45 giorni.
Date con spazi vuoti senza duplicati
Sfortunatamente, i nostri dati hanno delle lacune nelle date. Per risolvere questo problema, possiamo utilizzare una Calendar
tabella per generare un set di date senza spazi vuoti, quindi i LEFT JOIN
dati originali per questo set e utilizzare la stessa query con ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
. Ciò produrrebbe risultati corretti solo se le date non si ripetono (all'interno della stessa ProductID
).
Date con lacune con duplicati
Sfortunatamente, i nostri dati hanno entrambi lacune nelle date e le date possono ripetersi all'interno dello stesso ProductID
. Per risolvere questo problema, possiamo generare GROUP
dati originali ProductID, TransactionDate
generando un insieme di date senza duplicati. Quindi utilizzare la Calendar
tabella per generare un insieme di date senza spazi vuoti. Quindi possiamo usare la query con ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
per calcolare il rolling SUM
. Ciò produrrebbe risultati corretti. Vedi i commenti nella query qui sotto.
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
;
Ho confermato che questa query produce gli stessi risultati dell'approccio della domanda che utilizza la subquery.
Piani di esecuzione
La prima query utilizza la subquery, la seconda: questo approccio. Puoi vedere che la durata e il numero di letture sono molto inferiori in questo approccio. La maggior parte dei costi stimati in questo approccio è la finale ORDER BY
, vedi sotto.
L'approccio di subquery ha un piano semplice con cicli nidificati e O(n*n)
complessità.
Pianificare questo approccio scansiona TransactionHistory
più volte, ma non ci sono loop. Come puoi vedere, oltre il 70% del costo stimato è Sort
il finale ORDER BY
.
Risultato migliore - subquery
, in basso - OVER
.
Evitare scansioni extra
L'ultima scansione dell'indice, Unisci join e ordinamento nel piano sopra è causata dal finale INNER JOIN
con la tabella originale per rendere il risultato finale esattamente uguale a un approccio lento con subquery. Il numero di righe restituite è lo stesso della TransactionHistory
tabella. Sono presenti righe in TransactionHistory
cui si sono verificate più transazioni nello stesso giorno per lo stesso prodotto. Se è OK mostrare solo un riepilogo giornaliero nel risultato, è JOIN
possibile rimuovere questo finale e la query diventa un po 'più semplice e un po' più veloce. L'ultima scansione dell'indice, Unisci unione e Ordina dal piano precedente vengono sostituite con Filtro, che rimuove le righe aggiunte da 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
;
Tuttavia, TransactionHistory
viene scansionato due volte. È necessaria una scansione extra per ottenere l'intervallo di date per ciascun prodotto. Ero interessato a vedere come si confronta con un altro approccio, in cui utilizziamo le conoscenze esterne sull'intervallo globale di date TransactionHistory
, oltre a una tabella aggiuntiva Product
che ha tutto ProductIDs
per evitare quella scansione aggiuntiva. Ho rimosso il calcolo del numero di transazioni al giorno da questa query per rendere valido il confronto. Può essere aggiunto in entrambe le query, ma vorrei renderlo semplice per il confronto. Ho anche dovuto usare altre date, perché utilizzo la versione 2014 del database.
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);
Entrambe le query restituiscono lo stesso risultato nello stesso ordine.
Confronto
Ecco il tempo e le statistiche IO.
La variante a due scansioni è un po 'più veloce e ha meno letture, perché la variante a una scansione deve usare molto Worktable. Inoltre, la variante a scansione singola genera più righe del necessario, come puoi vedere nei piani. Genera date per ciascuno ProductID
che è nella Product
tabella, anche se a ProductID
non ha transazioni. Ci sono 504 righe nella Product
tabella, ma solo 441 prodotti hanno transazioni in TransactionHistory
. Inoltre, genera lo stesso intervallo di date per ciascun prodotto, che è più del necessario. Se TransactionHistory
avesse una storia complessiva più lunga, con ogni singolo prodotto con una storia relativamente breve, il numero di file extra non necessarie sarebbe ancora più alto.
D'altra parte, è possibile ottimizzare ulteriormente la variante a due scansioni creando un altro indice più stretto su just (ProductID, TransactionDate)
. Questo indice verrebbe utilizzato per calcolare le date di inizio / fine per ciascun prodotto ( CTE_Products
) e avrebbe meno pagine rispetto all'indice di copertura e di conseguenza causerebbe meno letture.
Quindi, possiamo scegliere, o avere una scansione extra esplicita o avere un Worktable implicito.
A proposito, se è OK avere un risultato con solo riepiloghi giornalieri, è meglio creare un indice che non includa ReferenceOrderID
. Userebbe meno pagine => meno IO.
CREATE NONCLUSTERED INDEX [i2] ON [Production].[TransactionHistory]
(
[ProductID] ASC,
[TransactionDate] ASC
)
INCLUDE ([ActualCost])
Soluzione single pass con CROSS APPLY
Diventa una risposta davvero lunga, ma ecco un'altra variante che restituisce di nuovo solo un riepilogo giornaliero, ma esegue solo una scansione dei dati e non richiede conoscenze esterne sull'intervallo di date o sull'elenco dei ProductID. Non fa anche i tipi intermedi. Le prestazioni complessive sono simili alle varianti precedenti, anche se sembrano essere leggermente peggiori.
L'idea principale è quella di utilizzare una tabella di numeri per generare righe che riempirebbero le lacune nelle date. Per ogni data esistente utilizzare LEAD
per calcolare la dimensione del divario in giorni e quindi utilizzare CROSS APPLY
per aggiungere il numero richiesto di righe nel set di risultati. All'inizio l'ho provato con una tabella di numeri permanente. Il piano mostrava un gran numero di letture in questa tabella, sebbene la durata effettiva fosse praticamente la stessa, come quando ho generato numeri al volo 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
;
Questo piano è "più lungo", poiché la query utilizza due funzioni di finestra ( LEAD
e SUM
).
RunningTotal.TBE IS NOT NULL
condizione (e, di conseguenza, laTBE
colonna) non è necessaria. Non otterrai righe ridondanti se la lasci, perché la tua condizione di join interna include la colonna della data, quindi il set di risultati non può avere date che non erano originariamente nella fonte.