8 Stimmen

SQL: Suche nach der längsten Datumslücke

Ich habe eine Tabelle mit 2 Feldern: eindeutige ID, Benutzer-ID (Fremdschlüssel) und Datum-Zeit. Es handelt sich um ein Zugriffsprotokoll für einen Dienst. Ich arbeite in SQL Server, aber ich würde agnostische Antworten schätzen.

Ich möchte mit SQL für einen bestimmten Benutzer die ID finden, bei der die längste Lücke beginnt.

Nehmen wir zum Beispiel an, meine Werte sind wie folgt (Vereinfachung für einen Benutzer):

ID |  User-ID |  Time
----------------------------------
1  |  1       |  11-MAR-09, 8:00am
2  |  1       |  11-MAR-09, 6:00pm
3  |  1       |  13-MAR-09, 7:00pm
4  |  1       |  14-MAR-09, 6:00pm

Wenn ich nach der längsten Lücke für Benutzer 1 suche, erhalte ich ID 2 (es wäre auch schön, die Länge der Lücke auf Anhieb zu erfahren, aber das ist viel weniger kritisch).

Wie lässt sich dies in SQL am effizientesten bewerkstelligen?

Hinweis: Die ID ist nicht unbedingt fortlaufend.

Dankeschön

0 Stimmen

Können Sie präzisieren: Suchen Sie nach der größten Lücke zwischen angrenzend Datensätze, geordnet nach ID und gefiltert nach Benutzer, oder die größte Lücke zwischen zwei beliebige Datensätze für denselben Benutzer? Für beide Fälle lautet die Antwort 2 für Ihren Testfall.

0 Stimmen

@richardtellent: Ich suche nach der längsten Lücke zwischen "benachbarten" Benutzereinträgen, wobei "benachbart" bedeutet, dass kein Datum-Zeit-Eintrag dazwischen liegt (und nicht auf IDs basiert). Ich hoffe, das war klar. Ich bin mir nicht sicher, ob ich Ihre zweite Erklärung verstanden habe, denn die größte Lücke zwischen zwei beliebigen Einträgen liegt zwischen dem ersten (1) und dem letzten (4).

13voto

Cowan Punkte 36327

Datenbank-agnostisch, eine Art Variante von richardtallent's , aber ohne die Einschränkungen. (Ich verwende hier SQL Server 2008, aber das sollte keine Rolle spielen).

Beginnen Sie mit dieser Einstellung:

create table test(id int, userid int, time datetime)
insert into test values (1, 1, '2009-03-11 08:00')
insert into test values (2, 1, '2009-03-11 18:00')
insert into test values (3, 1, '2009-03-13 19:00')
insert into test values (4, 1, '2009-03-14 18:00')

Diese Abfrage wird ausgeführt:

select 
  starttime.id as gapid, starttime.time as starttime, endtime.time as endtime, 
  /* Replace next line with your DB's way of calculating the gap */
  DATEDIFF(second, starttime.time, endtime.time) as gap
from 
  test as starttime
inner join test as endtime on 
  (starttime.userid = endtime.userid) 
  and (starttime.time < endtime.time) 
left join test as intermediatetime on 
  (starttime.userid = intermediatetime.userid) 
  and (starttime.time < intermediatetime.time) 
  and (intermediatetime.time < endtime.time) 
where 
  (intermediatetime.id is null)

Gibt das Folgende an:

gapid  starttime                endtime                  gap
1      2009-03-11 08:00:00.000  2009-03-11 18:00:00.000  36000
2      2009-03-11 18:00:00.000  2009-03-13 19:00:00.000  176400
3      2009-03-13 19:00:00.000  2009-03-14 18:00:00.000  82800

Sie können dann einfach ORDER BY den Lückenausdruck in absteigender Reihenfolge, und wählen Sie das oberste Ergebnis.

Eine Erklärung:

  • Wie richardtallent's Antwort verknüpfen Sie die Tabelle mit sich selbst, um einen "späteren" Datensatz zu finden - dies paart im Grunde alle Datensätze mit JEDEM ihrer späteren Datensätze, hier die Paarung {1+2, 1+3, 1+4, 2+3, 2+4, 3+4}.
  • Dann gibt es einen weiteren Self-Join, diesmal einen Left-Join, um Zeilen zwischen den beiden zuvor ausgewählten zu finden, also {1+2+null, 1+3+2, 1+4+2, 1+4+3, 2+3+null, 2+4+3, 3+4+null}.
  • El WHERE Klausel filtert diese jedoch heraus (behält nur die Zeilen ohne Zwischenzeile) und behält somit nur {1+2+null, 2+3+null, 3+4+null}. Taa-daa!

