Total cumulé avec compte?

34

Comme le titre l'indique, j'ai besoin d'aide pour obtenir un total cumulé dans T-SQL. Le problème est que la somme que je dois faire est la somme d'un compte:

sum(count (distinct (customers))) 

Disons que si je comptais seul, le résultat serait:

Day | CountCustomers
----------------------
5/1  |      1
5/2  |      0
5/3  |      5

J'ai besoin d'une sortie avec la somme pour être:

Day | RunningTotalCustomers
----------------------
5/1  |      1
5/2  |      1
5/3  |      6

J'ai exécuté les totaux avant d'utiliser la coalesceméthode, mais jamais avec un compte. Je ne sais pas comment faire maintenant que j'ai le décompte.

Aaron Bertrand
la source
2
Quelle version de SQL Server s'il vous plaît? Pouvez-vous partager la portée des données - parlons-nous d'environ 1000 lignes, un million, un milliard? S'agit-il uniquement de ces deux colonnes ou avez-vous simplifié le schéma pour nous? Enfin, Dayune clé et les valeurs sont-elles contiguës?
Aaron Bertrand
J'ai réalisé un blog complet sur le total cumulé (mise à jour Quirky vs CTE hybride récursif vs Cursor): ienablemuch.com/2012/05/… Je n'ai pas inclus le total cumulé qui utilise une approche basée sur des ensembles purs; désiré: sqlblog.com/blogs/adam_machanic/archive/2006/07/12/…
Michael Buen le

Réponses:

53

Voici quelques méthodes que vous pouvez comparer. Commençons par configurer une table avec des données factices. Je remplis cela avec un tas de données aléatoires de sys.all_columns. Eh bien, c'est un peu aléatoire - je m'assure que les dates sont contiguës (ce qui n'est vraiment important que pour l'une des réponses).

CREATE TABLE dbo.Hits(Day SMALLDATETIME, CustomerID INT);

CREATE CLUSTERED INDEX x ON dbo.Hits([Day]);

INSERT dbo.Hits SELECT TOP (5000) DATEADD(DAY, r, '20120501'),
  COALESCE(ASCII(SUBSTRING(name, s, 1)), 86)
FROM (SELECT name, r = ROW_NUMBER() OVER (ORDER BY name)/10,
       s = CONVERT(INT, RIGHT(CONVERT(VARCHAR(20), [object_id]), 1))
FROM sys.all_columns) AS x;

SELECT 
  Earliest_Day   = MIN([Day]), 
  Latest_Day     = MAX([Day]), 
  Unique_Days    = DATEDIFF(DAY, MIN([Day]), MAX([Day])) + 1, 
  Total_Rows     = COUNT(*)
FROM dbo.Hits;

Résultats:

Earliest_Day         Latest_Day           Unique_Days  Total_Days
-------------------  -------------------  -----------  ----------
2012-05-01 00:00:00  2013-09-13 00:00:00  501          5000

Les données ressemblent à ceci (5000 lignes) - mais auront une apparence légèrement différente sur votre système en fonction de la version et du numéro de construction:

Day                  CustomerID
-------------------  ---
2012-05-01 00:00:00  95
2012-05-01 00:00:00  97
2012-05-01 00:00:00  97
2012-05-01 00:00:00  117
2012-05-01 00:00:00  100
...
2012-05-02 00:00:00  110
2012-05-02 00:00:00  110
2012-05-02 00:00:00  95
...

Et les résultats des totaux cumulés devraient ressembler à ceci (501 lignes):

Day                  c   rt
-------------------  --  --
2012-05-01 00:00:00  6   6
2012-05-02 00:00:00  5   11
2012-05-03 00:00:00  4   15
2012-05-04 00:00:00  7   22
2012-05-05 00:00:00  6   28
...

Donc, les méthodes que je vais comparer sont les suivantes:

  • "auto-rejoindre" - l'approche puriste basée sur les ensembles
  • "CTE récursif avec dates" - cela repose sur des dates contiguës (pas de lacunes)
  • "CTE récursif avec row_number" - similaire au précédent mais plus lent, en s'appuyant sur ROW_NUMBER
  • "CTE récursif avec #temp table" - volé à la réponse de Mikael comme suggéré
  • "mise à jour originale" qui, bien que non pris en charge et ne promettant pas un comportement défini, semble être très populaire
  • "le curseur"
  • SQL Server 2012 utilisant la nouvelle fonctionnalité de fenêtrage

auto-rejoindre

C’est ainsi que les gens vous diront de le faire quand ils vous avertiront de rester à l’écart des curseurs, car «les réglages sur les ensembles sont toujours plus rapides». Dans certaines expériences récentes, j'ai constaté que le curseur surpassait cette solution.

