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

57

Je dois calculer une somme glissante sur une plage de dates. Pour illustrer, en utilisant l' exemple de base de données 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' RANGEétendue du cadre de la fenêtre n'autorise 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 régulier (sans 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:

Plan d'exécution

Bien qu'il ne soit pas terriblement inefficace, il semble possible d'exprimer cette requête à l'aide des seules fonctions d'agrégat et d'analyse de fenêtre prises en charge par SQL Server 2012, 2014 ou 2016 (jusqu'à présent).

Pour plus de clarté, je recherche une solution qui effectue un seul passage sur les données.

Dans T-SQL, cela signifie probablement que la OVERclause effectuera le travail et que le plan d'exécution comportera des spools de fenêtre et des agrégats de fenêtre. Tous les éléments linguistiques qui utilisent la OVERclause sont un jeu équitable. Une solution SQLCLR est acceptable, à condition de garantir des résultats corrects.

Pour les solutions T-SQL, moins il y a de hachages, de tris et de spoules / agrégats de fenêtres dans le plan d'exécution, mieux c'est. N'hésitez pas à ajouter des index, mais des structures séparées ne sont pas autorisées (par exemple, aucune table pré-calculée n'est synchronisée avec les déclencheurs). Les tables de référence sont autorisées (tables de chiffres, 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 correct est tout à fait correct. La performance est toujours une considération, les solutions doivent donc ê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 y participer directement. S'il vous plaît cinglez-moi dans un commentaire ci-dessous si vous avez moins de 20 rep et que vous souhaitez participer.

Paul White
la source

Réponses:

42

Bonne question, Paul! J'ai utilisé plusieurs approches différentes, une en T-SQL et une en CLR.

Résumé rapide de T-SQL

L'approche T-SQL peut être résumée comme suit:

  • Prendre le produit croisé des produits / dates
  • Fusionner dans les données de ventes observées
  • Agréger ces données au niveau produit / date
  • Calculez les sommes glissantes des 45 derniers jours en fonction de ces données globales (qui contiennent tous les jours "manquants" renseignés).
  • Filtrer ces résultats uniquement sur les associations produit / date ayant généré une ou plusieurs ventes

En utilisant SET STATISTICS IO ON, cette approche indique Table 'TransactionHistory'. Scan count 1, logical reads 484, ce qui confirme le "passage unique" sur la table. Pour référence, les rapports de requête de recherche de boucle d'origine Table 'TransactionHistory'. Scan count 113444, logical reads 438366.

Comme indiqué par SET STATISTICS TIME ON, le temps de calcul est 514ms. Cela se compare avantageusement à 2231msla requête d'origine.

Résumé rapide du CLR

Le résumé CLR peut être résumé comme suit:

  • Lire les données en mémoire, classé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 en cours sur 0.
  • Maintenez 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 celle-ci (produit, date) est rencontrée, calculez la somme glissante pour cette transaction et appliquez-la à toutes les transactions avec la même (produit, date)
  • Renvoyer tous les résultats à l'utilisateur!

En utilisant SET STATISTICS IO ON, cette approche indique qu’aucune E / S logique n’a eu lieu! Wow, une solution parfaite! (En fait, il semble que SET STATISTICS IOcela ne rapporte pas les E / S engagées dans le CLR. Mais à partir du code, il est facile de voir qu’un seul scan de la table est effectué et que les données sont récupérées dans l’ordre par l’index suggéré par Paul.

Comme indiqué par SET STATISTICS TIME ON, le temps de calcul est maintenant 187ms. Il s’agit donc d’une nette amélioration par rapport à l’approche T-SQL. Malheureusement, le temps total écoulé des deux approches est très similaire, à environ une demi-seconde chacune. Cependant, l'approche basée sur le CLR doit générer 113 000 lignes sur la console (contre seulement 52 000 pour l'approche T-SQL qui groupe par produit / date), c'est pourquoi je me suis concentré sur le temps processeur.

Un autre grand avantage de cette approche est qu’elle produit exactement les mêmes résultats que l’approche boucle / recherche originale, 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, du 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 strictement besoin que du cadre de fenêtre actuel en mémoire à un moment donné et peut être mis à jour pour fonctionner pour les ensembles de données dépassant la mémoire. Paul a illustré ce point dans sa réponse en produisant une implémentation de cet algorithme qui stocke uniquement la fenêtre glissante en mémoire. Cela se fait au détriment de l'octroi d'autorisations plus élevées à l'assemblage CLR, mais il serait certainement utile de faire évoluer cette solution jusqu'à des ensembles de données arbitrairement volumineux.


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

La 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

D'après le plan d'exécution, nous voyons que l'index d'origine proposé par Paul est suffisant pour nous permettre d'effectuer une analyse ordonnée unique en Production.TransactionHistoryutilisant une jointure de fusion pour combiner l'historique des transactions avec chaque combinaison produit / date possible.

entrez la description de l'image ici

Hypothèses

Quelques hypothèses importantes ont été retenues dans cette approche. Je suppose que ce sera à Paul de décider s’ils sont acceptables :)

  • J'utilise la Production.Producttable. Ce tableau est disponible gratuitement sur AdventureWorks2012et la relation est renforcée par une clé étrangère à partir 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 AdventureWorks2012; Si tel était le cas, il ne serait plus possible de générer l’ensemble complet de combinaisons produit / date sans passer au préalable sur l’historique des transactions.
  • Je produis un jeu de lignes qui ne contient qu'une ligne par paire produit / date. Je pense que cela est "sans doute correct" et dans de nombreux cas, un résultat plus souhaitable pour revenir. Pour chaque produit / date, j'ai ajouté une NumOrderscolonne pour indiquer le nombre de ventes réalisées. 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).

