Der schnellste Weg, um zu zählen, wie viele Datumsbereiche jedes Datum aus einer Serie abdecken

12

Ich habe eine Tabelle (in PostgreSQL 9.4), die so aussieht:

CREATE TABLE dates_ranges (kind int, start_date date, end_date date);
INSERT INTO dates_ranges VALUES 
    (1, '2018-01-01', '2018-01-31'),
    (1, '2018-01-01', '2018-01-05'),
    (1, '2018-01-03', '2018-01-06'),
    (2, '2018-01-01', '2018-01-01'),
    (2, '2018-01-01', '2018-01-02'),
    (3, '2018-01-02', '2018-01-08'),
    (3, '2018-01-05', '2018-01-10');

Jetzt möchte ich für die angegebenen Daten und für jede Art berechnen, in wie viele Zeilen von dates_rangesjedem Datum fallen. Nullen könnten möglicherweise weggelassen werden.

Erwünschtes Ergebnis:

+-------+------------+----+
|  kind | as_of_date |  n |
+-------+------------+----+
|     1 | 2018-01-01 |  2 |
|     1 | 2018-01-02 |  2 |
|     1 | 2018-01-03 |  3 |
|     2 | 2018-01-01 |  2 |
|     2 | 2018-01-02 |  1 |
|     3 | 2018-01-02 |  1 |
|     3 | 2018-01-03 |  1 |
+-------+------------+----+

Ich habe zwei Lösungen gefunden, eine mit LEFT JOINundGROUP BY

SELECT
kind, as_of_date, COUNT(*) n
FROM
    (SELECT d::date AS as_of_date FROM generate_series('2018-01-01'::timestamp, '2018-01-03'::timestamp, '1 day') d) dates
LEFT JOIN
    dates_ranges ON dates.as_of_date BETWEEN start_date AND end_date
GROUP BY 1,2 ORDER BY 1,2

und eins mit LATERAL, was etwas schneller ist:

SELECT
    kind, as_of_date, n
FROM
    (SELECT d::date AS as_of_date FROM generate_series('2018-01-01'::timestamp, '2018-01-03'::timestamp, '1 day') d) dates,
LATERAL
    (SELECT kind, COUNT(*) AS n FROM dates_ranges WHERE dates.as_of_date BETWEEN start_date AND end_date GROUP BY kind) ss
ORDER BY kind, as_of_date

Ich frage mich, ob es eine bessere Möglichkeit ist, diese Abfrage zu schreiben. Und wie kann man Datumspaare mit 0 zählen?

In Wirklichkeit gibt es einige verschiedene Arten, einen Zeitraum von bis zu fünf Jahren (1800 Daten) und ~ 30.000 Zeilen in der dates_rangesTabelle (aber es könnte erheblich wachsen).

Es gibt keine Indizes. In meinem Fall ist es ein Ergebnis einer Unterabfrage, aber ich wollte die Frage auf ein Problem beschränken, damit es allgemeiner ist.

BartekCh
quelle
Was tun Sie, wenn sich die Bereiche in der Tabelle nicht überlappen oder berühren? Zum Beispiel, wenn Sie einen Bereich haben, in dem (Art, Start, Ende) = (1,2018-01-01,2018-01-15)und (1,2018-01-20,2018-01-25)möchten Sie dies berücksichtigen, wenn Sie bestimmen, wie viele überlappende Daten Sie haben?
Evan Carroll
Ich bin auch verwirrt, warum Ihr Tisch klein ist? Warum ist nicht 2018-01-31oder 2018-01-30oder 2018-01-29drin, wenn der erste Bereich alle hat?
Evan Carroll
@ EvansCarroll-Daten in generate_seriessind externe Parameter - sie decken nicht unbedingt alle Bereiche in der dates_rangesTabelle ab. Was die erste Frage dates_rangesbetrifft, verstehe ich sie vermutlich nicht - Zeilen in sind unabhängig, ich möchte keine Überlappung feststellen.
BartekCh

Antworten:

4

Die folgende Abfrage funktioniert auch, wenn "fehlende Nullen" in Ordnung sind:

select *
from (
  select
    kind,
    generate_series(start_date, end_date, interval '1 day')::date as d,
    count(*)
  from dates_ranges
  group by 1, 2
) x
where d between date '2018-01-01' and date '2018-01-03'
order by 1, 2;

