Rollierende Summe des Datumsbereichs mithilfe von Fensterfunktionen


56

Ich muss eine fortlaufende Summe über einen Datumsbereich berechnen. Zur Veranschaulichung, unter Verwendung der AdventureWorks-Beispieldatenbank , würde die folgende hypothetische Syntax genau das tun, was ich brauche:

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;

Leider lässt die RANGEFensterrahmenausdehnung derzeit in SQL Server kein Intervall zu.

Ich weiß, dass ich eine Lösung mit einer Unterabfrage und einem regulären Aggregat (ohne Fenster) schreiben kann:

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;

Ausgehend von folgendem Index:

CREATE UNIQUE INDEX i
ON Production.TransactionHistory
    (ProductID, TransactionDate, ReferenceOrderID)
INCLUDE
    (ActualCost);

Der Ausführungsplan lautet:

Ausführungsplan

Obwohl diese Abfrage nicht fürchterlich ineffizient ist, scheint es möglich zu sein, sie nur mit Fensteraggregations- und Analysefunktionen auszudrücken, die in SQL Server 2012, 2014 oder 2016 (bisher) unterstützt werden.

Aus Gründen der Übersichtlichkeit suche ich nach einer Lösung, die einen einzelnen Durchgang über die Daten ausführt .

In T-SQL bedeutet dies wahrscheinlich, dass die OVERKlausel die Arbeit erledigt und der Ausführungsplan Window Spools und Window Aggregates enthält. Alle Sprachelemente, die die OVERKlausel verwenden, sind Freiwild. Eine SQLCLR-Lösung ist akzeptabel, vorausgesetzt, es werden korrekte Ergebnisse garantiert .

Für T-SQL-Lösungen gilt: Je weniger Hashes, Sortierungen und Window-Spools / Aggregate im Ausführungsplan enthalten sind, desto besser. Fühlen Sie sich frei, Indizes hinzuzufügen, aber separate Strukturen sind nicht zulässig (daher werden beispielsweise keine vorberechneten Tabellen mit Triggern synchronisiert). Referenztabellen sind erlaubt (Nummerntabellen, Datumsangaben etc.)

Im Idealfall führen Lösungen zu genau den gleichen Ergebnissen in der gleichen Reihenfolge wie die oben angegebene Unterabfrage, aber alles, was richtig ist, ist auch akzeptabel. Leistung ist immer eine Überlegung, daher sollten Lösungen zumindest einigermaßen effizient sein.

Eigener Chatroom: Ich habe einen öffentlichen Chatroom für Diskussionen zu dieser Frage und ihren Antworten eingerichtet. Jeder Benutzer mit mindestens 20 Reputationspunkten kann direkt teilnehmen. Bitte senden Sie mir einen Kommentar, wenn Sie weniger als 20 Wiederholungen haben und teilnehmen möchten.

Antworten:


42

Gute Frage, Paul! Ich habe ein paar verschiedene Ansätze verwendet, einen in T-SQL und einen in CLR.

T-SQL-Kurzzusammenfassung

Der T-SQL-Ansatz kann wie folgt zusammengefasst werden:

  • Nehmen Sie das Kreuzprodukt von Produkten / Daten
  • In den beobachteten Verkaufsdaten zusammenführen
  • Aggregieren Sie diese Daten auf Produkt- / Datumsebene
  • Berechnen Sie fortlaufende Summen der letzten 45 Tage basierend auf diesen aggregierten Daten (die alle "fehlenden" Tage enthalten, die ausgefüllt wurden).
  • Filtern Sie diese Ergebnisse nur auf die Produkt- / Datums-Paarungen, bei denen mindestens ein Umsatz erzielt wurde

Unter Verwendung SET STATISTICS IO ONdieses Ansatzes wird berichtet Table 'TransactionHistory'. Scan count 1, logical reads 484, dass der "einzelne Durchgang" über die Tabelle bestätigt wird. Als Referenz dienen die ursprünglichen Loop-Search-Abfrageberichte Table 'TransactionHistory'. Scan count 113444, logical reads 438366.

Wie von gemeldet SET STATISTICS TIME ON, ist die CPU-Zeit 514ms. Dies ist im Vergleich zur 2231msursprünglichen Abfrage günstig .

CLR-Kurzzusammenfassung

Die CLR-Zusammenfassung kann wie folgt zusammengefasst werden:

  • Lesen Sie die Daten in den Speicher, sortiert nach Produkt und Datum
  • Fügen Sie während der Verarbeitung jeder Transaktion eine laufende Summe der Kosten hinzu. Wenn eine Transaktion ein anderes Produkt als die vorherige Transaktion ist, setzen Sie die laufende Summe auf 0 zurück.
  • Pflegen Sie einen Zeiger auf die erste Transaktion, die dasselbe (Produkt, Datum) wie die aktuelle Transaktion hat. Immer wenn die letzte Transaktion mit diesem (Produkt, Datum) festgestellt wird, berechnen Sie die fortlaufende Summe für diese Transaktion und wenden Sie sie auf alle Transaktionen mit demselben (Produkt, Datum) an.
  • Geben Sie alle Ergebnisse an den Benutzer zurück!

