Postgresql 生成日期序列(性能)

3
使用 PostgreSQL 版本 > 10 时,使用内置的 generate_series 函数生成日期序列时出现了问题。实际上,它不能正确地处理每月的“日期”。
我有很多不同的频率(由用户提供),需要在给定的开始和结束日期之间计算。开始日期可以是任何日期,因此可以是每个月的任何一天。当有像“每月”这样的频率与“2018-01-31”或“2018-01-30”这样的起始日期组合时,就会出现问题,如下面的输出所示。
我创建了一个解决方案,并希望在这里发布,让其他人使用,因为我找不到其他解决方案。
然而,在一些测试之后,我发现我的解决方案在处理(荒谬的)大日期范围时与内置的 generate_series 相比性能不同。有没有人知道如何改进这个问题?
简而言之,尽可能避免循环,因为它们会影响性能,请向下滚动以获取改进的实现。
内置输出:
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

从输出结果可以看出,月份中的日期没有被尊重,而是被截断为沿途遇到的最小日期,例如在这种情况下:由于2月份的缘故,28天是最小的日期

期望的输出

由于这个问题,我创建了一个自定义函数:

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将获得相同的性能。不过,我想知道是什么原因导致了差异。

为什么内置函数能够获得平均2ms的恒定性能,无论提供什么范围?

是否有更好的方法来实现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;

@eurotrash提供的实现平均需要80毫秒,我认为是由于两次调用generate_series函数造成的。
4个回答

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并使用其计数。当然,如果您知道您的间隔只能是某些值,那么可能可以进行优化; 但是此版本处理任何间隔值。

在我的系统上,它比纯generate_series慢约50%,比您的plpgsql版本快约400%。


感谢您的输入。我喜欢您的实现方式,因为它可以让我的解决方案在没有任何诡计的情况下获得正确的日期,并且完全消除了循环。然而,对generate_series的双重调用确实让我感到困扰,所以我修改了您的实现方式,以消除其中一个调用。我扩展了我的问题,包括这个实现方式。 - chvndb
@chvndb 很好,我喜欢你的新实现。 - 404

2

简单的解决方案:

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

使用正确的估算值,这里使用哈希是正确的做法。如果估算值太低,它会使用嵌套循环索引扫描。如果一切都恰到好处,那么每页只需要进行一次随机IO操作。


2

修订后的解决方案

这个解决方案在不到7秒钟的时间内给我返回了97,212行数据(每行大约0.7毫秒),并且还支持闰年,其中二月有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)
输出结果: 请确保您同时更改红色数字上的日期。性能输出

输出数据:

数据输出


这并没有产生期望的输出结果。问题在于它仅适用于月末,但如果间隔从30号开始会怎么样。 - chvndb
修改了解决方案。 - jlee88my

2
您可以使用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

性能非常好,但我认为 OP 希望他的函数处理任何间隔,而不仅仅是一个月。 - 404
@eurotrash:这可能是真的。但是,这种方法可以巧妙地转换为一个泛化函数,并且拥有更或者几乎相同的性能(当然没有 OP 的 while 循环!) - Kaushik Nayak

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接