;WITH g AS 
(
  SELECT [Day], c = COUNT(DISTINCT CustomerID) 
    FROM dbo.Hits
    GROUP BY [Day]
)
SELECT g.[Day], g.c, rt = SUM(g2.c)
  FROM g INNER JOIN g AS g2
  ON g.[Day] >= g2.[Day]
GROUP BY g.[Day], g.c
ORDER BY g.[Day];

cte récursive avec dates

Rappel - cela repose sur des dates contiguës (sans espaces), sur une récurrence allant jusqu'à 10000, et sur la date de début de la plage qui vous intéresse (pour définir l'ancre). Vous pouvez définir l’ancre de manière dynamique en utilisant une sous-requête, bien sûr, mais je voulais garder les choses simples.

;WITH g AS 
(
  SELECT [Day], c = COUNT(DISTINCT CustomerID) 
    FROM dbo.Hits
    GROUP BY [Day]
), x AS
(
    SELECT [Day], c, rt = c
        FROM g
        WHERE [Day] = '20120501'
    UNION ALL
    SELECT g.[Day], g.c, x.rt + g.c
        FROM x INNER JOIN g
        ON g.[Day] = DATEADD(DAY, 1, x.[Day])
)
SELECT [Day], c, rt
    FROM x
    ORDER BY [Day]
    OPTION (MAXRECURSION 10000);

cte récursif avec row_number

Le calcul de Row_number est légèrement coûteux ici. Encore une fois, cela prend en charge le niveau maximal de récursivité de 10 000, mais vous n'avez pas besoin d'attribuer l'ancre.

;WITH g AS 
(
  SELECT [Day], rn = ROW_NUMBER() OVER (ORDER BY DAY), 
    c = COUNT(DISTINCT CustomerID) 
    FROM dbo.Hits
    GROUP BY [Day]
), x AS
(
    SELECT [Day], rn, c, rt = c
        FROM g
        WHERE rn = 1
    UNION ALL
    SELECT g.[Day], g.rn, g.c, x.rt + g.c
        FROM x INNER JOIN g
        ON g.rn = x.rn + 1
)
SELECT [Day], c, rt
    FROM x
    ORDER BY [Day]
    OPTION (MAXRECURSION 10000);

cte récursif avec table temporaire

Voler la réponse de Mikael, comme suggéré, pour l'inclure dans les tests.

CREATE TABLE #Hits
(
  rn INT PRIMARY KEY,
  c INT,
  [Day] SMALLDATETIME
);

INSERT INTO #Hits (rn, c, Day)
SELECT ROW_NUMBER() OVER (ORDER BY DAY),
       COUNT(DISTINCT CustomerID),
       [Day]
FROM dbo.Hits
GROUP BY [Day];

WITH x AS
(
    SELECT [Day], rn, c, rt = c
        FROM #Hits as c
        WHERE rn = 1
    UNION ALL
    SELECT g.[Day], g.rn, g.c, x.rt + g.c
        FROM x INNER JOIN #Hits as g
        ON g.rn = x.rn + 1
)
SELECT [Day], c, rt
    FROM x
    ORDER BY [Day]
    OPTION (MAXRECURSION 10000);

DROP TABLE #Hits;

mise à jour décalée

Encore une fois, je n'inclue ceci que par souci d'exhaustivité; Personnellement, je ne m'appuierais pas sur cette solution car, comme je l'ai mentionné dans une autre réponse, cette méthode n'est pas garantie du tout et peut complètement casser dans une future version de SQL Server. (Je fais de mon mieux pour contraindre SQL Server à obéir à l'ordre que je souhaite, en utilisant un indice pour le choix d'index.)

CREATE TABLE #x([Day] SMALLDATETIME, c INT, rt INT);
CREATE UNIQUE CLUSTERED INDEX x ON #x([Day]);

INSERT #x([Day], c) 
    SELECT [Day], c = COUNT(DISTINCT CustomerID) 
    FROM dbo.Hits
    GROUP BY [Day]
    ORDER BY [Day];

DECLARE @rt1 INT;
SET @rt1 = 0;

UPDATE #x
SET @rt1 = rt = @rt1 + c
FROM #x WITH (INDEX = x);

SELECT [Day], c, rt FROM #x ORDER BY [Day];

DROP TABLE #x;

le curseur

"Attention, il y a des curseurs ici! Les curseurs sont diaboliques! Vous devriez éviter les curseurs à tout prix!" Non, ce n'est pas moi qui parle, c'est juste des choses que j'entends beaucoup. Contrairement à l'opinion populaire, il existe des cas où les curseurs sont appropriés.

