Correndo total com contagem?

34

Como o título sugere, preciso de ajuda para obter um total em execução no T-SQL. O problema é que a soma que preciso fazer é a soma de uma contagem:

sum(count (distinct (customers))) 

Digamos que, se eu fizesse a contagem sozinho, o resultado seria:

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

Eu preciso de saída com a soma para ser:

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

Eu já executei totais antes de usar o coalescemétodo, mas nunca com uma contagem. Não sei como fazê-lo agora que tenho a contagem.

Aaron Bertrand
fonte
2
Qual versão do SQL Server, por favor? Você pode compartilhar o escopo dos dados - estamos falando de 1000 linhas, um milhão, um bilhão? São realmente apenas essas duas colunas ou você simplificou o esquema para nós? Finalmente, é Dayuma chave e os valores são contíguos?
Aaron Bertrand
Criei um blog abrangente sobre a execução total (atualização Quirky vs CTE híbrida recursiva x cursor): ienablemuch.com/2012/05/… Não incluí o total de execução que usa uma abordagem baseada em conjunto, o desempenho não é nada para ser desejado: sqlblog.com/blogs/adam_machanic/archive/2006/07/12/…
Michael Buen

Respostas:

53

Aqui estão alguns métodos que você pode comparar. Primeiro vamos configurar uma tabela com alguns dados fictícios. Estou preenchendo isso com um monte de dados aleatórios de sys.all_columns. Bem, é meio aleatório - estou garantindo que as datas sejam contíguas (o que é realmente importante apenas para uma das respostas).

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;

Resultados:

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

Os dados são assim (5000 linhas) - mas parecerão um pouco diferentes no seu sistema, dependendo da versão e número da compilação:

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

E os resultados dos totais em execução devem ficar assim (501 linhas):

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

Portanto, os métodos que vou comparar são:

  • "auto-junção" - a abordagem purista baseada em conjuntos
  • "CTE recursiva com datas" - isso depende de datas contíguas (sem lacunas)
  • "CTE recursivo com número de linha" - semelhante ao acima, mas mais lento, com base em ROW_NUMBER
  • "CTE recursivo com #temp table" - roubado da resposta de Mikael, conforme sugerido
  • "atualização peculiar" que, embora não seja suportada e não prometa um comportamento definido, parece bastante popular
  • "cursor"
  • SQL Server 2012 usando a nova funcionalidade de janelas

auto-junção

É assim que as pessoas o instruem a fazê-lo quando o alertam para ficar longe dos cursores, porque "baseado em conjuntos é sempre mais rápido". Em algumas experiências recentes, descobri que o cursor ultrapassa essa solução.

;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 recursivo com datas

Lembrete - isso depende de datas contíguas (sem intervalos), até 10000 níveis de recursão e que você saiba a data de início do intervalo em que está interessado (para definir a âncora). Você pode definir a âncora dinamicamente usando uma subconsulta, é claro, mas eu queria manter as coisas 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 recursivo com row_number

O cálculo do número da linha é um pouco caro aqui. Novamente, isso suporta o nível máximo de recursão de 10000, mas você não precisa atribuir a âncora.

;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 recursivo com tabela temporária

Roubo da resposta de Mikael, como sugerido, para incluí-lo nos testes.

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;

atualização peculiar

Mais uma vez, estou incluindo isso apenas por completude; Pessoalmente, eu não confiaria nessa solução, pois, como mencionei em outra resposta, não é garantido que este método funcione e pode ser completamente interrompido em uma versão futura do SQL Server. (Estou fazendo o possível para forçar o SQL Server a obedecer a ordem que desejo, usando uma dica para a escolha do índice.)

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;

cursor

"Cuidado, existem cursores aqui! Cursores são maus! Você deve evitar cursores a todo custo!" Não, isso não sou eu falando, são coisas que eu ouço muito. Ao contrário da opinião popular, existem alguns casos em que os cursores são apropriados.

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

Se você está na versão mais recente do SQL Server, os aprimoramentos na funcionalidade de janelas permitem calcular facilmente os totais em execução sem o custo exponencial da auto-associação (o SUM é calculado em uma passagem), a complexidade dos CTEs (incluindo o requisito de linhas contíguas para o melhor desempenho da CTE), a atualização peculiar não suportada e o cursor proibido. Apenas tenha cuidado com a diferença entre usar RANGEe ROWS, ou não especificar, apenas ROWSevita um spool no disco, o que dificultará significativamente o desempenho.

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

