Krocząca suma zakresu dat za pomocą funkcji okna

56

Muszę obliczyć sumę kroczącą w zakresie dat. Aby to zilustrować, korzystając z przykładowej bazy danych AdventureWorks , następująca hipotetyczna składnia zrobiłaby dokładnie to, czego potrzebuję:

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;

Niestety RANGEzasięg ramki okna obecnie nie pozwala na interwał w SQL Server.

Wiem, że mogę napisać rozwiązanie za pomocą podzapytania i zwykłej (innej niż okienkowa) agregacji:

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;

Biorąc pod uwagę następujący indeks:

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

Plan wykonania jest:

Plan wykonania

Chociaż nie jest to okropnie nieefektywne, wydaje się, że powinno być możliwe wyrażenie tego zapytania przy użyciu tylko funkcji agregujących okna i funkcji analitycznych obsługiwanych w SQL Server 2012, 2014 lub 2016 (jak dotąd).

Dla jasności szukam rozwiązania, które wykonuje pojedyncze przejście danych.

W języku T-SQL prawdopodobnie oznacza to, że OVERklauzula wykona pracę, a plan wykonania będzie zawierał bufory okien i agregaty okien. Wszystkie elementy językowe, które korzystają z tej OVERklauzuli, to uczciwa gra. Roztwór SQLCLR jest dopuszczalny, pod warunkiem, że gwarantuje prawidłowe wyniki.

W przypadku rozwiązań T-SQL im mniej skrótów, sortowań i buforów okien / agregatów w planie wykonania, tym lepiej. Dodawaj indeksy, ale oddzielne struktury nie są dozwolone (więc na przykład żadne wstępnie obliczone tabele nie są synchronizowane z wyzwalaczami). Tabele referencyjne są dozwolone (tabele liczb, dat itp.)

Idealnie byłoby, gdyby rozwiązania przyniosły dokładnie takie same wyniki w tej samej kolejności, co w powyższej wersji podzapytania, ale wszystko, co prawdopodobnie jest poprawne, jest również dopuszczalne. Wydajność jest zawsze brana pod uwagę, więc rozwiązania powinny być co najmniej względnie wydajne.

Dedykowany czat: Stworzyłem publiczny czat do dyskusji związanych z tym pytaniem i jego odpowiedziami. Każdy użytkownik z co najmniej 20 punktami reputacji może wziąć udział bezpośrednio. Proszę pingować mnie w komentarzu poniżej, jeśli masz mniej niż 20 powtórzeń i chcesz wziąć udział.

Paul White
źródło

Odpowiedzi:

42

Świetne pytanie, Paul! Użyłem kilku różnych podejść, jednego w T-SQL i jednego w CLR.

Szybkie podsumowanie T-SQL

Podejście T-SQL można podsumować w następujący sposób:

  • Weź krzyżowy produkt / daty
  • Scal obserwowane dane sprzedaży
  • Agreguj te dane do poziomu produktu / daty
  • Oblicz kroczące sumy z ostatnich 45 dni na podstawie tych zagregowanych danych (które zawierają wypełnione „brakujące” dni)
  • Filtruj te wyniki tylko do par produktu / daty, które miały co najmniej jedną sprzedaż

Za pomocą SET STATISTICS IO ONtego podejścia raportuje Table 'TransactionHistory'. Scan count 1, logical reads 484, co potwierdza „pojedyncze przejście” nad tabelą. Dla porównania, oryginalne raporty zapytań wyszukiwania pętli Table 'TransactionHistory'. Scan count 113444, logical reads 438366.

Jak podaje SET STATISTICS TIME ON, czas procesora wynosi 514ms. Porównuje się to korzystnie 2231msz pierwotnym zapytaniem.

Szybkie podsumowanie CLR

Podsumowanie CLR można podsumować w następujący sposób:

  • Wczytaj dane do pamięci, uporządkowane według produktu i daty
  • Przetwarzając każdą transakcję, dodaj do bieżącej sumy kosztów. Ilekroć transakcja jest innym produktem niż poprzednia, zresetuj bieżącą sumę do 0.
  • Zachowaj wskaźnik do pierwszej transakcji, która ma taki sam (produkt, data) jak bieżąca transakcja. Za każdym razem, gdy napotyka się ostatnią transakcję z tym produktem (data, data), oblicz kroczącą sumę dla tej transakcji i zastosuj ją do wszystkich transakcji z tym samym produktem (data, data)
  • Zwróć wszystkie wyniki użytkownikowi!