Wenn es möglich ist, dass die gleiche Zeit zweimal vorkommt (eine "Lücke" von 0), dann brauchen Sie eine Möglichkeit, Unentschieden zu brechen, wie Dems betont. Wenn Sie die ID als Tie-Breaker verwenden können, dann ändern Sie z.B.

and (starttime.time < intermediatetime.time) 

zu

and ((starttime.time < intermediatetime.time) 
  or ((starttime.time = intermediatetime.time) and (starttime.id < intermediatetime.id)))

in der Annahme, dass "id" eine gültige Methode ist, um Unentschieden zu brechen.

In der Tat, wenn Sie wissen dass die ID monoton ansteigt (ich weiß, Sie sagten "nicht sequentiell", aber es ist nicht klar, ob das bedeutet, dass sie nicht mit jeder Zeile ansteigt, oder nur, dass die IDs der beiden relevanten Einträge möglicherweise nicht sequentiell sind, weil z. B. ein anderer Benutzer Einträge dazwischen hat), können Sie ID anstelle von Zeit in todo die Vergleiche, um dies noch einfacher zu machen.

1 Stimmen

+1 für: Verwendung sinnvoller Tabellen-Aliase. (Danke!) Und ich mochte die äußere Verknüpfung, um die Datenpaare auf diejenigen zu reduzieren, bei denen nichts dazwischen liegt. Das habe ich noch nie gesehen, macht aber sehr viel Sinn. Und der Hinweis darauf, dass datediff SQL Server-spezifisch ist: Es wäre schön gewesen, wenn dies bis zum Filtern des Ergebnisses durchgezogen worden wäre, um nur die Informationen für max(gap) anzuzeigen.

0 Stimmen

Schön. Upvoting, ich mag die Verwendung des LEFT OUTER join besser als meine doppelte Verwendung einer korrelierten Subquery.

0 Stimmen

Das funktioniert auf jeden Fall, ist aber bei großen Tabellen verdammt langsam.

5voto

Remus Rusanu Punkte 280155

Nehmen Sie an der Rangliste Zeit auf einem einmaligen Rang teil, um den Abstand zu erhalten:

with cte_ranked as (
select *, row_number() over (partition by UserId order by Time) as rn
from table)
select l.*, datediff(minute, r.Time, l.Time) as gap_length
from cte_ranked l join cte_ranked r on l.UserId = r.UserId and l.rn = r.rn-1

Sie können dann viele Methoden anwenden, um die maximale Lücke zu ermitteln, wann sie begonnen hat usw.

Update

Meine ursprüngliche Antwort wurde von einem Mac aus geschrieben, ohne dass ich eine Datenbank zum Testen hatte. Ich hatte etwas mehr Zeit, um mit diesem Problem zu spielen und tatsächlich zu testen und zu messen, wie es mit einer Tabelle mit 1 Mio. Datensätzen funktioniert. Meine Testtabelle ist wie folgt definiert:

create table access (id int identity(1,1)
    , UserId int not null
    , Time datetime not null);
create clustered index cdx_access on access(UserID, Time);
go

Für die Auswahl des Datensatzes für jegliche Informationen ist meine bevorzugte Antwort bisher diese:

with cte_gap as (
    select Id, UserId, a.Time, (a.Time - prev.Time) as gap
    from access a
    cross apply (
        select top(1) Time 
        from access b
        where a.UserId = b.UserId
            and a.Time > b.Time
        order by Time desc) as prev)
, cte_max_gap as (
    select UserId, max(gap) as max_gap
    from cte_gap
    group by UserId)
select g.* 
    from cte_gap g
    join cte_max_gap m on m.UserId = g.UserId and m.max_gap = g.gap
where g.UserId = 42;

Von 1M Datensatz, ~47k verschiedene Benutzer, wird das Ergebnis für diese in 1ms auf meinem Test puny Instanz (warmen Cache), 48 Seite liest zurückgegeben.

Wenn der Filter UserId=42 entfernt wird, benötigen die maximale Lücke und die Zeit, zu der sie für jeden Benutzer auftrat (mit Duplikaten für mehrere maximale Lücken) 6379139 Lesungen, was ziemlich schwer ist und auf meinem Testrechner 14 Sekunden dauert.

Die Zeit kann halbiert werden, wenn nur die UserId und der maximale Abstand benötigt werden (keine Info wenn die maximale Lücke aufgetreten ist):

select UserId, max(a.Time-prev.Time) as gap
    from access a
    cross apply (
        select top(1) Time 
        from access b
        where a.UserId = b.UserId
            and a.Time > b.Time
        order by Time desc
    ) as prev
group by UserId