aber es ist nicht schneller als die lateralVersion mit dem kleinen Datensatz. Es kann zwar besser skaliert werden, da kein Join erforderlich ist, die obige Version jedoch über alle Zeilen aggregiert wird, sodass es dort möglicherweise wieder verloren geht.

Die folgende Abfrage versucht, unnötige Arbeit zu vermeiden, indem alle Serien entfernt werden, die sich ohnehin nicht überlappen:

select
  kind,
  generate_series(greatest(start_date, date '2018-01-01'), least(end_date, date '2018-01-03'), interval '1 day')::date as d,
  count(*)
from dates_ranges
where (start_date, end_date + interval '1 day') overlaps (date '2018-01-01', date '2018-01-03' + interval '1 day')
group by 1, 2
order by 1, 2;

- und ich muss den overlapsOperator benutzen ! Beachten Sie, dass Sie interval '1 day'rechts hinzufügen müssen , da der Überlappungsoperator die Zeiträume rechts als offen betrachtet (was ziemlich logisch ist, da ein Datum häufig als Zeitstempel mit einer Zeitkomponente von Mitternacht betrachtet wird).

Colin 't Hart
quelle
Schön, ich wusste nicht, dass generate_seriesman so etwas verwenden kann. Nach einigen Tests habe ich folgende Beobachtungen. Ihre Anfrage lässt sich in der Tat sehr gut mit der ausgewählten Bereichslänge skalieren - es gibt praktisch keinen Unterschied zwischen 3 und 10 Jahren. Für kürzere Zeiträume (1 Jahr) sind meine Lösungen jedoch schneller - ich vermute, der Grund dafür ist, dass es einige wirklich große Bereiche gibt dates_ranges(wie 2010-2100), die Ihre Anfrage verlangsamen. Das Einschränken start_dateund end_dateinnerhalb der inneren Abfrage sollte jedoch helfen. Ich muss noch ein paar Tests machen.
BartekCh
6

Und wie kann man Datumspaare mit 0 zählen?

Erstellen Sie ein Raster aller Kombinationen und LATERAL verbinden Sie es dann wie folgt mit Ihrer Tabelle:

SELECT k.kind, d.as_of_date, c.n
FROM  (SELECT DISTINCT kind FROM dates_ranges) k
CROSS  JOIN (
   SELECT d::date AS as_of_date
   FROM   generate_series(timestamp '2018-01-01', timestamp '2018-01-03', interval '1 day') d
   ) d
CROSS  JOIN LATERAL (
   SELECT count(*)::int AS n
   FROM   dates_ranges
   WHERE  kind = k.kind
   AND    d.as_of_date BETWEEN start_date AND end_date
   ) c
ORDER  BY k.kind, d.as_of_date;

Sollte auch so schnell wie möglich sein.

Ich hatte LEFT JOIN LATERAL ... on truezuerst, aber es gibt ein Aggregat in der Unterabfrage c, so dass wir immer eine Zeile bekommen und auch verwenden können CROSS JOIN. Kein Leistungsunterschied.

Wenn Sie eine Tabelle mit allen relevanten Arten haben , verwenden Sie diese, anstatt die Liste mit Unterabfragen zu generieren k.

Die Besetzung integerist optional. Sonst bekommst du bigint.

Indizes würden helfen, insbesondere ein mehrspaltiger Index auf (kind, start_date, end_date). Da Sie auf einer Unterabfrage aufbauen, kann dies möglicherweise nicht erreicht werden.

Die Verwendung von Set-Return-Funktionen wie generate_series()in der SELECTListe ist in Postgres-Versionen vor 10 im Allgemeinen nicht ratsam (es sei denn, Sie wissen genau, was Sie tun). Sehen:

Wenn Sie viele Kombinationen mit wenigen oder keinen Zeilen haben, ist diese äquivalente Form möglicherweise schneller:

SELECT k.kind, d.as_of_date, count(dr.kind)::int AS n
FROM  (SELECT DISTINCT kind FROM dates_ranges) k
CROSS JOIN (
   SELECT d::date AS as_of_date
   FROM   generate_series(timestamp '2018-01-01', timestamp '2018-01-03', interval '1 day') d
   ) d
LEFT   JOIN dates_ranges dr ON dr.kind = k.kind
                           AND d.as_of_date BETWEEN dr.start_date AND dr.end_date