CREATE TABLE #x2([Day] SMALLDATETIME, c INT, rt INT);
CREATE UNIQUE CLUSTERED INDEX x ON #x2([Day]);

INSERT #x2([Day], c) 
    SELECT [Day], COUNT(DISTINCT CustomerID) 
    FROM dbo.Hits
    GROUP BY [Day]
    ORDER BY [Day];

DECLARE @rt2 INT, @d SMALLDATETIME, @c INT;
SET @rt2 = 0;

DECLARE c CURSOR LOCAL STATIC READ_ONLY FORWARD_ONLY
  FOR SELECT [Day], c FROM #x2 ORDER BY [Day];

OPEN c;

FETCH NEXT FROM c INTO @d, @c;

WHILE @@FETCH_STATUS = 0
BEGIN
  SET @rt2 = @rt2 + @c;
  UPDATE #x2 SET rt = @rt2 WHERE [Day] = @d;
  FETCH NEXT FROM c INTO @d, @c;
END

SELECT [Day], c, rt FROM #x2 ORDER BY [Day];

DROP TABLE #x2;

SQL Server 2012

Si vous utilisez la version la plus récente de SQL Server, les améliorations apportées à la fonctionnalité de fenêtrage nous permettent de calculer facilement les totaux cumulés sans le coût exponentiel de l’adhésion automatique (la somme est calculée en un seul passage), la complexité des CTE (y compris la de lignes contiguës pour le CTE le plus performant), la mise à jour décalée non prise en charge et le curseur interdit. Méfiez-vous simplement de la différence entre utiliser RANGEet ROWS, ou ne pas spécifier du tout - cela ROWSévite seulement un spool sur disque, ce qui nuirait considérablement aux performances.

;WITH g AS 
(
  SELECT [Day], c = COUNT(DISTINCT CustomerID) 
    FROM dbo.Hits
    GROUP BY [Day]
)
SELECT g.[Day], c, 
  rt = SUM(c) OVER (ORDER BY [Day] ROWS UNBOUNDED PRECEDING)
FROM g
ORDER BY g.[Day];

comparaisons de performance

J'ai pris chaque approche et l'ai emballée un lot en utilisant ce qui suit:

SELECT SYSUTCDATETIME();
GO
DBCC DROPCLEANBUFFERS;DBCC FREEPROCCACHE;
-- query here
GO 10
SELECT SYSUTCDATETIME();

Voici les résultats de la durée totale, en millisecondes (rappelez-vous, cela inclut également les commandes DBCC):

method                          run 1     run 2
-----------------------------   --------  --------
self-join                        1296 ms   1357 ms -- "supported" non-SQL 2012 winner
recursive cte with dates         1655 ms   1516 ms
recursive cte with row_number   19747 ms  19630 ms
recursive cte with #temp table   1624 ms   1329 ms
quirky update                     880 ms   1030 ms -- non-SQL 2012 winner
cursor                           1962 ms   1850 ms
SQL Server 2012                   847 ms    917 ms -- winner if SQL 2012 available

Et je l'ai encore fait sans les commandes DBCC:

method                          run 1     run 2
-----------------------------   --------  --------
self-join                        1272 ms   1309 ms -- "supported" non-SQL 2012 winner
recursive cte with dates         1247 ms   1593 ms
recursive cte with row_number   18646 ms  18803 ms
recursive cte with #temp table   1340 ms   1564 ms
quirky update                    1024 ms   1116 ms -- non-SQL 2012 winner
cursor                           1969 ms   1835 ms
SQL Server 2012                   600 ms    569 ms -- winner if SQL 2012 available

Supprimer à la fois le DBCC et les boucles, en mesurant une seule itération brute:

method                          run 1     run 2
-----------------------------   --------  --------
self-join                         313 ms    242 ms
recursive cte with dates          217 ms    217 ms
recursive cte with row_number    2114 ms   1976 ms
recursive cte with #temp table     83 ms    116 ms -- "supported" non-SQL 2012 winner
quirky update                      86 ms     85 ms -- non-SQL 2012 winner
cursor                           1060 ms    983 ms
SQL Server 2012                    68 ms     40 ms -- winner if SQL 2012 available

Enfin, j'ai multiplié par 10 le nombre de lignes de la table source (en remplaçant top par 50000 et en ajoutant une autre table en jointure croisée). Les résultats de cela, une seule itération sans commande DBCC (simplement pour gagner du temps):

method                           run 1      run 2
-----------------------------    --------   --------
self-join                         2401 ms    2520 ms
recursive cte with dates           442 ms     473 ms
recursive cte with row_number   144548 ms  147716 ms
recursive cte with #temp table     245 ms     236 ms -- "supported" non-SQL 2012 winner
quirky update                      150 ms     148 ms -- non-SQL 2012 winner
cursor                            1453 ms    1395 ms
SQL Server 2012                    131 ms     133 ms -- winner

