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ę
ProductIDs
z 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 datetime
na 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 OVER
klauzuli zawiera link do doskonałego posta na blogu o funkcjach okien autorstwa Itzika Ben-Gana. W tym poście wyjaśnia, jak OVER
działa, różnica między ROWS
i RANGE
opcji i wspomina ten właśnie problem obliczania toczenia sumę ponad zakresu dat. Wspomina, że bieżąca wersja SQL Server nie implementuje RANGE
w pełni i nie implementuje typów danych przedziałów czasowych. Jego wyjaśnienie różnicy między ROWS
i RANGE
dało mi pomysł.
Daty bez przerw i duplikatów
Jeśli TransactionHistory
tabela 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ć Calendar
tabeli do wygenerowania zestawu dat bez przerw, a następnie LEFT JOIN
oryginalnych 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 GROUP
oryginalne dane, ProductID, TransactionDate
generując zestaw dat bez duplikatów. Następnie użyj Calendar
tabeli, aby wygenerować zestaw dat bez przerw. Następnie możemy użyć zapytania ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
do 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
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.
Podejście kwerendy ma prosty plan z zagnieżdżonymi pętlami i O(n*n)
złożonością.
Zaplanuj skanowanie tego podejścia TransactionHistory
kilka razy, ale nie ma żadnych pętli. Jak widać, ponad 70% szacowanego kosztu to Sort
finał ORDER BY
.
Najlepszy wynik - na subquery
dole - OVER
.
Unikanie dodatkowych skanów
Ostatnie skanowanie indeksu, łączenie i sortowanie w powyższym planie jest spowodowane przez finał INNER JOIN
z 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 TransactionHistory
tabeli. Istnieją wiersze, w TransactionHistory
któ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 JOIN
moż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
;
Mimo TransactionHistory
to 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ę, Product
która ma wszystko, ProductIDs
aby 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);
Oba zapytania zwracają ten sam wynik w tej samej kolejności.
Porównanie
Oto statystyki czasu i IO.
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, ProductID
który jest w Product
tabeli, nawet jeśli ProductID
nie ma żadnych transakcji. Product
Tabela 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 TransactionHistory
miał 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, LEAD
aby obliczyć rozmiar luki w dniach, a następnie użyj, CROSS APPLY
aby 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 ( LEAD
i SUM
).
RunningTotal.TBE IS NOT NULL
Warunek (i w konsekwencjiTBE
kolumna) 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.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
TransactionDate
pomocą 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ługTransactionDate
.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: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:Dodaj drugą kopię danych. Druga kopia ma 46 dni dodanych do daty, a koszt pomnożony przez -1:
Weź sumę bieżącą uporządkowaną
Date
rosnąco iCopiedRow
malejąco:Odfiltruj skopiowane wiersze, aby uzyskać pożądany wynik:
Poniższy kod SQL jest jednym ze sposobów implementacji powyższego algorytmu:
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
TransactionDate
kolumny. 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 przezFilterFlag
, 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
TransactionDate
wartościami dla tego samego,ProductId
był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
ProductId
iTransactionDate
. 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, żeProductID
nigdy nie ma wartości NULL) bez dodatkowego sortowania. Aby uzyskać końcową bieżącą wartość sumy, używamMAX
jako funkcji okna do zastosowania wartości z ostatniego wiersza partycji do wszystkich wierszy partycji.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.
źródło