Postgresql 生成日期系列(性能)

Posted

技术标签:

【中文标题】Postgresql 生成日期系列(性能)【英文标题】:Postgresql generate date series (performance) 【发布时间】:2019-04-13 20:18:40 【问题描述】:

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

我有许多不同的频率(由用户提供)需要在给定的开始日期和结束日期之间计算。开始日期可以是任何日期,因此可以是一个月中的任何一天。当频率(例如 monthly)与 2018-01-312018-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 的平均性能2ms

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

而自定义函数generate_date_series 的平均性能为120ms

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 函数所致。

【问题讨论】:

【参考方案1】:

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

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 很好,我喜欢你的新实现。【参考方案2】:

修改后的解决方案

这在 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 RED 数字上更改日期。

输出数据:

【讨论】:

这不会产生预期的输出。这样做的问题是它只适用于月底,但如果间隔从 30 日开始呢。 修改了解决方案。【参考方案3】:

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

【讨论】:

性能非常好,但我认为 OP 希望他的函数能够处理任何间隔,而不仅仅是 1 个月。 @eurotrash :这可能是真的。但是,这种方法可以巧妙地转换为具有或多或少相同性能的泛化函数(当然,没有 OP 的 while 循环!)【参考方案4】:

简单的解决方案:

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。

【讨论】:

以上是关于Postgresql 生成日期系列(性能)的主要内容,如果未能解决你的问题,请参考以下文章

PostgreSQL 在按日期索引的时间戳字段上按日期搜索性能不佳

在PostgreSQL 和 Hive中生成日期序列

从 PostgreSQL 中的行生成系列

Postgresql 序列生成器如何工作?

PostgreSQL 查找按日期分组的前 N ​​行

PostgreSQL根据表字段生成月份和年份系列,如果没有给定月份的数据则填充空值