Database
 sql >> Base de données >  >> RDS >> Database

Meilleures approches pour les totaux cumulés groupés

Le tout premier article de blog sur ce site, en juillet 2012, parlait des meilleures approches pour les totaux cumulés. Depuis lors, on m'a demandé à plusieurs reprises comment j'aborderais le problème si les totaux cumulés étaient plus complexes - en particulier, si j'avais besoin de calculer les totaux cumulés pour plusieurs entités - par exemple, les commandes de chaque client.

L'exemple original utilisait un cas fictif d'une ville émettant des contraventions pour excès de vitesse; le total cumulé consistait simplement à agréger et à tenir un décompte du nombre de contraventions pour excès de vitesse par jour (indépendamment de la personne à qui la contravention avait été émise ou de son montant). Un exemple plus complexe (mais pratique) pourrait être l'agrégation de la valeur totale cumulée des contraventions pour excès de vitesse, regroupées par permis de conduire, par jour. Imaginons le tableau suivant :

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

Vous pourriez demander, DECIMAL(7,2) , vraiment? A quelle vitesse vont ces gens ? Eh bien, au Canada, par exemple, il n'est pas si difficile d'obtenir une amende de 10 000 $ pour excès de vitesse.

Maintenant, remplissons le tableau avec quelques exemples de données. Je n'entrerai pas dans tous les détails ici, mais cela devrait produire environ 6 000 lignes représentant plusieurs conducteurs et plusieurs montants de tickets sur une période d'un mois :

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

Cela peut sembler un peu trop compliqué, mais l'un des plus grands défis que j'ai souvent à relever lors de la rédaction de ces articles de blog est de construire une quantité appropriée de données "aléatoires" / arbitraires réalistes. Si vous avez une meilleure méthode pour la population de données arbitraires, n'utilisez pas mes marmonnements comme exemple - ils sont périphériques au point de ce post.

Approches

Il existe différentes façons de résoudre ce problème dans T-SQL. Voici sept approches, ainsi que leurs plans associés. J'ai laissé de côté des techniques comme les curseurs (car ils seront indéniablement plus lents) et les CTE récursifs basés sur la date (car ils dépendent de jours contigus).

    Sous-requête #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;


    Planifier la sous-requête #1

    Sous-requête #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;


    Planifier la sous-requête #2

    Auto-jointure

    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;


    Planifier l'auto-jointure

    Application extérieure

    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;


    Planifier l'application externe

    SUM OVER() en utilisant RANGE (2012+ uniquement)

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


    Planifiez SUM OVER() en utilisant RANGE

    SUM OVER() en utilisant ROWS (2012+ uniquement)

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


    Planifiez SUM OVER() en utilisant ROWS

    Itération basée sur des ensembles

    Grâce à Hugo Kornelis (@Hugo_Kornelis) pour le chapitre 4 du volume 1 de SQL Server MVP Deep Dives, cette approche combine une approche basée sur les ensembles et une approche par curseur.

    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;

    En raison de sa nature, cette approche produit de nombreux plans identiques lors du processus de mise à jour de la variable de table, qui sont tous similaires aux plans d'auto-jointure et d'application externe, mais peuvent utiliser une recherche :


    L'un des nombreux plans UPDATE produits par itération basée sur des ensembles

    La seule différence entre chaque plan dans chaque itération est le nombre de lignes. À chaque itération successive, le nombre de lignes affectées doit rester le même ou diminuer, puisque le nombre de lignes affectées à chaque itération représente le nombre de conducteurs avec des tickets sur ce nombre de jours (ou, plus précisément, le nombre de jours à ce "rang").

Résultats de performances

Voici comment les approches se sont empilées, comme le montre SQL Sentry Plan Explorer, à l'exception de l'approche d'itération basée sur un ensemble qui, parce qu'elle se compose de nombreuses instructions individuelles, ne représente pas bien par rapport au reste.


Métriques d'exécution de Plan Explorer pour six des sept approches

En plus d'examiner les plans et de comparer les métriques d'exécution dans Plan Explorer, j'ai également mesuré l'exécution brute dans Management Studio. Voici les résultats de l'exécution de chaque requête 10 fois, en gardant à l'esprit que cela inclut également le temps de rendu dans SSMS :


Durée d'exécution, en millisecondes, pour les sept approches (10 itérations )

Donc, si vous êtes sur SQL Server 2012 ou supérieur, la meilleure approche semble être SUM OVER() en utilisant ROWS UNBOUNDED PRECEDING . Si vous n'êtes pas sur SQL Server 2012, la deuxième approche de sous-requête semble être optimale en termes d'exécution, malgré le nombre élevé de lectures par rapport, disons, à OUTER APPLY requête. Dans tous les cas, bien sûr, vous devez tester ces approches, adaptées à votre schéma, sur votre propre système. Vos données, index et autres facteurs peuvent faire en sorte qu'une solution différente soit la plus optimale dans votre environnement.

Autres complexités

Désormais, l'index unique signifie que toute combinaison LicenseNumber + IncidentDate contiendra un seul total cumulé, dans le cas où un conducteur spécifique reçoit plusieurs tickets un jour donné. Cette règle métier permet de simplifier un peu notre logique, en évitant la nécessité d'un bris d'égalité pour produire des totaux cumulés déterministes.

Si vous avez des cas où vous pouvez avoir plusieurs lignes pour une combinaison LicenseNumber + IncidentDate donnée, vous pouvez rompre le lien en utilisant une autre colonne qui aide à rendre la combinaison unique (évidemment, la table source n'aurait plus de contrainte unique sur ces deux colonnes) . Notez que cela est possible même dans les cas où la DATE la colonne est en fait DATETIME – de nombreuses personnes supposent que les valeurs de date/heure sont uniques, mais ce n'est certainement pas toujours garanti, quelle que soit la granularité.

Dans mon cas, je pourrais utiliser le IDENTITY colonne, IncidentID; voici comment j'ajusterais chaque solution (en reconnaissant qu'il peut y avoir de meilleures façons ; je lance simplement des idées) :

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

Une autre complication que vous pouvez rencontrer est lorsque vous ne recherchez pas toute la table, mais plutôt un sous-ensemble (par exemple, dans ce cas, la première semaine de janvier). Vous devrez faire des ajustements en ajoutant WHERE clauses et gardez ces prédicats à l'esprit lorsque vous avez également des sous-requêtes corrélées.