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
ProductIDs
mit 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 datetime
nach 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 OVER
Klausel 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 ROWS
und RANGE
Optionen 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 RANGE
vollständig implementiert ist und keine temporären Intervalldatentypen implementiert sind. Seine Erklärung des Unterschieds zwischen ROWS
und RANGE
gab mir eine Idee.
Termine ohne Lücken und Duplikate
Wenn die TransactionHistory
Tabelle 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 Calendar
Tabelle verwenden, um einen Satz von Daten ohne Lücken zu generieren, dann LEFT JOIN
Originaldaten 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 GROUP
Originaldaten erstellen, indem wir ProductID, TransactionDate
eine Reihe von Daten ohne Duplikate erstellen . Verwenden Sie dann die Calendar
Tabelle, 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
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.
Der Unterabfrageansatz hat einen einfachen Plan mit verschachtelten Schleifen und O(n*n)
Komplexität.
Planen Sie diesen Ansatz TransactionHistory
mehrmals ein, aber es gibt keine Schleifen. Wie Sie sehen können, fallen mehr als 70% der geschätzten Kosten Sort
für das Finale an ORDER BY
.
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 JOIN
mit der Originaltabelle verursacht, sodass das Endergebnis genau dem langsamen Ansatz mit Unterabfrage entspricht. Die Anzahl der zurückgegebenen Zeilen entspricht der in TransactionHistory
Tabelle. Es sind Zeilen in, in TransactionHistory
denen 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, JOIN
kann 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
;
Trotzdem TransactionHistory
wird 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 TransactionHistory
sowie eine zusätzliche Tabelle verwenden Product
, ProductIDs
um 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);
Beide Abfragen geben dasselbe Ergebnis in derselben Reihenfolge zurück.
Vergleich
Hier sind Zeit- und IO-Statistiken.
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 ProductID
in der Product
Tabelle Daten generiert , auch wenn a ProductID
keine Transaktionen enthält. Es gibt 504 Zeilen in der Product
Tabelle, aber nur 441 Produkte haben Transaktionen in TransactionHistory
. Außerdem wird für jedes Produkt derselbe Zeitraum generiert, der mehr als erforderlich ist. Wenn TransactionHistory
die 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. LEAD
Berechnen Sie für jedes vorhandene Datum die Größe der Lücke in Tagen und CROSS APPLY
fü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 ( LEAD
und SUM
) verwendet.
RunningTotal.TBE IS NOT NULL
Bedingung (und folglich dieTBE
Spalte) 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.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
TransactionDate
mit 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önnenTransactionDate
.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: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: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:
Nimm die laufende Summe in
Date
aufsteigender undCopiedRow
absteigender Reihenfolge:Filtern Sie die kopierten Zeilen heraus, um das gewünschte Ergebnis zu erhalten:
Das folgende SQL ist eine Möglichkeit, den obigen Algorithmus zu implementieren:
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
TransactionDate
Spalte 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, stellteFilterFlag
ich 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
TransactionDate
Werten für dieselbe zurückgeben müssen,ProductId
waren 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
ProductId
und gibtTransactionDate
. 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 mitLEAD
(vorausgesetzt, esProductID
ist nie NULL) ohne eine zusätzliche Sortierung erfolgen. Für den endgültigen laufenden Summenwert verwende ichMAX
als Fensterfunktion den Wert in der letzten Zeile der Partition auf alle Zeilen in der Partition.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.
quelle