web-dev-qa-db-fra.com

Somme mobile de plage de dates à l'aide des fonctions de fenêtre

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:

Execution plan

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.

58
Paul White 9

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:

  • Prendre le produit croisé des produits/dates
  • Fusion des données de ventes observées
  • Agréger ces données au niveau produit/date
  • Calculer les sommes glissantes au cours des 45 derniers jours sur la base de ces données agrégées (qui contiennent tous les jours "manquants" remplis)
  • Filtrer ces résultats uniquement pour les paires produit/date ayant généré une ou plusieurs ventes

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:

  • Lire les données en mémoire, triées par produit et par date
  • Lors du traitement de chaque transaction, ajoutez à un total cumulé des coûts. Chaque fois qu'une transaction est un produit différent de la transaction précédente, réinitialisez le total cumulé à 0.
  • Conservez un pointeur sur la première transaction qui a le même (produit, date) que la transaction en cours. Chaque fois que la dernière transaction avec ce (produit, date) est rencontrée, calculez la somme glissante pour cette transaction et appliquez-la à toutes les transactions avec le même (produit, date)
  • Renvoyez tous les résultats à l'utilisateur!

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.


T-SQL - une analyse, regroupée par date

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.

enter image description here

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 :)

  • J'utilise le 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.
  • Cette approche repose sur le fait que les transactions n'ont pas de composante temporelle sur 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.
  • Je produis un ensemble de lignes qui contient une seule ligne par paire produit/date. Je pense que c'est "sans doute correct" et dans de nombreux cas un résultat plus souhaitable à revenir. Pour chaque produit/date, j'ai ajouté une colonne 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)

enter image description here


CLR - un balayage, ensemble de résultats complet non groupé

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:

  • Cette approche CLR conserve une copie de l'ensemble de données en mémoire. Il est possible d'utiliser une approche de streaming, mais j'ai rencontré des difficultés initiales et j'ai constaté qu'il y a n problème de connexion en suspens se plaignant que les changements dans SQL 2008+ rendent plus difficile l'utilisation de ce type d'approche. C'est toujours possible (comme Paul le démontre), mais nécessite un niveau plus élevé d'autorisations en définissant la base de données sur 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.
  • CLR peut être moins accessible à certains administrateurs de base de données, ce qui rend une telle fonction plus d'une boîte noire qui n'est pas aussi transparente, pas aussi facilement modifiée, pas aussi facilement déployée et peut-être pas aussi facilement déboguée. C'est un assez gros inconvénient par rapport à une approche T-SQL.


Bonus: T-SQL # 2 - l'approche pratique que j'utiliserais réellement

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:

enter image description hereenter image description here

Quelques raisons pour lesquelles j'aime cette approche:

  • Il donne le jeu de résultats complet demandé dans l'énoncé du problème (contrairement à la plupart des autres solutions T-SQL, qui renvoient une version groupée des résultats).
  • Il est facile à expliquer, à comprendre et à déboguer; Je ne reviendrai pas un an plus tard et je me demande comment diable je peux faire un petit changement sans ruiner l'exactitude ou la performance
  • Il s'exécute dans environ 900ms sur l'ensemble de données fourni, plutôt que sur le 2700ms de la recherche de boucle d'origine
  • Si les données étaient beaucoup plus denses (plus de transactions par jour), la complexité de calcul ne croît pas de façon quadratique avec le nombre de transactions dans la fenêtre glissante (comme c'est le cas pour la requête d'origine); Je pense que cela répond en partie à la préoccupation de Paul de vouloir éviter plusieurs analyses
  • Il en résulte essentiellement aucune E/S tempdb dans les récentes mises à jour de SQL 2012+ en raison de nouvelle fonctionnalité d'écriture différée tempdb
  • Pour les ensembles de données très volumineux, il est trivial de diviser le travail en lots séparés pour chaque produit si la pression de la mémoire devenait un problème

Quelques mises en garde potentielles:

  • Bien qu'il techniquement analyse Production.TransactionHistory une seule fois, ce n'est pas vraiment une approche "à une analyse" car la table #temp de taille similaire et devra également effectuer des E/S de logique supplémentaire sur cette table. Cependant, je ne vois pas cela comme trop différent d'une table de travail sur laquelle nous avons plus de contrôle manuel puisque nous avons défini sa structure précise
  • Selon votre environnement, l'utilisation de tempdb peut être considérée comme positive (par exemple, sur un ensemble distinct de disques SSD) ou négative (forte concurrence sur le serveur, beaucoup de conflits tempdb déjà)
43
Geoff Patterson

C'est une longue réponse, j'ai donc décidé d'ajouter un résumé ici.

  • Dans un premier temps, je présente une solution qui produit exactement le même résultat dans le même ordre que dans la question. Il analyse le tableau principal 3 fois: pour obtenir une liste de 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.
  • Ensuite, je compare deux approches qui simplifient la tâche et évitent une dernière analyse de la table principale. Leur résultat est un résumé 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 balaye le tableau deux fois. Approach by Geoff Patterson scanne le tableau une fois, car il utilise des connaissances externes sur la plage de dates et la liste des produits.
  • Enfin, je présente une solution à passage unique qui renvoie à nouveau un résumé quotidien, mais elle ne nécessite pas de connaissances externes sur la plage de dates ou la liste de ProductIDs.

J'utiliserai AdventureWorks2014 base de données et SQL Server Express 2014.

Modifications de la base de données d'origine:

  • Changement du type de [Production].[TransactionHistory].[TransactionDate] De datetime à date. La composante temporelle était de toute façon nulle.
  • Ajouté tableau de calendrier[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 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

stats

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.

subquery

L'approche de sous-requête a un plan simple avec des boucles imbriquées et une complexité O(n*n).

over

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.

io

Résultat supérieur - subquery, bas - OVER.


Éviter les analyses supplémentaires

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
;

two-scan

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);

one-scan

Les deux requêtes renvoient le même résultat dans le même ordre.

Comparaison

Voici le temps et les statistiques IO.

stats2

io2

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])

Solution en un seul passage utilisant CROSS APPLY

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).

cross apply

ca stats

ca io

25
Vladimir Baranov

Une solution alternative SQLCLR qui s'exécute plus rapidement et nécessite moins de mémoire:

Script de déploiement

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:

SQLCLR TVF execution plan

SQLCLR Source Query execution plan

Plan Explorer performance statistics

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:

  1. Toutes les lignes en double (même produit et même date de transaction). Cela est nécessaire car jusqu'à ce que le produit ou la date change, nous ne savons pas quel sera le montant final. Dans les exemples de données, il existe une combinaison de produit et de date qui comporte 64 lignes.
  2. Une fourchette mobile de 45 jours de dates de coût et de transaction uniquement, pour le produit actuel. Cela est nécessaire pour ajuster la somme cumulée simple pour les lignes qui quittent la fenêtre coulissante de 45 jours.

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.

Code source

24
Paul White 9

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.

17
Mikael Eriksson

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:

enter image description hereenter image description hereenter image description here

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)
13
Kenneth Fisher

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é.

4
Joe Obbish