Używając SET STATISTICS IO ONtego podejścia, raportuje, że nie wystąpiły logiczne operacje we / wy! Wow, idealne rozwiązanie! (W rzeczywistości wydaje się, że SET STATISTICS IOnie zgłasza operacji we / wy poniesionych w CLR. Ale z kodu łatwo jest zobaczyć, że dokładnie jeden skan tabeli jest wykonywany i pobiera dane w kolejności według indeksu Paula.

Jak donosi: SET STATISTICS TIME ONczas procesora jest teraz 187ms. Jest to więc znaczna poprawa w stosunku do podejścia T-SQL. Niestety ogólny czas, jaki upłynął dla obu podejść, jest bardzo podobny i wynosi około pół sekundy. Jednak podejście oparte na CLR musi wyprowadzać do konsoli 113 KB wierszy (w porównaniu do 52 KB dla T-SQL grupującego według produktu / daty), dlatego zamiast tego skupiłem się na czasie procesora.

Inną dużą zaletą tego podejścia jest to, że daje dokładnie takie same wyniki jak w przypadku pierwotnego podejścia pętla / wyszukiwanie, w tym wiersz dla każdej transakcji, nawet w przypadkach, gdy produkt jest sprzedawany wiele razy tego samego dnia. (W AdventureWorks dokładnie porównałem wyniki rząd po rzędzie i potwierdziłem, że są one powiązane z pierwotnym zapytaniem Paula).

Wadą tego podejścia, przynajmniej w jego obecnej formie, jest to, że czyta wszystkie dane w pamięci. Jednak algorytm, który został zaprojektowany tylko ściśle potrzebuje bieżącej ramki okna w pamięci w dowolnym momencie i może zostać zaktualizowany do pracy z zestawami danych, które przekraczają pamięć. Paul zilustrował ten punkt w swojej odpowiedzi, tworząc implementację tego algorytmu, który przechowuje tylko przesuwane okno w pamięci. Dzieje się tak kosztem przyznawania wyższych uprawnień do montażu CLR, ale zdecydowanie warto byłoby skalować to rozwiązanie do dowolnie dużych zbiorów danych.


T-SQL - jedno skanowanie, pogrupowane według daty

Początkowe ustawienia

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

Zapytanie

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)

Plan wykonania

Z planu wykonania widzimy, że oryginalny indeks zaproponowany przez Paula jest wystarczający, aby umożliwić nam wykonanie pojedynczego zamówionego skanu Production.TransactionHistoryprzy użyciu połączenia scalającego, aby połączyć historię transakcji z każdą możliwą kombinacją produktu / daty.

wprowadź opis zdjęcia tutaj

Założenia

Istnieje kilka istotnych założeń przyjętych w tym podejściu. Przypuszczam, że to do Pawła należy decyzja, czy są do przyjęcia :)

  • Korzystam ze Production.Productstołu. Ta tabela jest dostępna bezpłatnie, AdventureWorks2012a relacja jest wymuszana przez klucz obcy z Production.TransactionHistory, więc zinterpretowałem to jako uczciwą grę.
  • Podejście to opiera się na fakcie, że transakcje nie zawierają elementu czasowego AdventureWorks2012; gdyby tak się stało, wygenerowanie pełnego zestawu kombinacji produkt / data nie byłoby już możliwe bez uprzedniego przejrzenia historii transakcji.
  • Tworzę zestaw wierszy, który zawiera tylko jeden wiersz na parę produktu / daty. Myślę, że jest to „prawdopodobnie poprawne”, aw wielu przypadkach bardziej pożądany rezultat. Dla każdego produktu / daty dodałem NumOrderskolumnę wskazującą, ile sprzedaży miało miejsce. Poniższy zrzut ekranu przedstawia porównanie wyników pierwotnego zapytania z proponowanym zapytaniem w przypadkach, gdy produkt był sprzedawany wiele razy w tym samym dniu (np. 319/ 2007-09-05 00:00:00.000)

wprowadź opis zdjęcia tutaj


CLR - jeden skan, pełny niezgrupowany zestaw wyników

Główny organ funkcji

Nie ma tu wiele do zobaczenia; główna część funkcji deklaruje dane wejściowe (które muszą pasować do odpowiedniej funkcji SQL), konfiguruje połączenie SQL i otwiera 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;
    }
}