entrez la description de l'image ici


CLR - une analyse, 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 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 il 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 en SQL

Jusqu'à présent, tout était en C #, voyons donc le code SQL impliqué. (Vous pouvez également utiliser ce script de déploiement pour créer l'assembly directement à partir des éléments de mon assemblage plutôt que de le compiler vous-même.)

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 un expert en C # pourrait probablement l’ajuster davantage. Cependant, la stratégie CLR présente également des inconvénients. Quelques points à garder à l'esprit:

  • Cette approche CLR conserve une copie du jeu de données en mémoire. Il est possible d’utiliser une approche de transmission en continu, mais j’ai rencontré des difficultés initiales et constaté qu’il existait un problème en suspens lié à Connect qui se plaignait du fait que les modifications apportées dans SQL 2008+ rendaient plus difficile l’utilisation de ce type d’approche. Cela est toujours possible (comme Paul l'a démontré), mais requiert un niveau d'autorisations plus élevé en définissant la base de données comme TRUSTWORTHYet en l'attribuant EXTERNAL_ACCESSà l'assembly CLR. Il y a donc des complications et des implications potentielles pour la sécurité, mais le bénéfice est une approche de diffusion en continu qui peut mieux s'adapter à des ensembles de données beaucoup plus volumineux que ceux sur AdventureWorks.
  • Le CLR peut être moins accessible pour certains administrateurs de base de données, ce qui en fait une boîte noire moins transparente, pas aussi facilement modifiée, moins facilement déployée et peut-être moins facile à mettre au point. C'est un gros inconvénient par rapport à une approche T-SQL.


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

Après avoir essayé de réfléchir de manière créative au problème pendant un moment, je pensais que je publierais aussi la manière assez simple et pratique que je choisirais probablement pour aborder ce problème si cela se présentait dans mon travail quotidien. Il utilise les fonctionnalités de la fenêtre SQL 2012+, mais pas de manière révolutionnaire, comme espérait la question:

-- 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 en regardant ensemble les deux plans de requête pertinents:

entrez la description de l'image ici entrez la description de l'image ici

Quelques raisons pour lesquelles j'aime cette approche:

  • Il produit l'ensemble complet de résultats 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 d'expliquer, de comprendre et de 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 fonctionne 900mssur le jeu de données fourni, plutôt que sur celui 2700msde la recherche de boucle
  • Si les données étaient beaucoup plus denses (plus de transactions par jour), la complexité de calcul ne croît pas de manière quadratique avec le nombre de transactions dans la fenêtre glissante (comme c'est le cas pour la requête initiale); Je pense que cela répond en partie à la préoccupation de Paul à propos de la volonté d'éviter plusieurs analyses
  • Cela entraîne essentiellement l'absence d'E / S tempdb dans les mises à jour récentes de SQL 2012+ en raison de la nouvelle fonctionnalité d'écriture différée tempdb
  • Pour les très grands ensembles de données, il est simple de diviser le travail en lots distincts pour chaque produit si la pression sur la mémoire devenait une préoccupation

Quelques mises en garde potentielles:

  • Bien que techniquement, il n’effectue une analyse Production.TransactionHistory qu’une seule fois, mais ce n’est pas vraiment une approche «une analyse», car la table #temp de taille similaire nécessitera également d’effectuer des E / S logiques supplémentaires sur cette table. Cependant, je ne vois pas cela comme très différent d'une table de travail sur laquelle nous avons plus de contrôle manuel depuis que nous avons défini sa structure précise.
  • Selon votre environnement, l'utilisation de tempdb peut être considérée comme positive (par exemple, elle se trouve sur un jeu distinct de disques SSD) ou négative (forte simultanéité sur le serveur, beaucoup de conflits tempdb déjà).
Geoff Patterson
la source
25

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

  • Au début, je présente une solution qui produit exactement le même résultat dans le même ordre que dans la question. Il scanne la table principale 3 fois: pour obtenir une liste ProductIDsavec la plage de dates de chaque produit, pour résumer les coûts pour chaque jour (car plusieurs transactions portant les mêmes dates), pour joindre le résultat aux lignes d'origine.
  • Ensuite, je compare deux approches qui simplifient la tâche et évitent une dernière analyse du tableau principal. Leur résultat est un récapitulatif quotidien, c'est-à-dire que si plusieurs transactions sur un produit ont la même date, elles sont regroupées sur une seule ligne. Mon approche de l'étape précédente scanne la table deux fois. Geoff Patterson analyse une fois la table, car il utilise des connaissances externes sur la plage de dates et la liste des produits.
  • Enfin, je présente une solution en un seul passage qui renvoie à nouveau un récapitulatif quotidien, mais elle ne nécessite aucune connaissance externe de la plage de dates ou de la liste des ProductIDs.

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

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

  • Changement du type de [Production].[TransactionHistory].[TransactionDate]de datetimeà date. La composante temps était de toute façon nulle.
  • Tableau de calendrier ajouté [dbo].[Calendar]
  • Index ajouté à [Production].[TransactionHistory]

.

CREATE TABLE [dbo].[Calendar]
(
    [dt] [date] NOT NULL,
    CONSTRAINT [PK_Calendar] PRIMARY KEY CLUSTERED 
(
    [dt] ASC
))

CREATE UNIQUE NONCLUSTERED INDEX [i] ON [Production].[TransactionHistory]
(
    [ProductID] ASC,
    [TransactionDate] ASC,
    [ReferenceOrderID] ASC
)
INCLUDE ([ActualCost])

-- Init calendar table
INSERT INTO dbo.Calendar (dt)
SELECT TOP (50000)
    DATEADD(day, ROW_NUMBER() OVER (ORDER BY s1.[object_id])-1, '2000-01-01') AS dt
FROM sys.all_objects AS s1 CROSS JOIN sys.all_objects AS s2
OPTION (MAXDOP 1);

L'article de MSDN sur la OVERclause contient un lien vers un excellent article de blog sur les fonctions de fenêtre d'Itzik Ben-Gan. Dans ce poste , il explique comment OVERfonctionne, la différence entre ROWSet les RANGEoptions et mentionne ce problème même de calculer une somme roulant sur une plage de dates. Il mentionne que la version actuelle de SQL Server n'implémente pas RANGEintégralement ni les types de données d'intervalle temporel. Son explication de la différence entre ROWSet RANGEm'a donné une idée.

Dates sans lacunes ni doublons

Si la TransactionHistorytable contenait des dates sans lacunes ni doublons, la requête suivante produirait des résultats corrects:

SELECT
    TH.ProductID,
    TH.TransactionDate,
    TH.ActualCost,
    RollingSum45 = SUM(TH.ActualCost) OVER (
        PARTITION BY TH.ProductID
        ORDER BY TH.TransactionDate
        ROWS BETWEEN 
            45 PRECEDING
            AND CURRENT ROW)
FROM Production.TransactionHistory AS TH
ORDER BY
    TH.ProductID,
    TH.TransactionDate,
    TH.ReferenceOrderID;

En effet, une fenêtre de 45 rangées couvrirait exactement 45 jours.

Dates avec lacunes sans doublons

Malheureusement, nos données ont des lacunes dans les dates. Pour résoudre ce problème, nous pouvons utiliser une Calendartable pour générer un ensemble de dates sans espace, puis LEFT JOINles données d'origine pour cet ensemble et utiliser la même requête avec ROWS BETWEEN 45 PRECEDING AND CURRENT ROW. Cela produirait des résultats corrects uniquement si les dates ne se répètent pas (dans les mêmes conditions ProductID).

Dates avec des lacunes avec des doublons

Malheureusement, nos données ont des lacunes dans les dates et les dates peuvent se répéter dans la même chose ProductID. Pour résoudre ce problème, nous pouvons créer des GROUPdonnées originales en ProductID, TransactionDategénérant un ensemble de dates sans les dupliquer. Ensuite, utilisez Calendartable pour générer un ensemble de dates sans lacunes. Ensuite, nous pouvons utiliser la requête avec ROWS BETWEEN 45 PRECEDING AND CURRENT ROWpour calculer le roulement SUM. Cela produirait des résultats corrects. Voir les commentaires dans la requête ci-dessous.

WITH

-- calculate Start/End dates for each product
CTE_Products
AS
(
    SELECT TH.ProductID
        ,MIN(TH.TransactionDate) AS MinDate
        ,MAX(TH.TransactionDate) AS MaxDate
    FROM [Production].[TransactionHistory] AS TH
    GROUP BY TH.ProductID
)

-- generate set of dates without gaps for each product
,CTE_ProductsWithDates
AS
(
    SELECT CTE_Products.ProductID, C.dt
    FROM
        CTE_Products
        INNER JOIN dbo.Calendar AS C ON
            C.dt >= CTE_Products.MinDate AND
            C.dt <= CTE_Products.MaxDate
)

-- generate set of dates without duplicates for each product
-- calculate daily cost as well
,CTE_DailyCosts
AS
(
    SELECT TH.ProductID, TH.TransactionDate, SUM(ActualCost) AS DailyActualCost
    FROM [Production].[TransactionHistory] AS TH
    GROUP BY TH.ProductID, TH.TransactionDate
)

-- calculate rolling sum over 45 days
,CTE_Sum
AS
(
    SELECT
        CTE_ProductsWithDates.ProductID
        ,CTE_ProductsWithDates.dt
        ,CTE_DailyCosts.DailyActualCost
        ,SUM(CTE_DailyCosts.DailyActualCost) OVER (
            PARTITION BY CTE_ProductsWithDates.ProductID
            ORDER BY CTE_ProductsWithDates.dt
            ROWS BETWEEN 45 PRECEDING AND CURRENT ROW) AS RollingSum45
    FROM
        CTE_ProductsWithDates
        LEFT JOIN CTE_DailyCosts ON 
            CTE_DailyCosts.ProductID = CTE_ProductsWithDates.ProductID AND
            CTE_DailyCosts.TransactionDate = CTE_ProductsWithDates.dt
)

-- remove rows that were added by Calendar, which fill the gaps in dates
-- add back duplicate dates that were removed by GROUP BY
SELECT
    TH.ProductID
    ,TH.TransactionDate
    ,TH.ActualCost
    ,CTE_Sum.RollingSum45
FROM
    [Production].[TransactionHistory] AS TH
    INNER JOIN CTE_Sum ON
        CTE_Sum.ProductID = TH.ProductID AND
        CTE_Sum.dt = TH.TransactionDate
ORDER BY
    TH.ProductID
    ,TH.TransactionDate
    ,TH.ReferenceOrderID
;

J'ai confirmé que cette requête produisait les mêmes résultats que l'approche de la question qui utilise une sous-requête.

Plans d'exécution

Statistiques

La première requête utilise une sous-requête, la seconde - cette approche. Vous pouvez voir que la durée et le nombre de lectures sont beaucoup moins dans cette approche. La majorité des coûts estimés dans cette approche est la finale ORDER BY, voir ci-dessous.

sous-requête

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

plus de

Planifiez cette approche TransactionHistoryplusieurs fois, mais il n’ya pas de boucle. Comme vous pouvez le constater, plus de 70% du coût estimé correspond Sortà la finale ORDER BY.

io

Top résultat - subquery, bas - OVER.


Éviter les analyses supplémentaires

La dernière analyse d'index, jointure et fusion de fusion dans le plan ci-dessus est provoquée par la INNER JOINtable finale avec la table d'origine afin que le résultat final soit identique à une approche lente avec une sous-requête. Le nombre de lignes renvoyées est identique à celui de la TransactionHistorytable. Il y a des lignes dans TransactionHistorylesquelles plusieurs transactions ont eu lieu le même jour pour le même produit. S'il est correct d'afficher uniquement le résumé quotidien dans le résultat, cette dernière JOINpeut être supprimée et la requête devient un peu plus simple et un peu plus rapide. Les dernières analyses d'index, de jointure de fusion et de tri du plan précédent sont remplacées par Filtre, ce qui supprime les lignes ajoutées par Calendar.

WITH
-- two scans
-- calculate Start/End dates for each product
CTE_Products
AS
(
    SELECT TH.ProductID
        ,MIN(TH.TransactionDate) AS MinDate
        ,MAX(TH.TransactionDate) AS MaxDate
    FROM [Production].[TransactionHistory] AS TH
    GROUP BY TH.ProductID
)

-- generate set of dates without gaps for each product
,CTE_ProductsWithDates
AS
(
    SELECT CTE_Products.ProductID, C.dt
    FROM
        CTE_Products
        INNER JOIN dbo.Calendar AS C ON
            C.dt >= CTE_Products.MinDate AND
            C.dt <= CTE_Products.MaxDate
)

-- generate set of dates without duplicates for each product
-- calculate daily cost as well
,CTE_DailyCosts
AS
(
    SELECT TH.ProductID, TH.TransactionDate, SUM(ActualCost) AS DailyActualCost
    FROM [Production].[TransactionHistory] AS TH
    GROUP BY TH.ProductID, TH.TransactionDate
)

-- calculate rolling sum over 45 days
,CTE_Sum
AS
(
    SELECT
        CTE_ProductsWithDates.ProductID
        ,CTE_ProductsWithDates.dt
        ,CTE_DailyCosts.DailyActualCost
        ,SUM(CTE_DailyCosts.DailyActualCost) OVER (
            PARTITION BY CTE_ProductsWithDates.ProductID
            ORDER BY CTE_ProductsWithDates.dt
            ROWS BETWEEN 45 PRECEDING AND CURRENT ROW) AS RollingSum45
    FROM
        CTE_ProductsWithDates
        LEFT JOIN CTE_DailyCosts ON 
            CTE_DailyCosts.ProductID = CTE_ProductsWithDates.ProductID AND
            CTE_DailyCosts.TransactionDate = CTE_ProductsWithDates.dt
)

-- remove rows that were added by Calendar, which fill the gaps in dates
SELECT
    CTE_Sum.ProductID
    ,CTE_Sum.dt AS TransactionDate
    ,CTE_Sum.DailyActualCost
    ,CTE_Sum.RollingSum45
FROM CTE_Sum
WHERE CTE_Sum.DailyActualCost IS NOT NULL
ORDER BY
    CTE_Sum.ProductID
    ,CTE_Sum.dt
;

deux scan

Pourtant, TransactionHistoryest scanné deux fois. Une analyse supplémentaire est nécessaire pour obtenir la plage de dates pour chaque produit. J'ai été intéressé de voir comment il se compare à une autre approche, où nous utilisons des connaissances externes sur la plage globale de dates TransactionHistory, ainsi Productqu'un tableau supplémentaire contenant tout ProductIDspour éviter cette analyse supplémentaire. J'ai retiré le calcul du nombre de transactions par jour de cette requête pour que la comparaison soit valide. Il peut être ajouté dans les deux requêtes, mais j'aimerais que ce soit simple pour la comparaison. J'ai également dû utiliser d'autres dates, car j'utilise la version 2014 de la base de données.

DECLARE @minAnalysisDate DATE = '2013-07-31', 
-- Customizable start date depending on business needs
        @maxAnalysisDate DATE = '2014-08-03'  
-- Customizable end date depending on business needs
SELECT 
    -- one scan
    ProductID, TransactionDate, ActualCost, RollingSum45
--, NumOrders
FROM (
    SELECT ProductID, TransactionDate, 
    --NumOrders, 
    ActualCost,
        SUM(ActualCost) OVER (
                PARTITION BY ProductId ORDER BY TransactionDate 
                ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
            ) AS RollingSum45
    FROM (
        -- The full cross-product of products and dates, 
        -- combined with actual cost information for that product/date
        SELECT p.ProductID, c.dt AS TransactionDate,
            --COUNT(TH.ProductId) AS NumOrders, 
            SUM(TH.ActualCost) AS ActualCost
        FROM Production.Product p
        JOIN dbo.calendar c
            ON c.dt BETWEEN @minAnalysisDate AND @maxAnalysisDate
        LEFT OUTER JOIN Production.TransactionHistory TH
            ON TH.ProductId = p.productId
            AND TH.TransactionDate = c.dt
        GROUP BY P.ProductID, c.dt
    ) aggsByDay
) rollingSums
--WHERE NumOrders > 0
WHERE ActualCost IS NOT NULL
ORDER BY ProductID, TransactionDate
-- MAXDOP 1 to avoid parallel scan inflating the scan count
OPTION (MAXDOP 1);

un 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 comporte moins de lectures, car la variante à une analyse doit beaucoup utiliser Worktable. En outre, la variante à une analyse génère plus de lignes que nécessaire, comme vous pouvez le constater dans les plans. Il génère des dates pour chaque élément de ProductIDla Producttable, même si ProductIDaucune transaction n'a été effectuée. Il y a 504 lignes dans la Producttable, mais seuls 441 produits ont des transactions en TransactionHistory. En outre, il génère la même plage de dates pour chaque produit, ce qui est plus que nécessaire. Si l' TransactionHistoryhistorique global était plus long et que chaque produit individuel avait un historique relativement court, le nombre de lignes supplémentaires inutiles serait encore plus élevé.

D'autre part, il est possible d'optimiser un peu plus la variante à deux balayages en créant un autre index, plus étroit, sur just (ProductID, TransactionDate). Cet index serait utilisé pour calculer les dates de début / fin pour chaque produit ( CTE_Products) et aurait moins de pages que l’index couvrant, ce qui causerait moins de lectures.

Nous pouvons donc choisir soit d’avoir une analyse simple, très explicite, soit d’avoir une table de travail implicite.

BTW, s'il est correct d'avoir un résultat avec des résumés quotidiens, il est préférable de créer un index qui n'inclut pas ReferenceOrderID. Cela utiliserait moins de pages => moins d'IO.

CREATE NONCLUSTERED INDEX [i2] ON [Production].[TransactionHistory]
(
    [ProductID] ASC,
    [TransactionDate] ASC
)
INCLUDE ([ActualCost])

Solution en un seul passage utilisant CROSS APPLY

Cela devient une très longue réponse, mais voici une variante supplémentaire qui ne renvoie que le résumé quotidien, mais elle effectue une analyse unique des données et ne nécessite aucune connaissance externe de la plage de dates ou de la liste des ProductID. Il ne fait pas aussi bien les tris intermédiaires. La performance globale est similaire aux variantes précédentes, mais semble être un peu pire.

L'idée principale est d'utiliser un tableau de nombres pour générer des lignes permettant de combler les lacunes dans les dates. Pour chaque date existante, utilisez LEADpour calculer la taille de l'écart en jours, puis CROSS APPLYpour ajouter le nombre de lignes requis dans le jeu de résultats. Au début, j'ai essayé avec une table de chiffres permanente. Le plan indiquait un grand nombre de lectures dans ce tableau, bien que la durée réelle soit à peu près la même que lorsque j'ai généré des nombres à la volée CTE.

WITH 
e1(n) AS
(
    SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL 
    SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL 
    SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1
) -- 10
,e2(n) AS (SELECT 1 FROM e1 CROSS JOIN e1 AS b) -- 10*10
,e3(n) AS (SELECT 1 FROM e1 CROSS JOIN e2) -- 10*100
,CTE_Numbers
AS
(
    SELECT ROW_NUMBER() OVER (ORDER BY n) AS Number
    FROM e3
)
,CTE_DailyCosts
AS
(
    SELECT
        TH.ProductID
        ,TH.TransactionDate
        ,SUM(ActualCost) AS DailyActualCost
        ,ISNULL(DATEDIFF(day,
            TH.TransactionDate,
            LEAD(TH.TransactionDate) 
            OVER(PARTITION BY TH.ProductID ORDER BY TH.TransactionDate)), 1) AS DiffDays
    FROM [Production].[TransactionHistory] AS TH
    GROUP BY TH.ProductID, TH.TransactionDate
)
,CTE_NoGaps
AS
(
    SELECT
        CTE_DailyCosts.ProductID
        ,CTE_DailyCosts.TransactionDate
        ,CASE WHEN CA.Number = 1 
        THEN CTE_DailyCosts.DailyActualCost
        ELSE NULL END AS DailyCost
    FROM
        CTE_DailyCosts
        CROSS APPLY
        (
            SELECT TOP(CTE_DailyCosts.DiffDays) CTE_Numbers.Number
            FROM CTE_Numbers
            ORDER BY CTE_Numbers.Number
        ) AS CA
)
,CTE_Sum
AS
(
    SELECT
        ProductID
        ,TransactionDate
        ,DailyCost
        ,SUM(DailyCost) OVER (
            PARTITION BY ProductID
            ORDER BY TransactionDate
            ROWS BETWEEN 45 PRECEDING AND CURRENT ROW) AS RollingSum45
    FROM CTE_NoGaps
)
SELECT
    ProductID
    ,TransactionDate
    ,DailyCost
    ,RollingSum45
FROM CTE_Sum
WHERE DailyCost IS NOT NULL
ORDER BY 
    ProductID
    ,TransactionDate
;

Ce plan est "plus long", car la requête utilise deux fonctions de fenêtre ( LEADet SUM).

appliquer en croix

ca stats

ca io

Vladimir Baranov
la source
23

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

Script de déploiement

Cela nécessite le EXTERNAL_ACCESSjeu d'autorisations car il utilise une connexion en boucle avec 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:

Plan d'exécution SQLCLR TVF

Plan d'exécution de la requête source SQLCLR

Statistiques de performances de Plan Explorer

Profiler lit logique: 481

Le principal avantage de cette implémentation est qu’elle est plus rapide que la connexion contextuelle et utilise moins de mémoire. Il ne garde que deux choses en mémoire à 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 changent, nous ne savons pas quelle sera la somme finale. Dans les exemples de données, il existe une combinaison de produit et de date comportant 64 lignes.
  2. Une plage glissante de 45 jours de coûts et de dates de transaction uniquement pour le produit actuel. Ceci est nécessaire pour ajuster la somme courante simple pour les lignes qui quittent la fenêtre glissante de 45 jours.

Cette mise en cache minimale devrait garantir la bonne évolutivité de cette méthode; certainement mieux que d’essayer de conserver la totalité de l’entrée définie dans la mémoire du CLR.

Code source

Paul White
la source
17

Si vous utilisez l'édition Enterprise, Developer ou Evaluation 64 bits de SQL Server 2014, vous pouvez utiliser OLTP en mémoire . La solution ne consistera pas en un balayage unique et n'utilisera pratiquement aucune fonction de la fenêtre, mais cela pourrait ajouter de la valeur à cette question et l'algorithme utilisé pourrait éventuellement servir d'inspiration à d'autres solutions.

Vous devez d'abord activer OLTP en mémoire 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 In-Memory et doit être défini 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 cette table, il est unique pour chaque combinaison de ProductIDet TransactionDate.

Certains commentaires de la procédure vous expliquent ce qu’il fait, mais globalement, il calcule le total courant dans une boucle et, à chaque itération, il effectue une recherche du total cumulé tel qu’il était il ya 45 jours (ou plus).

Le total cumulé actuel moins le total cumulé tel qu'il était il y a 45 jours correspond à la somme glissante sur 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;

Invoquez la procédure comme ceci.

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

Tester ceci sur mon ordinateur Statistiques client indique 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 classique. Calculez le total cumulé en utilisant rangenon des lignes et stockez le résultat dans une table temporaire. Ensuite, vous pouvez interroger cette table avec une auto-jointure sur le total cumulé tel qu'il était il y a 45 jours et calculer la somme glissante. Cependant, la mise en œuvre de rangecompare to rowsest relativement lente en raison du fait qu'il est nécessaire de traiter différemment les doublons de la commande, si bien que cette approche ne m'a pas donné de si bons résultats. Une solution de contournement pourrait consister à utiliser une autre fonction de la fenêtre, comme last_value()sur un total cumulé calculé, en utilisant rowspour simuler un rangetotal cumulé. Une autre façon est d'utiliser max() over(). Les deux avaient des problèmes. Trouver l’index approprié à utiliser pour éviter les tris et éviter les spools avec lemax() over()version. J'ai abandonné l'optimisation de ces choses, mais si le code que j'ai vous intéresse vous intéresse, faites-le moi savoir.

Mikael Eriksson
la source
13

Eh bien, c’était amusant :) Ma solution est un peu plus lente que celle de @ GeoffPatterson, mais c’est en partie le fait que je reviens à la table originale pour éliminer l’une des hypothèses de Geoff (c’est-à-dire une rangée par paire produit / date). . J'ai supposé qu'il s'agissait d'une version simplifiée d'une requête finale et que des informations supplémentaires pouvaient être demandées en dehors de la table d'origine.

Remarque: j'emprunte la table de calendrier de Geoff et j'ai en fait abouti à 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;

En gros, j’ai décidé que le moyen le plus simple de traiter ce problème était d’utiliser le option pour la clause ROWS. Mais cela exigeait que je ne dispose que d' une ligne par ProductID, TransactionDatecombinaison et non seulement cela, mais je devais avoir une ligne par ProductIDet possible date. Je l'ai fait 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 rejoignais directement la table originale, j’obtenais une élimination de rangée qui effaçait mes résultats. Après cela, il me suffisait de joindre mon deuxième CTE à la table originale. J'ai ajouté la TBEcolonne (à éliminer) pour supprimer les lignes vides créées dans les CTE. De plus, j'ai utilisé un CROSS APPLYdans 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:

entrez la description de l'image ici entrez la description de l'image ici entrez la description de l'image ici

EDIT: En fin de compte, j'ai ajouté un index sur la table de calendrier qui accélérait les performances avec une marge raisonnable.

CREATE INDEX ix_calendar ON calendar(d)
Kenneth Fisher
la source
2
La RunningTotal.TBE IS NOT NULLcondition (et, par conséquent, la TBEcolonne) est inutile. Si vous la supprimez, vous n'obtiendrez pas de lignes redondantes, car votre condition de jointure interne inclut la colonne de date. Par conséquent, le jeu de résultats ne peut pas comporter de dates qui n'étaient pas à l'origine dans la source.
Andriy M
2
Oui. Je suis complètement d'accord. Et pourtant, cela m'a fait gagner environ 0,2 seconde. Je pense que cela a permis à l'optimiseur de connaître certaines informations supplémentaires.
Kenneth Fisher
4

J'ai quelques solutions alternatives qui n'utilisent pas d'index ou de tables de référence. Peut-être pourraient-ils être utiles dans des situations dans lesquelles vous n'avez pas accès à des tables supplémentaires et ne pouvez pas créer d'index. Il semble possible d’obtenir des résultats corrects lors du regroupement en TransactionDateune seule passe des données et en une seule fonction de fenêtre. Cependant, je ne pouvais pas trouver un moyen de le faire avec une seule fonction de fenêtre lorsque vous ne pouvez pas grouper par TransactionDate.

Pour fournir un cadre de référence, la solution originale affichée dans la question, sur ma machine, dispose d'un temps processeur de 2808 ms sans l'indice de couverture et de 1950 ms avec l'index de couverture. Je teste avec la base de données AdventureWorks2014 et SQL Server Express 2014.

Commençons par une solution pour quand nous pouvons grouper par TransactionDate. Une somme cumulée au cours des X derniers jours peut également être exprimée de la manière suivante:

Somme en cours pour une ligne = somme en cours de toutes les lignes précédentes - somme en cours 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 d’effectuer deux copies de vos données et, pour la deuxième copie, de multiplier le coût par -1 et d’ajouter X + 1 jours à la colonne de date. Le calcul d'une somme courante sur toutes les données implémentera la formule ci-dessus. Je montrerai ceci pour quelques données d'exemple. Vous trouverez ci-dessous un exemple de date pour un single ProductID. Je représente les dates sous forme de nombres pour faciliter les calculs. Données de départ:

╔══════╦══════╗
 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 commandée par ordre Datecroissant et CopiedRowdécroissant:

╔══════╦══════╦═══════════╦════════════╗
 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 un moyen 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 prenait 702 ms de temps CPU avec l’indice de couverture et 734 ms de temps CPU sans l’index. Le plan de requête peut être trouvé ici: https://www.brentozar.com/pastetheplan/?id=SJdCsGVSl

Un inconvénient de cette solution est qu’il semble exister un tri inévitable lors de la commande par la nouvelle TransactionDatecolonne. Je ne pense pas que ce type de problème puisse être résolu en ajoutant des index, car nous devons combiner deux copies des données avant de procéder à la commande. J'ai pu supprimer une sorte à la fin de la requête en ajoutant une colonne différente à ORDER BY. Si j'avais commandé par, FilterFlagje trouvais que SQL Server optimiserait cette colonne et effectuerait un tri explicite.

Les solutions pour quand nous avons besoin de renvoyer un ensemble de résultats avec des TransactionDatevaleurs en double identiques ProductIdétaient beaucoup plus compliquées. Je résumerais le problème comme nécessitant simultanément de partitionner et de classer par la même colonne. La syntaxe fournie par Paul résout ce problème. Il n'est donc pas surprenant qu'il soit si difficile à exprimer avec les fonctions de fenêtre actuellement disponibles dans SQL Server (si ce n'était pas difficile à exprimer, il ne serait pas nécessaire de développer la syntaxe).

Si j'utilise la requête ci-dessus sans regroupement, j'obtiens des valeurs différentes pour la somme glissante lorsqu'il y a plusieurs lignes avec le même ProductIdet TransactionDate. Une façon de résoudre ce problème consiste à effectuer le même calcul de somme en cours d'exécution que ci-dessus, mais également à marquer la dernière ligne de la partition. Cela peut être fait avec LEAD(en supposant que ce ProductIDne soit jamais NULL) sans un tri supplémentaire. Pour la valeur de somme finale en cours d'exécution, j'utilise MAXune 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 CPU sans l’indice de couverture. Comme auparavant, il semble y avoir une sorte inévitable. Le plan de requête peut être trouvé ici: https://www.brentozar.com/pastetheplan/?id=HyWxhGVBl

Je pense qu'il y a place à l'amélioration dans la requête ci-dessus. Il existe certainement d'autres moyens d'utiliser les fonctions Windows pour obtenir le résultat souhaité.

Joe Obbish
la source