Postgresql 生成日期系列(性能)
Postgresql generate date series (performance)
使用 postgresql 版本 > 10,我在使用内置 generate_series
函数生成日期系列时遇到了问题。本质上,它不符合 day of the month
正确。
我有许多不同的频率(由用户提供)需要在给定的开始日期和结束日期之间进行计算。开始日期可以是任何日期,因此可以是一个月中的任何一天。当频率 monthly
与开始日期 2018-01-31
或 2018-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
测试
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。
使用 postgresql 版本 > 10,我在使用内置 generate_series
函数生成日期系列时遇到了问题。本质上,它不符合 day of the month
正确。
我有许多不同的频率(由用户提供)需要在给定的开始日期和结束日期之间进行计算。开始日期可以是任何日期,因此可以是一个月中的任何一天。当频率 monthly
与开始日期 2018-01-31
或 2018-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
测试
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。