Podstawowa logika

Wyodrębniłem główną logikę, dzięki czemu łatwiej jest skupić się na:

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

Pomocnicy

Poniższą logikę można napisać w tekście, ale nieco łatwiej jest ją odczytać, gdy są one podzielone na własne metody.

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

Wiązanie wszystkiego razem w SQL

Wszystko do tej pory było w C #, więc zobaczmy rzeczywisty zaangażowany SQL. (Alternatywnie możesz użyć tego skryptu wdrażania, aby utworzyć zestaw bezpośrednio z fragmentów mojego zestawu zamiast kompilować się samodzielnie).

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

Ostrzeżenia

Podejście CLR zapewnia znacznie większą elastyczność w zakresie optymalizacji algorytmu i prawdopodobnie może być jeszcze bardziej dostrojone przez eksperta w języku C #. Istnieją jednak także wady strategii CLR. Kilka rzeczy, o których należy pamiętać:

  • To podejście CLR zachowuje kopię zestawu danych w pamięci. Możliwe jest zastosowanie podejścia strumieniowego, ale napotkałem początkowe trudności i odkryłem, że istnieje nierozwiązany problem z Connect, narzekający, że zmiany w SQL 2008+ utrudniają stosowanie tego rodzaju podejścia. Nadal jest to możliwe (jak pokazuje Paul), ale wymaga wyższego poziomu uprawnień, ustawiając bazę danych jako TRUSTWORTHYi udzielając EXTERNAL_ACCESSzestawu CLR. Występują więc pewne problemy i potencjalne konsekwencje dla bezpieczeństwa, ale wypłatą jest podejście strumieniowe, które można lepiej skalować do znacznie większych zestawów danych niż w AdventureWorks.
  • CLR może być mniej dostępny dla niektórych DBA, czyniąc taką funkcję bardziej czarną skrzynką, która nie jest tak przezroczysta, nie jest tak łatwa do modyfikacji, nie tak łatwa do wdrożenia i być może nie jest tak łatwa do debugowania. Jest to dość duża wada w porównaniu do podejścia T-SQL.


Bonus: T-SQL # 2 - praktyczne podejście, którego faktycznie użyłem

Po pewnym okresie twórczego zastanowienia się nad problemem, pomyślałem, że opublikuję również dość prosty, praktyczny sposób, w jaki prawdopodobnie postanowiłbym rozwiązać ten problem, gdyby pojawił się w mojej codziennej pracy. Wykorzystuje funkcjonalność okna SQL 2012+, ale nie w przełomowy sposób, na który liczyło pytanie:

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

W rezultacie uzyskuje się dość prosty ogólny plan zapytań, nawet gdy jednocześnie analizuje się oba oba odpowiednie plany zapytań:

wprowadź opis zdjęcia tutaj wprowadź opis zdjęcia tutaj

Kilka powodów podoba mi się to podejście:

  • Daje pełny zestaw wyników wymagany w instrukcji problemu (w przeciwieństwie do większości innych rozwiązań T-SQL, które zwracają zgrupowaną wersję wyników).
  • Łatwo jest wyjaśnić, zrozumieć i debugować; Nie wrócę rok później i zastanawiam się, jak do cholery mogę dokonać niewielkiej zmiany bez zepsucia poprawności lub wydajności
  • Działa w przybliżeniu 900msna dostarczonym zestawie danych, a nie 2700msna oryginalnym poszukiwaniu pętli
  • Gdyby dane były znacznie gęstsze (więcej transakcji dziennie), złożoność obliczeniowa nie rośnie kwadratowo wraz z liczbą transakcji w przesuwanym oknie (jak ma to miejsce w przypadku pierwotnego zapytania); Myślę, że to rozwiązuje część obaw Pawła, że ​​chciał uniknąć wielu skanów
  • Powoduje to zasadniczo brak we / wy tempdb w ostatnich aktualizacjach SQL 2012+ ze względu na nową funkcję opóźnionego zapisu tempdb
  • W przypadku bardzo dużych zestawów danych podział pracy na osobne partie dla każdego produktu jest trywialny, jeśli presja pamięci ma stać się problemem