GROUP  BY 1, 2
ORDER  BY 1, 2;
Erwin Brandstetter
quelle
Was Set-Return-Funktionen in der SELECTListe betrifft - ich habe gelesen, dass dies nicht ratsam ist, aber es sieht so aus, als ob es gut funktioniert, wenn es nur eine solche Funktion gibt. Wenn ich sicher bin, dass es nur einen geben wird, könnte dann etwas schief gehen?
BartekCh
@BartekCh: Eine einzelne SRF in der SELECTListe funktioniert wie erwartet. Fügen Sie möglicherweise einen Kommentar hinzu, um vor dem Hinzufügen eines weiteren zu warnen. Oder verschieben Sie es in die FROMListe, um mit älteren Versionen von Postgres zu beginnen. Warum Risikokomplikationen? (Das ist auch Standard-SQL und verwirrt nicht Leute, die von anderen RDBMS kommen.)
Erwin Brandstetter
1

Verwenden Sie den daterangeTyp

PostgreSQL hat eine daterange. Die Verwendung ist ziemlich einfach. Beginnend mit Ihren Beispieldaten verwenden wir den Typ in der Tabelle.

BEGIN;
  ALTER TABLE dates_ranges ADD COLUMN myrange daterange;
  UPDATE dates_ranges
    SET myrange = daterange(start_date, end_date, '[]');
  ALTER TABLE dates_ranges
    DROP COLUMN start_date,
    DROP COLUMN end_date;
COMMIT;

-- Now you can create GIST index on it...
CREATE INDEX ON dates_ranges USING gist (myrange);

TABLE dates_ranges;
 kind |         myrange         
------+-------------------------
    1 | [2018-01-01,2018-02-01)
    1 | [2018-01-01,2018-01-06)
    1 | [2018-01-03,2018-01-07)
    2 | [2018-01-01,2018-01-02)
    2 | [2018-01-01,2018-01-03)
    3 | [2018-01-02,2018-01-09)
    3 | [2018-01-05,2018-01-11)
(7 rows)

Ich möchte für die angegebenen Daten und für jede Art berechnen, in wie viele Zeilen von Datumsangaben jedes Datum fällt.

Um es abzufragen, kehren wir die Prozedur um und generieren eine Datumsreihe. Hier ist jedoch der Haken, den die Abfrage selbst mithilfe des @>Operators includement ( ) verwenden kann, um mithilfe eines Index zu überprüfen, ob die Datumsangaben im Bereich liegen .

Beachten Sie, dass wir verwenden timestamp without time zone(um DST-Gefahren zu stoppen)

SELECT d1.kind, day::date, count(d2.kind)
FROM dates_ranges AS d1
CROSS JOIN LATERAL generate_series(
  lower(myrange)::timestamp without time zone,
  upper(myrange)::timestamp without time zone,
  '1 day'
) AS gs(day)
INNER JOIN dates_ranges AS d2
  ON d2.myrange @> day::date
GROUP BY d1.kind, day;

Welches sind die aufgeschlüsselten Tagesüberschneidungen im Index?

Als Nebenbonus können Sie mit dem Daterange-Typ das Einfügen von Bereichen, die sich mit anderen überlappen, mit einem stoppenEXCLUDE CONSTRAINT

Evan Carroll
quelle
Mit Ihrer Abfrage stimmt etwas nicht. Es sieht so aus, als würden Zeilen mehrmals gezählt, eine JOINzu viel, denke ich.
BartekCh
@BartekCh nein, Sie haben überlappende Zeilen, Sie können dies umgehen, indem Sie die überlappenden Bereiche entfernen (vorgeschlagen) odercount(DISTINCT kind)
Evan Carroll
aber ich möchte überlappende Zeilen. Zum Beispiel für Art 1Datum 2018-01-01ist innerhalb der ersten zwei Zeilen von dates_ranges, aber Ihre Anfrage gibt 8.
BartekCh
oder mitcount(DISTINCT kind) haben Sie dort das DISTINCTSchlüsselwort hinzugefügt ?
Evan Carroll
Leider DISTINCTfunktioniert es mit dem Schlüsselwort immer noch nicht wie erwartet. Es werden unterschiedliche Arten für jedes Datum gezählt, aber ich möchte alle Zeilen jeder Art für jedes Datum zählen.
BartekCh