Postgresql генерирует серию дат (производительность)

Используя postgresql версии › 10, я столкнулся с проблемой при создании ряда дат с использованием встроенной функции generate_series. По сути, это не соответствует day of the month правильно.

У меня есть много разных частот (предоставленных пользователем), которые необходимо рассчитать между заданной датой начала и окончания. Датой начала может быть любая дата и, следовательно, любой день месяца. Это создает проблемы, когда такие частоты, как monthly, сочетаются с датой начала 2018-01-31 или 2018-01-30, как показано в выходных данных ниже.

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

Однако после некоторых тестов я увидел, что мое решение имеет другую производительность по сравнению со встроенным generate_series при использовании в (абсурдно) больших диапазонах дат. Кто-нибудь знает, как это можно улучшить?

TL;DR: если возможно, избегайте циклов, поскольку они снижают производительность, прокрутите вниз, чтобы улучшить реализацию.

Встроенный выход

select generate_series(date '2018-01-31', 
                       date '2018-05-31', 
                       interval '1 month')::date
as frequency;

генерирует:

 frequency
------------
 2018-01-31
 2018-02-28
 2018-03-28
 2018-04-28
 2018-05-28

Как видно из вывода, день месяца не учитывается и усекается до минимального дня, встречающегося на пути, в данном случае: 28 due to the month of februari.

Ожидаемый результат

В результате этой проблемы я создал пользовательскую функцию:

create or replace function generate_date_series(
  starts_on date, 
  ends_on date, 
  frequency interval)
returns setof date as $$
declare
  interval_on date := starts_on;
  count int := 1;
begin
  while interval_on <= ends_on loop
    return next interval_on;
    interval_on := starts_on + (count * frequency);
    count := count + 1;
  end loop;
  return;
end;
$$ language plpgsql immutable;

select generate_date_series(date '2018-01-31', 
                            date '2018-05-31', 
                            interval '1 month')
as frequency;

генерирует:

 frequency
------------
 2018-01-31
 2018-02-28
 2018-03-31
 2018-04-30
 2018-05-31

Сравнение производительности

Независимо от того, какой диапазон дат указан, встроенный generate_series имеет производительность в среднем 2 мс для:

select generate_series(date '1900-01-01', 
                       date '10000-5-31', 
                       interval '1 month')::date 
as frequency;

в то время как пользовательская функция generate_date_series имеет производительность в среднем 120 мс для:

select generate_date_series(date '1900-01-01', 
                            date '10000-5-31', 
                            interval '1 month')::date 
as frequency;

Вопрос

На самом деле таких диапазонов никогда не будет, и поэтому это не проблема. Для большинства запросов пользовательский generate_date_series будет иметь такую ​​же производительность. Хотя мне интересно, в чем причина разницы.

Есть ли причина, по которой встроенная функция может достигать постоянной производительности в среднем 2 мс независимо от предоставленного диапазона?

Есть ли лучший способ реализовать generate_date_series, который работает не хуже встроенного generate_series?

Улучшенная реализация без циклов

(получено из ответа @eurotrash)

create or replace function generate_date_series(
  starts_on date, 
  ends_on date, 
  frequency interval)
returns setof date as $$
select (starts_on + (frequency * count))::date
from (
  select (row_number() over ()) - 1 as count
  from generate_series(starts_on, ends_on, frequency)
) series
$$ language sql immutable;

с улучшенной реализацией функция generate_date_series имеет производительность в среднем 45 мс для:

select generate_date_series(date '1900-01-01', 
                            date '10000-5-31', 
                            interval '1 month')::date 
as frequency;

Реализация, предоставленная @eurorash, дает мне в среднем 80 мс, что, как я полагаю, связано с двойным вызовом функции generate_series.


person chvndb    schedule 10.11.2018    source источник


Ответы (4)


Почему ваша функция работает медленно: вы используете переменные и (что более важно) цикл. Циклы медленные. Переменные также означают чтение и запись в эти переменные.

CREATE OR REPLACE FUNCTION generate_date_series_2(starts_on DATE, ends_on DATE, frequency INTERVAL)
        RETURNS SETOF DATE AS