Kilka potencjalnych zastrzeżeń:

  • Chociaż technicznie skanuje Production.TransactionHistory tylko raz, nie jest to tak naprawdę podejście „jednego skanu”, ponieważ tablica #temp o podobnej wielkości i będzie musiała wykonać dodatkową logikę we / wy również na tej tabeli. Jednak nie uważam tego za zbyt różne od stołu roboczego, nad którym mamy większą kontrolę ręczną, ponieważ zdefiniowaliśmy jego dokładną strukturę
  • W zależności od środowiska użycie tempdb może być postrzegane jako dodatnie (np. Na osobnym zestawie dysków SSD) lub ujemne (wysoka współbieżność na serwerze, wiele rywalizacji o tempdb już)
Geoff Patterson
źródło
25

To długa odpowiedź, więc postanowiłem dodać tutaj streszczenie.

  • Na początku przedstawiam rozwiązanie, które daje dokładnie taki sam wynik w tej samej kolejności, co w pytaniu. Skanuje główną tabelę 3 razy: aby uzyskać listę ProductIDsz zakresem dat dla każdego produktu, podsumować koszty dla każdego dnia (ponieważ istnieje kilka transakcji z tymi samymi datami), aby połączyć wynik z oryginalnymi wierszami.
  • Następnie porównuję dwa podejścia, które upraszczają zadanie i unikają ostatniego skanowania głównego stołu. Ich wynikiem jest codzienne podsumowanie, tzn. Jeśli kilka transakcji na produkcie ma tę samą datę, są one łączone w jeden wiersz. Moje podejście z poprzedniego kroku dwukrotnie skanuje stół. Podejście Geoffa Pattersona skanuje tabelę raz, ponieważ wykorzystuje zewnętrzną wiedzę na temat zakresu dat i listy Produktów.
  • W końcu przedstawiam rozwiązanie jednoprzebiegowe, które ponownie zwraca codzienne podsumowanie, ale nie wymaga zewnętrznej wiedzy na temat zakresu dat lub listy ProductIDs.

Będę korzystać z bazy danych AdventureWorks2014 i SQL Server Express 2014.

Zmiany w oryginalnej bazie danych:

  • Zmieniono typ [Production].[TransactionHistory].[TransactionDate]z datetimena date. Komponent czasu i tak był zerowy.
  • Dodano tabelę kalendarza [dbo].[Calendar]
  • Dodano indeks do [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);

Artykuł MSDN o OVERklauzuli zawiera link do doskonałego posta na blogu o funkcjach okien autorstwa Itzika Ben-Gana. W tym poście wyjaśnia, jak OVERdziała, różnica między ROWSi RANGEopcji i wspomina ten właśnie problem obliczania toczenia sumę ponad zakresu dat. Wspomina, że ​​bieżąca wersja SQL Server nie implementuje RANGEw pełni i nie implementuje typów danych przedziałów czasowych. Jego wyjaśnienie różnicy między ROWSi RANGEdało mi pomysł.

Daty bez przerw i duplikatów

Jeśli TransactionHistorytabela zawiera daty bez przerw i duplikatów, to następujące zapytanie przyniosłoby prawidłowe wyniki:

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;

Rzeczywiście, okno 45 rzędów zajmowałoby dokładnie 45 dni.

Daty z przerwami bez duplikatów

Niestety nasze dane zawierają luki w terminach. Aby rozwiązać ten problem, możemy użyć Calendartabeli do wygenerowania zestawu dat bez przerw, a następnie LEFT JOINoryginalnych danych do tego zestawu i użyć tego samego zapytania ROWS BETWEEN 45 PRECEDING AND CURRENT ROW. Dałoby to prawidłowe wyniki tylko wtedy, gdy daty się nie powtarzają (w tym samym czasie ProductID).

Daty z przerwami z duplikatami

Niestety w naszych danych występują zarówno luki w datach, jak i daty mogą się powtarzać w tym samym czasie ProductID. Aby rozwiązać ten problem, możemy GROUPoryginalne dane, ProductID, TransactionDategenerując zestaw dat bez duplikatów. Następnie użyj Calendartabeli, aby wygenerować zestaw dat bez przerw. Następnie możemy użyć zapytania ROWS BETWEEN 45 PRECEDING AND CURRENT ROWdo obliczenia kroczącego SUM. To dałoby prawidłowe wyniki. Zobacz komentarze w zapytaniu poniżej.

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
;

Potwierdziłem, że to zapytanie daje takie same wyniki, jak podejście z pytania wykorzystującego podkwerendę.