Mit SET STATISTICS IO ONdiesem Ansatz wird gemeldet, dass keine logische E / A aufgetreten ist! Wow, eine perfekte Lösung! (Tatsächlich scheint SET STATISTICS IOdies nicht die innerhalb von CLR angefallenen E / A zu melden. Aus dem Code geht jedoch leicht hervor, dass genau ein Scan der Tabelle durchgeführt wird und die Daten in der von Paul vorgeschlagenen Reihenfolge abgerufen werden.

Wie von gemeldet SET STATISTICS TIME ON, ist die CPU-Zeit jetzt 187ms. Das ist also eine deutliche Verbesserung gegenüber dem T-SQL-Ansatz. Leider ist die verstrichene Gesamtzeit beider Ansätze mit jeweils etwa einer halben Sekunde sehr ähnlich. Der CLR-basierte Ansatz muss jedoch 113 KB Zeilen an die Konsole ausgeben (im Gegensatz zu 52 KB für den T-SQL-Ansatz, der nach Produkt / Datum gruppiert ist). Deshalb habe ich mich stattdessen auf die CPU-Zeit konzentriert.

Ein weiterer großer Vorteil dieses Ansatzes ist, dass er genau die gleichen Ergebnisse liefert wie der ursprüngliche Loop / Search-Ansatz, einschließlich einer Zeile für jede Transaktion, selbst wenn ein Produkt am selben Tag mehrmals verkauft wird. (In AdventureWorks habe ich die Ergebnisse Zeile für Zeile verglichen und bestätigt, dass sie mit der ursprünglichen Abfrage von Paul übereinstimmen.)

Ein Nachteil dieses Ansatzes ist, zumindest in seiner gegenwärtigen Form, dass er alle Daten im Speicher liest. Der entworfene Algorithmus benötigt jedoch immer nur den aktuellen Fensterrahmen im Speicher und könnte aktualisiert werden, um für Datensätze zu funktionieren, die den Speicher überschreiten. Paul hat diesen Punkt in seiner Antwort veranschaulicht, indem er eine Implementierung dieses Algorithmus erstellt hat, die nur das Gleitfenster im Speicher speichert. Dies geht zu Lasten der Erteilung höherer Berechtigungen für die CLR-Assembly, lohnt sich jedoch auf jeden Fall, um diese Lösung auf beliebig große Datenmengen zu skalieren.


T-SQL - ein Scan, sortiert nach Datum

Ersteinrichtung

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

Die Abfrage

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)

Der Ausführungsplan

Aus dem Ausführungsplan geht hervor, dass der von Paul vorgeschlagene ursprüngliche Index ausreicht, um einen einzelnen geordneten Scan durchzuführen. Dabei wird Production.TransactionHistoryein Merge-Join verwendet, um die Transaktionshistorie mit jeder möglichen Produkt- / Datumskombination zu kombinieren.

Bildbeschreibung hier eingeben

Annahmen

Es gibt einige wichtige Annahmen, die in diesen Ansatz einfließen. Ich nehme an, es liegt an Paul, zu entscheiden, ob sie akzeptabel sind :)

  • Ich benutze den Production.ProductTisch. Diese Tabelle ist auf frei verfügbar AdventureWorks2012und die Beziehung wird durch einen Fremdschlüssel von erzwungen Production.TransactionHistory, daher habe ich dies als faires Spiel interpretiert.
  • Dieser Ansatz beruht auf der Tatsache, dass Transaktionen keine Zeitkomponente aufweisen AdventureWorks2012; In diesem Fall wäre es nicht mehr möglich, den vollständigen Satz von Produkt- / Datumskombinationen zu generieren, ohne zuvor die Transaktionshistorie zu durchlaufen.
  • Ich produziere ein Rowset, das nur eine Zeile pro Produkt / Datumspaar enthält. Ich denke, dass dies "wohl richtig" und in vielen Fällen ein wünschenswerteres Ergebnis ist, um zurückzukehren. Für jedes Produkt / Datum habe ich eine NumOrdersSpalte hinzugefügt , in der angegeben ist, wie viele Verkäufe getätigt wurden. Der folgende Screenshot zeigt einen Vergleich der Ergebnisse der ursprünglichen Abfrage mit der vorgeschlagenen Abfrage in Fällen, in denen ein Produkt am selben Datum mehrmals verkauft wurde (z. B. 319/ 2007-09-05 00:00:00.000).

Bildbeschreibung hier eingeben


CLR - One Scan, vollständige Ergebnismenge ohne Gruppierung

Der Hauptfunktionskörper

