Database
 sql >> Datenbank >  >> RDS >> Database

Beste Ansätze für gruppierte laufende Summen

Der allererste Blogbeitrag auf dieser Seite, weit zurück im Juli 2012, sprach über die besten Ansätze für laufende Gesamtsummen. Seitdem wurde ich mehrfach gefragt, wie ich das Problem angehen würde, wenn die laufenden Summen komplexer wären – insbesondere, wenn ich die laufenden Summen für mehrere Entitäten berechnen müsste – sagen wir die Bestellungen jedes Kunden.

Das ursprüngliche Beispiel verwendete einen fiktiven Fall einer Stadt, die Strafzettel für zu schnelles Fahren ausstellt; Die laufende Summe bestand einfach darin, die Anzahl der Strafzettel für zu schnelles Fahren pro Tag zu aggregieren und zu zählen (unabhängig davon, für wen der Strafzettel ausgestellt wurde oder für wie viel er war). Ein komplexeres (aber praktisches) Beispiel könnte die Zusammenfassung des laufenden Gesamtwerts von Strafzetteln für zu schnelles Fahren, gruppiert nach Führerschein, pro Tag sein. Stellen wir uns folgende Tabelle vor:

CREATE TABLE dbo.SpeedingTickets
(
  IncidentID    INT IDENTITY(1,1) PRIMARY KEY,
  LicenseNumber INT          NOT NULL,
  IncidentDate  DATE         NOT NULL,
  TicketAmount  DECIMAL(7,2) NOT NULL
);
 
CREATE UNIQUE INDEX x 
  ON dbo.SpeedingTickets(LicenseNumber, IncidentDate) 
  INCLUDE(TicketAmount);

Sie könnten fragen, DECIMAL(7,2) , Ja wirklich? Wie schnell fahren diese Leute? Nun, in Kanada zum Beispiel ist es gar nicht so schwer, ein Bußgeld von 10.000 $ zu bekommen.

Lassen Sie uns nun die Tabelle mit einigen Beispieldaten füllen. Ich werde hier nicht auf alle Einzelheiten eingehen, aber dies sollte ungefähr 6.000 Zeilen ergeben, die mehrere Fahrer und mehrere Ticketbeträge über einen Zeitraum von einem Monat darstellen:

;WITH TicketAmounts(ID,Value) AS 
(
  -- 10 arbitrary ticket amounts
  SELECT i,p FROM 
  (
    VALUES(1,32.75),(2,75), (3,109),(4,175),(5,295),
          (6,68.50),(7,125),(8,145),(9,199),(10,250)
  ) AS v(i,p)
),
LicenseNumbers(LicenseNumber,[newid]) AS 
(
  -- 1000 random license numbers
  SELECT TOP (1000) 7000000 + number, n = NEWID()
    FROM [master].dbo.spt_values 
	WHERE number BETWEEN 1 AND 999999
    ORDER BY n
),
JanuaryDates([day]) AS 
(
  -- every day in January 2014
  SELECT TOP (31) DATEADD(DAY, number, '20140101') 
    FROM [master].dbo.spt_values 
    WHERE [type] = N'P' 
	ORDER BY number
),
Tickets(LicenseNumber,[day],s) AS
(
  -- match *some* licenses to days they got tickets
  SELECT DISTINCT l.LicenseNumber, d.[day], s = RTRIM(l.LicenseNumber) 
    FROM LicenseNumbers AS l CROSS JOIN JanuaryDates AS d
    WHERE CHECKSUM(NEWID()) % 100 = l.LicenseNumber % 100
	AND (RTRIM(l.LicenseNumber) LIKE '%' + RIGHT(CONVERT(CHAR(8), d.[day], 112),1) + '%')
	OR (RTRIM(l.LicenseNumber+1) LIKE '%' + RIGHT(CONVERT(CHAR(8), d.[day], 112),1) + '%')
)
INSERT dbo.SpeedingTickets(LicenseNumber,IncidentDate,TicketAmount)
SELECT t.LicenseNumber, t.[day], ta.Value 
  FROM Tickets AS t 
  INNER JOIN TicketAmounts AS ta
  ON ta.ID = CONVERT(INT,RIGHT(t.s,1))-CONVERT(INT,LEFT(RIGHT(t.s,2),1))
  ORDER BY t.[day], t.LicenseNumber;

Dies mag ein wenig zu kompliziert erscheinen, aber eine der größten Herausforderungen, die ich beim Verfassen dieser Blog-Posts oft habe, ist das Erstellen einer angemessenen Menge realistischer "zufälliger" / willkürlicher Daten. Wenn Sie eine bessere Methode für die willkürliche Datenbestückung haben, verwenden Sie auf keinen Fall mein Gemurmel als Beispiel – es ist für diesen Beitrag nebensächlich.

Ansätze

