Lücken und Inseln: Client-Lösung gegen T-SQL-Abfrage

10

Kann eine T-SQL-Lösung für Lücken und Inseln schneller ausgeführt werden als eine C # -Lösung, die auf dem Client ausgeführt wird?

Um genau zu sein, geben wir einige Testdaten an:

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

Dieser erste Satz von Testdaten weist genau eine Lücke auf:

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

Der zweite Satz von Testdaten weist 2M -1 Lücken auf, eine Lücke zwischen jeweils zwei benachbarten Intervallen:

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

Derzeit verwende ich 2008 R2, aber 2012-Lösungen sind sehr willkommen. Ich habe meine C # -Lösung als Antwort veröffentlicht.

AK
quelle

Antworten:

4

Und eine 1-Sekunden-Lösung ...

;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
quelle
Dies ist ungefähr 30% schneller als Ihre andere Lösung. 1 Lücke: (00: 00: 12.1355011 00: 00: 11.6406581), 2M-1 Lücken (00: 00: 12.4526817 00: 00: 11.7442217). Trotzdem ist dies im schlimmsten Fall etwa 25% langsamer als die clientseitige Lösung, genau wie von Adam Machanic auf Twitter vorhergesagt.
AK
4

Der folgende C # -Code löst das Problem:

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

Dieser Code ruft diese gespeicherte Prozedur auf:

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

Es findet und druckt eine Lücke in 2M-Intervallen in den folgenden Zeiten, warmer Cache:

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

Es findet und druckt 2M-1 Lücken in 2M Intervallen in den folgenden Zeiten, warmer Cache:

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

Dies ist eine sehr einfache Lösung - ich habe 10 Minuten gebraucht, um sie zu entwickeln. Ein neuer Hochschulabsolvent kann sich das einfallen lassen. Auf der Datenbankseite ist der Ausführungsplan ein trivialer Zusammenführungs-Join, der nur sehr wenig CPU und Speicher benötigt.

Bearbeiten: Um realistisch zu sein, führe ich Client und Server auf separaten Boxen aus.

AK
quelle
Ja, aber was ist, wenn ich die Ergebnismenge als Datensatz und nicht als Datei zurückhaben möchte?
Peter Larsson
Die meisten Anwendungen möchten IEnumerable <SomeClassOrStruct> verwenden. In diesem Fall geben wir nur return zurück, anstatt einer Liste eine Zeile hinzuzufügen. Um dieses Beispiel kurz zu halten, habe ich viele Dinge entfernt, die für die Messung der Rohleistung nicht unbedingt erforderlich sind.
AK
Und das ist frei von CPU? Oder verlängert es Ihre Lösung um Zeit?
Peter Larsson
@PeterLarsson können Sie einen besseren Weg zum Benchmarking vorschlagen? Das Schreiben in eine Datei ahmt den recht langsamen Datenverbrauch des Clients nach.
AK
3

Ich glaube, ich habe die Grenzen meines Wissens in SQL Server in diesem Fall ausgeschöpft ...

