C'est une longue réponse, alors j'ai décidé d'ajouter un résumé ici.
- Au début, je présente une solution qui produit exactement le même résultat dans le même ordre que dans la question. Il scanne la table principale 3 fois: pour obtenir une liste
ProductIDs
avec la plage de dates de chaque produit, pour résumer les coûts pour chaque jour (car plusieurs transactions portant les mêmes dates), pour joindre le résultat aux lignes d'origine.
- Ensuite, je compare deux approches qui simplifient la tâche et évitent une dernière analyse du tableau principal. Leur résultat est un récapitulatif quotidien, c'est-à-dire que si plusieurs transactions sur un produit ont la même date, elles sont regroupées sur une seule ligne. Mon approche de l'étape précédente scanne la table deux fois. Geoff Patterson analyse une fois la table, car il utilise des connaissances externes sur la plage de dates et la liste des produits.
- Enfin, je présente une solution en un seul passage qui renvoie à nouveau un récapitulatif quotidien, mais elle ne nécessite aucune connaissance externe de la plage de dates ou de la liste des
ProductIDs
.
J'utiliserai la base de données AdventureWorks2014 et SQL Server Express 2014.
Modifications apportées à la base de données d'origine:
- Changement du type de
[Production].[TransactionHistory].[TransactionDate]
de datetime
à date
. La composante temps était de toute façon nulle.
- Tableau de calendrier ajouté
[dbo].[Calendar]
- Index ajouté à
[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'article de MSDN sur la OVER
clause contient un lien vers un excellent article de blog sur les fonctions de fenêtre d'Itzik Ben-Gan. Dans ce poste , il explique comment OVER
fonctionne, la différence entre ROWS
et les RANGE
options et mentionne ce problème même de calculer une somme roulant sur une plage de dates. Il mentionne que la version actuelle de SQL Server n'implémente pas RANGE
intégralement ni les types de données d'intervalle temporel. Son explication de la différence entre ROWS
et RANGE
m'a donné une idée.
Dates sans lacunes ni doublons
Si la TransactionHistory
table contenait des dates sans lacunes ni doublons, la requête suivante produirait des résultats corrects:
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;
En effet, une fenêtre de 45 rangées couvrirait exactement 45 jours.
Dates avec lacunes sans doublons
Malheureusement, nos données ont des lacunes dans les dates. Pour résoudre ce problème, nous pouvons utiliser une Calendar
table pour générer un ensemble de dates sans espace, puis LEFT JOIN
les données d'origine pour cet ensemble et utiliser la même requête avec ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
. Cela produirait des résultats corrects uniquement si les dates ne se répètent pas (dans les mêmes conditions ProductID
).
Dates avec des lacunes avec des doublons
Malheureusement, nos données ont des lacunes dans les dates et les dates peuvent se répéter dans la même chose ProductID
. Pour résoudre ce problème, nous pouvons créer des GROUP
données originales en ProductID, TransactionDate
générant un ensemble de dates sans les dupliquer. Ensuite, utilisez Calendar
table pour générer un ensemble de dates sans lacunes. Ensuite, nous pouvons utiliser la requête avec ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
pour calculer le roulement SUM
. Cela produirait des résultats corrects. Voir les commentaires dans la requête ci-dessous.
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
;
J'ai confirmé que cette requête produisait les mêmes résultats que l'approche de la question qui utilise une sous-requête.
Plans d'exécution
La première requête utilise une sous-requête, la seconde - cette approche. Vous pouvez voir que la durée et le nombre de lectures sont beaucoup moins dans cette approche. La majorité des coûts estimés dans cette approche est la finale ORDER BY
, voir ci-dessous.
L'approche de sous-requête a un plan simple avec des boucles imbriquées et une O(n*n)
complexité.
Planifiez cette approche TransactionHistory
plusieurs fois, mais il n’ya pas de boucle. Comme vous pouvez le constater, plus de 70% du coût estimé correspond Sort
à la finale ORDER BY
.
Top résultat - subquery
, bas - OVER
.
Éviter les analyses supplémentaires
La dernière analyse d'index, jointure et fusion de fusion dans le plan ci-dessus est provoquée par la INNER JOIN
table finale avec la table d'origine afin que le résultat final soit identique à une approche lente avec une sous-requête. Le nombre de lignes renvoyées est identique à celui de la TransactionHistory
table. Il y a des lignes dans TransactionHistory
lesquelles plusieurs transactions ont eu lieu le même jour pour le même produit. S'il est correct d'afficher uniquement le résumé quotidien dans le résultat, cette dernière JOIN
peut être supprimée et la requête devient un peu plus simple et un peu plus rapide. Les dernières analyses d'index, de jointure de fusion et de tri du plan précédent sont remplacées par Filtre, ce qui supprime les lignes ajoutées par 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
;
Pourtant, TransactionHistory
est scanné deux fois. Une analyse supplémentaire est nécessaire pour obtenir la plage de dates pour chaque produit. J'ai été intéressé de voir comment il se compare à une autre approche, où nous utilisons des connaissances externes sur la plage globale de dates TransactionHistory
, ainsi Product
qu'un tableau supplémentaire contenant tout ProductIDs
pour éviter cette analyse supplémentaire. J'ai retiré le calcul du nombre de transactions par jour de cette requête pour que la comparaison soit valide. Il peut être ajouté dans les deux requêtes, mais j'aimerais que ce soit simple pour la comparaison. J'ai également dû utiliser d'autres dates, car j'utilise la version 2014 de la base de données.
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);
Les deux requêtes renvoient le même résultat dans le même ordre.
Comparaison
Voici le temps et les statistiques IO.
La variante à deux analyses est un peu plus rapide et comporte moins de lectures, car la variante à une analyse doit beaucoup utiliser Worktable. En outre, la variante à une analyse génère plus de lignes que nécessaire, comme vous pouvez le constater dans les plans. Il génère des dates pour chaque élément de ProductID
la Product
table, même si ProductID
aucune transaction n'a été effectuée. Il y a 504 lignes dans la Product
table, mais seuls 441 produits ont des transactions en TransactionHistory
. En outre, il génère la même plage de dates pour chaque produit, ce qui est plus que nécessaire. Si l' TransactionHistory
historique global était plus long et que chaque produit individuel avait un historique relativement court, le nombre de lignes supplémentaires inutiles serait encore plus élevé.
D'autre part, il est possible d'optimiser un peu plus la variante à deux balayages en créant un autre index, plus étroit, sur just (ProductID, TransactionDate)
. Cet index serait utilisé pour calculer les dates de début / fin pour chaque produit ( CTE_Products
) et aurait moins de pages que l’index couvrant, ce qui causerait moins de lectures.
Nous pouvons donc choisir soit d’avoir une analyse simple, très explicite, soit d’avoir une table de travail implicite.
BTW, s'il est correct d'avoir un résultat avec des résumés quotidiens, il est préférable de créer un index qui n'inclut pas ReferenceOrderID
. Cela utiliserait moins de pages => moins d'IO.
CREATE NONCLUSTERED INDEX [i2] ON [Production].[TransactionHistory]
(
[ProductID] ASC,
[TransactionDate] ASC
)
INCLUDE ([ActualCost])
Solution en un seul passage utilisant CROSS APPLY
Cela devient une très longue réponse, mais voici une variante supplémentaire qui ne renvoie que le résumé quotidien, mais elle effectue une analyse unique des données et ne nécessite aucune connaissance externe de la plage de dates ou de la liste des ProductID. Il ne fait pas aussi bien les tris intermédiaires. La performance globale est similaire aux variantes précédentes, mais semble être un peu pire.
L'idée principale est d'utiliser un tableau de nombres pour générer des lignes permettant de combler les lacunes dans les dates. Pour chaque date existante, utilisez LEAD
pour calculer la taille de l'écart en jours, puis CROSS APPLY
pour ajouter le nombre de lignes requis dans le jeu de résultats. Au début, j'ai essayé avec une table de chiffres permanente. Le plan indiquait un grand nombre de lectures dans ce tableau, bien que la durée réelle soit à peu près la même que lorsque j'ai généré des nombres à la volée 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
;
Ce plan est "plus long", car la requête utilise deux fonctions de fenêtre ( LEAD
et SUM
).
RunningTotal.TBE IS NOT NULL
condition (et, par conséquent, laTBE
colonne) est inutile. Si vous la supprimez, vous n'obtiendrez pas de lignes redondantes, car votre condition de jointure interne inclut la colonne de date. Par conséquent, le jeu de résultats ne peut pas comporter de dates qui n'étaient pas à l'origine dans la source.