Es gibt verschiedene Möglichkeiten, dieses Problem in T-SQL zu lösen. Hier sind sieben Ansätze mit den dazugehörigen Plänen. Ich habe Techniken wie Cursor (weil sie unbestreitbar langsamer sind) und datumsbasierte rekursive CTEs (weil sie von zusammenhängenden Tagen abhängen) weggelassen.

    Unterabfrage #1

    SELECT LicenseNumber, IncidentDate, TicketAmount,
      RunningTotal = TicketAmount + COALESCE(
      (
        SELECT SUM(TicketAmount)
          FROM dbo.SpeedingTickets AS s
          WHERE s.LicenseNumber = o.LicenseNumber
          AND s.IncidentDate < o.IncidentDate
      ), 0)
    FROM dbo.SpeedingTickets AS o
    ORDER BY LicenseNumber, IncidentDate;


    Planen Sie Unterabfrage Nr. 1

    Unterabfrage #2

    SELECT LicenseNumber, IncidentDate, TicketAmount, 
      RunningTotal = 
      (
        SELECT SUM(TicketAmount) FROM dbo.SpeedingTickets
        WHERE LicenseNumber = t.LicenseNumber 
        AND IncidentDate <= t.IncidentDate 
      )
    FROM dbo.SpeedingTickets AS t
    ORDER BY LicenseNumber, IncidentDate;


    Planen Sie Unterabfrage #2

    Selbst beitreten

    SELECT t1.LicenseNumber, t1.IncidentDate, t1.TicketAmount, 
      RunningTotal = SUM(t2.TicketAmount)
    FROM dbo.SpeedingTickets AS t1
    INNER JOIN dbo.SpeedingTickets AS t2
      ON t1.LicenseNumber = t2.LicenseNumber
      AND t1.IncidentDate >= t2.IncidentDate
    GROUP BY t1.LicenseNumber, t1.IncidentDate, t1.TicketAmount
    ORDER BY t1.LicenseNumber, t1.IncidentDate;


    Selbstbeitritt planen

    Äußere Anwendung

    SELECT t1.LicenseNumber, t1.IncidentDate, t1.TicketAmount, 
      RunningTotal = SUM(t2.TicketAmount)
    FROM dbo.SpeedingTickets AS t1
    OUTER APPLY
    (
      SELECT TicketAmount 
        FROM dbo.SpeedingTickets 
        WHERE LicenseNumber = t1.LicenseNumber
        AND IncidentDate <= t1.IncidentDate
    ) AS t2
    GROUP BY t1.LicenseNumber, t1.IncidentDate, t1.TicketAmount
    ORDER BY t1.LicenseNumber, t1.IncidentDate;


    Plan für äußere Anwendung

    SUM OVER() mit RANGE (nur 2012+)

    SELECT LicenseNumber, IncidentDate, TicketAmount, 
      RunningTotal = SUM(TicketAmount) OVER 
      (
        PARTITION BY LicenseNumber 
        ORDER BY IncidentDate RANGE UNBOUNDED PRECEDING
      )
      FROM dbo.SpeedingTickets
      ORDER BY LicenseNumber, IncidentDate;


    Planen Sie SUM OVER() mit RANGE

    SUM OVER() mit ROWS (nur 2012+)

    SELECT LicenseNumber, IncidentDate, TicketAmount, 
      RunningTotal = SUM(TicketAmount) OVER 
      (
        PARTITION BY LicenseNumber 
        ORDER BY IncidentDate ROWS UNBOUNDED PRECEDING
      )
      FROM dbo.SpeedingTickets
      ORDER BY LicenseNumber, IncidentDate;


    Planen Sie SUM OVER() mit ROWS

    Satzbasierte Iteration

    Mit Anerkennung von Hugo Kornelis (@Hugo_Kornelis) für Kapitel Nr. 4 in SQL Server MVP Deep Dives Band Nr. 1 kombiniert dieser Ansatz einen satzbasierten Ansatz und einen Cursor-Ansatz.

    DECLARE @x TABLE
    (
      LicenseNumber INT NOT NULL, 
      IncidentDate DATE NOT NULL, 
      TicketAmount DECIMAL(7,2) NOT NULL, 
      RunningTotal DECIMAL(7,2) NOT NULL, 
      rn INT NOT NULL, 
      PRIMARY KEY(LicenseNumber, IncidentDate)
    );
     
    INSERT @x(LicenseNumber, IncidentDate, TicketAmount, RunningTotal, rn)
    SELECT LicenseNumber, IncidentDate, TicketAmount, TicketAmount,
      ROW_NUMBER() OVER (PARTITION BY LicenseNumber ORDER BY IncidentDate)
      FROM dbo.SpeedingTickets;
     
    DECLARE @rn INT = 1, @rc INT = 1;
     
    WHILE @rc > 0
    BEGIN
      SET @rn += 1;
     
      UPDATE [current]
        SET RunningTotal = [last].RunningTotal + [current].TicketAmount
        FROM @x AS [current] 
        INNER JOIN @x AS [last]
        ON [current].LicenseNumber = [last].LicenseNumber 
        AND [last].rn = @rn - 1
        WHERE [current].rn = @rn;
     
      SET @rc = @@ROWCOUNT;
    END
     
    SELECT LicenseNumber, IncidentDate, TicketAmount, RunningTotal
      FROM @x
      ORDER BY LicenseNumber, IncidentDate;

    Aufgrund seiner Natur erzeugt dieser Ansatz viele identische Pläne im Prozess der Aktualisierung der Tabellenvariablen, die alle den Plänen Self-Join und Outer Apply ähneln, aber in der Lage sind, eine Suche zu verwenden:


    Einer von vielen UPDATE-Plänen, die durch satzbasierte Iteration erstellt wurden

    Der einzige Unterschied zwischen jedem Plan in jeder Iteration ist die Zeilenanzahl. Bei jeder aufeinanderfolgenden Iteration sollte die Anzahl der betroffenen Zeilen gleich bleiben oder abnehmen, da die Anzahl der betroffenen Zeilen bei jeder Iteration die Anzahl der Fahrer mit Tickets an dieser Anzahl von Tagen (oder genauer gesagt der Anzahl von Tagen an) darstellt dieser "Rang").