Hierfür sind nur 3193448 Lesevorgänge erforderlich, also nur die Hälfte im Vergleich zu früher, und der Vorgang ist bei 1 Mio. Datensätzen in 6 Sekunden abgeschlossen. Der Unterschied ist darauf zurückzuführen, dass in der vorherigen Version jede Lücke einmal ausgewertet werden musste, um die maximale Lücke zu finden, und dann erneut ausgewertet werden musste, um die Lücken zu finden, die mit der maximalen Lücke übereinstimmen. Beachten Sie, dass die Struktur der von mir vorgeschlagenen Tabelle mit einem Index auf (UserId, Time) für diese Leistungsergebnisse wie folgt aussieht kritisch .

Was die Verwendung von CTEs und "Partitionen" (besser bekannt als Ranking-Funktionen) betrifft, so ist dies alles ANSI SQL-99 und wird von den meisten Anbietern unterstützt. Das einzige SQL-Server-spezifische Konstrukt war die Verwendung der datediff Funktion, die nun entfernt ist. Ich habe das Gefühl, dass einige Leser "agnostisch" als "kleinster gemeinsamer Nenner von SQL, das auch von meinem Lieblingsanbieter verstanden wird" verstehen. Beachten Sie auch, dass die Verwendung von gemeinsamen Tabellenausdrücken und des Cross-Apply-Operators nur dazu dient, die Lesbarkeit der Abfrage zu verbessern. Beide können durch abgeleitete Tabellen ersetzt werden, indem man eine einfache, mechanische Ersetzung vornimmt. Hier ist die genau dasselbe Abfrage, bei der die CTEs durch abgeleitete Tabellen ersetzt wurden. Ich überlasse es Ihnen, die Lesbarkeit im Vergleich zur CTE-basierten Abfrage selbst zu beurteilen:

select g.*
    from (    
        select Id, UserId, a.Time, (a.Time - (
            select top(1) Time 
            from access b
            where a.UserId = b.UserId
                and a.Time > b.Time
            order by Time desc
        )) as gap
        from access a) as g
    join (
        select UserId, max(gap) as max_gap
            from (
                select Id, UserId, a.Time, (a.Time - (
                   select top(1) Time 
                   from access b
                   where a.UserId = b.UserId
                     and a.Time > b.Time
                   order by Time desc
                   )) as gap
            from access a) as cte_gap
        group by UserId) as m on m.UserId = g.UserId and m.max_gap = g.gap
    where g.UserId = 42

Verdammt, ich hatte gehofft, dass es noch komplizierter wird, lol. Dies ist recht lesbar, weil es nur zwei CTEs zu starten hatte. Dennoch, auf Abfragen mit 5-6 abgeleitete Tabellen, die CTE-Form ist Weg, Weg mehr lesbar.

Der Vollständigkeit halber ist hier dieselbe Transformation auf meine vereinfachte Abfrage angewandt (nur maximale Lücken, keine Lückenendzeit und Zugangskennung):

select UserId, max(gap)
    from (
        select UserId, a.Time-(
            select top(1) Time 
            from access b
            where a.UserId = b.UserId
                and a.Time > b.Time
            order by Time desc) as gap
    from access a) as gaps
group by UserId

0 Stimmen

Aber wenn Sie auf SQL Server implementieren, kann CTE mit windowed Funktion ziemlich viel schneller sein. Es ist gut, sowohl agnostische als auch spezifische Antworten zu geben. Ich denke, manchmal, wenn man den Leistungsunterschied sieht, kann der Wunsch nach einem agnostischen Ansatz verschwinden.

0 Stimmen

Dies ist jedoch keine vollständige Antwort. Sie sollten das Select, das gap_lengh erzeugt, in ein anderes benanntes CTE verpacken, es dann nach Benutzer ordnen und schließlich select where rank = 1.

0 Stimmen

Mein Anwendungsfall war eine einmalige Abfrageextraktion. Ich habe mich für Ihren ersten Vorschlag entschieden (cte, Zeilennummer), und die Abfrage dauerte einige Sekunden bei > 1 Mio. Zeilen. Ich ziehe das für die Einfachheit der Abfrage vor, als 0,5 Sekunden zu sparen und etwas Komplizierteres zu haben

1voto

MatBailie Punkte 77040

Sehr ähnlich der Antwort von RichardTallent...

SELECT
   t1.id,
   t1.[user-id],
   t1.time,
   DATEDIFF(s, t1.time, t2.time) AS GapTime
FROM
   t AS t1
INNER JOIN
   t AS t2
      ON  t2.[user-id] = t1.[user-id]
      AND t2.time = (
         SELECT
            MIN(time)
         FROM
            t
         WHERE
            [user-id] = t1.[user-id]
            AND time > t1.time
      )

Da Sie nur den Zeitwert von t2 verwenden, können Sie die Organisation wie folgt ändern, um mit Benutzern mit nur einem Eintrag umzugehen...

SELECT
   t1.id,
   t1.[user-id],
   t1.time,
   DATEDIFF(
      s,
      t1.time,
      (
         SELECT
            MIN(time)
         FROM
            t
         WHERE
            [user-id] = t1.[user-id]
            AND time > t1.time
      )
   ) AS GapTime