Hier gibt es keine Tonne zu sehen; Der Hauptteil der Funktion deklariert die Eingaben (die mit der entsprechenden SQL-Funktion übereinstimmen müssen), richtet eine SQL-Verbindung ein und öffnet den 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;
    }
}

Die Kernlogik

Ich habe die Hauptlogik herausgearbeitet, damit Sie sich besser darauf konzentrieren können:

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

Helfer

Die folgende Logik könnte inline geschrieben werden, aber es ist etwas einfacher zu lesen, wenn sie in ihre eigenen Methoden aufgeteilt werden.

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

Alles in SQL zusammenbinden

Alles bis zu diesem Punkt war in C #. Sehen wir uns also das eigentliche SQL an. (Alternativ können Sie dieses Bereitstellungsskript verwenden , um die Assembly direkt aus den Bits meiner Assembly zu erstellen, anstatt sich selbst zu kompilieren.)

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

Vorbehalte

Der CLR-Ansatz bietet wesentlich mehr Flexibilität bei der Optimierung des Algorithmus und könnte möglicherweise von einem C # -Experten noch weiter optimiert werden. Die CLR-Strategie hat jedoch auch Nachteile. Ein paar Dinge zu beachten:

  • Dieser CLR-Ansatz speichert eine Kopie des Datensatzes. Es ist möglich, einen Streaming-Ansatz zu verwenden, aber ich stieß auf anfängliche Schwierigkeiten und stellte fest, dass ein ausstehendes Connect-Problem darin besteht , dass Änderungen in SQL 2008+ die Verwendung dieses Ansatzes erschweren. Dies ist weiterhin möglich (wie Paul demonstriert), erfordert jedoch eine höhere Berechtigungsstufe, indem die Datenbank als festgelegt TRUSTWORTHYund EXTERNAL_ACCESSder CLR-Assembly erteilt wird . Es gibt also einige Probleme und potenzielle Auswirkungen auf die Sicherheit, aber die Auszahlung ist ein Streaming-Ansatz, der sich besser auf viel größere Datensätze als auf AdventureWorks skalieren lässt.
  • CLR ist für einige Datenbankadministratoren möglicherweise weniger zugänglich, sodass eine solche Funktion eher einer Black Box ähnelt, die nicht so transparent, nicht so einfach zu ändern, nicht so einfach bereitzustellen und möglicherweise nicht so einfach zu debuggen ist. Dies ist ein ziemlich großer Nachteil im Vergleich zu einem T-SQL-Ansatz.


Bonus: T-SQL # 2 - der praktische Ansatz, den ich tatsächlich verwenden würde

Nachdem ich eine Weile versucht hatte, mich kreativ mit dem Problem zu beschäftigen, dachte ich, ich würde auch die ziemlich einfache, praktische Methode veröffentlichen, mit der ich dieses Problem wahrscheinlich angehen würde, wenn es in meiner täglichen Arbeit auftauchte. Es nutzt die SQL 2012+ -Fensterfunktionalität, jedoch nicht die bahnbrechende Art und Weise, auf die die Frage gehofft hatte:

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

Dies ergibt tatsächlich einen ziemlich einfachen Gesamtabfrageplan, selbst wenn beide relevanten Abfragepläne zusammen betrachtet werden:

Bildbeschreibung hier eingeben Bildbeschreibung hier eingeben

Ein paar Gründe, warum mir dieser Ansatz gefällt:

  • Es gibt die vollständige Ergebnismenge an, die in der Problembeschreibung angefordert wurde (im Gegensatz zu den meisten anderen T-SQL-Lösungen, die eine gruppierte Version der Ergebnisse zurückgeben).
  • Es ist leicht zu erklären, zu verstehen und zu debuggen. Ich werde ein Jahr später nicht wiederkommen und mich fragen, wie zum Teufel ich eine kleine Änderung vornehmen kann, ohne die Korrektheit oder Leistung zu beeinträchtigen
  • Es läuft in etwa 900msauf dem bereitgestellten Datensatz und nicht auf dem 2700msdes ursprünglichen Loop-Suchlaufs
  • Wenn die Daten viel dichter waren (mehr Transaktionen pro Tag), wächst die Rechenkomplexität nicht quadratisch mit der Anzahl der Transaktionen im Gleitfenster (wie bei der ursprünglichen Abfrage). Ich denke, dies spricht einen Teil von Pauls Besorgnis darüber an, Mehrfach-Scans zu vermeiden
  • Aufgrund der neuen tempdb Lazy Write-Funktionalität werden in den letzten Updates von SQL 2012+ im Wesentlichen keine Tempdb-E / A- Vorgänge ausgeführt
  • Bei sehr großen Datenmengen ist es trivial, die Arbeit für jedes Produkt in separate Chargen aufzuteilen, wenn der Speicherdruck zu einem Problem wird

