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.J'ai quelques solutions alternatives qui n'utilisent pas d'index ou de tables de référence. Peut-être pourraient-ils être utiles dans des situations dans lesquelles vous n'avez pas accès à des tables supplémentaires et ne pouvez pas créer d'index. Il semble possible d’obtenir des résultats corrects lors du regroupement en
TransactionDate
une seule passe des données et en une seule fonction de fenêtre. Cependant, je ne pouvais pas trouver un moyen de le faire avec une seule fonction de fenêtre lorsque vous ne pouvez pas grouper parTransactionDate
.Pour fournir un cadre de référence, la solution originale affichée dans la question, sur ma machine, dispose d'un temps processeur de 2808 ms sans l'indice de couverture et de 1950 ms avec l'index de couverture. Je teste avec la base de données AdventureWorks2014 et SQL Server Express 2014.
Commençons par une solution pour quand nous pouvons grouper par
TransactionDate
. Une somme cumulée au cours des X derniers jours peut également être exprimée de la manière suivante:En SQL, une façon d’exprimer cela est d’effectuer deux copies de vos données et, pour la deuxième copie, de multiplier le coût par -1 et d’ajouter X + 1 jours à la colonne de date. Le calcul d'une somme courante sur toutes les données implémentera la formule ci-dessus. Je montrerai ceci pour quelques données d'exemple. Vous trouverez ci-dessous un exemple de date pour un single
ProductID
. Je représente les dates sous forme de nombres pour faciliter les calculs. Données de départ:Ajoutez une deuxième copie des données. La deuxième copie a 46 jours ajoutés à la date et le coût multiplié par -1:
Prenez la somme courante commandée par ordre
Date
croissant etCopiedRow
décroissant:Filtrez les lignes copiées pour obtenir le résultat souhaité:
Le SQL suivant est un moyen d'implémenter l'algorithme ci-dessus:
Sur ma machine, cela prenait 702 ms de temps CPU avec l’indice de couverture et 734 ms de temps CPU sans l’index. Le plan de requête peut être trouvé ici: https://www.brentozar.com/pastetheplan/?id=SJdCsGVSl
Un inconvénient de cette solution est qu’il semble exister un tri inévitable lors de la commande par la nouvelle
TransactionDate
colonne. Je ne pense pas que ce type de problème puisse être résolu en ajoutant des index, car nous devons combiner deux copies des données avant de procéder à la commande. J'ai pu supprimer une sorte à la fin de la requête en ajoutant une colonne différente à ORDER BY. Si j'avais commandé par,FilterFlag
je trouvais que SQL Server optimiserait cette colonne et effectuerait un tri explicite.Les solutions pour quand nous avons besoin de renvoyer un ensemble de résultats avec des
TransactionDate
valeurs en double identiquesProductId
étaient beaucoup plus compliquées. Je résumerais le problème comme nécessitant simultanément de partitionner et de classer par la même colonne. La syntaxe fournie par Paul résout ce problème. Il n'est donc pas surprenant qu'il soit si difficile à exprimer avec les fonctions de fenêtre actuellement disponibles dans SQL Server (si ce n'était pas difficile à exprimer, il ne serait pas nécessaire de développer la syntaxe).Si j'utilise la requête ci-dessus sans regroupement, j'obtiens des valeurs différentes pour la somme glissante lorsqu'il y a plusieurs lignes avec le même
ProductId
etTransactionDate
. Une façon de résoudre ce problème consiste à effectuer le même calcul de somme en cours d'exécution que ci-dessus, mais également à marquer la dernière ligne de la partition. Cela peut être fait avecLEAD
(en supposant que ceProductID
ne soit jamais NULL) sans un tri supplémentaire. Pour la valeur de somme finale en cours d'exécution, j'utiliseMAX
une fonction de fenêtre pour appliquer la valeur de la dernière ligne de la partition à toutes les lignes de la partition.Sur ma machine, cela a pris 2464 ms de temps CPU sans l’indice de couverture. Comme auparavant, il semble y avoir une sorte inévitable. Le plan de requête peut être trouvé ici: https://www.brentozar.com/pastetheplan/?id=HyWxhGVBl
Je pense qu'il y a place à l'amélioration dans la requête ci-dessus. Il existe certainement d'autres moyens d'utiliser les fonctions Windows pour obtenir le résultat souhaité.
la source