Calculer un total cumulé dans SQL Server

170

Imaginez le tableau suivant (appelé TestTable):

id     somedate    somevalue
--     --------    ---------
45     01/Jan/09   3
23     08/Jan/09   5
12     02/Feb/09   0
77     14/Feb/09   7
39     20/Feb/09   34
33     02/Mar/09   6

Je voudrais une requête qui renvoie un total cumulé dans l'ordre des dates, comme:

id     somedate    somevalue  runningtotal
--     --------    ---------  ------------
45     01/Jan/09   3          3
23     08/Jan/09   5          8
12     02/Feb/09   0          8
77     14/Feb/09   7          15  
39     20/Feb/09   34         49
33     02/Mar/09   6          55

Je sais qu'il existe différentes manières de faire cela dans SQL Server 2000/2005/2008.

Je suis particulièrement intéressé par ce type de méthode qui utilise l'astuce de la déclaration d'agrégation:

INSERT INTO @AnotherTbl(id, somedate, somevalue, runningtotal) 
   SELECT id, somedate, somevalue, null
   FROM TestTable
   ORDER BY somedate

DECLARE @RunningTotal int
SET @RunningTotal = 0

UPDATE @AnotherTbl
SET @RunningTotal = runningtotal = @RunningTotal + somevalue
FROM @AnotherTbl

... c'est très efficace, mais j'ai entendu dire qu'il y a des problèmes à ce sujet car vous ne pouvez pas nécessairement garantir que l' UPDATEinstruction traitera les lignes dans le bon ordre. Peut-être pouvons-nous obtenir des réponses définitives à ce sujet.

Mais peut-être y a-t-il d'autres façons que les gens peuvent suggérer?

edit: Maintenant avec un SqlFiddle avec la configuration et l'exemple 'update trick' ci-dessus

codeulike
la source
blogs.msdn.com/sqltips/archive/2005/07/20/441053.aspx Ajoutez une commande par à votre mise à jour ... définie et vous obtenez une garantie.
Simon D
Mais Order by ne peut pas être appliqué à une instruction UPDATE ... n'est-ce pas?
codeulike
Voir également sqlperformance.com/2012/07/t-sql-queries/running-totals surtout si vous utilisez SQL Server 2012.
Aaron Bertrand

Réponses:

133

Mettez à jour , si vous exécutez SQL Server 2012, voir: https://stackoverflow.com/a/10309947

Le problème est que l'implémentation SQL Server de la clause Over est quelque peu limitée .

Oracle (et ANSI-SQL) vous permettent de faire des choses comme:

 SELECT somedate, somevalue,
  SUM(somevalue) OVER(ORDER BY somedate 
     ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) 
          AS RunningTotal
  FROM Table

SQL Server ne vous donne aucune solution propre à ce problème. Mon instinct me dit que c'est l'un de ces rares cas où un curseur est le plus rapide, même si je devrai faire des analyses comparatives sur de gros résultats.

L'astuce de mise à jour est pratique mais je la trouve assez fragile. Il semble que si vous mettez à jour une table complète, elle procédera dans l'ordre de la clé primaire. Donc, si vous définissez votre date comme clé primaire ascendante, vous serez probablyen sécurité. Mais vous vous appuyez sur un détail d'implémentation SQL Server non documenté (également si la requête finit par être exécutée par deux processus, je me demande ce qui va se passer, voir: MAXDOP):

Échantillon de travail complet:

drop table #t 
create table #t ( ord int primary key, total int, running_total int)

insert #t(ord,total)  values (2,20)
-- notice the malicious re-ordering 
insert #t(ord,total) values (1,10)
insert #t(ord,total)  values (3,10)
insert #t(ord,total)  values (4,1)

declare @total int 
set @total = 0
update #t set running_total = @total, @total = @total + total 

select * from #t
order by ord 

ord         total       running_total
----------- ----------- -------------
1           10          10
2           20          30
3           10          40
4           1           41

Vous avez demandé un point de repère, c'est la vérité.

Le moyen le plus rapide de le faire serait le curseur, c'est un ordre de grandeur plus rapide que la sous-requête corrélée de jointure croisée.

Le moyen le plus rapide absolu est l'astuce UPDATE. Ma seule préoccupation est que je ne suis pas certain que, dans toutes les circonstances, la mise à jour se déroulera de manière linéaire. Il n'y a rien dans la requête qui l'indique explicitement.

En bout de ligne, pour le code de production, j'irais avec le curseur.

Données de test:

create table #t ( ord int primary key, total int, running_total int)

set nocount on 
declare @i int
set @i = 0 
begin tran
while @i < 10000
begin
   insert #t (ord, total) values (@i,  rand() * 100) 
    set @i = @i +1
end
commit

Test 1:

SELECT ord,total, 
    (SELECT SUM(total) 
        FROM #t b 
        WHERE b.ord <= a.ord) AS b 
FROM #t a

-- CPU 11731, Reads 154934, Duration 11135 

Test 2:

SELECT a.ord, a.total, SUM(b.total) AS RunningTotal 
FROM #t a CROSS JOIN #t b 
WHERE (b.ord <= a.ord) 
GROUP BY a.ord,a.total 
ORDER BY a.ord

-- CPU 16053, Reads 154935, Duration 4647

Test 3:

DECLARE @TotalTable table(ord int primary key, total int, running_total int)

DECLARE forward_cursor CURSOR FAST_FORWARD 
FOR 
SELECT ord, total
FROM #t 
ORDER BY ord


OPEN forward_cursor 

DECLARE @running_total int, 
    @ord int, 
    @total int
SET @running_total = 0

FETCH NEXT FROM forward_cursor INTO @ord, @total 
WHILE (@@FETCH_STATUS = 0)
BEGIN
     SET @running_total = @running_total + @total
     INSERT @TotalTable VALUES(@ord, @total, @running_total)
     FETCH NEXT FROM forward_cursor INTO @ord, @total 
END

CLOSE forward_cursor
DEALLOCATE forward_cursor

SELECT * FROM @TotalTable

-- CPU 359, Reads 30392, Duration 496

Test 4:

declare @total int 
set @total = 0
update #t set running_total = @total, @total = @total + total 

select * from #t

-- CPU 0, Reads 58, Duration 139
Sam Safran
la source
1
Merci. Donc, votre exemple de code est de démontrer qu'il totalisera dans l'ordre de la clé primaire, je suppose. Il serait intéressant de savoir si les curseurs sont encore plus efficaces que les jointures pour des ensembles de données plus volumineux.
codeulike
1
Je viens de tester le CTE @Martin, rien ne se rapproche de l'astuce de mise à jour - le curseur semble plus bas sur les lectures. Voici une trace du profileur i.stack.imgur.com/BbZq3.png
Sam Saffron
3
@Martin Denali va avoir une assez bonne solution pour ce msdn.microsoft.com/en-us/library/ms189461(v=SQL.110).aspx
Sam Saffron
1
+1 pour tout le travail mis dans cette réponse - J'adore l'option UPDATE; une partition peut-elle être intégrée à ce script UPDATE? Par exemple, s'il y avait un champ supplémentaire "Car Color", ce script pourrait-il renvoyer les totaux en cours dans chaque partition "Car Color"?
whytheq
2
la réponse initiale (Oracle (et ANSI-SQL)) fonctionne maintenant dans SQL Server 2017. Merci, très élégant!
DaniDev
121

Dans SQL Server 2012, vous pouvez utiliser SUM () avec la clause OVER () .

select id,
       somedate,
       somevalue,
       sum(somevalue) over(order by somedate rows unbounded preceding) as runningtotal
from TestTable

Violon SQL

Mikael Eriksson
la source
40

Bien que Sam Saffron ait fait un excellent travail, il n'a toujours pas fourni de code d' expression de table commune récursive pour ce problème. Et pour nous qui travaillons avec SQL Server 2008 R2 et non Denali, c'est toujours le moyen le plus rapide d'obtenir un total en cours d'exécution, c'est environ 10 fois plus rapide que le curseur sur mon ordinateur de travail pour 100000 lignes, et c'est aussi une requête en ligne.
Donc, le voici (je suppose qu'il y a une ordcolonne dans le tableau et son numéro séquentiel sans lacunes, pour un traitement rapide, il devrait également y avoir une contrainte unique sur ce nombre):

;with 
CTE_RunningTotal
as
(
    select T.ord, T.total, T.total as running_total
    from #t as T
    where T.ord = 0
    union all
    select T.ord, T.total, T.total + C.running_total as running_total
    from CTE_RunningTotal as C
        inner join #t as T on T.ord = C.ord + 1
)
select C.ord, C.total, C.running_total
from CTE_RunningTotal as C
option (maxrecursion 0)

-- CPU 140, Reads 110014, Duration 132

sql fiddle demo

mise à jour J'étais également curieux de connaître cette mise à jour avec une mise à jour variable ou décalée . Donc, généralement, cela fonctionne bien, mais comment pouvons-nous être sûrs que cela fonctionne à chaque fois? eh bien, voici un petit truc (trouvé ici - http://www.sqlservercentral.com/Forums/Topic802558-203-21.aspx#bm981258 ) - il vous suffit de vérifier les affectations actuelles et précédentes ordet d'utiliser les 1/0affectations au cas où elles seraient différentes de celles vous attendez:

declare @total int, @ord int

select @total = 0, @ord = -1

update #t set
    @total = @total + total,
    @ord = case when ord <> @ord + 1 then 1/0 else ord end,
    ------------------------
    running_total = @total

select * from #t

-- CPU 0, Reads 58, Duration 139

D'après ce que j'ai vu si vous avez un index cluster / clé primaire approprié sur votre table (dans notre cas, il serait indexé par ord_id), la mise à jour se déroulera de manière linéaire tout le temps (jamais rencontré de division par zéro). Cela dit, c'est à vous de décider si vous souhaitez l'utiliser dans le code de production :)

