Encontrar "ToTime" por agregados em vez de uma associação
Gostaria de compartilhar uma consulta realmente selvagem que leva apenas 1 varredura da tabela com 1 leitura lógica. Em comparação, a melhor outra resposta na página, a consulta de Simon Kingston, leva 2 verificações.
Em um conjunto muito grande de dados (17.408 linhas de entrada, produzindo 8.193 linhas de resultado) é preciso CPU 574 e tempo 2645, enquanto a consulta de Simon Kingston leva CPU 63.820 e tempo 37.108.
É possível que com índices as outras consultas na página possam ter um desempenho muito melhor, mas é interessante para mim conseguir uma melhoria de CPU de 111x e uma melhoria de velocidade de 14x apenas reescrevendo a consulta.
(Observe:não quero desrespeitar Simon Kingston ou qualquer outra pessoa; estou simplesmente empolgado com a minha ideia de que essa consulta seja tão boa. A consulta dele é melhor do que a minha, pois seu desempenho é bastante e, na verdade, é compreensível e sustentável , ao contrário do meu.)
Aqui está a pergunta impossível. É difícil de entender. Foi difícil escrever. Mas é incrível. :)
WITH Ranks AS (
SELECT
T = Dense_Rank() OVER (ORDER BY Time, Num),
N = Dense_Rank() OVER (PARTITION BY Name ORDER BY Time, Num),
*
FROM
#Data D
CROSS JOIN (
VALUES (1), (2)
) X (Num)
), Items AS (
SELECT
FromTime = Min(Time),
ToTime = Max(Time),
Name = IsNull(Min(CASE WHEN Num = 2 THEN Name END), Min(Name)),
I = IsNull(Min(CASE WHEN Num = 2 THEN T - N END), Min(T - N)),
MinNum = Min(Num)
FROM
Ranks
GROUP BY
T / 2
)
SELECT
FromTime = Min(FromTime),
ToTime = CASE WHEN MinNum = 2 THEN NULL ELSE Max(ToTime) END,
Name
FROM Items
GROUP BY
I, Name, MinNum
ORDER BY
FromTime
Observação:isso requer SQL 2008 ou superior. Para fazê-lo funcionar no SQL 2005, altere a cláusula VALUES para
SELECT 1 UNION ALL SELECT 2
. Consulta atualizada
Depois de pensar um pouco sobre isso, percebi que estava realizando duas tarefas lógicas separadas ao mesmo tempo, e isso tornou a consulta desnecessariamente complicada:1) remover linhas intermediárias que não têm relação com a solução final (linhas que não começam uma nova tarefa) e 2) puxe o valor "ToTime" da próxima linha. Realizando #1 antes #2, a consulta é mais simples e funciona com aproximadamente metade da CPU!
Então, aqui está a consulta simplificada que primeiro corta as linhas com as quais não nos importamos, depois obtém o valor ToTime usando agregações em vez de um JOIN. Sim, ele tem 3 funções de janela em vez de 2, mas, em última análise, por causa das poucas linhas (depois de podar aquelas com as quais não nos importamos), ele tem menos trabalho a fazer:
WITH Ranks AS (
SELECT
Grp =
Row_Number() OVER (ORDER BY Time)
- Row_Number() OVER (PARTITION BY Name ORDER BY Time),
[Time], Name
FROM #Data D
), Ranges AS (
SELECT
Result = Row_Number() OVER (ORDER BY Min(R.[Time]), X.Num) / 2,
[Time] = Min(R.[Time]),
R.Name, X.Num
FROM
Ranks R
CROSS JOIN (VALUES (1), (2)) X (Num)
GROUP BY
R.Name, R.Grp, X.Num
)
SELECT
FromTime = Min([Time]),
ToTime = CASE WHEN Count(*) = 1 THEN NULL ELSE Max([Time]) END,
Name = IsNull(Min(CASE WHEN Num = 2 THEN Name ELSE NULL END), Min(Name))
FROM Ranges R
WHERE Result > 0
GROUP BY Result
ORDER BY FromTime;
Essa consulta atualizada tem todos os mesmos problemas que apresentei na minha explicação, no entanto, eles são mais fáceis de resolver porque não estou lidando com as linhas extras desnecessárias. Também vejo que o
Row_Number() / 2
valor de 0 eu tive que excluir, e não sei por que não o excluí da consulta anterior, mas em qualquer caso isso funciona perfeitamente e é incrivelmente rápido! Aplicação externa arruma as coisas
Por último, aqui está uma versão basicamente idêntica à consulta de Simon Kingston que eu acho que é uma sintaxe mais fácil de entender.
SELECT
FromTime = Min(D.Time),
X.ToTime,
D.Name
FROM
#Data D
OUTER APPLY (
SELECT TOP 1 ToTime = D2.[Time]
FROM #Data D2
WHERE
D.[Time] < D2.[Time]
AND D.[Name] <> D2.[Name]
ORDER BY D2.[Time]
) X
GROUP BY
X.ToTime,
D.Name
ORDER BY
FromTime;
Aqui está o script de configuração se você quiser fazer uma comparação de desempenho em um conjunto de dados maior:
CREATE TABLE #Data (
RecordId int,
[Time] int,
Name varchar(10)
);
INSERT #Data VALUES
(1, 10, 'Running'),
(2, 18, 'Running'),
(3, 21, 'Running'),
(4, 29, 'Walking'),
(5, 33, 'Walking'),
(6, 57, 'Running'),
(7, 66, 'Running'),
(8, 77, 'Running'),
(9, 81, 'Walking'),
(10, 89, 'Running'),
(11, 93, 'Walking'),
(12, 99, 'Running'),
(13, 107, 'Running'),
(14, 113, 'Walking'),
(15, 124, 'Walking'),
(16, 155, 'Walking'),
(17, 178, 'Running');
GO
insert #data select recordid + (select max(recordid) from #data), time + (select max(time) +25 from #data), name from #data
GO 10
Explicação
Aqui está a ideia básica por trás da minha consulta.
-
Os tempos que representam uma troca devem aparecer em duas linhas adjacentes, uma para encerrar a atividade anterior e outra para iniciar a próxima atividade. A solução natural para isso é uma junção para que uma linha de saída possa extrair de sua própria linha (para o horário de início) e a próxima alteração linha (para o horário de término).
-
No entanto, minha consulta atende à necessidade de fazer com que os horários de término apareçam em duas linhas diferentes, repetindo a linha duas vezes, comCROSS JOIN (VALUES (1), (2))
. Agora temos todas as nossas linhas duplicadas. A ideia é que, em vez de usar um JOIN para fazer cálculos entre colunas, usaremos alguma forma de agregação para recolher cada par de linhas desejado em um.
-
A próxima tarefa é fazer com que cada linha duplicada seja dividida corretamente para que uma instância vá com o par anterior e outra com o próximo par. Isso é feito com a coluna T, umROW_NUMBER()
ordenado porTime
, e depois dividido por 2 (embora eu tenha alterado faça um DENSE_RANK() para simetria, pois neste caso ele retorna o mesmo valor que ROW_NUMBER). Por eficiência, realizei a divisão no próximo passo para que o número da linha pudesse ser reutilizado em outro cálculo (continue lendo). Como o número da linha começa em 1 e a divisão por 2 converte implicitamente em int, isso tem o efeito de produzir a sequência0 1 1 2 2 3 3 4 4 ...
que tem o resultado desejado:agrupando por este valor calculado, pois também ordenamos porNum
no número da linha, agora concluímos que todos os conjuntos após o primeiro são compostos por um Num =2 da linha "anterior" e um Num =1 da linha "próxima".
-
A próxima tarefa difícil é descobrir uma maneira de eliminar as linhas com as quais não nos importamos e, de alguma forma, reduzir a hora de início de um bloco na mesma linha que a hora de término de um bloco. O que queremos é uma maneira de fazer com que cada conjunto discreto de Corrida ou Caminhada receba seu próprio número para que possamos agrupar por ele.DENSE_RANK()
é uma solução natural, mas um problema é que ela presta atenção em cada valor noORDER BY
cláusula--não temos sintaxe para fazerDENSE_RANK() OVER (PREORDER BY Time ORDER BY Name)
para que oTime
não causa oRANK
cálculo a ser alterado, exceto em cada alteração emName
. Depois de pensar um pouco, percebi que poderia tirar um pouco da lógica por trás da solução de ilhas agrupadas de Itzik Ben-Gan, e descobri que a classificação das linhas ordenadas porTime
, subtraído da classificação das linhas particionadas porName
e ordenado porTime
, geraria um valor igual para cada linha no mesmo grupo, mas diferente de outros grupos. A técnica genérica de ilhas agrupadas é criar dois valores calculados que ascendem em sincronia com as linhas, como4 5 6
e1 2 3
, que quando subtraído produzirá o mesmo valor (neste caso de exemplo3 3 3
como resultado de4 - 1
,5 - 2
e6 - 3
). Observação:inicialmente comecei comROW_NUMBER()
para meuN
cálculo, mas não estava funcionando. A resposta correta eraDENSE_RANK()
embora lamento dizer que não me lembro por que concluí isso na época, e teria que mergulhar novamente para descobrir. Mas de qualquer forma, é isso queT-N
calcula:um número que pode ser agrupado para isolar cada "ilha" de um status (Correndo ou Caminhando).
-
Mas este não foi o fim porque existem algumas rugas. Em primeiro lugar, a linha "próximo" em cada grupo contém os valores incorretos paraName
,N
eT
. Contornamos isso selecionando, de cada grupo, o valor doNum = 2
linha quando existe (mas se não existir, então usamos o valor restante). Isso produz as expressões comoCASE WHEN NUM = 2 THEN x END
:isso eliminará corretamente os valores incorretos da linha "próximo".
-
Após algumas experiências, percebi que não bastava agrupar porT - N
por si só, porque os grupos Caminhada e Corrida podem ter o mesmo valor calculado (no caso dos meus dados de amostra fornecidos até 17, existem doisT - N
valores de 6). Mas simplesmente agrupando porName
também resolve esse problema. Nenhum grupo de "Correndo" ou "Caminhando" terá o mesmo número de valores intermediários do tipo oposto. Ou seja, como o primeiro grupo começa com "Running" e há duas linhas "Walking" intervindo antes do próximo grupo "Running", o valor de N será 2 a menos que o valor deT
nesse próximo grupo "Em execução". Acabei de perceber que uma maneira de pensar sobre isso é que oT - N
cálculo conta o número de linhas antes da linha atual que NÃO pertencem ao mesmo valor "Running" ou "Walking". Alguns pensamentos mostrarão que isso é verdade:se passarmos para o terceiro grupo "Correndo", é apenas o terceiro grupo em virtude de ter um grupo "Caminhando" separando-os, então ele tem um número diferente de linhas intermediárias entrando antes dele, e por começar em uma posição mais alta, é alto o suficiente para que os valores não possam ser duplicados.
-
Finalmente, como nosso grupo final consiste em apenas uma linha (não há hora de término e precisamos exibir umNULL
em vez disso) eu tive que lançar um cálculo que poderia ser usado para determinar se tínhamos um tempo final ou não. Isso é feito com oMin(Num)
expressão e, finalmente, detectando que quando o Min(Num) era 2 (o que significa que não tínhamos uma linha "próxima"), exibia umNULL
em vez doMax(ToTime)
valor.
Espero que esta explicação seja de alguma utilidade para as pessoas. Não sei se minha técnica de "multiplicação de linhas" será geralmente útil e aplicável à maioria dos escritores de consultas SQL em ambientes de produção devido à dificuldade de entendê-la e à dificuldade de manutenção que certamente apresentará para a próxima pessoa que visitar o código (a reação é provavelmente "O que diabos ele está fazendo!?!" seguido por um rápido "Hora de reescrever!").
Se você chegou até aqui, agradeço-lhe pelo seu tempo e por me ceder em minha pequena excursão em incrivelmente-divertido-sql-puzzle-land.
Veja você mesmo
Também conhecido como simulando um "PREORDER BY":
Uma última nota. Para ver como
T - N
faz o trabalho - e observando que usar esta parte do meu método pode não ser geralmente aplicável à comunidade SQL - execute a seguinte consulta nas primeiras 17 linhas dos dados de exemplo:WITH Ranks AS (
SELECT
T = Dense_Rank() OVER (ORDER BY Time),
N = Dense_Rank() OVER (PARTITION BY Name ORDER BY Time),
*
FROM
#Data D
)
SELECT
*,
T - N
FROM Ranks
ORDER BY
[Time];
Isso rende:
RecordId Time Name T N T - N
----------- ---- ---------- ---- ---- -----
1 10 Running 1 1 0
2 18 Running 2 2 0
3 21 Running 3 3 0
4 29 Walking 4 1 3
5 33 Walking 5 2 3
6 57 Running 6 4 2
7 66 Running 7 5 2
8 77 Running 8 6 2
9 81 Walking 9 3 6
10 89 Running 10 7 3
11 93 Walking 11 4 7
12 99 Running 12 8 4
13 107 Running 13 9 4
14 113 Walking 14 5 9
15 124 Walking 15 6 9
16 155 Walking 16 7 9
17 178 Running 17 10 7
A parte importante é que cada grupo de "Caminhando" ou "Correndo" tem o mesmo valor para
T - N
que é distinto de qualquer outro grupo com o mesmo nome. Desempenho
Não quero insistir no fato de minha consulta ser mais rápida que a de outras pessoas. No entanto, dado o quão marcante é a diferença (quando não há índices), eu queria mostrar os números em formato de tabela. Esta é uma boa técnica quando é necessário um alto desempenho desse tipo de correlação linha a linha.
Antes de cada consulta ser executada, usei
DBCC FREEPROCCACHE; DBCC DROPCLEANBUFFERS;
. Configurei MAXDOP para 1 para cada consulta para remover os efeitos de redução de tempo do paralelismo. Selecionei cada conjunto de resultados em variáveis em vez de devolvê-los ao cliente para medir apenas o desempenho e não a transmissão de dados do cliente. Todas as consultas receberam as mesmas cláusulas ORDER BY. Todos os testes usaram 17.408 linhas de entrada, gerando 8.193 linhas de resultados. Nenhum resultado é exibido para as seguintes pessoas/motivos:
RichardTheKiwi *Could not test--query needs updating*
ypercube *No SQL 2012 environment yet :)*
Tim S *Did not complete tests within 5 minutes*
Sem índice:
CPU Duration Reads Writes
----------- ----------- ----------- -----------
ErikE 344 344 99 0
Simon Kingston 68672 69582 549203 49
Com índice
CREATE UNIQUE CLUSTERED INDEX CI_#Data ON #Data (Time);
: CPU Duration Reads Writes
----------- ----------- ----------- -----------
ErikE 328 336 99 0
Simon Kingston 70391 71291 549203 49 * basically not worse
Com índice
CREATE UNIQUE CLUSTERED INDEX CI_#Data ON #Data (Time, Name);
: CPU Duration Reads Writes
----------- ----------- ----------- -----------
ErikE 375 414 359 0 * IO WINNER
Simon Kingston 172 189 38273 0 * CPU WINNER
Então a moral da história é:
Índices apropriados são mais importantes do que o recurso de consulta
Com o índice apropriado, a versão de Simon Kingston ganha em geral, especialmente ao incluir a complexidade/manutenção da consulta.
Preste atenção a esta lição! 38k reads não são muito, e a versão de Simon Kingston funcionou na metade do tempo que a minha. O aumento de velocidade da minha consulta foi inteiramente devido a não haver índice na tabela, e o custo catastrófico concomitante que isso deu a qualquer consulta que precisasse de uma junção (o que a minha não precisava):uma varredura completa da tabela Hash Match matando seu desempenho. Com um índice, sua consulta foi capaz de fazer um loop aninhado com uma busca de índice clusterizado (também conhecido como pesquisa de favoritos), o que tornou as coisas realmente velozes.
É interessante que apenas um índice clusterizado no Tempo não foi suficiente. Embora Times fossem únicos, significando que apenas um Nome ocorria por vez, ainda era necessário que o Nome fizesse parte do índice para utilizá-lo corretamente.
Adicionar o índice clusterizado à tabela quando cheio de dados levou menos de 1 segundo! Não negligencie seus índices.