FROM
   t1

Schließlich besteht die Möglichkeit, dass es mehrere Einträge mit demselben Zeitstempel gibt. In diesem Fall benötigen wir zusätzliche Informationen, um die Reihenfolge festzulegen, damit wir bestimmen können, welcher Eintrag der nächste ist.

Wenn es mehrere Einträge mit demselben Zeitstempel gibt, haben alle bis auf einen eine GapTime von 0:
- 12:00' (Lücke von 1 bis zum nächsten Eintrag)
- 12:01' (Lücke von 0 bis zum nächsten Eintrag)
- 12:01' (Lücke von 0 bis zum nächsten Eintrag)
- 12:01' (Lücke von 0 bis zum nächsten Eintrag)
- 12:01' (Lücke von 1 bis zum nächsten Eintrag)

- 12:02' (Lücke von NULL bis zum nächsten Eintrag)

Nur die "letzte" hat einen Zeitstempel ungleich Null. Obwohl die Frage besagt, dass die "id" möglicherweise nicht in Ordnung ist, ist dies die einzige Information, die wir haben, um zu bestimmen, welcher Reocrd der "letzte" ist, wenn die Zeitstempel gleich sind.

SELECT
   t1.id,
   t1.[user-id],
   t1.time,
   DATEDIFF(
      s,
      t1.time,
      (
         SELECT
            MIN(time)
         FROM
            t
         WHERE
            [user-id] = t1.[user-id]
            AND
            (
               (time > t1.time)
               OR
               (time = t1.time AND id > t1.id)
            )
      )
   ) AS GapTime
FROM
   t1

0 Stimmen

Ersetzen Sie DATEDIFF durch die Funktion, die in Ihrer Datenbankimplementierung vorhanden ist, der Rest sollte ziemlich allgemein sein.

0 Stimmen

Nicht schlecht... Ich habe mich für die Verknüpfung zwischen dem Start- und dem Enddatensatz entschieden und nicht für korrelierte Unterabfragen, weil dies flexibler ist, wenn der Benutzer später zusätzliche Informationen von beiden Seiten auswählen möchte. Beide sollten eine ähnliche Leistung haben.

0voto

richardtallent Punkte 33425

Verknüpfen Sie zunächst die Tabelle mit sich selbst, so dass jeder Datensatz für einen bestimmten Benutzer mit jedem Datensatz für denselben Benutzer gepaart wird.

Wählen Sie dann nur die Paare aus, bei denen das erste vor dem letzten liegt, kein Datensatz vor dem ersten und kein Datensatz nach dem letzten vorhanden ist.

 SELECT t1.id, t1.[user-id], t1.time, (t2.time - t1.time) AS GapTime
 FROM
     t AS t1
     INNER JOIN t AS t2 ON t1.[user-id] = t2.[user-id]
 WHERE
     t1.time < t2.time
     AND NOT EXISTS (SELECT NULL FROM t AS t3 WHERE t3.[user-id] = t1.[user-id]
         AND t3.time > t2.time)
     AND NOT EXISTS (SELECT NULL FROM t AS t4 WHERE t4.[user-id] = t1.[user-id]
         AND t4.time < t1.time)

Vorbehalte:

  1. Gibt keine Benutzer zurück, die 0 oder 1 Datensätze haben.
  2. Gibt keine Benutzer zurück, bei denen alle Datensätze das gleiche Datum/Uhrzeit haben.
  3. Gibt mehrere Datensätze für einen Benutzer zurück, wenn der Benutzer doppelte Datensätze an der Anfangs- oder Endgrenze seiner größten Lücke hat.

Falls gewünscht, können Sie die obige Nr. 2 korrigieren, indem Sie "t1.time < t2.time" in "t1.time <= t2.time" ändern, wodurch Sie eine Lücke von 0 erhalten, wenn es nur einen Datensatz für den Benutzer gibt.

0 Stimmen

Diese ist wesentlich datenbankunabhängig, also +1 :)

0 Stimmen

EXISTS (SELECT * FROM x) ist erwiesenermaßen schneller als SELECT NULL in SQL Server. Im Grunde genommen wurde SQL Server für diesen Zweck optimiert.

0 Stimmen

-1, Sie betrachten nicht die Lücken zwischen aufeinanderfolgenden Zeitpunkten, sondern das Erhalten: select "user-id", min(time), max(time), diff(..) from t group by "user-id Und die ID, die min(Zeit) für diese Benutzerkennung entspricht.

CodeJaeger.com

CodeJaeger ist eine Gemeinschaft für Programmierer, die täglich Hilfe erhalten..
Wir haben viele Inhalte, und Sie können auch Ihre eigenen Fragen stellen oder die Fragen anderer Leute lösen.

Powered by:

X