Ein paar mögliche Einschränkungen:

  • Obwohl Production.TransactionHistory technisch nur einmal gescannt wird, handelt es sich nicht wirklich um einen Ein-Scan-Ansatz, da die Tabelle #temp von ähnlicher Größe zusätzliche Logik-E / A für diese Tabelle ausführen muss. Ich sehe dies jedoch nicht als zu verschieden von einem Arbeitstisch, über den wir mehr manuelle Kontrolle haben, da wir dessen genaue Struktur definiert haben
  • Abhängig von Ihrer Umgebung kann die Verwendung von Tempdb als positiv (z. B. auf einem separaten Satz von SSD-Laufwerken) oder negativ (hohe Parallelität auf dem Server, viele Tempdb-Konflikte bereits) angesehen werden.

25

Da dies eine lange Antwort ist, habe ich beschlossen, hier eine Zusammenfassung hinzuzufügen.

  • Zunächst stelle ich eine Lösung vor, die in der gleichen Reihenfolge wie in der Frage genau das gleiche Ergebnis liefert. Es durchsucht die Haupttabelle dreimal: um eine Liste ProductIDsmit dem Datumsbereich für jedes Produkt zu erhalten, um die Kosten für jeden Tag zusammenzufassen (da es mehrere Transaktionen mit demselben Datum gibt), um das Ergebnis mit den ursprünglichen Zeilen zu verbinden.
  • Als nächstes vergleiche ich zwei Ansätze, die die Aufgabe vereinfachen und einen letzten Scan der Haupttabelle vermeiden. Das Ergebnis ist eine tägliche Zusammenfassung. Wenn also mehrere Transaktionen für ein Produkt dasselbe Datum haben, werden sie in einer Zeile zusammengefasst. Mein Ansatz aus dem vorherigen Schritt durchsucht den Tisch zweimal. Vorgehensweise von Geoff Patterson scannt die Tabelle einmal, da er externes Wissen über den Zeitraum und die Liste der Produkte verwendet.
  • Zuletzt präsentiere ich eine Single-Pass-Lösung, die wieder eine tägliche Zusammenfassung zurückgibt, aber keine externen Kenntnisse über den Zeitraum oder die Liste der Termine erfordert ProductIDs.

Ich werde die AdventureWorks2014- Datenbank und SQL Server Express 2014 verwenden.

Änderungen an der ursprünglichen Datenbank:

  • Typ von [Production].[TransactionHistory].[TransactionDate]von datetimenach geändert date. Die Zeitkomponente war ohnehin Null.
  • Kalendertabelle hinzugefügt [dbo].[Calendar]
  • Index zu hinzugefügt [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);

Der MSDN-Artikel über OVERKlausel enthält einen Link zu einem hervorragenden Blogbeitrag über Fensterfunktionen von Itzik Ben-Gan. In diesem Beitrag erklärt er die Funktionsweise OVER, den Unterschied zwischen ROWSund RANGEOptionen und erwähnt genau dieses Problem der Berechnung einer fortlaufenden Summe über einen Datumsbereich. Er erwähnt, dass die aktuelle Version von SQL Server nicht RANGEvollständig implementiert ist und keine temporären Intervalldatentypen implementiert sind. Seine Erklärung des Unterschieds zwischen ROWSund RANGEgab mir eine Idee.

Termine ohne Lücken und Duplikate

Wenn die TransactionHistoryTabelle Daten ohne Lücken und ohne Duplikate enthält, würde die folgende Abfrage zu korrekten Ergebnissen führen:

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;

In der Tat würde ein Fenster von 45 Reihen genau 45 Tage abdecken.

Termine mit Lücken ohne Duplikate

Leider weisen unsere Daten Datumslücken auf. Um dieses Problem zu lösen, können wir eine CalendarTabelle verwenden, um einen Satz von Daten ohne Lücken zu generieren, dann LEFT JOINOriginaldaten zu diesem Satz und dieselbe Abfrage mit ROWS BETWEEN 45 PRECEDING AND CURRENT ROW. Dies würde nur dann zu korrekten Ergebnissen führen, wenn sich die Daten nicht wiederholen (innerhalb derselben ProductID).

Termine mit Lücken mit Duplikaten

Leider weisen unsere Daten sowohl Datumslücken auf, als auch Daten, die sich innerhalb derselben wiederholen können ProductID. Um dieses Problem zu lösen, können wir GROUPOriginaldaten erstellen, indem wir ProductID, TransactionDateeine Reihe von Daten ohne Duplikate erstellen . Verwenden Sie dann die CalendarTabelle, um eine Reihe von Daten ohne Lücken zu generieren. Dann können wir die Abfrage mit verwenden ROWS BETWEEN 45 PRECEDING AND CURRENT ROW, um das Rollen zu berechnen SUM. Dies würde zu korrekten Ergebnissen führen. Siehe Kommentare in der Abfrage unten.

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
;