Leistungsergebnisse

Hier sehen Sie, wie sich die Ansätze stapeln, wie vom SQL Sentry Plan Explorer gezeigt, mit Ausnahme des satzbasierten Iterationsansatzes, der, da er aus vielen einzelnen Anweisungen besteht, im Vergleich zu den anderen nicht gut dargestellt wird.


Planen Sie Explorer-Laufzeitmetriken für sechs der sieben Ansätze

Neben der Überprüfung der Pläne und dem Vergleich der Laufzeitmetriken im Plan-Explorer habe ich auch die reine Laufzeit in Management Studio gemessen. Hier sind die Ergebnisse der 10-maligen Ausführung jeder Abfrage, wobei zu beachten ist, dass dies auch die Renderzeit in SSMS umfasst:


Laufzeitdauer in Millisekunden für alle sieben Ansätze (10 Iterationen )

Wenn Sie also SQL Server 2012 oder besser verwenden, scheint der beste Ansatz SUM OVER() zu sein mit ROWS UNBOUNDED PRECEDING . Wenn Sie nicht auf SQL Server 2012 unterwegs sind, schien der zweite Subquery-Ansatz trotz der hohen Anzahl von Lesevorgängen im Vergleich zu beispielsweise OUTER APPLY hinsichtlich der Laufzeit optimal zu sein Anfrage. In jedem Fall sollten Sie diese an Ihr Schema angepassten Ansätze natürlich gegen Ihr eigenes System testen. Ihre Daten, Indizes und andere Faktoren können dazu führen, dass eine andere Lösung für Ihre Umgebung am besten geeignet ist.

Andere Komplexitäten

Nun bedeutet der eindeutige Index, dass jede Kombination von LicenseNumber + IncidentDate eine einzige kumulative Summe enthält, falls ein bestimmter Fahrer an einem bestimmten Tag mehrere Tickets erhält. Diese Geschäftsregel vereinfacht unsere Logik ein wenig und vermeidet die Notwendigkeit eines Tie-Breakers, um deterministische laufende Summen zu erstellen.

Wenn Sie Fälle haben, in denen Sie möglicherweise mehrere Zeilen für eine bestimmte Kombination aus LicenseNumber + IncidentDate haben, können Sie die Bindung aufheben, indem Sie eine andere Spalte verwenden, die dazu beiträgt, die Kombination eindeutig zu machen (offensichtlich hätte die Quelltabelle keine Eindeutigkeitsbeschränkung mehr für diese beiden Spalten). . Beachten Sie, dass dies auch in Fällen möglich ist, in denen DATE Spalte ist eigentlich DATETIME – Viele Leute gehen davon aus, dass Datums-/Uhrzeitwerte eindeutig sind, aber dies ist sicherlich nicht immer garantiert, unabhängig von der Granularität.

In meinem Fall könnte ich den IDENTITY verwenden Spalte, IncidentID; Hier ist, wie ich jede Lösung anpassen würde (wobei ich anerkenne, dass es möglicherweise bessere Möglichkeiten gibt; nur Ideen verwerfen):

/* --------- subquery #1 --------- */
 
