由 John Doe 八月 14, 2025
你需要经常优化报表分析类 SQL 的执行计划吗?新的 PostgreSQL 19,可以在 EXPLAIN 中显示 Memoize 计划的估算值了。
特性提交日志
在 EXPLAIN 输出中显示 Memoize 计划估算值。
一直有一些反馈称,很难弄清楚规划器为何选择了 Memoize 计划。为了帮助解决这个问题,我们对 EXPLAIN 输出进行了调整,以显示以下额外细节:
- 预计可同时存储的缓存条目数量
- 预计会出现的唯一查找键数量
- 预计的查找次数
- 预计的命中率
从技术上讲,第 4 项可以通过第 1、2、3 项计算得出,但这个计算过程并不是特别直观,因此我们选择直接显示它。最初的提交只显示了命中率,但有人担心这可能会引发更多关于其计算方式的疑问。显示所有 4 项信息的目的是提高透明度,这或许能让查询调优变得更容易。例如,如果第 2 项(唯一查找键数量)的估算不准确,那么可以使用扩展统计信息或手动设置 n_distinct 估算值,来帮助修正不佳的计划选择。
讨论:https://postgr.es/m/CAP53Pky29GWAVVk3oBgKBDqhND0BRBN6yTPeguV_qSivFL5N_g%40mail.gmail.com
示例
下面来解释下什么是 Memoize 节点。它们最早出现在 2021 年发布的 PostgreSQL 14 中。
大致来说,其理念是,如果在单个查询内必须反复扫描某个表,且它认为查询键会重复出现,那么就可以选择缓存结果。
例如,假设我们有一个 users 表和一个与之相关的 sessions 表。我们想显示最近 100 个会话的用户 ID,因此需要以某种方式关联 users 表,并且必须对 users 表进行 100 次扫描,连接可以使用不同的方法,但现在我们假设使用的是嵌套循环。如果在最近的 100 个会话中,只有两个用户,那么在没有记忆化(Memoization)的情况下,PostgreSQL 必须扫描 users 表 100 次,每次都提供这两个用户 ID 中的一个。
记忆化解决了这个问题。当 PostgreSQL 从数据统计信息中得知会出现重复查询同一键的情况时,它会添加记忆化步骤,从而提高处理效率。
让我们快速演示一下:
create table users (
id int8 generated always as identity primary key,
payload float8
);
insert into users (payload)
select random() from generate_series(1, 1000000) i;
create table sessions (
id int8 generated always as identity primary key,
user_id int8 not null references users (id),
payload float8
);
insert into sessions (user_id, payload)
select floor( 1 + random() * 1000000)::int4, random()
from generate_series(1, 1000);
vacuum analyze users, sessions;
这样就创建了一个包含 100 万行的 users 表,和一个包含 1000 行的 sessions 表,其中 sessions 表关联了一些随机用户。现在,如果想要查询所有会话及其关联用户的数据,可以使用如下查询:
select * from sessions s join users u on s.user_id = u.id;
其explain analyze
输出如下:
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------
Nested Loop (cost=0.42..7507.50 rows=1000 width=40) (actual time=0.042..19.157 rows=1000 loops=1)
Buffers: shared hit=4007
-> Seq Scan on sessions s (cost=0.00..17.00 rows=1000 width=24) (actual time=0.013..2.028 rows=1000 loops=1)
Buffers: shared hit=7
-> Index Scan using users_pkey on users u (cost=0.42..7.49 rows=1 width=16) (actual time=0.011..0.011 rows=1 loops=1000)
Index Cond: (id = s.user_id)
Buffers: shared hit=4000
Planning:
Buffers: shared hit=23
Planning Time: 0.394 ms
Execution Time: 21.099 ms
(11 rows)
现在,如果还有 1000 个会话,但所有会话都属于仅 2 个用户:
truncate sessions ;
insert into sessions (user_id, payload)
select case when random() < .5 then 50 else 100 end, random()
from generate_series(1, 1000);
vacuum analyze sessions ;
同一查询的 explain 输出显示,会突然出现一个记忆化节点(Memoize):
explain (analyze, buffers)
select * from sessions s join users u on s.user_id = u.id;
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------
Nested Loop (cost=0.43..64.45 rows=1000 width=40) (actual time=0.027..4.715 rows=1000 loops=1)
Buffers: shared hit=15
-> Seq Scan on sessions s (cost=0.00..17.00 rows=1000 width=24) (actual time=0.007..0.916 rows=1000 loops=1)
Buffers: shared hit=7
-> Memoize (cost=0.43..7.50 rows=1 width=16) (actual time=0.001..0.001 rows=1 loops=1000)
Cache Key: s.user_id
Cache Mode: logical
Hits: 998 Misses: 2 Evictions: 0 Overflows: 0 Memory Usage: 1kB
Buffers: shared hit=8
-> Index Scan using users_pkey on users u (cost=0.42..7.49 rows=1 width=16) (actual time=0.005..0.005 rows=1 loops=2)
Index Cond: (id = s.user_id)
Buffers: shared hit=8
Planning:
Buffers: shared hit=22
Planning Time: 0.256 ms
Execution Time: 5.628 ms
(16 rows)
记忆化信息显示,它使用了 1kB 内存,使用 user_id 进行缓存,有 2 次未命中(对每个用户 ID 的前两次扫描)和 998 次命中(对这两个用户 ID 的其他扫描)。
一切都很好。但在某些情况下,可能不太清楚为什么 PostgreSQL 会决定使用记忆化。
得益于本次提交的特性,在相同情况下(两个用户 ID),同一个 explain 输出现在显示如下内容:
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------
Nested Loop (cost=0.43..64.45 rows=1000 width=40) (actual time=0.017..0.277 rows=1000.00 loops=1)
Buffers: shared hit=15
-> Seq Scan on sessions s (cost=0.00..17.00 rows=1000 width=24) (actual time=0.006..0.036 rows=1000.00 loops=1)
Buffers: shared hit=7
-> Memoize (cost=0.43..7.50 rows=1 width=16) (actual time=0.000..0.000 rows=1.00 loops=1000)
Cache Key: s.user_id
Cache Mode: logical
Estimates: capacity=2 distinct keys=2 lookups=1000 hit percent=99.80%
Hits: 998 Misses: 2 Evictions: 0 Overflows: 0 Memory Usage: 1kB
Buffers: shared hit=8
-> Index Scan using users_pkey on users u (cost=0.42..7.49 rows=1 width=16) (actual time=0.003..0.003 rows=1.00 loops=2)
Index Cond: (id = s.user_id)
Index Searches: 2
Buffers: shared hit=8
Planning:
Buffers: shared hit=39
Planning Time: 0.219 ms
Execution Time: 0.318 ms
(18 rows)
其中,Estimates:
这一行显示了 PostgreSQL 对 users 表上索引扫描使用情况的预期:具体来说,预计只会有 2 个键,1000 次单独查找,在启用缓存的情况下,命中率应该为 99.8%。
显然,在上面的示例中,这些数字非常简单,容易检查和计算,但在实际场景中,可能会很难检查、计算和验证。
非常不错的特性。感谢社区的所有相关人员。
参考
提交日志:https://git.postgresql.org/pg/commitdiff/4bc62b86849065939a6b85273fece6b92d6e97bf