Ich habe bestätigt, dass diese Abfrage dieselben Ergebnisse liefert wie der Ansatz aus der Frage, die die Unterabfrage verwendet.

Ausführungspläne

Statistiken

Die erste Abfrage verwendet eine Unterabfrage, die zweite - diesen Ansatz. Sie können feststellen, dass die Dauer und Anzahl der Lesevorgänge bei diesem Ansatz viel geringer ist. Die Mehrheit der geschätzten Kosten bei diesem Ansatz ist der endgültige ORDER BY, siehe unten.

Unterabfrage

Der Unterabfrageansatz hat einen einfachen Plan mit verschachtelten Schleifen und O(n*n)Komplexität.

Über

Planen Sie diesen Ansatz TransactionHistorymehrmals ein, aber es gibt keine Schleifen. Wie Sie sehen können, fallen mehr als 70% der geschätzten Kosten Sortfür das Finale an ORDER BY.

io

Top Ergebnis - subquery, unten - OVER.


Vermeiden Sie zusätzliche Scans

Die letzte Indexsuche, Zusammenführung und Sortierung im obigen Plan wird durch das Finale INNER JOINmit der Originaltabelle verursacht, sodass das Endergebnis genau dem langsamen Ansatz mit Unterabfrage entspricht. Die Anzahl der zurückgegebenen Zeilen entspricht der in TransactionHistoryTabelle. Es sind Zeilen in, in TransactionHistorydenen mehrere Transaktionen am selben Tag für dasselbe Produkt durchgeführt wurden. Wenn es in Ordnung ist, nur die tägliche Zusammenfassung im Ergebnis anzuzeigen, JOINkann dieses Finale entfernt werden, und die Abfrage wird ein bisschen einfacher und ein bisschen schneller. Der letzte Index-Scan, die letzte Zusammenführung und die letzte Sortierung aus dem vorherigen Plan werden durch Filter ersetzt, wodurch die durch hinzugefügten Zeilen entfernt werden 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
;

Zwei-Scan

Trotzdem TransactionHistorywird zweimal gescannt. Ein zusätzlicher Scan ist erforderlich, um den Datumsbereich für jedes Produkt zu ermitteln. Ich war interessiert zu sehen, wie es mit einem anderen Ansatz verglichen wird, bei dem wir externes Wissen über den globalen Datumsbereich TransactionHistorysowie eine zusätzliche Tabelle verwenden Product, ProductIDsum diesen zusätzlichen Scan zu vermeiden. Ich habe die Berechnung der Anzahl der Transaktionen pro Tag aus dieser Abfrage entfernt, um den Vergleich gültig zu machen. Es kann in beiden Abfragen hinzugefügt werden, aber ich möchte es zum Vergleich einfach halten. Ich musste auch andere Daten verwenden, da ich die Version 2014 der Datenbank verwende.

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

Beide Abfragen geben dasselbe Ergebnis in derselben Reihenfolge zurück.

Vergleich

Hier sind Zeit- und IO-Statistiken.

stats2

io2

Die Two-Scan-Variante ist etwas schneller und hat weniger Lesevorgänge, da die One-Scan-Variante häufig mit Worktable arbeiten muss. Außerdem generiert eine Ein-Scan-Variante mehr Zeilen als benötigt, wie Sie in den Plänen sehen können. Es werden für jedes Datum ProductIDin der ProductTabelle Daten generiert , auch wenn a ProductIDkeine Transaktionen enthält. Es gibt 504 Zeilen in der ProductTabelle, aber nur 441 Produkte haben Transaktionen in TransactionHistory. Außerdem wird für jedes Produkt derselbe Zeitraum generiert, der mehr als erforderlich ist. Wenn TransactionHistorydie gesamte Historie länger wäre und jedes einzelne Produkt eine relativ kurze Historie hätte, wäre die Anzahl der zusätzlichen nicht benötigten Reihen sogar noch höher.

Andererseits ist es möglich, die Two-Scan-Variante ein Stück weiter zu optimieren, indem ein weiterer, engerer Index für nur erstellt wird (ProductID, TransactionDate). Dieser Index wird verwendet, um Start- / Enddaten für jedes Produkt zu berechnen ( CTE_Products). Er hat weniger Seiten als der Deckungsindex und führt daher zu weniger Lesevorgängen.

So können wir wählen, ob wir einen expliziten einfachen Scan oder eine implizite Arbeitstabelle haben möchten.

Übrigens, wenn es in Ordnung ist, ein Ergebnis nur mit täglichen Zusammenfassungen zu erhalten, ist es besser, einen Index zu erstellen, der keine enthält ReferenceOrderID. Es würde weniger Seiten => weniger IO verwenden.

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

Single-Pass-Lösung mit CROSS APPLY

