J'ai besoin de calculer une somme mobile sur une plage de dates. Pour illustrer, à l'aide de la base de données exemple AdventureWorks , la syntaxe hypothétique suivante ferait exactement ce dont j'ai besoin:
SELECT
TH.ProductID,
TH.TransactionDate,
TH.ActualCost,
RollingSum45 = SUM(TH.ActualCost) OVER (
PARTITION BY TH.ProductID
ORDER BY TH.TransactionDate
RANGE BETWEEN
INTERVAL 45 DAY PRECEDING
AND CURRENT ROW)
FROM Production.TransactionHistory AS TH
ORDER BY
TH.ProductID,
TH.TransactionDate,
TH.ReferenceOrderID;
Malheureusement, l'étendue du cadre de fenêtre RANGE
ne permet pas actuellement un intervalle dans SQL Server.
Je sais que je peux écrire une solution en utilisant une sous-requête et un agrégat normal (non-fenêtre):
SELECT
TH.ProductID,
TH.TransactionDate,
TH.ActualCost,
RollingSum45 =
(
SELECT SUM(TH2.ActualCost)
FROM Production.TransactionHistory AS TH2
WHERE
TH2.ProductID = TH.ProductID
AND TH2.TransactionDate <= TH.TransactionDate
AND TH2.TransactionDate >= DATEADD(DAY, -45, TH.TransactionDate)
)
FROM Production.TransactionHistory AS TH
ORDER BY
TH.ProductID,
TH.TransactionDate,
TH.ReferenceOrderID;
Étant donné l'indice suivant:
CREATE UNIQUE INDEX i
ON Production.TransactionHistory
(ProductID, TransactionDate, ReferenceOrderID)
INCLUDE
(ActualCost);
Le plan d'exécution est le suivant:
Bien qu'elle ne soit pas horriblement inefficace, il semble qu'il devrait être possible d'exprimer cette requête en utilisant uniquement les fonctions d'agrégation de fenêtres et d'analyse prises en charge dans SQL Server 2012, 2014 ou 2016 (jusqu'à présent).
Pour plus de clarté, je recherche une solution qui effectue un passage unique sur les données.
Dans T-SQL, cela signifie probablement que la clause OVER
fera le travail, et le plan d'exécution comportera des spools et des agrégats de fenêtres. Tous les éléments de langage qui utilisent la clause OVER
sont équitables. Une solution SQLCLR est acceptable, à condition qu'elle soit garantie pour produire des résultats corrects.
Pour les solutions T-SQL, moins il y a de hachages, de tris et de spools/agrégats de fenêtres dans le plan d'exécution, mieux c'est. N'hésitez pas à ajouter des index, mais les structures distinctes ne sont pas autorisées (donc pas de tables précalculées gardées synchronisées avec les déclencheurs, par exemple). Les tableaux de référence sont autorisés (tableaux de nombres, dates etc.)
Idéalement, les solutions produiront exactement les mêmes résultats dans le même ordre que la version de sous-requête ci-dessus, mais tout ce qui est sans doute correct est également acceptable. La performance est toujours une considération, donc les solutions doivent être au moins raisonnablement efficaces.
Salon de discussion dédié: J'ai créé un salon de discussion public pour les discussions liées à cette question et ses réponses. Tout utilisateur avec au moins 20 points de réputation peut participer directement. Veuillez me cingler dans un commentaire ci-dessous si vous avez moins de 20 représentants et que vous souhaitez participer.
Grande question, Paul! J'ai utilisé quelques approches différentes, une en T-SQL et une en CLR.
Résumé rapide T-SQL
L'approche T-SQL peut être résumée comme suit:
En utilisant SET STATISTICS IO ON
, cette approche rapporte Table 'TransactionHistory'. Scan count 1, logical reads 484
, qui confirme le "passage unique" sur la table. Pour référence, la requête de recherche de boucle d'origine signale Table 'TransactionHistory'. Scan count 113444, logical reads 438366
.
Tel que rapporté par SET STATISTICS TIME ON
, le temps CPU est 514ms
. Cela se compare favorablement à 2231ms
pour la requête d'origine.
Résumé rapide du CLR
Le résumé du CLR peut être résumé comme suit:
En utilisant SET STATISTICS IO ON
, cette approche signale qu'aucune E/S logique ne s'est produite! Wow, une solution parfaite! (En fait, il semble que SET STATISTICS IO
ne signale pas les E/S encourues dans CLR. Mais à partir du code, il est facile de voir qu'exactement un scan de la table est effectué et récupère les données dans l'ordre par l'index suggéré par Paul.
Tel que rapporté par SET STATISTICS TIME ON
, le temps CPU est maintenant 187ms
. Il s'agit donc d'une amélioration par rapport à l'approche T-SQL. Malheureusement, le temps global écoulé des deux approches est très similaire à environ une demi-seconde chacune. Cependant, l'approche basée sur CLR doit produire 113K lignes sur la console (contre seulement 52K pour l'approche T-SQL qui regroupe par produit/date), c'est pourquoi je me suis plutôt concentré sur le temps CPU.
Un autre grand avantage de cette approche est qu'elle donne exactement les mêmes résultats que l'approche boucle/recherche d'origine, y compris une ligne pour chaque transaction même dans les cas où un produit est vendu plusieurs fois le même jour. (Sur AdventureWorks, j'ai spécifiquement comparé les résultats ligne par ligne et confirmé qu'ils correspondaient à la requête originale de Paul.)
Un inconvénient de cette approche, au moins dans sa forme actuelle, est qu'elle lit toutes les données en mémoire. Cependant, l'algorithme qui a été conçu n'a besoin que du cadre de fenêtre actuel en mémoire à tout moment et pourrait être mis à jour pour fonctionner pour les ensembles de données qui dépassent la mémoire. Paul a illustré ce point dans sa réponse en produisant une implémentation de cet algorithme qui ne stocke que la fenêtre coulissante en mémoire. Cela se fait au détriment de l'octroi d'autorisations plus élevées à l'assemblage CLR, mais serait certainement utile pour faire évoluer cette solution jusqu'à des ensembles de données arbitrairement volumineux.
Configuration initiale
USE AdventureWorks2012
GO
-- Create Paul's index
CREATE UNIQUE INDEX i
ON Production.TransactionHistory (ProductID, TransactionDate, ReferenceOrderID)
INCLUDE (ActualCost);
GO
-- Build calendar table for 2000 ~ 2020
CREATE TABLE dbo.calendar (d DATETIME NOT NULL CONSTRAINT PK_calendar PRIMARY KEY)
GO
DECLARE @d DATETIME = '1/1/2000'
WHILE (@d < '1/1/2021')
BEGIN
INSERT INTO dbo.calendar (d) VALUES (@d)
SELECT @d = DATEADD(DAY, 1, @d)
END
GO
La requête
DECLARE @minAnalysisDate DATE = '2007-09-01', -- Customizable start date depending on business needs
@maxAnalysisDate DATE = '2008-09-03' -- Customizable end date depending on business needs
SELECT 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.d AS TransactionDate,
COUNT(TH.ProductId) AS NumOrders, SUM(TH.ActualCost) AS ActualCost
FROM Production.Product p
JOIN dbo.calendar c
ON c.d BETWEEN @minAnalysisDate AND @maxAnalysisDate
LEFT OUTER JOIN Production.TransactionHistory TH
ON TH.ProductId = p.productId
AND TH.TransactionDate = c.d
GROUP BY P.ProductID, c.d
) aggsByDay
) rollingSums
WHERE NumOrders > 0
ORDER BY ProductID, TransactionDate
-- MAXDOP 1 to avoid parallel scan inflating the scan count
OPTION (MAXDOP 1)
Le plan d'exécution
Du plan d'exécution, nous voyons que l'index original proposé par Paul est suffisant pour nous permettre d'effectuer un seul scan ordonné de Production.TransactionHistory
, en utilisant une jointure de fusion pour combiner l'historique des transactions avec chaque combinaison produit/date possible.
Hypothèses
Il y a quelques hypothèses importantes intégrées à cette approche. Je suppose que ce sera à Paul de décider si elles sont acceptables :)
Production.Product
table. Ce tableau est disponible gratuitement sur AdventureWorks2012
et la relation est appliquée par une clé étrangère de Production.TransactionHistory
, j'ai donc interprété cela comme un jeu équitable.AdventureWorks2012
; dans l'affirmative, la génération de l'ensemble complet des combinaisons produit/date ne serait plus possible sans avoir au préalable passé en revue l'historique des transactions.NumOrders
pour indiquer combien de ventes ont eu lieu. Voir la capture d'écran suivante pour une comparaison des résultats de la requête d'origine par rapport à la requête proposée dans les cas où un produit a été vendu plusieurs fois à la même date (par exemple, 319
/2007-09-05 00:00:00.000
)Le corps de la fonction principale
Il n'y a pas une tonne à voir ici; le corps principal de la fonction déclare les entrées (qui doivent correspondre à la fonction SQL correspondante), établit une connexion SQL et ouvre le SQLReader.
// SQL CLR function for rolling SUMs on AdventureWorks2012.Production.TransactionHistory
[SqlFunction(DataAccess = DataAccessKind.Read,
FillRowMethodName = "RollingSum_Fill",
TableDefinition = "ProductId INT, TransactionDate DATETIME, ReferenceOrderID INT," +
"ActualCost FLOAT, PrevCumulativeSum FLOAT, RollingSum FLOAT")]
public static IEnumerable RollingSumTvf(SqlInt32 rollingPeriodDays) {
using (var connection = new SqlConnection("context connection=true;")) {
connection.Open();
List<TrxnRollingSum> trxns;
using (var cmd = connection.CreateCommand()) {
//Read the transaction history (note: the order is important!)
cmd.CommandText = @"SELECT ProductId, TransactionDate, ReferenceOrderID,
CAST(ActualCost AS FLOAT) AS ActualCost
FROM Production.TransactionHistory
ORDER BY ProductId, TransactionDate";
using (var reader = cmd.ExecuteReader()) {
trxns = ComputeRollingSums(reader, rollingPeriodDays.Value);
}
}
return trxns;
}
}
La logique de base
J'ai séparé la logique principale de sorte qu'il est plus facile de se concentrer sur:
// Given a SqlReader with transaction history data, computes / returns the rolling sums
private static List<TrxnRollingSum> ComputeRollingSums(SqlDataReader reader,
int rollingPeriodDays) {
var startIndexOfRollingPeriod = 0;
var rollingSumIndex = 0;
var trxns = new List<TrxnRollingSum>();
// Prior to the loop, initialize "next" to be the first transaction
var nextTrxn = GetNextTrxn(reader, null);
while (nextTrxn != null)
{
var currTrxn = nextTrxn;
nextTrxn = GetNextTrxn(reader, currTrxn);
trxns.Add(currTrxn);
// If the next transaction is not the same product/date as the current
// transaction, we can finalize the rolling sum for the current transaction
// and all previous transactions for the same product/date
var finalizeRollingSum = nextTrxn == null || (nextTrxn != null &&
(currTrxn.ProductId != nextTrxn.ProductId ||
currTrxn.TransactionDate != nextTrxn.TransactionDate));
if (finalizeRollingSum)
{
// Advance the pointer to the first transaction (for the same product)
// that occurs within the rolling period
while (startIndexOfRollingPeriod < trxns.Count
&& trxns[startIndexOfRollingPeriod].TransactionDate <
currTrxn.TransactionDate.AddDays(-1 * rollingPeriodDays))
{
startIndexOfRollingPeriod++;
}
// Compute the rolling sum as the cumulative sum (for this product),
// minus the cumulative sum for prior to the beginning of the rolling window
var sumPriorToWindow = trxns[startIndexOfRollingPeriod].PrevSum;
var rollingSum = currTrxn.ActualCost + currTrxn.PrevSum - sumPriorToWindow;
// Fill in the rolling sum for all transactions sharing this product/date
while (rollingSumIndex < trxns.Count)
{
trxns[rollingSumIndex++].RollingSum = rollingSum;
}
}
// If this is the last transaction for this product, reset the rolling period
if (nextTrxn != null && currTrxn.ProductId != nextTrxn.ProductId)
{
startIndexOfRollingPeriod = trxns.Count;
}
}
return trxns;
}
Aides
La logique suivante pourrait être écrite en ligne, mais c'est un peu plus facile à lire quand ils sont divisés en leurs propres méthodes.
private static TrxnRollingSum GetNextTrxn(SqlDataReader r, TrxnRollingSum currTrxn) {
TrxnRollingSum nextTrxn = null;
if (r.Read()) {
nextTrxn = new TrxnRollingSum {
ProductId = r.GetInt32(0),
TransactionDate = r.GetDateTime(1),
ReferenceOrderId = r.GetInt32(2),
ActualCost = r.GetDouble(3),
PrevSum = 0 };
if (currTrxn != null) {
nextTrxn.PrevSum = (nextTrxn.ProductId == currTrxn.ProductId)
? currTrxn.PrevSum + currTrxn.ActualCost : 0;
}
}
return nextTrxn;
}
// Represents the output to be returned
// Note that the ReferenceOrderId/PrevSum fields are for debugging only
private class TrxnRollingSum {
public int ProductId { get; set; }
public DateTime TransactionDate { get; set; }
public int ReferenceOrderId { get; set; }
public double ActualCost { get; set; }
public double PrevSum { get; set; }
public double RollingSum { get; set; }
}
// The function that generates the result data for each row
// (Such a function is mandatory for SQL CLR table-valued functions)
public static void RollingSum_Fill(object trxnWithRollingSumObj,
out int productId,
out DateTime transactionDate,
out int referenceOrderId, out double actualCost,
out double prevCumulativeSum,
out double rollingSum) {
var trxn = (TrxnRollingSum)trxnWithRollingSumObj;
productId = trxn.ProductId;
transactionDate = trxn.TransactionDate;
referenceOrderId = trxn.ReferenceOrderId;
actualCost = trxn.ActualCost;
prevCumulativeSum = trxn.PrevSum;
rollingSum = trxn.RollingSum;
}
Lier le tout ensemble dans SQL
Jusqu'à présent, tout était en C #, voyons donc le SQL réel impliqué. (Alternativement, vous pouvez utiliser ce script de déploiement pour créer l'assembly directement à partir des bits de mon assembly plutôt que de vous compiler.)
USE AdventureWorks2012; /* GPATTERSON2\SQL2014DEVELOPER */
GO
-- Enable CLR
EXEC sp_configure 'clr enabled', 1;
GO
RECONFIGURE;
GO
-- Create the Assembly based on the dll generated by compiling the CLR project
-- I've also included the "Assembly bits" version that can be run without compiling
CREATE Assembly ClrPlayground
-- See http://Pastebin.com/dfbv1w3z for a "from Assembly bits" version
FROM 'C:\FullPathGoesHere\ClrPlayground\bin\Debug\ClrPlayground.dll'
WITH PERMISSION_SET = safe;
GO
--Create a function from the Assembly
CREATE FUNCTION dbo.RollingSumTvf (@rollingPeriodDays INT)
RETURNS TABLE ( ProductId INT, TransactionDate DATETIME, ReferenceOrderID INT,
ActualCost FLOAT, PrevCumulativeSum FLOAT, RollingSum FLOAT)
-- The function yields rows in order, so let SQL Server know to avoid an extra sort
ORDER (ProductID, TransactionDate, ReferenceOrderID)
AS EXTERNAL NAME ClrPlayground.UserDefinedFunctions.RollingSumTvf;
GO
-- Now we can actually use the TVF!
SELECT *
FROM dbo.RollingSumTvf(45)
ORDER BY ProductId, TransactionDate, ReferenceOrderId
GO
Mises en garde
L'approche CLR offre beaucoup plus de flexibilité pour optimiser l'algorithme, et il pourrait probablement être réglé encore plus loin par un expert en C #. Cependant, la stratégie CLR présente également des inconvénients. Quelques points à garder à l'esprit:
TRUSTWORTHY
et en accordant EXTERNAL_ACCESS
à l'Assemblée du CLR. Il y a donc des tracas et des implications de sécurité potentielles, mais le gain est une approche de streaming qui peut mieux s'adapter à des ensembles de données beaucoup plus grands que ceux sur AdventureWorks.Après avoir essayé de réfléchir au problème de manière créative pendant un certain temps, j'ai pensé publier également la manière assez simple et pratique que je choisirais probablement de résoudre ce problème s'il apparaissait dans mon travail quotidien. Il utilise la fonctionnalité de fenêtre SQL 2012+, mais pas de manière révolutionnaire que la question espérait:
-- Compute all running costs into a #temp table; Note that this query could simply read
-- from Production.TransactionHistory, but a CROSS APPLY by product allows the window
-- function to be computed independently per product, supporting a parallel query plan
SELECT t.*
INTO #runningCosts
FROM Production.Product p
CROSS APPLY (
SELECT t.ProductId, t.TransactionDate, t.ReferenceOrderId, t.ActualCost,
-- Running sum of the cost for this product, including all ties on TransactionDate
SUM(t.ActualCost) OVER (
ORDER BY t.TransactionDate
RANGE UNBOUNDED PRECEDING) AS RunningCost
FROM Production.TransactionHistory t
WHERE t.ProductId = p.ProductId
) t
GO
-- Key the table in our output order
ALTER TABLE #runningCosts
ADD PRIMARY KEY (ProductId, TransactionDate, ReferenceOrderId)
GO
SELECT r.ProductId, r.TransactionDate, r.ReferenceOrderId, r.ActualCost,
-- Cumulative running cost - running cost prior to the sliding window
r.RunningCost - ISNULL(w.RunningCost,0) AS RollingSum45
FROM #runningCosts r
OUTER APPLY (
-- For each transaction, find the running cost just before the sliding window begins
SELECT TOP 1 b.RunningCost
FROM #runningCosts b
WHERE b.ProductId = r.ProductId
AND b.TransactionDate < DATEADD(DAY, -45, r.TransactionDate)
ORDER BY b.TransactionDate DESC
) w
ORDER BY r.ProductId, r.TransactionDate, r.ReferenceOrderId
GO
Cela donne en fait un plan de requête global assez simple, même lorsque vous examinez ensemble les deux plans de requête pertinents:
Quelques raisons pour lesquelles j'aime cette approche:
900ms
sur l'ensemble de données fourni, plutôt que sur le 2700ms
de la recherche de boucle d'origineQuelques mises en garde potentielles:
C'est une longue réponse, j'ai donc décidé d'ajouter un résumé ici.
ProductIDs
avec la plage de dates pour chaque produit, pour résumer les coûts pour chaque jour (car il y a plusieurs transactions avec les mêmes dates), pour joindre le résultat avec des lignes originales.ProductIDs
.J'utiliserai AdventureWorks2014 base de données et SQL Server Express 2014.
Modifications de la base de données d'origine:
[Production].[TransactionHistory].[TransactionDate]
De datetime
à date
. La composante temporelle était de toute façon nulle.[dbo].[Calendar]
[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 MSDN sur OVER
contient un lien vers un excellent article de blog sur les fonctions des fenêtres par Itzik Ben-Gan. Dans cet article, il explique comment fonctionne OVER
, la différence entre les options ROWS
et RANGE
et mentionne ce problème même de calcul d'une somme mobile sur une plage de dates. Il mentionne que la version actuelle de SQL Server n'implémente pas RANGE
dans son intégralité et n'implémente pas 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 table TransactionHistory
contenait des dates sans intervalles et sans 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 des lacunes sans doublons
Malheureusement, nos données présentent des lacunes dans les dates. Pour résoudre ce problème, nous pouvons utiliser une table Calendar
pour générer un ensemble de dates sans lacunes, puis LEFT JOIN
Les données d'origine de 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 le même ProductID
).
Dates avec des lacunes avec des doublons
Malheureusement, nos données présentent à la fois des lacunes dans les dates et les dates peuvent se répéter dans le même ProductID
. Pour résoudre ce problème, nous pouvons GROUP
les données d'origine par ProductID, TransactionDate
Pour générer un ensemble de dates sans doublons. Utilisez ensuite la table Calendar
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 produit les mêmes résultats que l'approche de la question qui utilise la 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 importants dans cette approche. La majorité du coût estimé dans cette approche est le ORDER BY
Final, voir ci-dessous.
L'approche de sous-requête a un plan simple avec des boucles imbriquées et une complexité O(n*n)
.
Planifiez cette approche plusieurs fois TransactionHistory
, mais il n'y a pas de boucles. Comme vous pouvez le voir, plus de 70% du coût estimé est le Sort
pour le ORDER BY
Final.
Résultat supérieur - subquery
, bas - OVER
.
Le dernier balayage d'index, fusionner la jointure et trier dans le plan ci-dessus est provoqué par le INNER JOIN
Final avec la table d'origine pour que le résultat final soit exactement le même qu'une approche lente avec sous-requête. Le nombre de lignes retournées est le même que dans la table TransactionHistory
. Il y a des lignes dans TransactionHistory
lorsque plusieurs transactions ont eu lieu le même jour pour le même produit. Si vous pouvez afficher uniquement le résumé quotidien dans le résultat, ce JOIN
final peut être supprimé et la requête devient un peu plus simple et un peu plus rapide. Le dernier balayage d'index, la fusion de jointure et le tri du plan précédent sont remplacés par Filter, 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
;
Néanmoins, TransactionHistory
est analysé deux fois. Une analyse supplémentaire est nécessaire pour obtenir la plage de dates pour chaque produit. J'étais intéressé de voir comment cela se compare à une autre approche, où nous utilisons des connaissances externes sur la plage mondiale de dates dans TransactionHistory
, plus une table supplémentaire Product
qui a tout ProductIDs
pour éviter cette analyse supplémentaire. J'ai supprimé 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 je voudrais rester 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 a 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 voir dans les plans. Il génère des dates pour chaque ProductID
qui se trouve dans la table Product
, même si un ProductID
n'a aucune transaction. Il y a 504 lignes dans la table Product
, mais seuls 441 produits ont des transactions dans TransactionHistory
. En outre, il génère la même plage de dates pour chaque produit, ce qui est plus que nécessaire. Si TransactionHistory
avait un historique global plus long, chaque produit individuel ayant un historique relativement court, le nombre de lignes supplémentaires inutiles serait encore plus élevé.
D'un autre côté, il est possible d'optimiser un peu plus la variante à deux balayages en créant un autre index plus étroit sur juste (ProductID, TransactionDate)
. Cet index serait utilisé pour calculer les dates de début/fin pour chaque produit (CTE_Products
) Et il aurait moins de pages que l'index de couverture et par conséquent entraînerait moins de lectures.
Ainsi, nous pouvons choisir, soit d'avoir une analyse simple supplémentaire explicite, soit d'avoir une table de travail implicite.
BTW, s'il est correct d'avoir un résultat avec seulement des résumés quotidiens, alors il est préférable de créer un index qui n'inclut pas ReferenceOrderID
. Il utiliserait moins de pages => moins d'E/S.
CREATE NONCLUSTERED INDEX [i2] ON [Production].[TransactionHistory]
(
[ProductID] ASC,
[TransactionDate] ASC
)
INCLUDE ([ActualCost])
Cela devient une réponse très longue, mais voici une autre variante qui ne renvoie à nouveau qu'un résumé quotidien, mais elle ne fait qu'une seule analyse des données et ne nécessite pas de connaissances externes sur la plage de dates ou la liste des ProductID. Il ne fait pas aussi bien les tris intermédiaires. Les performances globales sont similaires aux variantes précédentes, mais semblent être un peu pires.
L'idée principale est d'utiliser un tableau de nombres pour générer des lignes qui combleraient les lacunes dans les dates. Pour chaque date existante, utilisez LEAD
pour calculer la taille de l'écart en jours, puis utilisez CROSS APPLY
Pour ajouter le nombre requis de lignes dans le jeu de résultats. Au début, je l'ai essayé avec une table permanente de nombres. Le plan a montré un grand nombre de lectures dans ce tableau, bien que la durée réelle soit à peu près la même, comme lorsque j'ai généré des nombres à la volée en utilisant 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
).
Une solution alternative SQLCLR qui s'exécute plus rapidement et nécessite moins de mémoire:
Cela nécessite le EXTERNAL_ACCESS
jeu d'autorisations car il utilise une connexion de bouclage vers le serveur cible et la base de données au lieu de la connexion de contexte (lente). Voici comment appeler la fonction:
SELECT
RS.ProductID,
RS.TransactionDate,
RS.ActualCost,
RS.RollingSum45
FROM dbo.RollingSum
(
N'.\SQL2014', -- Instance name
N'AdventureWorks2012' -- Database name
) AS RS
ORDER BY
RS.ProductID,
RS.TransactionDate,
RS.ReferenceOrderID;
Produit exactement les mêmes résultats, dans le même ordre, que la question.
Plan d'exécution:
Lectures logiques du profileur: 481
Le principal avantage de cette implémentation est qu'elle est plus rapide que l'utilisation de la connexion contextuelle et qu'elle utilise moins de mémoire. Il ne garde en mémoire que deux éléments à la fois:
Cette mise en cache minimale devrait garantir que cette méthode évolue bien; certainement mieux que d'essayer de conserver l'ensemble des entrées dans la mémoire CLR.
Si vous utilisez l'édition 64 bits Enterprise, Developer ou Evaluation de SQL Server 2014, vous pouvez utiliser OLTP en mémoire . La solution ne sera pas une analyse unique et n'utilisera pratiquement aucune fonction de fenêtre, mais cela pourrait ajouter de la valeur à cette question et l'algorithme utilisé pourrait éventuellement être utilisé comme source d'inspiration pour d'autres solutions.
Vous devez d'abord activer In-Memory OLTP sur la base de données AdventureWorks.
alter database AdventureWorks2014
add filegroup InMem contains memory_optimized_data;
alter database AdventureWorks2014
add file (name='AW2014_InMem',
filename='D:\SQL Server\MSSQL12.MSSQLSERVER\MSSQL\DATA\AW2014')
to filegroup InMem;
alter database AdventureWorks2014
set memory_optimized_elevate_to_snapshot = on;
Le paramètre de la procédure est une variable de table en mémoire qui doit être définie en tant que type.
create type dbo.TransHistory as table
(
ID int not null,
ProductID int not null,
TransactionDate datetime not null,
ReferenceOrderID int not null,
ActualCost money not null,
RunningTotal money not null,
RollingSum45 money not null,
-- Index used in while loop
index IX_T1 nonclustered hash (ID) with (bucket_count = 1000000),
-- Used to lookup the running total as it was 45 days ago (or more)
index IX_T2 nonclustered (ProductID, TransactionDate desc)
) with (memory_optimized = on);
L'ID n'est pas unique dans ce tableau, il est unique pour chaque combinaison de ProductID
et TransactionDate
.
Il y a quelques commentaires dans la procédure qui vous indiquent ce qu'elle fait, mais dans l'ensemble, elle calcule le total cumulé dans une boucle et pour chaque itération, elle recherche le total cumulé tel qu'il était il y a 45 jours (ou plus).
Le total cumulé actuel moins le total cumulé tel qu'il était il y a 45 jours est la somme mobile de 45 jours que nous recherchons.
create procedure dbo.GetRolling45
@TransHistory dbo.TransHistory readonly
with native_compilation, schemabinding, execute as owner as
begin atomic with(transaction isolation level = snapshot, language = N'us_english')
-- Table to hold the result
declare @TransRes dbo.TransHistory;
-- Loop variable
declare @ID int = 0;
-- Current ProductID
declare @ProductID int = -1;
-- Previous ProductID used to restart the running total
declare @PrevProductID int;
-- Current transaction date used to get the running total 45 days ago (or more)
declare @TransactionDate datetime;
-- Sum of actual cost for the group ProductID and TransactionDate
declare @ActualCost money;
-- Running total so far
declare @RunningTotal money = 0;
-- Running total as it was 45 days ago (or more)
declare @RunningTotal45 money = 0;
-- While loop for each unique occurence of the combination of ProductID, TransactionDate
while @ProductID <> 0
begin
set @ID += 1;
set @PrevProductID = @ProductID;
-- Get the current values
select @ProductID = min(ProductID),
@TransactionDate = min(TransactionDate),
@ActualCost = sum(ActualCost)
from @TransHistory
where ID = @ID;
if @ProductID <> 0
begin
set @RunningTotal45 = 0;
if @ProductID <> @PrevProductID
begin
-- New product, reset running total
set @RunningTotal = @ActualCost;
end
else
begin
-- Same product as last row, aggregate running total
set @RunningTotal += @ActualCost;
-- Get the running total as it was 45 days ago (or more)
select top(1) @RunningTotal45 = TR.RunningTotal
from @TransRes as TR
where TR.ProductID = @ProductID and
TR.TransactionDate < dateadd(day, -45, @TransactionDate)
order by TR.TransactionDate desc;
end;
-- Add all rows that match ID to the result table
-- RollingSum45 is calculated by using the current running total and the running total as it was 45 days ago (or more)
insert into @TransRes(ID, ProductID, TransactionDate, ReferenceOrderID, ActualCost, RunningTotal, RollingSum45)
select @ID,
@ProductID,
@TransactionDate,
TH.ReferenceOrderID,
TH.ActualCost,
@RunningTotal,
@RunningTotal - @RunningTotal45
from @TransHistory as TH
where ID = @ID;
end
end;
-- Return the result table to caller
select TR.ProductID, TR.TransactionDate, TR.ReferenceOrderID, TR.ActualCost, TR.RollingSum45
from @TransRes as TR
order by TR.ProductID, TR.TransactionDate, TR.ReferenceOrderID;
end;
Appelez la procédure comme celle-ci.
-- Parameter to stored procedure GetRollingSum
declare @T dbo.TransHistory;
-- Load data to in-mem table
-- ID is unique for each combination of ProductID, TransactionDate
insert into @T(ID, ProductID, TransactionDate, ReferenceOrderID, ActualCost, RunningTotal, RollingSum45)
select dense_rank() over(order by TH.ProductID, TH.TransactionDate),
TH.ProductID,
TH.TransactionDate,
TH.ReferenceOrderID,
TH.ActualCost,
0,
0
from Production.TransactionHistory as TH;
-- Get the rolling 45 days sum
exec dbo.GetRolling45 @T;
Le test sur mon ordinateur Statistiques client signale un temps d'exécution total d'environ 750 millisecondes. Pour les comparaisons, la version de la sous-requête prend 3,5 secondes.
Ramblings supplémentaires:
Cet algorithme pourrait également être utilisé par T-SQL ordinaire. Calculez le total cumulé, en utilisant range
et non des lignes, et stockez le résultat dans une table temporaire. Ensuite, vous pouvez interroger cette table avec une auto-jointure au total cumulé tel qu'il était il y a 45 jours et calculer la somme mobile. Cependant, l'implémentation de range
par rapport à rows
est assez lente en raison du fait qu'il faut traiter les doublons de la clause order by différemment, donc je n'ai pas obtenu toutes ces bonnes performances avec cette approche . Une solution de contournement pourrait être d'utiliser une autre fonction de fenêtre comme last_value()
sur un total cumulé calculé en utilisant rows
pour simuler un total cumulé range
. Une autre façon consiste à utiliser max() over()
. Les deux avaient des problèmes. Recherche de l'index approprié à utiliser pour éviter les tris et éviter les spools avec la version max() over()
. J'ai abandonné l'optimisation de ces choses, mais si vous êtes intéressé par le code que j'ai jusqu'à présent, faites-le moi savoir.
Eh bien, c'était amusant :) Ma solution est un peu plus lente que celle de @ GeoffPatterson, mais une partie de cela est le fait que je reviens au tableau d'origine afin d'éliminer l'une des hypothèses de Geoff (c'est-à-dire une ligne par paire produit/date) . Je suis parti de l'hypothèse qu'il s'agissait d'une version simplifiée d'une requête finale et peut nécessiter des informations supplémentaires de la table d'origine.
Remarque: j'emprunte la table de calendrier de Geoff et, en fait, nous nous sommes retrouvés avec une solution très similaire:
-- Build calendar table for 2000 ~ 2020
CREATE TABLE dbo.calendar (d DATETIME NOT NULL CONSTRAINT PK_calendar PRIMARY KEY)
GO
DECLARE @d DATETIME = '1/1/2000'
WHILE (@d < '1/1/2021')
BEGIN
INSERT INTO dbo.calendar (d) VALUES (@d)
SELECT @d = DATEADD(DAY, 1, @d)
END
Voici la requête elle-même:
WITH myCTE AS (SELECT PP.ProductID, calendar.d AS TransactionDate,
SUM(ActualCost) AS CostPerDate
FROM Production.Product PP
CROSS JOIN calendar
LEFT OUTER JOIN Production.TransactionHistory PTH
ON PP.ProductID = PTH.ProductID
AND calendar.d = PTH.TransactionDate
CROSS APPLY (SELECT MAX(TransactionDate) AS EndDate,
MIN(TransactionDate) AS StartDate
FROM Production.TransactionHistory) AS Boundaries
WHERE calendar.d BETWEEN Boundaries.StartDate AND Boundaries.EndDate
GROUP BY PP.ProductID, calendar.d),
RunningTotal AS (
SELECT ProductId, TransactionDate, CostPerDate AS TBE,
SUM(myCTE.CostPerDate) OVER (
PARTITION BY myCTE.ProductID
ORDER BY myCTE.TransactionDate
ROWS BETWEEN
45 PRECEDING
AND CURRENT ROW) AS RollingSum45
FROM myCTE)
SELECT
TH.ProductID,
TH.TransactionDate,
TH.ActualCost,
RollingSum45
FROM Production.TransactionHistory AS TH
JOIN RunningTotal
ON TH.ProductID = RunningTotal.ProductID
AND TH.TransactionDate = RunningTotal.TransactionDate
WHERE RunningTotal.TBE IS NOT NULL
ORDER BY
TH.ProductID,
TH.TransactionDate,
TH.ReferenceOrderID;
Fondamentalement, j'ai décidé que la façon la plus simple de le gérer était d'utiliser l'option pour la clause ROWS. Mais cela exigeait que je n'ai qu'une seule ligne par combinaison de ProductID
, TransactionDate
et pas seulement cela, mais je devais avoir une ligne par ProductID
et possible date
. J'ai fait cela en combinant les tables Product, Calendar et TransactionHistory dans un CTE. Ensuite, j'ai dû créer un autre CTE pour générer les informations glissantes. Je devais le faire parce que si je le rejoignais directement dans le tableau d'origine, j'obtenais l'élimination des lignes qui annulait mes résultats. Après cela, il s'agissait simplement de remettre mon deuxième CTE à la table d'origine. J'ai ajouté la colonne TBE
(à éliminer) pour se débarrasser des lignes vides créées dans les CTE. J'ai aussi utilisé un CROSS APPLY
dans le CTE initial pour générer des limites pour ma table de calendrier.
J'ai ensuite ajouté l'index recommandé:
CREATE NONCLUSTERED INDEX [TransactionHistory_IX1]
ON [Production].[TransactionHistory] ([TransactionDate])
INCLUDE ([ProductID],[ReferenceOrderID],[ActualCost])
Et obtenu le plan d'exécution final:
EDIT: Au final, j'ai ajouté un index sur la table du calendrier qui accélérait les performances avec une marge raisonnable.
CREATE INDEX ix_calendar ON calendar(d)
J'ai quelques solutions alternatives qui n'utilisent pas d'index ou de tables de référence. Peut-être qu'ils pourraient être utiles dans des situations où 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 par TransactionDate
avec un seul passage des données et 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 regrouper par TransactionDate
.
Pour fournir un cadre de référence, sur ma machine la solution originale affichée dans la question a un temps CPU de 2808 ms sans indice de recouvrement et 1950 ms avec indice de recouvrement. Je teste avec la base de données AdventureWorks2014 et SQL Server Express 2014.
Commençons par une solution pour quand nous pouvons regrouper par TransactionDate
. Une somme cumulée sur les X derniers jours peut également être exprimée de la manière suivante:
Somme cumulée pour une ligne = somme cumulée de toutes les lignes précédentes - somme cumulée de toutes les lignes précédentes pour lesquelles la date est en dehors de la fenêtre de date.
En SQL, une façon d'exprimer cela est de faire deux copies de vos données et pour la deuxième copie, en multipliant le coût par -1 et en ajoutant X + 1 jours à la colonne de date. Le calcul d'une somme cumulée sur toutes les données implémentera la formule ci-dessus. Je vais montrer cela pour quelques exemples de données. Vous trouverez ci-dessous un exemple de date pour un seul ProductID
. Je représente les dates sous forme de nombres pour faciliter les calculs. Données de départ:
╔══════╦══════╗
║ Date ║ Cost ║
╠══════╬══════╣
║ 1 ║ 3 ║
║ 2 ║ 6 ║
║ 20 ║ 1 ║
║ 45 ║ -4 ║
║ 47 ║ 2 ║
║ 64 ║ 2 ║
╚══════╩══════╝
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:
╔══════╦══════╦═══════════╗
║ Date ║ Cost ║ CopiedRow ║
╠══════╬══════╬═══════════╣
║ 1 ║ 3 ║ 0 ║
║ 2 ║ 6 ║ 0 ║
║ 20 ║ 1 ║ 0 ║
║ 45 ║ -4 ║ 0 ║
║ 47 ║ -3 ║ 1 ║
║ 47 ║ 2 ║ 0 ║
║ 48 ║ -6 ║ 1 ║
║ 64 ║ 2 ║ 0 ║
║ 66 ║ -1 ║ 1 ║
║ 91 ║ 4 ║ 1 ║
║ 93 ║ -2 ║ 1 ║
║ 110 ║ -2 ║ 1 ║
╚══════╩══════╩═══════════╝
Prenez la somme courante ordonnée par Date
ascendant et CopiedRow
descendant:
╔══════╦══════╦═══════════╦════════════╗
║ Date ║ Cost ║ CopiedRow ║ RunningSum ║
╠══════╬══════╬═══════════╬════════════╣
║ 1 ║ 3 ║ 0 ║ 3 ║
║ 2 ║ 6 ║ 0 ║ 9 ║
║ 20 ║ 1 ║ 0 ║ 10 ║
║ 45 ║ -4 ║ 0 ║ 6 ║
║ 47 ║ -3 ║ 1 ║ 3 ║
║ 47 ║ 2 ║ 0 ║ 5 ║
║ 48 ║ -6 ║ 1 ║ -1 ║
║ 64 ║ 2 ║ 0 ║ 1 ║
║ 66 ║ -1 ║ 1 ║ 0 ║
║ 91 ║ 4 ║ 1 ║ 4 ║
║ 93 ║ -2 ║ 1 ║ 0 ║
║ 110 ║ -2 ║ 1 ║ 0 ║
╚══════╩══════╩═══════════╩════════════╝
Filtrez les lignes copiées pour obtenir le résultat souhaité:
╔══════╦══════╦═══════════╦════════════╗
║ Date ║ Cost ║ CopiedRow ║ RunningSum ║
╠══════╬══════╬═══════════╬════════════╣
║ 1 ║ 3 ║ 0 ║ 3 ║
║ 2 ║ 6 ║ 0 ║ 9 ║
║ 20 ║ 1 ║ 0 ║ 10 ║
║ 45 ║ -4 ║ 0 ║ 6 ║
║ 47 ║ 2 ║ 0 ║ 5 ║
║ 64 ║ 2 ║ 0 ║ 1 ║
╚══════╩══════╩═══════════╩════════════╝
Le SQL suivant est une façon d'implémenter l'algorithme ci-dessus:
WITH THGrouped AS
(
SELECT
ProductID,
TransactionDate,
SUM(ActualCost) ActualCost
FROM Production.TransactionHistory
GROUP BY ProductID,
TransactionDate
)
SELECT
ProductID,
TransactionDate,
ActualCost,
RollingSum45
FROM
(
SELECT
TH.ProductID,
TH.ActualCost,
t.TransactionDate,
SUM(t.ActualCost) OVER (PARTITION BY TH.ProductID ORDER BY t.TransactionDate, t.OrderFlag) AS RollingSum45,
t.OrderFlag,
t.FilterFlag -- define this column to avoid another sort at the end
FROM THGrouped AS TH
CROSS APPLY (
VALUES
(TH.ActualCost, TH.TransactionDate, 1, 0),
(-1 * TH.ActualCost, DATEADD(DAY, 46, TH.TransactionDate), 0, 1)
) t (ActualCost, TransactionDate, OrderFlag, FilterFlag)
) tt
WHERE tt.FilterFlag = 0
ORDER BY
tt.ProductID,
tt.TransactionDate,
tt.OrderFlag
OPTION (MAXDOP 1);
Sur ma machine, cela a pris 702 ms de temps CPU avec l'index 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 y avoir un tri inévitable lors de la commande par la nouvelle colonne TransactionDate
. Je ne pense pas que ce type puisse être résolu en ajoutant des index car nous devons combiner deux copies des données avant de passer la commande. J'ai pu me débarrasser d'un tri à la fin de la requête en ajoutant une autre colonne à ORDER BY. Si j'ai commandé par FilterFlag
, j'ai trouvé que SQL Server optimiserait cette colonne du tri et effectuerait un tri explicite.
Les solutions pour quand nous devons retourner un jeu de résultats avec des valeurs TransactionDate
en double pour les mêmes ProductId
étaient beaucoup plus compliquées. Je résumerais le problème comme ayant simultanément besoin de partitionner et d'ordonner 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 actuelles disponibles dans SQL Server (si ce n'était pas difficile à exprimer, il ne serait pas nécessaire d'étendre la syntaxe).
Si j'utilise la requête ci-dessus sans regroupement, j'obtiens des valeurs différentes pour la somme mobile lorsqu'il y a plusieurs lignes avec les mêmes ProductId
et TransactionDate
. Pour résoudre ce problème, vous pouvez effectuer le même calcul de somme cumulée que ci-dessus, mais également signaler la dernière ligne de la partition. Cela peut être fait avec LEAD
(en supposant que ProductID
n'est jamais NULL) sans tri supplémentaire. Pour la valeur finale de la somme cumulée, j'utilise MAX
comme fonction de fenêtre pour appliquer la valeur de la dernière ligne de la partition à toutes les lignes de la partition.
SELECT
ProductID,
TransactionDate,
ReferenceOrderID,
ActualCost,
MAX(CASE WHEN LasttRowFlag = 1 THEN RollingSum ELSE NULL END) OVER (PARTITION BY ProductID, TransactionDate) RollingSum45
FROM
(
SELECT
TH.ProductID,
TH.ActualCost,
TH.ReferenceOrderID,
t.TransactionDate,
SUM(t.ActualCost) OVER (PARTITION BY TH.ProductID ORDER BY t.TransactionDate, t.OrderFlag, TH.ReferenceOrderID) RollingSum,
CASE WHEN LEAD(TH.ProductID) OVER (PARTITION BY TH.ProductID, t.TransactionDate ORDER BY t.OrderFlag, TH.ReferenceOrderID) IS NULL THEN 1 ELSE 0 END LasttRowFlag,
t.OrderFlag,
t.FilterFlag -- define this column to avoid another sort at the end
FROM Production.TransactionHistory AS TH
CROSS APPLY (
VALUES
(TH.ActualCost, TH.TransactionDate, 1, 0),
(-1 * TH.ActualCost, DATEADD(DAY, 46, TH.TransactionDate), 0, 1)
) t (ActualCost, TransactionDate, OrderFlag, FilterFlag)
) tt
WHERE tt.FilterFlag = 0
ORDER BY
tt.ProductID,
tt.TransactionDate,
tt.OrderFlag,
tt.ReferenceOrderID
OPTION (MAXDOP 1);
Sur ma machine, cela a pris 2464 ms de temps processeur sans l'index 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 à amélioration dans la requête ci-dessus. Il existe certainement d'autres façons d'utiliser les fonctions Windows pour obtenir le résultat souhaité.