comparações de desempenho

Peguei cada abordagem e agrupei um lote usando o seguinte:

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

Aqui estão os resultados da duração total, em milissegundos (lembre-se de incluir também os comandos DBCC sempre):

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

E fiz de novo sem os comandos 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

Removendo o DBCC e os loops, apenas medindo uma iteração bruta:

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

Finalmente, multipliquei a contagem de linhas na tabela de origem por 10 (alterando o topo para 50000 e adicionando outra tabela como uma junção cruzada). Os resultados disso, uma única iteração sem comandos DBCC (simplesmente no interesse do tempo):

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

Apenas medi a duração - deixarei como um exercício para o leitor comparar essas abordagens em seus dados, comparando outras métricas que podem ser importantes (ou podem variar de acordo com o esquema / dados). Antes de tirar conclusões dessa resposta, você deve testá-lo com base em seus dados e seu esquema ... esses resultados quase certamente mudarão à medida que a contagem de linhas aumentar.


demonstração

Eu adicionei um sqlfiddle . Resultados:

insira a descrição da imagem aqui


conclusão

Nos meus testes, a escolha seria:

  1. Método do SQL Server 2012, se eu tiver o SQL Server 2012 disponível.
  2. Se o SQL Server 2012 não estiver disponível e minhas datas forem contíguas, eu usaria o método recursive cte with datas.
  3. Se nem 1. nem 2. são aplicáveis, eu faria a auto-junção sobre a atualização peculiar, mesmo que o desempenho estivesse próximo, apenas porque o comportamento está documentado e garantido. Estou menos preocupado com a compatibilidade futura, porque, esperançosamente, se a atualização for interrompida, será depois que eu já tiver convertido todo o meu código para 1. :-)

Mas, novamente, você deve testá-los no seu esquema e dados. Como este foi um teste artificial com contagens de linha relativamente baixas, também pode ser um peido ao vento. Fiz outros testes com contagens diferentes de esquema e linha, e as heurísticas de desempenho eram bem diferentes ... e foi por isso que fiz tantas perguntas de acompanhamento à sua pergunta original.


ATUALIZAR

Eu escrevi mais sobre isso aqui:

Melhores abordagens para execução de totais - atualizado para o SQL Server 2012

Aaron Bertrand
fonte
1

Aparentemente, esta é a solução ideal

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
Code Magician
fonte
Alguma idéia sem implementar uma tabela temporária (meu proc já está forçando valores através de várias tabelas temporárias por necessidade, por isso estou tentando encontrar uma maneira de evitar o uso de outra tabela temporária)? Caso contrário, usarei esse método. Eu acho que ele vai funcionar #
Isso também pode ser feito com uma junção automática ou uma subconsulta aninhada, mas essas opções não têm um desempenho tão bom. Além disso, é provável que você esteja atingindo o tempdb de qualquer maneira com essas alternativas em algumas spools ou tabelas de trabalho.
3
Esteja ciente de que não é garantido que este método de "atualização peculiar" funcione - essa sintaxe não é suportada e seu comportamento é indefinido, podendo ser interrompido em uma versão futura, hot fix ou service pack. Portanto, embora sim, seja mais rápido do que algumas alternativas suportadas, isso tem um potencial custo de compatibilidade futura.
Aaron Bertrand
6
Existem muitas ressalvas nessa abordagem que Jeff Moden escreveu em algum lugar. Você deve ter um índice em cluster, daypor exemplo.
Martin Smith
2
@MartinSmith É um artigo MUITO GRANDE no sqlservercentral.com (vá para a página do autor e encontre seus artigos sobre atualizações de quirck).
Fabricio Araujo
-2

Apenas outra maneira, cara, mas independente de versão. Ele não usa tabelas ou variáveis ​​temporárias.

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 

fonte
2
Isso não é bom, é muito lento. Mesmo que você tenha apenas 100 linhas, ele fará uma leitura de pingue-pongue entre tabelas em 5.050 vezes. 200 linhas, é 20.100 vezes. Com apenas 1.000 linhas, ele salta exponencialmente para 500.500 lê sqlblog.com/blogs/adam_machanic/archive/2006/07/12/...
Michael Buen
Eu vi o link para o seu blog depois de postar isso, agora vejo que é uma péssima idéia, obrigado!