Plany wykonania

statystyki

Pierwsze zapytanie wykorzystuje podkwerendę, drugie - takie podejście. Widać, że w tym podejściu czas trwania i liczba odczytów jest znacznie mniejsza. Większość szacunkowych kosztów w tym podejściu jest ostateczna ORDER BY, patrz poniżej.

podzapytanie

Podejście kwerendy ma prosty plan z zagnieżdżonymi pętlami i O(n*n)złożonością.

koniec

Zaplanuj skanowanie tego podejścia TransactionHistorykilka razy, ale nie ma żadnych pętli. Jak widać, ponad 70% szacowanego kosztu to Sortfinał ORDER BY.

io

Najlepszy wynik - na subquerydole - OVER.


Unikanie dodatkowych skanów

Ostatnie skanowanie indeksu, łączenie i sortowanie w powyższym planie jest spowodowane przez finał INNER JOINz oryginalną tabelą, aby końcowy wynik był dokładnie taki sam jak powolne podejście z podzapytaniem. Liczba zwróconych wierszy jest taka sama jak w TransactionHistorytabeli. Istnieją wiersze, w TransactionHistoryktórych kilka transakcji miało miejsce tego samego dnia dla tego samego produktu. Jeśli w wyniku można wyświetlić tylko podsumowanie dzienne, wynik końcowy JOINmożna usunąć, a zapytanie staje się nieco prostsze i nieco szybsze. Ostatnie skanowanie indeksu, łączenie i sortowanie z poprzedniego planu są zastępowane przez Filtr, który usuwa wiersze dodane przez 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
;

dwa skanowanie

Mimo TransactionHistoryto jest skanowany dwukrotnie. Potrzebny jest jeden dodatkowy skan, aby uzyskać zakres dat dla każdego produktu. Byłem zainteresowany, aby zobaczyć, jak to wygląda w porównaniu z innym podejściem, w którym wykorzystujemy zewnętrzną wiedzę na temat globalnego zakresu dat TransactionHistory, a także dodatkową tabelę, Productktóra ma wszystko, ProductIDsaby uniknąć tego dodatkowego skanowania. Z tego zapytania usunąłem obliczanie liczby transakcji dziennie, aby porównanie było prawidłowe. Można go dodać w obu zapytaniach, ale chciałbym, aby było to łatwe do porównania. Musiałem także użyć innych dat, ponieważ korzystam z wersji 2014 bazy danych.

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

jedno skanowanie

Oba zapytania zwracają ten sam wynik w tej samej kolejności.

Porównanie

Oto statystyki czasu i IO.

statystyki2

io2

Wariant z dwoma skanami jest nieco szybszy i ma mniej odczytów, ponieważ wariant z jednym skanem musi często używać tabeli roboczej. Poza tym wariant jednego skanu generuje więcej wierszy, niż jest to potrzebne, jak widać w planach. Generuje daty dla każdego, ProductIDktóry jest w Producttabeli, nawet jeśli ProductIDnie ma żadnych transakcji. ProductTabela zawiera 504 wiersze , ale tylko 441 produktów zawiera transakcje TransactionHistory. Ponadto generuje ten sam zakres dat dla każdego produktu, co jest więcej niż potrzebne. Gdyby TransactionHistorymiał dłuższą historię ogólną, a każdy produkt miałby stosunkowo krótką historię, liczba dodatkowych niepotrzebnych rzędów byłaby jeszcze wyższa.

Z drugiej strony można zoptymalizować wariant dwóch skanów, tworząc inny, bardziej wąski indeks na just (ProductID, TransactionDate). Indeks ten służyłby do obliczania dat rozpoczęcia / zakończenia dla każdego produktu ( CTE_Products) i miałby mniej stron niż indeks i dlatego powodowałby mniej odczytów.

Możemy więc wybrać albo przeprowadzić wyraźny prosty skan, albo mieć domyślny stół roboczy.

Przy okazji, jeśli wynik jest możliwy tylko z codziennymi podsumowaniami, lepiej jest utworzyć indeks, który nie będzie zawierał ReferenceOrderID. Zużyłoby mniej stron => mniej IO.

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

Rozwiązanie jednoprzebiegowe z zastosowaniem APLIKACJI KRZYŻOWEJ

