Najszybszy sposób na policzenie, ile zakresów dat obejmuje każdą datę z serii

12

Mam tabelę (w PostgreSQL 9.4), która wygląda następująco:

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

Teraz chcę obliczyć dla podanych dat i dla każdego rodzaju, ile wierszy z dates_rangeskażdej daty przypada. Zera można ewentualnie pominąć.

Pożądany rezultat:

+-------+------------+----+
|  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 |
+-------+------------+----+

Wymyśliłem dwa rozwiązania, jedno z LEFT JOINiGROUP 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

i jeden z LATERAL, który jest nieco szybszy:

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

Zastanawiam się, czy jest lepszy sposób na napisanie tego zapytania? A jak uwzględnić pary dat z liczbą 0?

W rzeczywistości istnieje kilka różnych rodzajów, okres do pięciu lat (1800 dat) i ~ 30 000 wierszy w dates_rangestabeli (ale może znacznie wzrosnąć).

Brak indeksów. Mówiąc dokładniej, w moim przypadku jest to wynikiem podzapytania, ale chciałem ograniczyć pytanie do jednego problemu, więc jest bardziej ogólne.

BartekCh
źródło
Co robisz, jeśli zakresy w tabeli się nie pokrywają ani nie dotykają. Na przykład, jeśli masz zakres, w którym (rodzaj, początek, koniec) = (1,2018-01-01,2018-01-15)i (1,2018-01-20,2018-01-25)czy chcesz wziąć to pod uwagę przy określaniu liczby pokrywających się dat?
Evan Carroll
Jestem również zdezorientowany, dlaczego twój stół jest mały? Dlaczego nie jest 2018-01-31lub 2018-01-30czy 2018-01-29w tym, kiedy pierwszy zakres ma wszystkie z nich?
Evan Carroll
@EvanCarroll daty w generate_seriessą parametrami zewnętrznymi - niekoniecznie obejmują wszystkie zakresy w dates_rangestabeli. Co do pierwszego pytania, to chyba nie rozumiem - wiersze dates_rangessą niezależne, nie chcę określać nakładania się.
BartekCh

Odpowiedzi:

4

Następujące zapytanie działa również, jeśli „brak zer” jest poprawny:

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;

ale nie jest to szybsze niż lateralwersja z małym zestawem danych. Może jednak skalować się lepiej, ponieważ nie jest wymagane łączenie, ale powyższa wersja agreguje wszystkie wiersze, więc może ponownie stracić.

Poniższe zapytanie próbuje uniknąć niepotrzebnej pracy poprzez usunięcie serii, które i tak się nie nakładają:

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;

- i muszę użyć overlapsoperatora! Zauważ, że musisz dodać interval '1 day'po prawej, ponieważ nakładający się operator uważa, że ​​przedziały czasowe są otwarte po prawej stronie (co jest dość logiczne, ponieważ data jest często uważana za znacznik czasu z komponentem czasu północy).

Colin 't Hart
źródło
Fajnie, nie wiedziałem, że generate_seriesmożna tak używać. Po kilku testach mam następujące obserwacje. Twoje zapytanie rzeczywiście skaluje się naprawdę dobrze z wybraną długością zakresu - praktycznie nie ma różnicy między okresem 3 lat a 10 lat. Jednak w przypadku krótszych okresów (1 rok) moje rozwiązania są szybsze - zgaduję, że powodem jest to, że istnieją naprawdę duże odległości dates_ranges(np. 2010-2100), które spowalniają Twoje zapytanie. Ograniczenie start_datei end_datewewnętrzne zapytanie powinno jednak pomóc. Muszę zrobić jeszcze kilka testów.
BartekCh
6

A jak uwzględnić pary dat z liczbą 0?

Zbuduj siatkę wszystkich kombinacji, a następnie LATERAL dołącz do stołu, tak jak to:

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;

Powinien też być tak szybki, jak to możliwe.

Na początku miałem LEFT JOIN LATERAL ... on true, ale w podzapytaniu jest agregat c, więc zawsze otrzymujemy wiersz i możemy go również użyć CROSS JOIN. Bez różnicy w wydajności.

Jeśli masz tabelę zawierającą wszystkie odpowiednie rodzaje , użyj jej zamiast generowania listy z podzapytaniem k.

Przesyłanie do integerjest opcjonalne. Jeszcze raz bigint.

Pomogą w tym indeksy, zwłaszcza indeks wielokolumnowy (kind, start_date, end_date). Ponieważ budujesz na podzapytaniu, może to być lub nie być możliwe do osiągnięcia.

Używanie funkcji zwracających zestaw, takich jak generate_series()na SELECTliście, na ogół nie jest zalecane w wersjach Postgres wcześniejszych niż 10 (chyba że wiesz dokładnie, co robisz). Widzieć:

Jeśli masz wiele kombinacji z kilkoma wierszami lub bez, ten równoważny formularz może być szybszy:

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
źródło
Jeśli chodzi o funkcje zwracające zestaw na SELECTliście - przeczytałem, że nie jest to wskazane, ale wygląda na to, że działa dobrze, jeśli jest tylko jedna taka funkcja. Jeśli jestem pewien, że będzie tylko jeden, czy coś może pójść nie tak?
BartekCh
@BartekCh: Pojedynczy plik SRF na SELECTliście działa zgodnie z oczekiwaniami. Może dodaj komentarz, aby ostrzec przed dodaniem kolejnego. Lub przenieś go na FROMlistę, aby zacząć od starszych wersji Postgres. Po co ryzykować komplikacje? (Jest to również standardowy SQL i nie dezorientuje ludzi pochodzących z innych RDBMS.)
Erwin Brandstetter
1

Używanie daterangetypu

PostgreSQL ma daterange. Korzystanie z niego jest dość proste. Zaczynając od przykładowych danych, przechodzimy do użycia typu z tabeli.

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)

Chcę obliczyć dla podanych dat i dla każdego rodzaju, w ilu wierszach z dat_rangów przypada każda data.

Teraz, aby wykonać zapytanie, odwracamy procedurę i generujemy serie dat, ale oto haczyk samo zapytanie może użyć @>operatora zawierającego ( ), aby sprawdzić, czy daty mieszczą się w zakresie, za pomocą indeksu.

Uwaga, której używamy timestamp without time zone(aby zatrzymać zagrożenia DST)

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;

To jest wyszczególnione nakładki na dzień w indeksie.

Dodatkową korzyścią jest to, że dzięki typowi zmiany możesz zatrzymać wstawianie zakresów, które pokrywają się z innymi za pomocąEXCLUDE CONSTRAINT

Evan Carroll
źródło
Coś jest nie tak z twoim zapytaniem, wygląda na to, że zlicza wiersze wiele razy, JOINchyba o jeden za dużo.
BartekCh
@BartekCh nie, masz nakładające się wiersze, możesz obejść ten problem, usuwając nakładające się zakresy (sugerowane) lub używająccount(DISTINCT kind)
Evan Carroll
ale chcę nakładających się wierszy. Na przykład 1data rodzaju 2018-01-01znajduje się w pierwszych dwóch wierszach od dates_ranges, ale Twoje zapytanie podaje 8.
BartekCh
lub za pomocącount(DISTINCT kind) czy dodałeś DISTINCTtam słowo kluczowe?
Evan Carroll
Niestety ze DISTINCTsłowem kluczowym nadal nie działa zgodnie z oczekiwaniami. Liczy różne rodzaje dla każdej daty, ale chcę policzyć wszystkie wiersze każdego rodzaju dla każdej daty.
BartekCh