Es wird eine sehr lange Antwort, aber hier ist eine weitere Variante, die wieder nur eine tägliche Zusammenfassung zurückgibt, aber nur einen Scan der Daten durchführt und keine externen Kenntnisse über den Zeitraum oder die Liste der Produkt-IDs erfordert. Es werden auch keine Zwischensortierungen durchgeführt. Die Gesamtleistung ist vergleichbar mit früheren Varianten, scheint jedoch etwas schlechter zu sein.

Die Hauptidee besteht darin, eine Tabelle mit Zahlen zu verwenden, um Zeilen zu generieren, die die Lücken in Datumsangaben füllen. LEADBerechnen Sie für jedes vorhandene Datum die Größe der Lücke in Tagen und CROSS APPLYfügen Sie dann die erforderliche Anzahl von Zeilen zur Ergebnismenge hinzu. Zuerst habe ich es mit einer festen Zahlentabelle versucht. Der Plan zeigte eine große Anzahl von Lesevorgängen in dieser Tabelle, obwohl die tatsächliche Dauer ziemlich gleich war, als ich Zahlen im laufenden Betrieb mit generierte 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
;

Dieser Plan ist "länger", da die Abfrage zwei Fensterfunktionen ( LEADund SUM) verwendet.

Kreuz anwenden

ca stats

ca io


23

Eine alternative SQLCLR-Lösung, die schneller ausgeführt wird und weniger Speicher benötigt:

Bereitstellungsskript

Dafür ist der EXTERNAL_ACCESSBerechtigungssatz erforderlich , da anstelle der (langsamen) Kontextverbindung eine Loopback-Verbindung zum Zielserver und zur Datenbank verwendet wird. So rufen Sie die Funktion auf:

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;

Erzeugt genau die gleichen Ergebnisse in der gleichen Reihenfolge wie die Frage.

Ausführungsplan:

SQLCLR TVF-Ausführungsplan

Ausführungsplan für die SQLCLR-Quellabfrage

Planen Sie die Explorer-Leistungsstatistik

Logische Lesevorgänge des Profilers: 481

Der Hauptvorteil dieser Implementierung besteht darin, dass sie schneller als die Verwendung der Kontextverbindung ist und weniger Speicher benötigt. Es bleiben immer nur zwei Dinge im Gedächtnis:

  1. Beliebige doppelte Zeilen (gleiches Produkt- und Transaktionsdatum). Dies ist erforderlich, da wir bis zur Änderung des Produkts oder des Datums nicht wissen, wie hoch die endgültige laufende Summe sein wird. In den Beispieldaten gibt es eine Kombination aus Produkt und Datum mit 64 Zeilen.
  2. Ein gleitender Zeitraum von 45 Tagen für Kosten und Transaktionstermine für das aktuelle Produkt. Dies ist erforderlich, um die einfache laufende Summe für Zeilen anzupassen, die das 45-Tage-Schiebefenster verlassen.

Diese minimale Zwischenspeicherung sollte sicherstellen, dass diese Methode gut skaliert werden kann. sicherlich besser als zu versuchen, den gesamten Eingangssatz im CLR-Speicher zu halten.

Quellcode


17

Wenn Sie eine 64-Bit-Enterprise-, Developer- oder Evaluierungsversion von SQL Server 2014 verwenden, können Sie In-Memory-OLTP verwenden . Die Lösung wird kein einzelner Scan sein und wird kaum Fensterfunktionen verwenden, aber sie könnte dieser Frage einen Mehrwert verleihen, und der verwendete Algorithmus könnte möglicherweise als Inspiration für andere Lösungen dienen.

Zuerst müssen Sie In-Memory OLTP in der AdventureWorks-Datenbank aktivieren.

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;

Der Parameter für die Prozedur ist eine In-Memory-Tabellenvariable, die als Typ definiert werden muss.

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

ID ist in dieser Tabelle nicht eindeutig, sondern für jede Kombination von ProductIDund eindeutig TransactionDate.

Es gibt einige Kommentare in der Prozedur, die Ihnen sagen, was es tut, aber insgesamt wird die laufende Summe in einer Schleife berechnet und für jede Iteration wird nach der laufenden Summe gesucht, wie sie vor 45 Tagen (oder länger) war.

Die aktuelle laufende Summe abzüglich der laufenden Summe vor 45 Tagen ist die fortlaufende 45-Tage-Summe, nach der wir suchen.

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;

Rufen Sie die Prozedur wie folgt auf.

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

Wenn Sie dies auf meinem Computer testen, gibt Client Statistics eine Gesamtausführungszeit von ca. 750 Millisekunden an. Für Vergleiche benötigt die Unterabfrageversion 3,5 Sekunden.

Extra Geschwafel:

Dieser Algorithmus könnte auch von regulärem T-SQL verwendet werden. Berechnen Sie die laufende Summe rangeohne Zeilen und speichern Sie das Ergebnis in einer temporären Tabelle. Anschließend können Sie diese Tabelle mit einem Self-Join abfragen, um die laufende Summe wie vor 45 Tagen zu ermitteln und die fortlaufende Summe zu berechnen. Die Implementierung von rangecompare to rowsist jedoch recht langsam, da Duplikate der order by-Klausel unterschiedlich behandelt werden müssen, sodass ich mit diesem Ansatz nicht allzu gute Ergebnisse erzielt habe. Eine Problemumgehung könnte darin bestehen, eine andere Fensterfunktion wie last_value()über einer berechneten laufenden Summe zu verwenden rows, um eine rangelaufende Summe zu simulieren . Ein anderer Weg ist zu verwenden max() over(). Beide hatten einige Probleme. Suche nach dem geeigneten Index zur Vermeidung von Sortierungen und Vermeidung von Spools mit demmax() over()Ausführung. Ich habe aufgehört, diese Dinge zu optimieren, aber wenn Sie sich für den Code interessieren, den ich bisher habe, lassen Sie es mich bitte wissen.


13

Nun, das hat Spaß gemacht :) Meine Lösung ist etwas langsamer als die von @ GeoffPatterson, aber ein Teil davon ist die Tatsache, dass ich mich an die ursprüngliche Tabelle binde, um eine der Annahmen von Geoff zu eliminieren (dh eine Zeile pro Produkt / Datum-Paar). . Ich ging davon aus, dass dies eine vereinfachte Version einer endgültigen Abfrage war und möglicherweise zusätzliche Informationen aus der ursprünglichen Tabelle benötigt.

Hinweis: Ich leihe mir Geoffs Kalendertabelle aus und habe tatsächlich eine sehr ähnliche Lösung gefunden:

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

Hier ist die Abfrage selbst:

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;

Grundsätzlich habe ich beschlossen, dass der einfachste Weg, damit umzugehen, darin besteht, die zu verwenden Option für die ROWS-Klausel. Aber das verlangte , dass ich nur eine Zeile pro ProductID, TransactionDateKombination und nicht nur das, aber ich hatte eine Reihe haben pro ProductIDund possible date. Dabei habe ich die Tabellen Product, calendar und TransactionHistory in einem CTE kombiniert. Dann musste ich einen weiteren CTE erstellen, um die fortlaufenden Informationen zu generieren. Ich musste das tun, weil ich, wenn ich mich direkt zur ursprünglichen Tabelle zurückzog, eine Zeileneliminierung bekam, die meine Ergebnisse beeinträchtigte. Danach war es eine einfache Sache, meinen zweiten CTE wieder an den ursprünglichen Tisch zu bringen. Ich habe die TBE(zu eliminierende) Spalte hinzugefügt , um die in den CTEs erstellten leeren Zeilen zu entfernen. Außerdem habe ich CROSS APPLYim anfänglichen CTE einen verwendet, um Grenzen für meine Kalendertabelle zu generieren.

Ich habe dann den empfohlenen Index hinzugefügt:

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

Und bekam den endgültigen Ausführungsplan:

Bildbeschreibung hier eingeben Bildbeschreibung hier eingeben Bildbeschreibung hier eingeben

BEARBEITEN: Am Ende habe ich einen Index für die Kalendertabelle hinzugefügt, der die Leistung um einen angemessenen Betrag beschleunigte.

CREATE INDEX ix_calendar ON calendar(d)

2
Die RunningTotal.TBE IS NOT NULLBedingung (und folglich die TBESpalte) ist nicht erforderlich . Sie werden keine redundanten Zeilen erhalten, wenn Sie sie löschen, da Ihre innere Verknüpfungsbedingung die Datumsspalte enthält. Daher kann die Ergebnismenge keine Daten enthalten, die ursprünglich nicht in der Quelle enthalten waren.
Andriy M

2
Ja. Ich stimme vollkommen zu. Trotzdem habe ich etwa 0,2 Sekunden zugelegt. Ich denke, es hat dem Optimierer einige zusätzliche Informationen mitgeteilt.
Kenneth Fisher

4

Ich habe ein paar alternative Lösungen, die keine Indizes oder Referenztabellen verwenden. Vielleicht könnten sie in Situationen nützlich sein, in denen Sie keinen Zugriff auf zusätzliche Tabellen haben und keine Indizes erstellen können. Es scheint möglich zu sein, korrekte Ergebnisse zu erzielen, wenn die Gruppierung TransactionDatemit nur einem Durchlauf der Daten und nur einer einzigen Fensterfunktion erfolgt. Ich konnte jedoch keinen Weg finden, dies mit nur einer Fensterfunktion zu tun, wenn Sie nicht nach gruppieren können TransactionDate.

Um einen Referenzrahmen bereitzustellen, hat die in der Frage angegebene ursprüngliche Lösung auf meinem Computer eine CPU-Zeit von 2808 ms ohne den Abdeckungsindex und 1950 ms mit dem Abdeckungsindex. Ich teste mit der AdventureWorks2014-Datenbank und SQL Server Express 2014.

