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

I migliori approcci per i totali parziali raggruppati

Il primissimo post sul blog su questo sito, nel lontano luglio del 2012, parlava dei migliori approcci per i totali parziali. Da allora, mi è stato chiesto in più occasioni come avrei affrontato il problema se i totali parziali fossero più complessi, in particolare se avessi bisogno di calcolare i totali parziali per più entità, ad esempio gli ordini di ciascun cliente.

L'esempio originale utilizzava un caso fittizio di una città che emette multe per eccesso di velocità; il totale parziale era semplicemente l'aggregazione e il conteggio progressivo del numero di multe per eccesso di velocità di giorno (indipendentemente da chi fosse stato emesso il biglietto o da quanto costasse). Un esempio più complesso (ma pratico) potrebbe essere l'aggregazione del valore totale parziale delle multe per eccesso di velocità, raggruppato per patente, al giorno. Immaginiamo la seguente tabella:

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

Potresti chiedere, DECIMAL(7,2) , veramente? A che velocità stanno andando queste persone? Bene, in Canada, ad esempio, non è poi così difficile ottenere una multa di $ 10.000 per eccesso di velocità.

Ora, popola la tabella con alcuni dati di esempio. Non entrerò in tutti i dettagli qui, ma questo dovrebbe produrre circa 6.000 righe che rappresentano più conducenti e più importi di biglietti per un periodo di un mese:

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

Potrebbe sembrare un po' troppo complicato, ma una delle sfide più grandi che mi capita spesso di incontrare durante la composizione di questi post del blog è la costruzione di una quantità adeguata di dati realistici "casuali" / arbitrari. Se hai un metodo migliore per la popolazione di dati arbitraria, con tutti i mezzi, non usare i miei borbottii come esempio:sono periferici fino al punto di questo post.

Approcci

Esistono vari modi per risolvere questo problema in T-SQL. Ecco sette approcci, insieme ai piani associati. Ho tralasciato tecniche come i cursori (perché saranno innegabilmente più lenti) e le CTE ricorsive basate sulla data (perché dipendono da giorni contigui).

    Subquery n. 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;


    Pianifica per la sottoquery n. 1

    Subquery n. 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;


    Pianifica per la sottoquery n. 2

    Auto-unione

    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;


    Pianifica per l'auto-iscrizione

    Applicazione esterna

    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;


    Piano per l'applicazione esterna

    SUM OVER() utilizzando RANGE (solo 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;


    Pianifica per SUM OVER() utilizzando RANGE

    SUM OVER() utilizzando ROWS (solo 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;


    Pianifica per SUM OVER() utilizzando ROWS

    Iterazione basata su set

    Con credito a Hugo Kornelis (@Hugo_Kornelis) per il capitolo 4 in SQL Server MVP Deep Dives Volume 1, questo approccio combina un approccio basato su set e un approccio cursore.

    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;

    Per sua natura, questo approccio produce molti piani identici nel processo di aggiornamento della variabile table, che sono tutti simili ai piani di self-join e di applicazione esterna, ma sono in grado di utilizzare una ricerca:


    Uno dei tanti piani UPDATE prodotti attraverso l'iterazione basata su set

    L'unica differenza tra ogni piano in ogni iterazione è il conteggio delle righe. Ad ogni iterazione successiva, il numero di righe interessate dovrebbe rimanere lo stesso o diminuire, poiché il numero di righe interessate ad ogni iterazione rappresenta il numero di conducenti con biglietti in quel numero di giorni (o, più precisamente, il numero di giorni a quel "grado").

Risultati delle prestazioni

Ecco come si accumulano gli approcci, come mostrato da SQL Sentry Plan Explorer, con l'eccezione dell'approccio di iterazione basato su set che, poiché consiste di molte singole istruzioni, non rappresenta bene rispetto al resto.


Metriche di runtime Plan Explorer per sei dei sette approcci

Oltre a rivedere i piani e confrontare le metriche di runtime in Plan Explorer, ho anche misurato il runtime non elaborato in Management Studio. Ecco i risultati dell'esecuzione di ciascuna query 10 volte, tenendo presente che ciò include anche il tempo di rendering in SSMS:


Durata del runtime, in millisecondi, per tutti e sette gli approcci (10 iterazioni )

Quindi, se utilizzi SQL Server 2012 o superiore, l'approccio migliore sembra essere SUM OVER() utilizzando ROWS UNBOUNDED PRECEDING . Se non utilizzi SQL Server 2012, il secondo approccio di sottoquery sembrava essere ottimale in termini di runtime, nonostante l'elevato numero di letture rispetto, ad esempio, a OUTER APPLY interrogazione. In tutti i casi, ovviamente, dovresti testare questi approcci, adattati al tuo schema, contro il tuo sistema. I tuoi dati, gli indici e altri fattori possono portare a una soluzione diversa che risulta ottimale nel tuo ambiente.

Altre complessità

Ora, l'indice univoco indica che qualsiasi combinazione LicenseNumber + IncidentDate conterrà un unico totale cumulativo, nel caso in cui un conducente specifico riceva più biglietti in un dato giorno. Questa regola aziendale aiuta a semplificare un po' la nostra logica, evitando la necessità di un pareggio per produrre totali parziali deterministici.

Se hai casi in cui potresti avere più righe per una determinata combinazione LicenseNumber + IncidentDate, puoi rompere il pareggio usando un'altra colonna che aiuta a rendere unica la combinazione (ovviamente la tabella di origine non avrebbe più un vincolo univoco su quelle due colonne) . Nota che questo è possibile anche nei casi in cui il DATE la colonna è in realtà DATETIME – molte persone presumono che i valori di data/ora siano univoci, ma questo non è certamente sempre garantito, indipendentemente dalla granularità.

Nel mio caso, potrei usare IDENTITY colonna, IncidentID; ecco come regolerei ciascuna soluzione (riconoscendo che potrebbero esserci modi migliori; semplicemente buttando via le idee):

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

Un'altra complicazione che potresti incontrare è quando non stai cercando l'intero tavolo, ma piuttosto un sottoinsieme (diciamo, in questo caso, la prima settimana di gennaio). Dovrai apportare modifiche aggiungendo WHERE clausole e tieni a mente questi predicati quando hai anche sottoquery correlate.