Самый быстрый способ подсчитать, сколько диапазонов дат охватывает каждую дату из серии

12

У меня есть таблица (в PostgreSQL 9.4), которая выглядит следующим образом:

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

Теперь я хочу подсчитать для заданных дат и для каждого вида, во сколько строк dates_rangesпопадает каждая дата. Нули могут быть опущены.

Желаемый результат:

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

Я придумал два решения, одно с LEFT JOINиGROUP 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

и один с LATERAL, который немного быстрее:

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

Мне интересно, есть ли лучший способ написать этот запрос? А как включить пары date-kind с 0 count?

В действительности существует несколько различных видов, период до пяти лет (1800 дат) и ~ 30 тыс. Строк в dates_rangesтаблице (но это может значительно возрасти).

Там нет индексов. Если быть точным, в моем случае это результат подзапроса, но я хотел ограничить вопрос одной проблемой, поэтому она носит более общий характер.

BartekCh
источник
Что вы делаете, если диапазоны в таблице не перекрываются или не касаются друг друга? Например, если у вас есть диапазон где (вид, начало, конец) = (1,2018-01-01,2018-01-15)и (1,2018-01-20,2018-01-25)вы хотите принять это во внимание при определении количества перекрывающихся дат?
Эван Кэрролл
Я также запутался, почему твой столик маленький? Почему не 2018-01-31или 2018-01-30или 2018-01-29в ней , когда первый диапазон имеет все из них?
Эван Кэрролл
Даты @EvanCarroll generate_seriesявляются внешними параметрами - они не обязательно охватывают все диапазоны в dates_rangesтаблице. Что касается первого вопроса, я полагаю, что я его не понимаю - строки в нем dates_rangesнезависимы, я не хочу определять перекрытие.
BartekCh

Ответы:

4

Следующий запрос также работает, если «отсутствующие нули» в порядке:

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;

но это не быстрее, чем lateralверсия с небольшим набором данных. Хотя он может масштабироваться лучше, так как соединение не требуется, но вышеприведенная версия агрегирует по всем строкам, поэтому может снова потерять.

Следующий запрос пытается избежать ненужной работы, удаляя все серии, которые не перекрываются в любом случае:

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;

- и я должен использовать overlapsоператора! Обратите внимание, что вы должны добавить interval '1 day'справа, так как оператор перекрытий считает периоды времени открытыми справа (что довольно логично, потому что дата часто считается меткой времени с компонентом времени полуночи).

Колин т Харт
источник
Хорошо, я не знал, generate_seriesможно ли так использовать. После нескольких тестов у меня есть следующие наблюдения. Ваш запрос действительно хорошо масштабируется с выбранной длиной диапазона - практически нет разницы между 3 годами и 10 годами. Однако в течение более коротких периодов (1 год) мои решения работают быстрее - я предполагаю, что причина в том, что существуют очень длинные диапазоны dates_ranges(например, 2010-2100), которые замедляют ваш запрос. Ограничение start_dateи end_dateвнутренний запрос должны помочь. Мне нужно сделать еще несколько тестов.
BartekCh
6

А как включить пары date-kind с 0 count?

Создайте сетку из всех комбинаций, затем LATERAL присоединитесь к вашему столу, например так:

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;

Также должно быть максимально быстро.

LEFT JOIN LATERAL ... on trueСначала у меня было , но в подзапросе есть агрегат c, поэтому мы всегда получаем строку и можем использовать CROSS JOINтакже. Нет разницы в производительности.

Если у вас есть таблица, содержащая все соответствующие виды , используйте ее вместо генерации списка с подзапросом k.

Приведение к не integerявляется обязательным. Иначе вы получите bigint.

Помогут индексы, особенно многоколоночный индекс на (kind, start_date, end_date). Поскольку вы строите подзапрос, это может быть или не быть возможным достичь.

Использование функций, возвращающих множество, как generate_series()в SELECTсписке, обычно не рекомендуется в версиях Postgres до 10 (если вы точно не знаете, что делаете). Увидеть:

Если у вас есть много комбинаций с несколькими строками или без них, эта эквивалентная форма может быть быстрее:

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;
Эрвин Брандштеттер
источник
Что касается функций, возвращающих множество в SELECTсписке - я читал, что это не рекомендуется, однако, похоже, что это работает просто отлично, если есть только одна такая функция. Если я уверен, что будет только один, может что-то пойти не так?
BartekCh
@BartekCh: один SRF в SELECTсписке работает как положено. Возможно добавьте комментарий, чтобы предупредить против добавления другого. Или переместите его в FROMсписок для начала в более старых версиях Postgres. Почему риск осложнений? (Это также стандартный SQL, который не смущает людей, приходящих из других СУБД.)
Эрвин Брандштеттер,
1

Используя daterangeтип

PostgreSQL имеет daterange. Используя это довольно просто. Начиная с ваших образцов данных, мы переходим к использованию типа в таблице.

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)

Я хочу вычислить для заданных дат и для каждого вида, во сколько строк из date_ranges попадает каждая дата.

Теперь, чтобы запросить его, мы перевернем процедуру и сгенерируем ряд дат, но здесь есть уловка, в которой сам запрос может использовать @>оператор contentsment ( ), чтобы проверить, что даты находятся в диапазоне, используя индекс.

Обратите внимание, что мы используем timestamp without time zone(чтобы остановить 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;

Который является подробным перекрытием дня в индексе.

В качестве дополнительного бонуса, с типом daterange вы можете остановить вставку диапазонов, которые перекрываются с другими, используяEXCLUDE CONSTRAINT

Эван Кэрролл
источник
Что-то не так с Вашим запросом, похоже, он считает строки несколько раз, JOINя думаю , это слишком много.
BartekCh
@BartekCh нет, у вас есть перекрывающиеся строки, вы можете обойти это, удалив перекрывающиеся диапазоны (рекомендуется) или используяcount(DISTINCT kind)
Эван Кэрролл
но я хочу перекрывающихся строк. Например, для доброй 1даты 2018-01-01находится в первых двух строках от dates_ranges, но Ваш запрос дает 8.
BartekCh
или с помощьюcount(DISTINCT kind) вы добавили DISTINCTключевое слово там?
Эван Кэрролл
К сожалению, с DISTINCTключевым словом он все еще не работает, как ожидалось. Он рассчитывает различные виды для каждой даты, но я хочу подсчитать все строки каждого вида для каждой даты.
BartekCh