Sqlserver
 sql >> Base de Dados >  >> RDS >> Sqlserver

Como posso detectar e associar alterações entre valores de linha em uma tabela SQL?


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.

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

  2. 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, com CROSS 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.

  3. 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, um ROW_NUMBER() ordenado por Time , 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ência 0 1 1 2 2 3 3 4 4 ... que tem o resultado desejado:agrupando por este valor calculado, pois também ordenamos por Num 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".

  4. 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 no ORDER BY cláusula--não temos sintaxe para fazer DENSE_RANK() OVER (PREORDER BY Time ORDER BY Name) para que o Time não causa o RANK cálculo a ser alterado, exceto em cada alteração em Name . 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 por Time , subtraído da classificação das linhas particionadas por Name e ordenado por Time , 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, como 4 5 6 e 1 2 3 , que quando subtraído produzirá o mesmo valor (neste caso de exemplo 3 3 3 como resultado de 4 - 1 , 5 - 2 e 6 - 3 ). Observação:inicialmente comecei com ROW_NUMBER() para meu N cálculo, mas não estava funcionando. A resposta correta era DENSE_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 que T-N calcula:um número que pode ser agrupado para isolar cada "ilha" de um status (Correndo ou Caminhando).

  5. 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 para Name , N e T . Contornamos isso selecionando, de cada grupo, o valor do Num = 2 linha quando existe (mas se não existir, então usamos o valor restante). Isso produz as expressões como CASE WHEN NUM = 2 THEN x END :isso eliminará corretamente os valores incorretos da linha "próximo".

  6. Após algumas experiências, percebi que não bastava agrupar por T - 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 dois T - N valores de 6). Mas simplesmente agrupando por Name 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 de T nesse próximo grupo "Em execução". Acabei de perceber que uma maneira de pensar sobre isso é que o T - 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.

  7. Finalmente, como nosso grupo final consiste em apenas uma linha (não há hora de término e precisamos exibir um NULL 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 o Min(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 um NULL em vez do Max(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.