COUNT(1) OVER (PARTITION BY NULL) 的性能损失

Mat*_*sen 6 postgresql count window-functions postgresql-performance postgresql-13

在我的应用程序服务器中,我想使用LIMIT和对数据集进行分页OFFSET,并另外将数据集的总数返回给用户。

而不是对数据库进行两次远程调用:

select count(1) as total_count from foo;
select c1 from foo;
Run Code Online (Sandbox Code Playgroud)

我认为在单个数据库调用中完成此操作会更明智:

select c1, count(1) over (partition by null) from foo;
Run Code Online (Sandbox Code Playgroud)

但是,与不使用窗口函数相比,添加此窗口函数会导致执行时间长一个数量级。

我觉得这很令人惊讶,因为类似的时间select count(1) from foo只需要两倍的时间select c1 from foo。然而,将其转换为窗口函数会导致性能下降。

此外,使用以下使用子查询的替代方案非常快:

select c1, (select count(1) from foo) as total_count from foo;
Run Code Online (Sandbox Code Playgroud)

我本来期望 postgresql 能够优化partition by null

我在 Oracle 中尝试过这一点,发现了类似的性能损失。

如何解释为什么这里会出现性能损失?对于核心 postgresql 开发人员来说,进行更改以优化这一点是否相对容易,甚至值得,例如通过将 PARTITION BY NULL 的窗口函数转换为子查询?


设置:

drop table foo;
create table foo (c1 int);

insert into foo
select i from generate_series(0, 100000) i;

analyze foo;
Run Code Online (Sandbox Code Playgroud)

常规的SELECT

=> explain analyze select c1 from foo;
                                                QUERY PLAN
----------------------------------------------------------------------------------------------------------
 Seq Scan on foo  (cost=0.00..1443.01 rows=100001 width=4) (actual time=0.021..6.848 rows=100001 loops=1)
 Planning Time: 0.045 ms
 Execution Time: 10.021 ms
Run Code Online (Sandbox Code Playgroud)

不使用窗口函数会导致执行时间约为 10 毫秒。

具有COUNT(1) OVER (PARTITION BY NULL)窗口函数:

=> explain analyze select c1, count(1) over (partition by null) as total_count from foo;
                                                    QUERY PLAN
------------------------------------------------------------------------------------------------------------------
 WindowAgg  (cost=0.00..2943.03 rows=100001 width=44) (actual time=63.828..100.321 rows=100001 loops=1)
   ->  Seq Scan on foo  (cost=0.00..1443.01 rows=100001 width=36) (actual time=0.025..17.727 rows=100001 loops=1)
 Planning Time: 0.071 ms
 Execution Time: 106.386 ms
Run Code Online (Sandbox Code Playgroud)

使用窗口函数的执行时间为 100 ms。这比没有此窗口函数的相同查询要昂贵一个数量级。

与常规的SELECT COUNT(1)

=> explain analyze select count(1) from foo;
                                                   QUERY PLAN
----------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=1693.01..1693.02 rows=1 width=8) (actual time=19.876..19.876 rows=1 loops=1)
   ->  Seq Scan on foo  (cost=0.00..1443.01 rows=100001 width=0) (actual time=0.026..9.238 rows=100001 loops=1)
 Planning Time: 0.066 ms
 Execution Time: 19.908 ms
Run Code Online (Sandbox Code Playgroud)

常规SELECT COUNT(1)需要大约20ms。

并使用更优化的子查询形式:

=> explain analyze select c1, (select count(1) from foo) as total_count from foo;
                                                          QUERY PLAN                                                    
------------------------------------------------------------------------------------------------------------------------------
 Seq Scan on foo  (cost=1693.02..3136.03 rows=100001 width=12) (actual time=18.554..30.492 rows=100001 loops=1)
   InitPlan 1 (returns $0)
     ->  Aggregate  (cost=1693.01..1693.02 rows=1 width=8) (actual time=18.533..18.534 rows=1 loops=1)
           ->  Seq Scan on foo foo_1  (cost=0.00..1443.01 rows=100001 width=0) (actual time=0.010..8.438 rows=100001 loops=1)
 Planning Time: 0.074 ms
 Execution Time: 33.696 ms
Run Code Online (Sandbox Code Playgroud)

这大约需要 30 毫秒,这是我所期望的(select count(1)需要 20 毫秒,select c1需要 10 毫秒)。

Erw*_*ter 5

分页?

首先,OFFSET/LIMIT是用于分页的原始工具,扩展性不佳,并且在并发写入负载下无法正常工作。根据您的用例,有更多更智能的解决方案。看:

总计数性能

OVER (PARTITION BY NULL)是一种毫无意义的浪费。使用等效的、更快的OVER ()代替。

count(1)是另一种(轻微的)无意义的浪费。count(*)代替使用。

尽管如此,观察结果还是可靠的,我可以按预期重现它。

然而,它仍然具有误导性。测试表是不现实的,只有一个整数列。通常您会添加WHERE子句和/或涉及索引。所以你原来的测试有效性是有限的。

一个更现实的测试用例(至少)有一些基本的有效负载列和索引显示了不同的见解:

CREATE TABLE foo (
  id int PRIMARY KEY
, payload text                    -- mininal payload of 32 byte text ...
, dt timestamptz DEFAULT now());  -- ... and a timestamp column

INSERT INTO foo (id, payload)
SELECT i, md5(i::text)::text
FROM   generate_series(1, 100000) i;
Run Code Online (Sandbox Code Playgroud)

考虑 fiddle 中的各种测试(使用 Postgres 14;Postgres 13 非常相似,您只需切换引擎并重新运行即可):
db<>fiddle here

count(*) OVER ()比 快约 10% count(1) OVER (PARTITION BY NULL)。特别注意使用热缓存的更可靠的测试(“使用热缓存重复主测试 x”。
EXPLAIN甚至期望31853435.

对于没有WHERE.

添加选择性WHERE条款会改变游戏规则。这是更常见的用例。

如果查询没有(完美的)索引支持,情况会再次发生变化。现在,子查询的速度要慢得多。两次连续扫描超过了窗口函数所增加的开销。

现实生活中的示例通常更混乱,表/索引膨胀、更多列、连接、计算等。然后,对子查询中的计数进行单独扫描通常会变得更加昂贵。

有关的:

  • 我还注意到窗口函数似乎使用了work_mem,而子查询则没有。将 work_mem 增加到 8MB(而不是默认的 4MB)消除了执行计划中的一些临时缓冲区(仅通过“解释(分析,缓冲区)”可见,略微提高了性能。 (2认同)