update 2 Je lie cette réponse, car elle comprend des informations utiles sur le manque de fiabilité de la mise à jour excentrique - comportement inexplicable de concaténation nvarchar / index / nvarchar (max) .

Roman Pekar
la source
6
Cette réponse mérite plus de reconnaissance (ou peut-être qu'elle a un défaut que je ne vois pas?)
user1068352
il devrait y avoir un nombre séquentiel pour que vous puissiez vous joindre sur ord = ord + 1 et parfois cela nécessite un peu plus de travail. Mais de toute façon, sur SQL 2008 R2, j'utilise cette solution
Roman Pekar
+1 Sur SQLServer2008R2, je préfère également l'approche avec CTE récursif. Pour info, afin de trouver la valeur des tables, qui permettent des lacunes, j'utilise une sous-requête corrélée. Il ajoute deux opérations de recherche supplémentaires à la requête sqlfiddle.com/#!3/d41d8/18967
Aleksandr Fedorenko
2
Dans le cas où vous avez déjà un ordinal pour vos données et que vous recherchez une solution concise (sans curseur) basée sur un ensemble sur SQL 2008 R2, cela semble parfait.
Nick.McDermaid
1
Toutes les requêtes de total en cours ne comportent pas de champ ordinal contigu. Parfois, un champ datetime est ce que vous avez, ou des enregistrements ont été supprimés du milieu du tri. C'est peut-être pourquoi il n'est pas utilisé plus souvent.
Reuben
28

L'opérateur APPLY dans SQL 2005 et supérieur fonctionne pour cela:

select
    t.id ,
    t.somedate ,
    t.somevalue ,
    rt.runningTotal
from TestTable t
 cross apply (select sum(somevalue) as runningTotal
                from TestTable
                where somedate <= t.somedate
            ) as rt
order by t.somedate
Mike Forman
la source
5
Fonctionne très bien pour les petits ensembles de données. Un inconvénient est que vous devrez avoir des clauses where identiques sur la requête interne et externe.
Père
Comme certaines de mes dates étaient exactement les mêmes (jusqu'à la fraction de seconde), j'ai dû ajouter: row_number () over (order by txndate) à la table interne et externe et quelques indices composés pour le faire fonctionner. Solution lisse / simple. BTW, une application croisée testée contre une sous-requête ... c'est légèrement plus rapide.
pghcpa
c'est très propre et fonctionne bien avec de petits ensembles de données; plus rapide que le CTE récursif
jtate
c'est aussi une bonne solution (pour les petits ensembles de données), mais vous devez également être conscient que cela implique qu'une colonne soit unique
Roman Pekar
11
SELECT TOP 25   amount, 
    (SELECT SUM(amount) 
    FROM time_detail b 
    WHERE b.time_detail_id <= a.time_detail_id) AS Total FROM time_detail a

Vous pouvez également utiliser la fonction ROW_NUMBER () et une table temporaire pour créer une colonne arbitraire à utiliser dans la comparaison sur l'instruction SELECT interne.

Sam Axe
la source
1
C'est vraiment inefficace ... mais là encore, il n'y a pas de moyen vraiment propre de le faire dans le serveur SQL
Sam Saffron
C'est absolument inefficace - mais cela fait le travail et il n'est pas question de savoir si quelque chose doit être exécuté dans le bon ou le mauvais ordre.
Sam Axe
merci, il est utile d'avoir des réponses alternatives, et aussi utile d'avoir une critique efficace
codeulike
7

Utilisez une sous-requête corrélée. Très simple, voilà:

SELECT 
somedate, 
(SELECT SUM(somevalue) FROM TestTable t2 WHERE t2.somedate<=t1.somedate) AS running_total
FROM TestTable t1
GROUP BY somedate
ORDER BY somedate

Le code n'est peut-être pas tout à fait correct, mais je suis sûr que l'idée l'est.

Le GROUP BY est au cas où une date apparaît plus d'une fois, vous ne voudriez la voir qu'une seule fois dans le jeu de résultats.

Si cela ne vous dérange pas de voir des dates répétées ou si vous souhaitez voir la valeur et l'ID d'origine, voici ce que vous voulez:

SELECT 
id,
somedate, 
somevalue,
(SELECT SUM(somevalue) FROM TestTable t2 WHERE t2.somedate<=t1.somedate) AS running_total
FROM TestTable t1
ORDER BY somedate
KthProg
la source
Merci ... simple était super. Il y avait un index à ajouter pour les performances, mais c'était assez simple, (en prenant l'une des recommandations de Database Engine Tuning Advisor;), puis il a fonctionné comme un tir.
Doug_Ivison
4

En supposant que le fenêtrage fonctionne sur SQL Server 2008 comme il le fait ailleurs (que j'ai essayé), essayez ceci:

select testtable.*, sum(somevalue) over(order by somedate)
from testtable
order by somedate;

MSDN dit qu'il est disponible dans SQL Server 2008 (et peut-être aussi 2005?) Mais je n'ai pas d'instance à portée de main pour l'essayer.

EDIT: eh bien, apparemment, SQL Server n'autorise pas une spécification de fenêtre ("OVER (...)") sans spécifier "PARTITION BY" (diviser le résultat en groupes mais ne pas agréger tout à fait comme GROUP BY). Gênant - la référence de syntaxe MSDN suggère que c'est facultatif, mais je n'ai que des instances de SqlServer 2000 pour le moment.

La requête que j'ai donnée fonctionne à la fois dans Oracle 10.2.0.3.0 et PostgreSQL 8.4-beta. Alors dis à MS de se rattraper;)

araqnid
la source
2
L'utilisation de OVER avec SUM ne fonctionnera pas dans ce cas pour donner un total cumulé. La clause OVER n'accepte pas ORDER BY lorsqu'elle est utilisée avec SUM. Vous devez utiliser PARTITION BY, qui ne fonctionnera pas pour exécuter les totaux.
Sam Axe
merci, c'est vraiment utile d'entendre pourquoi cela ne fonctionnera pas. araqnid peut-être que vous pourriez modifier votre réponse pour expliquer pourquoi ce n'est pas une option
codeulike
Cela fonctionne en fait pour moi, car j'ai besoin de partitionner - donc même si ce n'est pas la réponse la plus populaire, c'est la solution la plus simple à mon problème de RT en SQL.
William MB
Je n'ai pas MSSQL 2008 avec moi, mais je pense que vous pourriez probablement partitionner par (sélectionnez null) et contourner le problème de partitionnement. Ou faites une sous-sélection avec 1 partitionmeet partitionnez par cela. En outre, la partition par est probablement nécessaire dans des situations réelles lors de la création de rapports.
nurettin le
4

Si vous utilisez Sql Server 2008 R2 ci-dessus. Ensuite, ce serait le moyen le plus court à faire;

Select id
    ,somedate
    ,somevalue,
LAG(runningtotal) OVER (ORDER BY somedate) + somevalue AS runningtotal
From TestTable 

LAG est utilisé pour obtenir la valeur de la ligne précédente. Vous pouvez faire google pour plus d'informations.

[1]:

shambhu yadav
la source
1
Je crois que LAG n'existe que dans le serveur SQL 2012 et au-dessus (pas 2008)
AaA
1
L'utilisation de LAG () ne s'améliore pas, SUM(somevalue) OVER(...) ce qui me semble beaucoup plus propre
Used_By_Already
2

Je crois qu'un total cumulé peut être atteint en utilisant la simple opération INNER JOIN ci-dessous.

SELECT
     ROW_NUMBER() OVER (ORDER BY SomeDate) AS OrderID
    ,rt.*
INTO
    #tmp
FROM
    (
        SELECT 45 AS ID, CAST('01-01-2009' AS DATETIME) AS SomeDate, 3 AS SomeValue
        UNION ALL
        SELECT 23, CAST('01-08-2009' AS DATETIME), 5
        UNION ALL
        SELECT 12, CAST('02-02-2009' AS DATETIME), 0
        UNION ALL
        SELECT 77, CAST('02-14-2009' AS DATETIME), 7
        UNION ALL
        SELECT 39, CAST('02-20-2009' AS DATETIME), 34
        UNION ALL
        SELECT 33, CAST('03-02-2009' AS DATETIME), 6
    ) rt

SELECT
     t1.ID
    ,t1.SomeDate
    ,t1.SomeValue
    ,SUM(t2.SomeValue) AS RunningTotal
FROM
    #tmp t1
    JOIN #tmp t2
        ON t2.OrderID <= t1.OrderID
GROUP BY
     t1.OrderID
    ,t1.ID
    ,t1.SomeDate
    ,t1.SomeValue
ORDER BY
    t1.OrderID

DROP TABLE #tmp
clevster
la source
Oui, je pense que cela équivaut au «Test 3» dans la réponse de Sam Saffron.
codeulike le
2

Ce qui suit produira les résultats requis.

SELECT a.SomeDate,
       a.SomeValue,
       SUM(b.SomeValue) AS RunningTotal
FROM TestTable a
CROSS JOIN TestTable b
WHERE (b.SomeDate <= a.SomeDate) 
GROUP BY a.SomeDate,a.SomeValue
ORDER BY a.SomeDate,a.SomeValue

Avoir un index clusterisé sur SomeDate améliorera considérablement les performances.

Dave Barker
la source
@Dave Je pense que cette question essaie de trouver un moyen efficace de le faire, le croisement va être très lent pour les grands ensembles
Sam Saffron
merci, il est utile d'avoir des réponses alternatives, et aussi utile d'avoir une critique efficace
codeulike
2

Bien que le meilleur moyen de le faire soit d'utiliser une fonction de fenêtre, cela peut également être fait en utilisant une simple sous-requête corrélée .

Select id, someday, somevalue, (select sum(somevalue) 
                                from testtable as t2
                                where t2.id = t1.id
                                and t2.someday <= t1.someday) as runningtotal
from testtable as t1
order by id,someday;
Krahul3
la source
0
BEGIN TRAN
CREATE TABLE #Table (_Id INT IDENTITY(1,1) ,id INT ,    somedate VARCHAR(100) , somevalue INT)


INSERT INTO #Table ( id  ,    somedate  , somevalue  )
SELECT 45 , '01/Jan/09', 3 UNION ALL
SELECT 23 , '08/Jan/09', 5 UNION ALL
SELECT 12 , '02/Feb/09', 0 UNION ALL
SELECT 77 , '14/Feb/09', 7 UNION ALL
SELECT 39 , '20/Feb/09', 34 UNION ALL
SELECT 33 , '02/Mar/09', 6 

;WITH CTE ( _Id, id  ,  _somedate  , _somevalue ,_totvalue ) AS
(

 SELECT _Id , id  ,    somedate  , somevalue ,somevalue
 FROM #Table WHERE _id = 1
 UNION ALL
 SELECT #Table._Id , #Table.id  , somedate  , somevalue , somevalue + _totvalue
 FROM #Table,CTE 
 WHERE #Table._id > 1 AND CTE._Id = ( #Table._id-1 )
)

SELECT * FROM CTE

ROLLBACK TRAN
Mansoor
la source
Vous devriez probablement donner quelques informations sur ce que vous faites ici et noter les avantages / inconvénients de cette méthode particulière.
TT.
0

Voici 2 façons simples de calculer le total cumulé:

Approche 1 : Elle peut être écrite de cette façon si votre SGBD prend en charge les fonctions analytiques

SELECT     id
           ,somedate
           ,somevalue
           ,runningtotal = SUM(somevalue) OVER (ORDER BY somedate ASC)
FROM       TestTable

Approche 2 : Vous pouvez utiliser OUTER APPLY si votre version de base de données / SGBD lui-même ne prend pas en charge les fonctions analytiques

SELECT     T.id
           ,T.somedate
           ,T.somevalue
           ,runningtotal = OA.runningtotal
FROM       TestTable T
           OUTER APPLY (
                           SELECT   runningtotal = SUM(TI.somevalue)
                           FROM     TestTable TI
                           WHERE    TI.somedate <= S.somedate
                       ) OA;

Remarque: - Si vous devez calculer le total cumulé pour différentes partitions séparément, cela peut être fait comme indiqué ici: Calcul des totaux cumulés sur les lignes et regroupement par ID

san
la source