Lacunas e ilhas: solução do cliente vs consulta T-SQL

10

Uma solução T-SQL para lacunas e ilhas pode ser executada mais rapidamente do que uma solução C # em execução no cliente?

Para ser específico, vamos fornecer alguns dados de teste:

CREATE TABLE dbo.Numbers
  (
    n INT NOT NULL
          PRIMARY KEY
  ) ; 
GO 

INSERT  INTO dbo.Numbers
        ( n )
VALUES  ( 1 ) ; 
GO 
DECLARE @i INT ; 
SET @i = 0 ; 
WHILE @i < 21 
  BEGIN 
    INSERT  INTO dbo.Numbers
            ( n 
            )
            SELECT  n + POWER(2, @i)
            FROM    dbo.Numbers ; 
    SET @i = @i + 1 ; 
  END ;  
GO

CREATE TABLE dbo.Tasks
  (
    StartedAt SMALLDATETIME NOT NULL ,
    FinishedAt SMALLDATETIME NOT NULL ,
    CONSTRAINT PK_Tasks PRIMARY KEY ( StartedAt, FinishedAt ) ,
    CONSTRAINT UNQ_Tasks UNIQUE ( FinishedAt, StartedAt )
  ) ;
GO

INSERT  INTO dbo.Tasks
        ( StartedAt ,
          FinishedAt
        )
        SELECT  DATEADD(MINUTE, n, '20100101') AS StartedAt ,
                DATEADD(MINUTE, n + 2, '20100101') AS FinishedAt
        FROM    dbo.Numbers
        WHERE   ( n < 500000
                  OR n > 500005
                )
GO

Este primeiro conjunto de dados de teste possui exatamente uma lacuna:

SELECT  StartedAt ,
        FinishedAt
FROM    dbo.Tasks
WHERE   StartedAt BETWEEN DATEADD(MINUTE, 499999, '20100101')
                  AND     DATEADD(MINUTE, 500006, '20100101')

O segundo conjunto de dados de teste possui intervalos de 2M -1, um intervalo entre cada dois intervalos adjacentes:

TRUNCATE TABLE dbo.Tasks;
GO

INSERT  INTO dbo.Tasks
        ( StartedAt ,
          FinishedAt
        )
        SELECT  DATEADD(MINUTE, 3*n, '20100101') AS StartedAt ,
                DATEADD(MINUTE, 3*n + 2, '20100101') AS FinishedAt
        FROM    dbo.Numbers
        WHERE   ( n < 500000
                  OR n > 500005
                )
GO

Atualmente, estou executando o 2008 R2, mas as soluções de 2012 são muito bem-vindas. Publiquei minha solução C # como resposta.

AK
fonte

Respostas:

4

E uma solução de 1 segundo ...

;WITH cteSource(StartedAt, FinishedAt)
AS (
    SELECT      s.StartedAt,
            e.FinishedAt
    FROM        (
                SELECT  StartedAt,
                    ROW_NUMBER() OVER (ORDER BY StartedAt) AS rn
                FROM    dbo.Tasks
            ) AS s
    INNER JOIN  (
                SELECT  FinishedAt,
                    ROW_NUMBER() OVER (ORDER BY FinishedAt) + 1 AS rn
                FROM    dbo.Tasks
            ) AS e ON e.rn = s.rn
    WHERE       s.StartedAt > e.FinishedAt

    UNION ALL

    SELECT  MIN(StartedAt),
        MAX(FinishedAt)
    FROM    dbo.Tasks
), cteGrouped(theTime, grp)
AS (
    SELECT  u.theTime,
        (ROW_NUMBER() OVER (ORDER BY u.theTime) - 1) / 2
    FROM    cteSource AS s
    UNPIVOT (
            theTime
            FOR theColumn IN (s.StartedAt, s.FinishedAt)
        ) AS u
)
SELECT      MIN(theTime),
        MAX(theTime)
FROM        cteGrouped
GROUP BY    grp
ORDER BY    grp
Peter Larsson
fonte
Isso é cerca de 30% mais rápido que sua outra solução. 1 intervalo: (00: 00: 12.1355011 00: 00: 11.6406581), intervalos 2M-1 (00: 00: 12.4526817 00: 00: 11.7442217). Ainda assim, isso é cerca de 25% mais lento que a solução do lado do cliente, na pior das hipóteses, exatamente como previsto por Adam Machanic no twitter.
AK
4

