Postgresql 生成日期系列(性能)

Postgresql generate date series (performance)

使用 postgresql 版本 > 10,我在使用内置 generate_series 函数生成日期系列时遇到了问题。本质上,它不符合 day of the month 正确。

我有许多不同的频率(由用户提供)需要在给定的开始日期和结束日期之间进行计算。开始日期可以是任何日期,因此可以是一个月中的任何一天。当频率 monthly 与开始日期 2018-01-312018-01-30 相结合时,这会产生问题,如下面的输出所示。

我创建了一个解决方案并想post在这里供其他人使用,因为我找不到任何其他解决方案。

但是,经过一些测试后,我发现我的解决方案在(荒谬的)大日期范围内使用时与内置 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 的平均性能为 2ms for:

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_series 一样好的 generate_date_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 函数两次。

修改后的解决方案

这让我在 7 秒内得到了 97,212 行(每行大约 0.7 毫秒)并且还支持 leap-years 其中 2 月有 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)

结果输出: 请确保更改 BOTH 红色号码的日期。

输出数据:

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

Demo

测试

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

为什么你的函数很慢:你使用了变量和(更重要的)循环。循环很慢。变量也意味着读取和写入这些变量。

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%。

简单的解决方案:

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

缺点:

由于generate_series()的参数是整数,所以需要计算。

巨大优势:

generate_series() 在其参数为整数时向优化器提供正确的行计数估计,但在其参数为日期和间隔时它不够智能:

这非常重要,尤其是当您使用它来构建一个庞大的系列时。使用日期参数将始终 return 默认 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。