Staje się naprawdę długą odpowiedzią, ale tutaj jest jeszcze jeden wariant, który zwraca tylko codzienne podsumowanie, ale wykonuje tylko jeden skan danych i nie wymaga zewnętrznej wiedzy na temat zakresu dat lub listy ProductID. Nie wykonuje również sortowania pośredniego. Ogólna wydajność jest podobna do poprzednich wariantów, choć wydaje się nieco gorsza.

Główną ideą jest użycie tabeli liczb do wygenerowania wierszy, które wypełnią luki w datach. Dla każdej istniejącej daty użyj, LEADaby obliczyć rozmiar luki w dniach, a następnie użyj, CROSS APPLYaby dodać wymaganą liczbę wierszy do zestawu wyników. Najpierw próbowałem z użyciem stałej tabeli liczb. Plan pokazał dużą liczbę odczytów w tej tabeli, chociaż faktyczny czas trwania był prawie taki sam, jak w przypadku generowania liczb w locie za pomocą 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
;

Ten plan jest „dłuższy”, ponieważ zapytanie używa dwóch funkcji okna ( LEADi SUM).

krzyż stosować

statystyki ca

ca io

Vladimir Baranov
źródło
23

Alternatywne rozwiązanie SQLCLR, które działa szybciej i wymaga mniej pamięci:

Skrypt wdrażania

Wymaga to EXTERNAL_ACCESSzestawu uprawnień, ponieważ wykorzystuje połączenie zwrotne z serwerem docelowym i bazą danych zamiast (wolnego) połączenia kontekstowego. Oto jak wywołać funkcję:

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;

Daje dokładnie takie same wyniki, w tej samej kolejności, co pytanie.

Plan wykonania:

Plan wykonania SQLCLR TVF

Plan wykonania zapytania źródłowego SQLCLR

Zaplanuj statystyki wydajności Eksploratora

Logiczne odczyty programu Profiler: 481

Główną zaletą tej implementacji jest to, że jest szybsza niż przy użyciu połączenia kontekstowego i zużywa mniej pamięci. Jednocześnie zachowuje tylko dwie rzeczy w pamięci:

  1. Wszelkie zduplikowane wiersze (ten sam produkt i data transakcji). Jest to wymagane, ponieważ do czasu zmiany produktu lub daty nie wiemy, jaka będzie ostateczna suma bieżąca. W przykładowych danych istnieje jedna kombinacja produktu i daty, która ma 64 wiersze.
  2. Przesuwny 45-dniowy zakres kosztów i dat transakcji tylko dla bieżącego produktu. Jest to konieczne, aby dostosować prostą sumę bieżącą dla wierszy, które opuszczają 45-dniowe okno przesuwne.

To minimalne buforowanie powinno zapewnić dobre skalowanie tej metody; na pewno lepsze niż próba utrzymania całego zestawu danych wejściowych w pamięci CLR.

Kod źródłowy

Paul White
źródło
17

Jeśli korzystasz z 64-bitowej wersji Enterprise, Developer lub Evaluation SQL Server 2014, możesz użyć OLTP w pamięci . Rozwiązaniem nie będzie pojedynczy skan i prawie w ogóle nie użyje żadnych funkcji okna, ale może to wnieść dodatkową wartość do tego pytania, a zastosowany algorytm może być prawdopodobnie inspiracją dla innych rozwiązań.

Najpierw musisz włączyć OLTP w pamięci w bazie danych 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;

Parametrem procedury jest zmienna tabeli In-Memory, którą należy zdefiniować jako typ.

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

Identyfikator nie jest unikalny w tej tabeli, jest unikalny dla każdej kombinacji ProductIDi TransactionDate.

W procedurze jest kilka komentarzy, które mówią ci, co robi, ale ogólnie oblicza sumę bieżącą w pętli i dla każdej iteracji sprawdza sumę bieżącą, jak to było 45 dni temu (lub więcej).

Bieżąca suma bieżąca minus suma bieżąca, jak to było 45 dni temu, jest ciągłą sumą 45 dni, której szukamy.

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;

Wywołaj taką procedurę.

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

Testowanie tego na moim komputerze Statystyki klienta zgłaszają całkowity czas wykonania około 750 milisekund. Dla porównania wersja zapytania podrzędnego zajmuje 3,5 sekundy.

Dodatkowe wędrówki:

Ten algorytm może być również używany przez zwykły T-SQL. Oblicz sumę bieżącą, rangenie używając wierszy, i zapisz wynik w tabeli tymczasowej. Następnie możesz wysłać zapytanie do tej tabeli z łączeniem własnym do bieżącej sumy sprzed 45 dni i obliczyć sumę kroczącą. Jednak implementacja w rangeporównaniu do rowsjest dość powolna, ponieważ trzeba traktować duplikaty zamówienia według klauzuli inaczej, więc nie uzyskałem tak dobrej wydajności przy takim podejściu. Obejściem tego problemu może być użycie innej funkcji okna, takiej jak last_value()nad obliczoną sumą bieżącą, za pomocą rowssymulacji rangesumy bieżącej. Innym sposobem jest użycie max() over(). Oba miały pewne problemy. Znalezienie odpowiedniego indeksu do użycia w celu uniknięcia sortowania i unikania buforowania za pomocąmax() over()wersja. Zrezygnowałem z optymalizacji tych rzeczy, ale jeśli jesteś zainteresowany kodem, który do tej pory mam, daj mi znać.

Mikael Eriksson
źródło
13

Cóż, było fajnie :) Moje rozwiązanie jest nieco wolniejsze niż w przypadku @ GeoffPatterson, ale część tego polega na tym, że przywiązuję się do oryginalnej tabeli, aby wyeliminować jedno z założeń Geoffa (tj. Jeden wiersz na parę produkt / data) . Przyjąłem założenie, że była to uproszczona wersja ostatniego zapytania i może wymagać dodatkowych informacji z oryginalnej tabeli.

Uwaga: Pożyczam tabelę kalendarza Geoffa i tak naprawdę otrzymałem bardzo podobne rozwiązanie:

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

Oto samo zapytanie:

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;

Zasadniczo zdecydowałem, że najłatwiejszym sposobem na poradzenie sobie z tym jest użycie opcja dla klauzuli ROWS. Ale to wymaga, że mam tylko jeden wiersz na ProductID, TransactionDatekombinacji, a nie tylko to, ale musiałem mieć jeden wiersz na ProductIDi possible date. Zrobiłem to, łącząc tabele produktu, kalendarza i TransactionHistory w CTE. Następnie musiałem utworzyć kolejny CTE, aby wygenerować informacje kroczące. Musiałem to zrobić, ponieważ jeśli dołączyłem bezpośrednio do oryginalnej tabeli, uzyskałem eliminację wierszy, co zepsuło moje wyniki. Potem było już proste połączenie mojego drugiego CTE z powrotem do oryginalnego stołu. I nie dodać TBEkolumnę (być wyeliminowane), aby pozbyć się pustych wierszy powstałych w CTE. Użyłem również CROSS APPLYw początkowej CTE do wygenerowania granic dla mojej tabeli kalendarza.

Następnie dodałem zalecany indeks:

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

I otrzymałem ostateczny plan wykonania:

wprowadź opis zdjęcia tutaj wprowadź opis zdjęcia tutaj wprowadź opis zdjęcia tutaj

EDYCJA: W końcu dodałem indeks do tabeli kalendarza, który przyspieszył wydajność o rozsądny margines.

CREATE INDEX ix_calendar ON calendar(d)
Kenneth Fisher
źródło
2
RunningTotal.TBE IS NOT NULLWarunek (i w konsekwencji TBEkolumna) nie jest konieczne. Nie dostaniesz zbędnych wierszy, jeśli go upuścisz, ponieważ twój wewnętrzny warunek łączenia zawiera kolumnę daty - dlatego zestaw wyników nie może mieć dat, które nie były pierwotnie w źródle.
Andriy M,
2
Tak. Zgadzam się całkowicie. A jednak wciąż powodowało to wzrost o około 2 sekundy. Myślę, że dzięki temu optymalizator pozna dodatkowe informacje.
Kenneth Fisher
4

Mam kilka alternatywnych rozwiązań, które nie używają indeksów ani tabel referencyjnych. Być może mogą być przydatne w sytuacjach, w których nie masz dostępu do żadnych dodatkowych tabel i nie możesz utworzyć indeksów. Wydaje się, że możliwe jest uzyskanie poprawnych wyników podczas grupowania za TransactionDatepomocą tylko jednego przebiegu danych i tylko jednej funkcji okna. Jednak nie mogłem znaleźć sposobu na zrobienie tego za pomocą tylko jednej funkcji okna, gdy nie można pogrupować według TransactionDate.