O seguinte código C # resolve o problema:

    var connString =
        "Initial Catalog=MyDb;Data Source=MyServer;Integrated Security=SSPI;Application Name=Benchmarks;";

    var stopWatch = new Stopwatch();
    stopWatch.Start();

    using (var conn = new SqlConnection(connString))
    {
        conn.Open();
        var command = conn.CreateCommand();
        command.CommandText = "dbo.GetAllTaskEvents";
        command.CommandType = CommandType.StoredProcedure;
        var gaps = new List<string>();
        using (var dr = command.ExecuteReader())
        {
            var currentEvents = 0;
            var gapStart = new DateTime();
            var gapStarted = false;
            while (dr.Read())
            {
                var change = dr.GetInt32(1);
                if (change == -1 && currentEvents == 1)
                {
                    gapStart = dr.GetDateTime(0);
                    gapStarted = true;
                }
                else if (change == 1 && currentEvents == 0 && gapStarted)
                {
                    gaps.Add(string.Format("({0},{1})", gapStart, dr.GetDateTime(0)));
                    gapStarted = false;
                }
                currentEvents += change;
            }
        }
        File.WriteAllLines(@"C:\Temp\Gaps.txt", gaps);
    }

    stopWatch.Stop();
    System.Console.WriteLine("Elapsed: " + stopWatch.Elapsed);

Este código chama este procedimento armazenado:

CREATE PROCEDURE dbo.GetAllTaskEvents
AS 
  BEGIN ;
    SELECT  EventTime ,
            Change
    FROM    ( SELECT  StartedAt AS EventTime ,
                      1 AS Change
              FROM    dbo.Tasks
              UNION ALL
              SELECT  FinishedAt AS EventTime ,
                      -1 AS Change
              FROM    dbo.Tasks
            ) AS TaskEvents
    ORDER BY EventTime, Change DESC ;
  END ;
GO

Ele localiza e imprime um intervalo em intervalos de 2 milhões nos seguintes horários, cache quente:

1 gap: Elapsed: 00:00:01.4852029 00:00:01.4444307 00:00:01.4644152

Ele localiza e imprime intervalos de 2M-1 em intervalos de 2M nos seguintes horários, cache quente:

2M-1 gaps Elapsed: 00:00:08.8576637 00:00:08.9123053 00:00:09.0372344 00:00:08.8545477

Esta é uma solução muito simples - levei 10 minutos para desenvolver. Um recém-formado pode fazer isso. No lado do banco de dados, o plano de execução é uma junção de mesclagem trivial que usa muito pouca CPU e memória.

Edit: para ser realista, estou executando cliente e servidor em caixas separadas.

AK
fonte
Sim, mas e se eu quiser o conjunto de resultados de volta como um conjunto de dados, não como um arquivo?
Peter Larsson
A maioria dos aplicativos deseja usar IEnumerable <SomeClassOrStruct> - nesse caso, apenas retornamos, adicionando uma linha a uma lista. Para manter este exemplo curto, removi muitas coisas que não são essenciais para medir o desempenho bruto.
AK
E isso é livre de CPU? Ou isso adiciona tempo à sua solução?
Peter Larsson
@PeterLarsson, você pode sugerir uma maneira melhor de comparar? A gravação em um arquivo imita o consumo bastante lento de dados pelo cliente.
AK
3

Eu acho que esgotei os limites do meu conhecimento no SQL Server neste ....

Para encontrar uma lacuna no SQL server (o que o código C # faz) e você não se importa com o início ou o término de lacunas (aquelas antes da primeira inicialização ou após a última conclusão), a seguinte consulta (ou variantes) é a o mais rápido que pude encontrar:

SELECT e.FinishedAt as GapStart, s.StartedAt as GapEnd
FROM 
(
    SELECT StartedAt, ROW_NUMBER() OVER (ORDER BY StartedAt) AS rn
    FROM dbo.Tasks
) AS s
INNER JOIN  
(
    SELECT  FinishedAt, ROW_NUMBER() OVER (ORDER BY FinishedAt) + 1 AS rn
    FROM    dbo.Tasks
) AS e ON e.rn = s.rn and s.StartedAt > e.FinishedAt

O que funciona muito bem, pois para cada conjunto de início e término, você pode tratar o início e o final como sequências separadas, compensar o final em um e as lacunas são mostradas.

por exemplo, take (S1, F1), (S2, F2), (S3, F3) e faça o pedido como: {S1, S2, S3, null} e {null, F1, F2, F3} Em seguida, compare a linha n com a linha n em cada conjunto, e as lacunas são onde o valor do conjunto F é menor que o valor do conjunto S ... o problema é que, no SQL Server, não há como associar ou comparar dois conjuntos separados apenas na ordem dos valores em o conjunto ... daí o uso da função row_number para nos permitir mesclar com base apenas no número da linha ... mas não há como dizer ao SQL Server que esses valores são únicos (sem inseri-los em uma tabela var com um índice) eu tentei), então acho que a junção de mesclagem é menor que a ideal? (embora difícil de provar quando é mais rápido do que qualquer outra coisa que eu poderia fazer)

Consegui obter soluções usando as funções LAG / LEAD:

select * from
(
    SELECT top (100) percent StartedAt, FinishedAt, LEAD(StartedAt, 1, null) OVER (Order by FinishedAt) as NextStart
    FROM dbo.Tasks
) as x
where NextStart > FinishedAt