SELECT LicenseNumber, IncidentDate, TicketAmount,
  RunningTotal = TicketAmount + COALESCE(
  (
    SELECT SUM(TicketAmount)
      FROM dbo.SpeedingTickets AS s
      WHERE s.LicenseNumber = o.LicenseNumber
      AND (s.IncidentDate < o.IncidentDate
      -- added this line:
      OR (s.IncidentDate = o.IncidentDate AND s.IncidentID < o.IncidentID))
  ), 0)
  FROM dbo.SpeedingTickets AS o
  ORDER BY LicenseNumber, IncidentDate;
 
 
 
/* --------- subquery #2 --------- */
 
SELECT LicenseNumber, IncidentDate, TicketAmount, 
  RunningTotal = 
  (
    SELECT SUM(TicketAmount) FROM dbo.SpeedingTickets
    WHERE LicenseNumber = t.LicenseNumber 
    AND IncidentDate <= t.IncidentDate 
    -- added this line:
    AND IncidentID <= t.IncidentID
  )
  FROM dbo.SpeedingTickets AS t
  ORDER BY LicenseNumber, IncidentDate;
 
 
 
/* --------- self-join --------- */
 
SELECT t1.LicenseNumber, t1.IncidentDate, t1.TicketAmount, 
  RunningTotal = SUM(t2.TicketAmount)
FROM dbo.SpeedingTickets AS t1
INNER JOIN dbo.SpeedingTickets AS t2
  ON t1.LicenseNumber = t2.LicenseNumber
  AND t1.IncidentDate >= t2.IncidentDate
  -- added this line:
  AND t1.IncidentID >= t2.IncidentID
GROUP BY t1.LicenseNumber, t1.IncidentDate, t1.TicketAmount
ORDER BY t1.LicenseNumber, t1.IncidentDate;
 
 
 
/* --------- outer apply --------- */
 
SELECT t1.LicenseNumber, t1.IncidentDate, t1.TicketAmount, 
  RunningTotal = SUM(t2.TicketAmount)
FROM dbo.SpeedingTickets AS t1
OUTER APPLY
(
  SELECT TicketAmount 
    FROM dbo.SpeedingTickets 
    WHERE LicenseNumber = t1.LicenseNumber
    AND IncidentDate <= t1.IncidentDate
    -- added this line:
    AND IncidentID <= t1.IncidentID
) AS t2
GROUP BY t1.LicenseNumber, t1.IncidentDate, t1.TicketAmount
ORDER BY t1.LicenseNumber, t1.IncidentDate;
 
 
 
/* --------- SUM() OVER using RANGE --------- */ 
 
SELECT LicenseNumber, IncidentDate, TicketAmount, 
  RunningTotal = SUM(TicketAmount) OVER 
  (
    PARTITION BY LicenseNumber 
    ORDER BY IncidentDate, IncidentID RANGE UNBOUNDED PRECEDING
    -- added this column ^^^^^^^^^^^^
  )
  FROM dbo.SpeedingTickets
  ORDER BY LicenseNumber, IncidentDate;
 
 
 
/* --------- SUM() OVER using ROWS --------- */ 
 
SELECT LicenseNumber, IncidentDate, TicketAmount, 
  RunningTotal = SUM(TicketAmount) OVER 
  (
      PARTITION BY LicenseNumber 
      ORDER BY IncidentDate, IncidentID ROWS UNBOUNDED PRECEDING
      -- added this column ^^^^^^^^^^^^
  )
  FROM dbo.SpeedingTickets
  ORDER BY LicenseNumber, IncidentDate;
 
 
 
/* --------- set-based iteration --------- */ 
 
DECLARE @x TABLE
(
  -- added this column, and made it the PK:
  IncidentID INT PRIMARY KEY,
  LicenseNumber INT NOT NULL, 
  IncidentDate DATE NOT NULL, 
  TicketAmount DECIMAL(7,2) NOT NULL, 
  RunningTotal DECIMAL(7,2) NOT NULL, 
  rn INT NOT NULL
);
 
-- added the additional column to the INSERT/SELECT:
INSERT @x(IncidentID, LicenseNumber, IncidentDate, TicketAmount, RunningTotal, rn)
SELECT IncidentID, LicenseNumber, IncidentDate, TicketAmount, TicketAmount,
  ROW_NUMBER() OVER (PARTITION BY LicenseNumber ORDER BY IncidentDate, IncidentID)
  -- and added this tie-breaker column ------------------------------^^^^^^^^^^^^
  FROM dbo.SpeedingTickets;
 
-- the rest of the set-based iteration solution remained unchanged

Eine weitere Komplikation, auf die Sie stoßen können, ist, wenn Sie nicht hinter der ganzen Tabelle her sind, sondern eher einer Teilmenge (sagen wir in diesem Fall der ersten Januarwoche). Sie müssen Anpassungen vornehmen, indem Sie WHERE hinzufügen Klauseln, und denken Sie auch an diese Prädikate, wenn Sie korrelierte Unterabfragen haben.