Aby zapewnić ramę odniesienia, na moim komputerze oryginalne rozwiązanie opublikowane w pytaniu ma czas pracy procesora wynoszący 2808 ms bez indeksu pokrycia i 1950 ms z indeksem pokrycia. Testuję z bazą danych AdventureWorks2014 i SQL Server Express 2014.

Zacznijmy od rozwiązania, kiedy możemy grupować według TransactionDate. Bieżącą sumę z ostatnich X dni można również wyrazić w następujący sposób:

Suma bieżąca dla wiersza = suma bieżąca wszystkich poprzednich wierszy - suma bieżąca wszystkich poprzednich wierszy, dla których data jest poza oknem daty.

W SQL jednym ze sposobów na wyrażenie tego jest zrobienie dwóch kopii danych, a dla drugiej kopii pomnożenie kosztu przez -1 i dodanie X + 1 dni do kolumny daty. Obliczenie sumy bieżącej dla wszystkich danych spowoduje wdrożenie powyższej formuły. Pokażę to dla niektórych przykładowych danych. Poniżej znajduje się przykładowa data dla singla ProductID. Przedstawiam daty jako liczby, aby ułatwić obliczenia. Dane początkowe:

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

Dodaj drugą kopię danych. Druga kopia ma 46 dni dodanych do daty, a koszt pomnożony przez -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 
╚══════╩══════╩═══════════╝

Weź sumę bieżącą uporządkowaną Daterosnąco i CopiedRowmalejąco:

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

Odfiltruj skopiowane wiersze, aby uzyskać pożądany wynik:

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

Poniższy kod SQL jest jednym ze sposobów implementacji powyższego algorytmu:

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

Na moim komputerze zajęło to 702 ms czasu procesora z indeksem pokrycia i 734 ms czasu procesora bez tego indeksu. Plan zapytań można znaleźć tutaj: https://www.brentozar.com/pastetheplan/?id=SJdCsGVSl

Jednym minusem tego rozwiązania jest to, że wydaje się, że istnieje nieunikniony rodzaj przy zamawianiu według nowej TransactionDatekolumny. Nie sądzę, że ten rodzaj można rozwiązać, dodając indeksy, ponieważ przed połączeniem musimy połączyć dwie kopie danych. Byłem w stanie pozbyć się czegoś na końcu zapytania, dodając inną kolumnę do ORDER BY. Jeśli zamówiłem przez FilterFlag, stwierdziłem, że SQL Server zoptymalizuje tę kolumnę z sortowania i wykona jawne sortowanie.

Rozwiązania, w których musimy zwrócić zestaw wyników ze zduplikowanymi TransactionDatewartościami dla tego samego, ProductIdbyły znacznie bardziej skomplikowane. Podsumowałbym problem jako jednoczesną potrzebę podziału według tej samej kolumny i uporządkowania według tej samej kolumny. Składnia podana przez Paula rozwiązuje ten problem, więc nie jest zaskakujące, że tak trudno jest wyrazić przy użyciu bieżących funkcji okna dostępnych w SQL Server (gdyby nie było trudno wyrazić, nie byłoby potrzeby rozszerzania składni).

Jeśli użyję powyższego zapytania bez grupowania, wówczas otrzymam różne wartości dla sumy kroczącej, gdy istnieje wiele wierszy o tym samym ProductIdi TransactionDate. Jednym ze sposobów rozwiązania tego problemu jest wykonanie tej samej obliczenia sumy bieżącej jak powyżej, ale także oflagowanie ostatniego wiersza na partycji. Można to zrobić za pomocą LEAD(zakładając, że ProductIDnigdy nie ma wartości NULL) bez dodatkowego sortowania. Aby uzyskać końcową bieżącą wartość sumy, używam MAXjako funkcji okna do zastosowania wartości z ostatniego wiersza partycji do wszystkich wierszy partycji.

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

Na moim komputerze zajęło to 2464 ms czasu procesora bez indeksu pokrycia. Tak jak poprzednio wydaje się, że istnieje nieunikniony rodzaj. Plan zapytań można znaleźć tutaj: https://www.brentozar.com/pastetheplan/?id=HyWxhGVBl

Myślę, że w powyższym zapytaniu jest miejsce na ulepszenia. Z pewnością istnieją inne sposoby korzystania z funkcji systemu Windows w celu uzyskania pożądanego rezultatu.

Joe Obbish
źródło