Beginnen wir mit einer Lösung, nach der wir gruppieren können TransactionDate. Eine laufende Summe der letzten X Tage kann auch folgendermaßen ausgedrückt werden:

Laufende Summe für eine Zeile = laufende Summe aller vorherigen Zeilen - laufende Summe aller vorherigen Zeilen, für die das Datum außerhalb des Datumsfensters liegt.

In SQL können Sie dies zum Ausdruck bringen, indem Sie zwei Kopien Ihrer Daten und für die zweite Kopie die Kosten mit -1 multiplizieren und der Datumsspalte X + 1 Tage hinzufügen. Durch Berechnen einer laufenden Summe über alle Daten wird die obige Formel implementiert. Ich zeige dies für einige Beispieldaten. Unten ist ein Beispieldatum für eine Single ProductID. Ich stelle Daten als Zahlen dar, um die Berechnungen zu vereinfachen. Startdaten:

╔══════╦══════╗
 Date  Cost 
╠══════╬══════╣
    1     3 
    2     6 
   20     1 
   45    -4 
   47     2 
   64     2 
╚══════╩══════╝

Fügen Sie eine zweite Kopie der Daten hinzu. Bei der zweiten Kopie wurden 46 Tage zum Datum hinzugefügt und die Kosten mit -1 multipliziert:

╔══════╦══════╦═══════════╗
 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 
╚══════╩══════╩═══════════╝

Nimm die laufende Summe in Dateaufsteigender und CopiedRowabsteigender Reihenfolge:

╔══════╦══════╦═══════════╦════════════╗
 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 
╚══════╩══════╩═══════════╩════════════╝

Filtern Sie die kopierten Zeilen heraus, um das gewünschte Ergebnis zu erhalten:

╔══════╦══════╦═══════════╦════════════╗
 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 
╚══════╩══════╩═══════════╩════════════╝

Das folgende SQL ist eine Möglichkeit, den obigen Algorithmus zu implementieren:

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

Auf meinem Computer dauerte dies 702 ms CPU-Zeit mit dem abdeckenden Index und 734 ms CPU-Zeit ohne den Index. Den Abfrageplan finden Sie hier: https://www.brentozar.com/pastetheplan/?id=SJdCsGVSl

Ein Nachteil dieser Lösung ist, dass es beim Ordnen nach der neuen TransactionDateSpalte eine unvermeidbare Sortierung zu geben scheint . Ich glaube nicht, dass diese Art durch Hinzufügen von Indizes gelöst werden kann, da wir vor der Bestellung zwei Kopien der Daten kombinieren müssen. Am Ende der Abfrage konnte ich eine Sortierung entfernen, indem ich ORDER BY eine andere Spalte hinzufügte. Wenn ich nach bestellte, stellte FilterFlagich fest, dass SQL Server diese Spalte aus der Sortierung heraus optimieren und eine explizite Sortierung durchführen würde.

Lösungen für den Fall, dass wir eine Ergebnismenge mit doppelten TransactionDateWerten für dieselbe zurückgeben müssen, ProductIdwaren viel komplizierter. Ich würde das Problem so zusammenfassen, dass es gleichzeitig nach derselben Spalte partitioniert und sortiert werden muss. Die von Paul bereitgestellte Syntax behebt dieses Problem, so dass es nicht überraschend ist, dass es mit den aktuellen in SQL Server verfügbaren Fensterfunktionen so schwer auszudrücken ist (wenn es nicht schwierig wäre, dies auszudrücken, müsste die Syntax nicht erweitert werden).

Wenn ich die obige Abfrage ohne Gruppierung verwende, erhalte ich unterschiedliche Werte für die fortlaufende Summe, wenn es mehrere Zeilen mit demselben ProductIdund gibt TransactionDate. Eine Möglichkeit, dies zu beheben, besteht darin, dieselbe Berechnung der laufenden Summe wie oben durchzuführen, aber auch die letzte Zeile in der Partition zu markieren. Dies kann mit LEAD(vorausgesetzt, es ProductIDist nie NULL) ohne eine zusätzliche Sortierung erfolgen. Für den endgültigen laufenden Summenwert verwende ich MAXals Fensterfunktion den Wert in der letzten Zeile der Partition auf alle Zeilen in der 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);  

Auf meinem Computer dauerte dies 2464 ms CPU-Zeit ohne den Deckungsindex. Nach wie vor scheint es eine unvermeidliche Art zu geben. Den Abfrageplan finden Sie hier: https://www.brentozar.com/pastetheplan/?id=HyWxhGVBl

Ich denke, dass die obige Abfrage noch verbesserungswürdig ist. Es gibt sicherlich auch andere Möglichkeiten, Windows-Funktionen zu verwenden, um das gewünschte Ergebnis zu erzielen.

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.