(que, a propósito, não garanto os resultados - parece funcionar, mas acho que depende do StartedAt estar em ordem na tabela Tarefas ... e foi mais lento)

Usando alteração de soma:

select * from
(
    SELECT EventTime, Change, SUM(Change) OVER (ORDER BY EventTime, Change desc ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) as RunTotal --, x.*
    FROM    
    ( 
        SELECT StartedAt AS EventTime, 1 AS Change
        FROM dbo.Tasks
    UNION ALL
        SELECT  FinishedAt AS EventTime, -1 AS Change
        FROM dbo.Tasks
    ) AS TaskEvents
) as x
where x.RunTotal = 0 or (x.RunTotal = 1 and x.Change = 1)
ORDER BY EventTime, Change DESC

(sem surpresa, também mais lento)

Eu até tentei uma função agregada CLR (para substituir a soma - era mais lenta que a soma e dependia de row_number () para manter a ordem dos dados) e CLR uma função com valor de tabela (para abrir dois conjuntos de resultados e comparar valores baseados puramente em sequência) ... e também foi mais lento. Eu bati minha cabeça tantas vezes nas limitações de SQL e CLR, tentando muitos outros métodos ...

E para quê?

Executando na mesma máquina e cuspindo os dados C # e SQL filtrados em um arquivo (conforme o código C # original), os tempos são praticamente os mesmos ... aproximadamente 2 segundos para os dados de 1 intervalo (C # geralmente mais rápido ), 8 a 10 segundos para o conjunto de dados com vários espaços (SQL geralmente mais rápido).

NOTA : Não use o SQL Server Development Environment para comparação de tempo, pois a exibição na grade leva tempo. Conforme testado com o SQL 2012, VS2010, .net 4.0 Perfil do cliente

Apontarei que ambas as soluções realizam praticamente a mesma classificação de dados no servidor SQL, portanto a carga do servidor para a busca-busca será semelhante, independentemente da solução usada, a única diferença é o processamento no cliente (e não no servidor) e a transferência pela rede.

Eu não sei qual pode ser a diferença ao particionar por diferentes membros da equipe, talvez, ou quando você precisar de dados extras com as informações de lacunas (embora eu não consiga pensar em outra coisa senão uma identificação de equipe), ou claro, se existe uma conexão de dados lenta entre o servidor SQL e a máquina cliente (ou um cliente lento ) ... Também não fiz uma comparação de tempos de bloqueio, problemas de contenção ou problemas de CPU / REDE para vários usuários ... não sei qual é mais provável que seja um gargalo neste caso.

O que eu sei é que sim, o SQL Server não é bom nesse tipo de comparação de conjuntos e, se você não escrever a consulta corretamente, pagará caro.

É mais fácil ou mais difícil do que escrever a versão C #? Não tenho certeza absoluta de que a solução total em execução Change +/- 1 também não é totalmente intuitiva, e eu, mas não é a primeira solução para a qual um graduado comum chegaria ... uma vez feito, é fácil copiar, mas é preciso discernimento para escrever em primeiro lugar ... o mesmo pode ser dito para a versão SQL. Qual é mais difícil? Qual é mais robusto para dados não autorizados? Qual tem mais potencial para operações paralelas? Realmente importa quando a diferença é tão pequena em comparação com o esforço de programação?

Uma última nota; há uma restrição não declarada nos dados - o StartedAt deve ser menor que o FinishedAt, ou você obterá resultados ruins.

puzsol
fonte
3

Aqui está uma solução que é executada em 4 segundos.

WITH cteRaw(ts, type, e, s)
AS (
    SELECT  StartedAt,
        1 AS type,
        NULL,
        ROW_NUMBER() OVER (ORDER BY StartedAt)
    FROM    dbo.Tasks

    UNION ALL

    SELECT  FinishedAt,
        -1 AS type, 
        ROW_NUMBER() OVER (ORDER BY FinishedAt),
        NULL
    FROM    dbo.Tasks
), cteCombined(ts, e, s, se)
AS (
    SELECT  ts,
        e,
        s,
        ROW_NUMBER() OVER (ORDER BY ts, type DESC)
    FROM    cteRaw
), cteFiltered(ts, grpnum)
AS (
    SELECT  ts, 
        (ROW_NUMBER() OVER (ORDER BY ts) - 1) / 2 AS grpnum
    FROM    cteCombined
    WHERE   COALESCE(s + s - se - 1, se - e - e) = 0
)
SELECT      MIN(ts) AS starttime,
        MAX(ts) AS endtime
FROM        cteFiltered
GROUP BY    grpnum;
Peter Larsson
fonte
Peter, em um conjunto de dados com um intervalo, isso é 10 vezes mais lento: (00: 00: 18.1016745 - 00: 00: 17.8190959) Nos dados com intervalos de 2M-1, é 2 vezes mais lento: (00:00 : 17.2409640 00: 00: 17.6068879)
AK