Divari e isole: soluzione client vs query T-SQL


10

Una soluzione T-SQL per gap e isole può essere eseguita più velocemente di una soluzione C # in esecuzione sul client?

Per essere precisi, forniamo alcuni dati di test:

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

Questo primo set di dati di test ha esattamente un gap:

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

La seconda serie di dati di test ha 2M -1 lacune, uno spazio tra ogni due intervalli adiacenti:

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

Attualmente gestisco 2008 R2, ma le soluzioni 2012 sono molto benvenute. Ho pubblicato la mia soluzione C # come risposta.

Risposte:


4

E una soluzione da 1 secondo ...

;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

Questo è circa il 30% più veloce rispetto alle altre soluzioni. 1 spazio: (00: 00: 12.1355011 00: 00: 11.6406581), spazi 2M-1 (00: 00: 12.4526817 00: 00: 11.7442217). Tuttavia, questo è circa il 25% più lento rispetto alla soluzione lato client nel suo caso peggiore, esattamente come previsto da Adam Machanic su Twitter.
AK,

4

Il seguente codice C # risolve il 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);

Questo codice richiama questa procedura memorizzata:

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

Trova e stampa uno spazio vuoto a intervalli di 2M nei seguenti orari, warm cache:

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

Trova e stampa lacune 2M-1 a intervalli di 2M nei seguenti orari, cache calda:

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

Questa è una soluzione molto semplice: mi ci sono voluti 10 minuti per svilupparla. Un neolaureato può trovarlo. Dal lato del database, il piano di esecuzione è un banale join di unione che utilizza pochissima CPU e memoria.

Modifica: per essere realistico, sto eseguendo client e server su caselle separate.


Sì, ma cosa succede se si desidera che il set di risultati venga restituito come set di dati, non come file?
Peter Larsson,

La maggior parte delle applicazioni desidera utilizzare IEnumerable <SomeClassOrStruct> - in questo caso restituiamo semplicemente il rendimento invece di aggiungere una riga a un elenco. Per farla breve, ho rimosso molte cose non essenziali per misurare le prestazioni grezze.
AK,

E questo è privo di CPU? O aggiunge tempo alla tua soluzione?
Peter Larsson,

@PeterLarsson puoi suggerire un modo migliore di benchmark? La scrittura su un file imita il consumo piuttosto lento di dati da parte del client.
AK,

3

Penso di aver esaurito i limiti delle mie conoscenze in SQL Server su questo ....

Per trovare un gap nel server SQL (cosa fa il codice C #) e non ti interessa iniziare o terminare i gap (quelli prima del primo avvio o dopo l'ultimo fine), la seguente query (o varianti) è la più veloce che ho trovato:

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

Che funziona anche se leggermente di mano che per ogni set di inizio-fine, puoi trattare l'inizio e la fine come sequenze separate, sfalsare la fine di uno e gli spazi sono mostrati.

es. prendere (S1, F1), (S2, F2), (S3, F3) e ordinare come: {S1, S2, S3, null} e {null, F1, F2, F3} Quindi confrontare la riga n con la riga n in ogni set, e le lacune sono dove il valore del set F è inferiore al valore del set S ... il problema penso sia che nel server SQL non c'è modo di unire o confrontare due set separati puramente nell'ordine dei valori in il set ... quindi l'uso della funzione row_number per permetterci di unirci basandoci esclusivamente sul numero di riga ... ma non c'è modo di dire a SQL Server che questi valori sono univoci (senza inserirli in una tabella var con un indice su di esso - che richiede più tempo - l'ho provato), quindi penso che l'unione di unione sia meno che ottimale? (anche se difficile da dimostrare quando è più veloce di qualsiasi altra cosa che potrei fare)

Sono stato in grado di ottenere soluzioni utilizzando le funzioni 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

(che a proposito, non garantisco i risultati - sembra funzionare, ma penso che si basi su Started: essere in ordine nella tabella delle attività ... ed è stato più lento)

Utilizzando la modifica della somma:

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

(nessuna sorpresa, anche più lento)

Ho anche provato una funzione aggregata CLR (per sostituire la somma: era più lenta della somma e mi sono affidato a row_number () per mantenere l'ordine dei dati) e CLR una funzione con valori di tabella (per aprire due set di risultati e confrontare i valori in base puramente in sequenza) ... ed era anche più lento. Ho sbattuto la testa così tante volte su SQL e limitazioni CLR, provando molti altri metodi ...

E per cosa?

In esecuzione sullo stesso computer e sputando sia i dati C # sia i dati filtrati SQL in un file (come per il codice C # originale), i tempi sono praticamente gli stessi .... circa 2 secondi per i dati 1 gap (C # solitamente più veloce ), 8-10 secondi per il set di dati multi-gap (SQL in genere più veloce).

NOTA : non utilizzare l'ambiente di sviluppo di SQL Server per il confronto dei tempi, poiché la visualizzazione sulla griglia richiede tempo. Testato con SQL 2012, VS2010, profilo client .net 4.0

Sottolineerò che entrambe le soluzioni eseguono praticamente lo stesso ordinamento di dati sul server SQL, quindi il carico del server per il fetch-sort sarà simile, qualunque sia la soluzione utilizzata, l'unica differenza è l'elaborazione sul client (anziché sul server) e il trasferimento in rete.

Non so quale potrebbe essere la differenza nel partizionare da diversi membri dello staff, o quando potresti aver bisogno di dati extra con le informazioni sul gap (anche se non riesco a pensare a nient'altro che un ID personale), o ovviamente se c'è una connessione dati lenta tra il server SQL e la macchina client (o un client lento ) ... Né ho fatto un confronto tra tempi di blocco, problemi di contesa o problemi di CPU / RETE per più utenti ... Quindi non so quale sia più probabile che sia un collo di bottiglia in questo caso.

Quello che so, è sì, SQL Server non è bravo in questo tipo di confronti set, e se non scrivi la query nel modo giusto pagherai a caro prezzo.

È più facile o più difficile che scrivere la versione C #? Non sono del tutto sicuro, la modifica +/- 1, che esegue la soluzione totale non è nemmeno del tutto intuitiva, e io ma non è la prima soluzione a cui un laureato medio dovrebbe arrivare ... una volta fatto è abbastanza facile da copiare, ma ci vuole un po 'di comprensione per scrivere ... lo stesso si può dire per la versione SQL. Qual è più difficile? Qual è più robusto per i dati non autorizzati? Quale ha più potenziale per operazioni parallele? Importa davvero quando la differenza è così piccola rispetto allo sforzo di programmazione?

Un'ultima nota; esiste un vincolo non dichiarato sui dati: StartedAt deve essere inferiore a FinishedAt o si otterranno risultati negativi.


3

Ecco una soluzione che funziona in 4 secondi.

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, su un set di dati con un gap questo è più di 10 volte più lento: (00: 00: 18.1016745 - 00: 00: 17.8190959) Sui dati con lacune 2M-1, è 2 volte più lento: (00:00 : 17.2409640 00: 00: 17.6068879)
AK,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.