关于sql:Postgresql生成日期系列(性能)

Postgresql generate date series (performance)

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

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

我创建了一个解决方案,并希望在此发布此内容供其他人使用,因为我找不到任何其他解决方案。

但是,经过一些测试后,我发现在(荒谬的)大日期范围内使用时,我的解决方案与内置generate_series相比具有不同的性能。有没有人知道如何改进这个?

TL; DR:如果可能的话,避免循环,因为它们是性能损失,滚动到底部以改进实现。

内置输出

1
2
3
4
SELECT generate_series(DATE '2018-01-31',
                       DATE '2018-05-31',
                       INTERVAL '1 month')::DATE
AS frequency;

产生:

1
2
3
4
5
6
7
 frequency
------------
 2018-01-31
 2018-02-28
 2018-03-28
 2018-04-28
 2018-05-28

从输出中可以看出,该月中的某一天未得到遵守并被截断为沿途遇到的最小日期,在这种情况下:28 due to the month of februari

预期产出

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
CREATE OR REPLACE FUNCTION generate_date_series(
  startsOn DATE,
  endsOn DATE,
  frequency INTERVAL)
RETURNS setof DATE AS $$
DECLARE
  intervalOn DATE := startsOn;
  COUNT INT := 1;
BEGIN
  while intervalOn <= endsOn loop
    RETURN NEXT intervalOn;
    intervalOn := startsOn + (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;

产生:

1
2
3
4
5
6
7
 frequency
------------
 2018-01-31
 2018-02-28
 2018-03-31
 2018-04-30
 2018-05-31

性能比较

无论提供什么日期范围,内置generate_series的平均性能为2ms:

1
2
3
4
SELECT generate_series(DATE '1900-01-01',
                       DATE '10000-5-31',
                       INTERVAL '1 month')::DATE
AS frequency;

而自定义函数generate_date_series的平均性能为120毫秒:

1
2
3
4
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的答案)

1
2
3
4
5
6
7
8
CREATE OR REPLACE FUNCTION generate_date_series(startsOn DATE, endsOn DATE, frequency INTERVAL)
RETURNS setof DATE AS $$
SELECT (startsOn + (frequency * COUNT))::DATE
FROM (
  SELECT (ROW_NUMBER() OVER ()) - 1 AS COUNT
  FROM generate_series(startsOn, endsOn, frequency)
) series
$$ LANGUAGE SQL immutable;

通过改进的实现,generate_date_series函数的平均性能为45ms:

1
2
3
4
SELECT generate_date_series(DATE '1900-01-01',
                            DATE '10000-5-31',
                            INTERVAL '1 month')::DATE
AS frequency;

@eurotrash提供的实现平均给我80ms,我假设是由于两次调用generate_series函数。


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

1
2
3
4
5
6
7
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%。


您可以使用date_trunc并在generate_series的输出中添加一个月,性能应该几乎相似。

1
2
3
4
5
6
7
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

演示

测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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


修订后的解决方案

这在7秒内给出了97,212行(每行大约0.7ms)并且还支持leap-years,其中2月有29天:

1
2
3
4
5
6
7
8
9
10
11
12
13
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)

结果输出:
请确保您在同一天更改RED号码。
Performance Output

输出数据:

Data Output