Um eine Lücke in SQL Server zu finden (was der C # -Code tut) und sich nicht darum zu kümmern, Lücken zu starten oder zu beenden (vor dem ersten Start oder nach dem letzten Ende), ist die folgende Abfrage (oder Varianten) die am schnellsten konnte ich finden:

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

Dies funktioniert zwar geringfügig, aber für jeden Start-Ziel-Satz können Sie Start und Ziel als separate Sequenzen behandeln, das Ziel um eins versetzen und Lücken werden angezeigt.

Nehmen Sie z. B. (S1, F1), (S2, F2), (S3, F3) und ordnen Sie wie folgt: {S1, S2, S3, null} und {null, F1, F2, F3}. Vergleichen Sie dann Zeile n mit Zeile n In jedem Satz und in Lücken ist der F-Satzwert kleiner als der S-Satzwert. Ich denke, das Problem ist, dass es in SQL Server keine Möglichkeit gibt, zwei separate Sätze nur in der Reihenfolge der Werte in zu verbinden oder zu vergleichen the set ... daher die Verwendung der row_number-Funktion, um das Zusammenführen nur anhand der Zeilennummer zu ermöglichen ... aber es gibt keine Möglichkeit, SQL Server mitzuteilen, dass diese Werte eindeutig sind (ohne sie in eine Tabelle var mit einem Index einzufügen darauf - was länger dauert - ich habe es versucht), also denke ich, dass der Merge-Join nicht optimal ist? (obwohl schwer zu beweisen, wenn es schneller ist als alles andere, was ich tun könnte)

Mit den LAG / LEAD-Funktionen konnte ich Lösungen finden:

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

(was ich übrigens nicht garantiere, die Ergebnisse - es scheint zu funktionieren, aber ich denke, dass StartedAt in der Aufgabentabelle in Ordnung ist ... und es war langsamer)

Verwenden der Summenänderung:

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

(keine Überraschung, auch langsamer)

Ich habe sogar versucht, eine CLR-Aggregatfunktion (um die Summe zu ersetzen - sie war langsamer als die Summe und stützte sich auf row_number (), um die Reihenfolge der Daten beizubehalten) und CLR eine Tabellenwertfunktion (um zwei Ergebnismengen zu öffnen und Werte zu vergleichen, die ausschließlich darauf basieren auf Sequenz) ... und es war auch langsamer. Ich habe mich so oft mit SQL- und CLR-Einschränkungen beschäftigt und viele andere Methoden ausprobiert ...

Und wofür?

Wenn Sie auf demselben Computer ausgeführt werden und sowohl die C # -Daten als auch die SQL-gefilterten Daten in eine Datei (gemäß dem ursprünglichen C # -Code) spucken, sind die Zeiten praktisch gleich ... ungefähr 2 Sekunden für die 1-Lücken-Daten (C # normalerweise schneller ), 8-10 Sekunden für den Multi-Gap-Datensatz (SQL normalerweise schneller).

ANMERKUNG : Verwenden Sie die SQL Server-Entwicklungsumgebung nicht für den Zeitvergleich, da die Anzeige im Raster einige Zeit in Anspruch nimmt. Wie mit SQL 2012, VS2010, .net 4.0-Clientprofil getestet

Ich werde darauf hinweisen, dass beide Lösungen fast die gleiche Sortierung von Daten auf dem SQL Server durchführen, sodass die Serverlast für die Abrufsortierung ähnlich ist, je nachdem, welche Lösung Sie verwenden. Der einzige Unterschied besteht in der Verarbeitung auf dem Client (und nicht auf dem Server). und die Übertragung über das Netzwerk.

Ich weiß nicht, was der Unterschied sein könnte, wenn Sie möglicherweise nach verschiedenen Mitarbeitern partitionieren oder wenn Sie zusätzliche Daten mit den Lückeninformationen benötigen (obwohl mir nicht viel anderes als eine Mitarbeiter-ID einfällt), oder natürlich, wenn Es besteht eine langsame Datenverbindung zwischen dem SQL Server und dem Clientcomputer (oder einem langsamen Client) ... Ich habe auch keine Vergleichszeiten, Probleme mit Konflikten oder Probleme mit CPU / NETWORK für mehrere Benutzer verglichen ... Also ich Ich weiß nicht, welches in diesem Fall eher ein Engpass ist.

Was ich weiß, ist ja, SQL Server ist nicht gut in dieser Art von Set-Vergleichen, und wenn Sie die Abfrage nicht richtig schreiben, werden Sie teuer dafür bezahlen.

Ist es einfacher oder schwieriger als die C # -Version zu schreiben? Ich bin mir nicht ganz sicher, ob die Änderung +/- 1, die Gesamtlösung ausführt, auch nicht ganz intuitiv ist, und ich, aber es ist nicht die erste Lösung, zu der ein durchschnittlicher Absolvent kommen würde ... wenn sie fertig ist, ist es einfach genug, sie zu kopieren, aber Es braucht Einsicht, um überhaupt zu schreiben ... das Gleiche gilt für die SQL-Version. Welches ist schwieriger? Welches ist robuster gegenüber unerwünschten Daten? Welches hat mehr Potenzial für Parallelbetrieb? Ist es wirklich wichtig, wenn der Unterschied im Vergleich zum Programmieraufwand so gering ist?

Eine letzte Anmerkung; Es gibt eine nicht angegebene Einschränkung für die Daten - StartedAt muss kleiner als FinishedAt sein, sonst erhalten Sie schlechte Ergebnisse.

puzsol
quelle
3

Hier ist eine Lösung, die in 4 Sekunden läuft.

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
quelle
Peter, bei einem Datensatz mit einer Lücke ist dies mehr als 10-mal langsamer: (00: 00: 18.1016745 - 00: 00: 17.8190959) Bei Daten mit 2M-1-Lücken ist es 2-mal langsamer: (00:00 : 17.2409640 00: 00: 17.6068879)
AK