J'ai seulement mesuré la durée - je laisserai au lecteur le soin de comparer ces approches sur leurs données, en comparant d'autres métriques pouvant être importantes (ou pouvant varier avec leurs schémas / données). Avant de tirer des conclusions de cette réponse, il vous appartiendra de la tester par rapport à vos données et à votre schéma ... ces résultats changeront presque certainement à mesure que le nombre de lignes augmentera.


démo

J'ai ajouté un sqlfiddle . Résultats:

entrez la description de l'image ici


conclusion

Dans mes tests, le choix serait:

  1. Méthode SQL Server 2012, si SQL Server 2012 est disponible.
  2. Si SQL Server 2012 n'est pas disponible et que mes dates sont contiguës, j'utiliserais la méthode cts récursive avec dates.
  3. Si ni 1. ni 2. ne sont applicables, j'appuierais l'auto-jointure sur la mise à jour originale, même si les performances étaient proches, simplement parce que le comportement est documenté et garanti. Je suis moins inquiet à propos de la compatibilité future, car si tout va bien, si la mise à jour excentrique tombe, ce sera après avoir déjà converti tout mon code en 1. :-)

Mais encore une fois, vous devriez les tester contre votre schéma et vos données. Comme il s’agissait d’un test artificiel avec un nombre de lignes relativement faible, il se peut tout aussi bien que ce soit un pet dans le vent. J'ai fait d'autres tests avec différents nombres de schémas et de lignes, et l'heuristique des performances était très différente ... C'est pourquoi j'ai posé autant de questions complémentaires à votre question initiale.


MISE À JOUR

J'ai blogué plus à ce sujet ici:

Meilleures approches pour les totaux cumulés - mises à jour pour SQL Server 2012

Aaron Bertrand
la source
1

C'est apparemment la solution optimale

DECLARE @dailyCustomers TABLE (day smalldatetime, CountCustomers int, RunningTotal int)

DECLARE @RunningTotal int

SET @RunningTotal = 0

INSERT INTO @dailyCustomers 
SELECT day, CountCustomers, null
FROM Sales
ORDER BY day

UPDATE @dailyCustomers
SET @RunningTotal = RunningTotal = @RunningTotal + CountCustomers
FROM @dailyCustomers

SELECT * FROM @dailyCustomers
Magicien de code
la source
Des idées sans implémenter une table temporaire (mon proc force déjà forcément des valeurs à travers plusieurs tables temporaires, alors j'essaie de trouver un moyen d'éviter d'utiliser une autre table temporaire)? Sinon, je vais utiliser cette méthode. Je pense que cela fonctionnera
Vous pouvez également le faire avec une jointure automatique ou une sous-requête imbriquée, mais ces options ne fonctionnent pas aussi bien. En outre, il est probable que vous rencontriez de toute façon tempdb avec ces alternatives avec des tables de spooling ou de travail.
3
Sachez simplement que cette méthode de "mise à jour décalée" n’est pas garantie - cette syntaxe n’est pas prise en charge, son comportement n’est pas défini et il peut tomber en panne dans une version ultérieure, un correctif logiciel ou un service pack. Ainsi, même s’il est plus rapide que certaines alternatives prises en charge, il en résulte un coût de compatibilité futur.
Aaron Bertrand
6
Jeff Moden a écrit quelque part cette mise en garde. Vous devriez avoir un index clusterisé daypar exemple.
Martin Smith
2
@MartinSmith C'est un très gros article sur sqlservercentral.com (allez à la page Auteur et retrouvez ses articles sur les mises à jour de quirck).
Fabricio Araujo
-2

Juste une autre manière, coûteuse, mais indépendante de la version. Il n'utilise pas de tables temporaires ni de variables.

select T.dday, T.CustomersByDay + 
    (select count(A.customer) from NewCustomersByDate A 
      where A.dday < T.dday) as TotalCustomerTillNow 
from (select dday, count(customer) as CustomersByDay 
        from NewCustomersByDate group by dday) T 

la source
2
Ce n'est pas bon, c'est très lent. Même si vous n’avez que 100 lignes, il effectuera une lecture ping-pong entre les tables 5 050 fois. 200 lignes, soit 20 100 fois. Avec seulement 1 000 lignes, il passe à 500 500 lectures de façon exponentielle. Sqlblog.com/blogs/adam_machanic/archive/2006/07/12/…
Michael Buen le
J'ai vu le lien vers votre blog après avoir posté ceci, maintenant je vois que c'est une très mauvaise idée, merci!