$BODY$
        SELECT (starts_on + (frequency * g))::DATE
        FROM generate_series(0, (SELECT COUNT(*)::INTEGER - 1 FROM generate_series(starts_on, ends_on, frequency))) g;
$BODY$
        LANGUAGE SQL IMMUTABLE;

Концепция в основном такая же, как у вашей функции plpgsql, но с помощью одного запроса вместо цикла. Единственная проблема заключается в том, чтобы решить, сколько итераций необходимо (т.е. второй параметр для generate_series). К сожалению, я не мог придумать лучшего способа получить необходимое количество интервалов, кроме вызова generate_series для дат и использования их подсчета. Конечно, если вы знаете, что ваши интервалы будут только определенными значениями, тогда можно будет оптимизировать; однако эта версия обрабатывает любые значения интервала.

В моей системе это примерно на 50% медленнее, чем чистый generate_series, и примерно на 400% быстрее, чем ваша версия plpgsql.

person 404    schedule 10.11.2018
comment
Спасибо за отзыв. Мне нравится ваша реализация, так как она сохраняет мое решение для получения правильных дат без каких-либо хитростей и полностью удаляет цикл. Однако двойной вызов generate_series меня беспокоил, поэтому я пересмотрел вашу реализацию, удалив один вызов. Я расширил свой вопрос, включив в него эту реализацию. - person chvndb; 11.11.2018
comment
@chvndb Хорошо, мне нравится твоя новая реализация. - person 404; 11.11.2018

Пересмотренное решение

Это дает мне 97 212 строк менее чем за 7 секунд (примерно 0,7 мс на строку), а также поддерживает leap-years, где в феврале 29 дней:

SELECT      t.day_of_month
FROM        (
                SELECT  ds.day_of_month
                        , date_part('day', ds.day_of_month) AS day
                        , date_part('day', ((day_of_month - date_part('day', ds.day_of_month)::INT + 1) + INTERVAL '1' MONTH) - INTERVAL '1' DAY) AS eom
                FROM    (
                            SELECT generate_series( date '1900-01-01', 
                                                    date '10000-12-31', 
                                                    INTERVAL '1 day')::DATE as day_of_month
                        ) AS ds
            ) AS t
            --> REMEMBER to change the day at both places below (eg. 31)
WHERE       t.day = 31 OR (t.day = t.eom AND t.day < 31)

Результирующий результат: убедитесь, что вы изменили день на ОБИХ КРАСНЫХ числах. Результаты производительности

Выходные данные:

Вывод данных

person Joseph Lee    schedule 10.11.2018
comment
Это не приводит к ожидаемому результату. Проблема в том, что это работает только на конец месяца, но что делать, если интервал начинается с 30-го числа. - person chvndb; 10.11.2018
comment
Пересмотрел решение. - person Joseph Lee; 10.11.2018

Вы можете использовать date_trunc и добавить месяц к выходу generate_series, производительность должна быть почти такой же.

SELECT 
  (date_trunc('month', dt) + INTERVAL '1 MONTH - 1 day') ::DATE AS frequency 
FROM 
  generate_series(
    DATE '2018-01-31', DATE '2018-05-31', 
    interval '1 MONTH'
  ) AS dt 

Демо

Тест

knayak=# select generate_series(date '2018-01-31',
knayak(#                        date '2018-05-31',
knayak(#                        interval '1 month')::date
knayak-# as frequency;
 frequency
------------
 2018-01-31
 2018-02-28
 2018-03-28
 2018-04-28
 2018-05-28
(5 rows)

Time: 0.303 ms
knayak=#
knayak=#
knayak=# SELECT
knayak-#   (date_trunc('month', dt) + INTERVAL '1 MONTH - 1 day' ):: DATE AS frequency
knayak-# FROM
knayak-#   generate_series(
knayak(#     DATE '2018-01-31', DATE '2018-05-31',
knayak(#     interval '1 MONTH'
knayak(#   ) AS dt
knayak-# ;
 frequency
------------
 2018-01-31
 2018-02-28
 2018-03-31
 2018-04-30
 2018-05-31
(5 rows)

Time: 0.425 ms
person Kaushik Nayak    schedule 10.11.2018
comment
Производительность очень хорошая, но я предполагаю, что OP хочет, чтобы его функция обрабатывала любой интервал, а не только 1 месяц. - person 404; 10.11.2018
comment
@eurotrash: Это может быть правдой. Но этот метод можно умело преобразовать в обобщенную функцию с более или менее такой же производительностью (конечно, без циклов while!) - person Kaushik Nayak; 10.11.2018

Простое решение:

SELECT '2000-01-31'::DATE + ('1 MONTH'::INTERVAL)*x FROM generate_series(0,100) x;

Недостаток:

Поскольку параметры generate_series() являются целыми числами, вам необходимо их вычислить.

Огромное преимущество:

generate_series() дает оптимизатору правильные оценки количества строк, когда его параметры являются целыми числами, но он недостаточно умен, чтобы сделать это, когда его параметрами являются даты и интервалы:

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

CREATE UNLOGGED TABLE foo( id SERIAL PRIMARY KEY, dt TIMESTAMP NOT NULL );
INSERT INTO foo (dt) SELECT '2000-01-01'::TIMESTAMP + ('1 SECOND'::INTERVAL)*x FROM generate_series(1,1000000) x;
CREATE INDEX foo_dt ON foo(dt);
VACUUM ANALYZE foo;

EXPLAIN ANALYZE
WITH d AS (SELECT '2000-01-01'::TIMESTAMP + ('10 SECOND'::INTERVAL)*x dt FROM generate_series(1,100000) x)
SELECT * FROM foo JOIN d USING (dt);
 Hash Join  (cost=27906.00..30656.00 rows=100000 width=12) (actual time=191.020..237.268 rows=100000 loops=1)
   Hash Cond: (('2000-01-01 00:00:00'::timestamp without time zone + ('00:00:10'::interval * (x.x)::double precision)) = foo.dt)
   ->  Function Scan on generate_series x  (cost=0.00..1000.00 rows=100000 width=4) (actual time=7.070..11.096 rows=100000 loops=1)
     CORRECT ESTIMATE -------------------------------------------------^
   ->  Hash  (cost=15406.00..15406.00 rows=1000000 width=12) (actual time=181.844..181.845 rows=1000000 loops=1)
         Buckets: 1048576  Batches: 1  Memory Usage: 51161kB
         ->  Seq Scan on foo  (cost=0.00..15406.00 rows=1000000 width=12) (actual time=0.009..64.702 rows=1000000 loops=1)

EXPLAIN ANALYZE
WITH d AS (SELECT generate_series('2000-01-01'::TIMESTAMP, '2000-01-12 13:46:40'::TIMESTAMP, '10 SECOND'::INTERVAL) dt)
SELECT * FROM foo JOIN d USING (dt);
 Nested Loop  (cost=0.42..7515.52 rows=1000 width=12) (actual time=0.050..139.251 rows=100000 loops=1)
   ->  ProjectSet  (cost=0.00..5.02 rows=1000 width=8) (actual time=0.006..5.493 rows=100001 loops=1)
     WRONG ESTIMATE ----------------------^
         ->  Result  (cost=0.00..0.01 rows=1 width=0) (actual time=0.002..0.002 rows=1 loops=1)
   ->  Index Scan using foo_dt on foo  (cost=0.42..7.49 rows=1 width=12) (actual time=0.001..0.001 rows=1 loops=100001)
         Index Cond: (dt = (generate_series('2000-01-01 00:00:00'::timestamp without time zone, '2000-01-12 13:46:40'::timestamp without time zone, '00:00:10'::interval)))

При правильной оценке он использует хеш, что здесь правильно. С неправильной, слишком низкой оценкой вместо этого используется сканирование индекса вложенного цикла. Если звезды выстраиваются правильно, это один случайный ввод-вывод на страницу.

person bobflux    schedule 19.02.2021