注:本文为安丫科技·刘峰原创。 转载请注明出处,严禁抄袭。
⚠️ 风险提示:
文中代码仅供测试环境学习。
生产环境复杂,严禁盲目套用,后果自负。
如遇紧急故障或需兜底支持,请联系安丫专家团队。
导****语
上一篇文章,我们做了一个实验。
结论是:Nested Loop 不会自动去重驱动键。唯一能救你的,是 Memoize。
然后文章最后我留了一句话:"下一篇聊 Memoize 的触发条件,和它什么时候会罢工。"
今天兑现。
但在讲"它什么时候罢工"之前,我得先讲清楚一件事:它到底是怎么工作的。
因为我发现很多人对 Memoize 的理解停留在:"开了就有缓存,没开就没缓存。"
这个理解,是错的。
01
先把原理讲透
Memoize 是 PostgreSQL 14 引入的执行层缓存机制。
它的位置在这里:
Nested Loop -> 驱动表 (Seq Scan on a) ← 外侧,逐行产出 -> Memoize ← 插在这里,拦截请求 Cache Key: a.id -> 被驱动表 (Index Scan on b) ← 内侧,真正执行查询它的工作逻辑是这样的:
a 表来了一行,id = 5 → Memoize 查缓存:有没有 id=5 的结果? → 没有(Miss)→ 真正去查 b,拿到结果,存入缓存 → 返回结果给上层 a 表又来一行,id = 5 → Memoize 查缓存:有没有 id=5 的结果? → 有(Hit)→ 直接返回,不查 b本质是一个以 join key 为 key 的 LRU 哈希表。
但这里有几个关键问题:
1
问题 1:谁决定要不要插入 Memoize 节点?
是优化器(Planner),不是你。
优化器在生成执行计划时,会估算:
驱动表有多少行
join key 有多少个 distinct 值
命中缓存的概率大不大
收益是否大于缓存管理开销
如果优化器觉得不划算,它就不插入 Memoize,直接裸奔。
2
问题 2:缓存放在哪里?有没有大小限制?
放在内存里。受 work_mem 参数控制。
如果缓存满了,会发生 Eviction(驱逐)——淘汰老数据,腾出空间放新数据。
Eviction 多了,命中率下降,Memoize 的收益就大打折扣。
3
问题 3:什么情况下优化器根本不考虑
Memoize?
这就是今天的核心。
下面,四个罢工现场,逐一验证。
实验环境
-- 被驱动表:10000 行,id 为主键 CREATE TABLE b_target (id int PRIMARY KEY, payload text); INSERT INTO b_target SELECT i, 'payload-' || i FROM generate_series(1, 10000) s(i); -- 驱动表 1:10万行,100 个 distinct 值(重复率 1000x) CREATE TABLE a_low (id int); INSERT INTO a_low SELECT (random() * 99)::int + 1 FROM generate_series(1, 100000); -- 驱动表 2:10万行,5000 个 distinct 值(重复率 20x) CREATE TABLE a_mid (id int); INSERT INTO a_mid SELECT (random() * 4999)::int + 1 FROM generate_series(1, 100000); -- 驱动表 3:10万行,全部 distinct(无重复) CREATE TABLE a_high (id int); INSERT INTO a_high SELECT i FROM generate_series(1, 100000) s(i); ANALYZE a_low; ANALYZE a_mid; ANALYZE a_high; ANALYZE b_target; -- 强制 Nested Loop,开启 Memoize SET enable_hashjoin = off; SET enable_mergejoin = off; SET enable_memoize = on;罢工现场一:distinct 值太多,优化器放弃插入 Memoize
核心原理
Memoize 的收益来自缓存命中。
如果驱动键的 distinct 值很少(比如 100 个),那么大量的请求都能命中缓存,收益巨大。
但如果 distinct 值有 10 万个,几乎每次请求都是 Miss,缓存形同虚设,还白白消耗内存和管理开销。
优化器会计算一个预期命中率,低于阈值就直接不插入 Memoize。
实验 1-A:低重复率(100 distinct)
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM a_low JOIN b_target ON a_low.id = b_target.id;Nested Loop (actual time=0.037..101.820 rows=100000 loops=1) Buffers: shared hit=743 -> Seq Scan on a_low (actual time=0.014..11.487 rows=100000 loops=1) Buffers: shared hit=443 -> Memoize (actual time=0.000..0.000 rows=1 loops=100000) Cache Key: a_low.id Cache Mode: logical Hits: 99900 Misses: 100 Evictions: 0 Memory Usage: 12kB Buffers: shared hit=300 -> Index Scan using b_target_pkey on b_target (loops=100) Index Searches: 100 Buffers: shared hit=300 Execution Time: 107.277 ms✅Memoize 正常工作。
100 个 distinct 值,命中率 99.9%,Evictions 为 0,非常理想。
实验 1-B:中等重复率(5000 distinct)
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM a_mid JOIN b_target ON a_mid.id = b_target.id;Nested Loop (actual time=0.047..135.644 rows=100000 loops=1) Buffers: shared hit=15443 -> Seq Scan on a_mid (actual time=0.015..11.635 rows=100000 loops=1) Buffers: shared hit=443 -> Memoize (actual time=0.001..0.001 rows=1 loops=100000) Cache Key: a_mid.id Cache Mode: logical Hits: 95000 Misses: 5000 Evictions: 0 Memory Usage: 571kB Buffers: shared hit=15000 -> Index Scan using b_target_pkey on b_target (loops=5000) Index Searches: 5000 Buffers: shared hit=15000 Execution Time: 141.923 ms⚠️ Memoize 还在,但收益在下降。
5000 个 distinct 值,命中率 95%,内存消耗从 12kB 涨到了 571kB。
这是一个临界区间——Memoize 还撑着,但已经有点吃力了。
实验 1-C:零重复(100000 distinct)
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM a_high JOIN b_target ON a_high.id = b_target.id;❌ Memoize 节点消失了。
执行计划里没有 Memoize,直接从驱动表打到被驱动表。
优化器判断
distinct 值等于行数,命中率接近 0,插入 Memoize 只有开销没有收益,放弃。
三个实验并排看:
结论
Memoize 不是你开了它就会用。
它的触发,取决于优化器对命中率的估算。
重复率不够高,它直接摆烂。
罢工现场二:内存不够,缓存开始"翻车"——Evictions
核心原理
Memoize 的缓存存在内存里。
当缓存条目数量超过限制,会触发 LRU 驱逐(Eviction):淘汰最久未使用的条目,腾出空间。
被驱逐的条目,下次再遇到相同 key,就得重新查一次被驱动表。
Evictions 多 → 命中率下降 → Memoize 的效果越来越差。
极端情况下,Evictions 数量接近 Misses,Memoize 几乎等于摆设。
实验:压缩 work_mem,观察 Evictions
SET work_mem = '64kB'; EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM a_mid JOIN b_target ON a_mid.id = b_target.id;正常内存下的 a_mid:
Hits: 95000 Misses: 5000 Evictions: 0 执行时间: 141ms内存压缩到 64kB 后:
Hits: 22437 Misses: 77563 Evictions: 76441 执行时间: 556ms直观对比:
Evictions 达到 76441 次。
意味着有 76441 次,一个 key 刚被缓存,又被踢出去,然后又被重新查了一遍。
缓存在疯狂"翻车"。
命中率从 95% 暴跌到 22.4%,执行时间从 141ms 暴涨到 556ms,慢了将近 4 倍。
怎么排查
执行计划里直接看 Memoize 节点的 Evictions 字段:
Memoize Hits: 22437 Misses: 77563 Evictions: 76441 ← 这个数字越大,说明内存越不够用 Memory Usage: 129kB如果 Evictions 接近 Misses,Memoize 已经在陪跑了。
此时的解法:
1) 适当调大 work_mem
2)或者考虑让优化器改用 Hash Join,彻底绕开这个问题
罢工现场三:统计信息过期——Memoize 被优化器骗了
核心原理
Memoize 是否触发,由优化器在生成计划时决定。
优化器的判断依据是:表的统计信息(通过 ANALYZE 收集)。
如果统计信息过期了,优化器对 distinct 值数量的估算就会出错:
实际 distinct 很少,统计信息显示很多 → 优化器以为命中率低,放弃 Memoize
实际 distinct 很多,统计信息显示很少 → 优化器以为命中率高,插入 Memoize,但实际命中率极低
这两种情况,都会导致执行计划偏离最优。
实验设计
DROP TABLE IF EXISTS a_stale; CREATE TABLE a_stale (id int); -- 第一步:高重复数据 + ANALYZE(统计信息准确) INSERT INTO a_stale SELECT (random() * 99)::int + 1 FROM generate_series(1, 100000); ANALYZE a_stale;实验 3-A:统计信息准确时
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM a_stale JOIN b_target ON a_stale.id = b_target.id;Nested Loop (actual time=0.039..100.108 rows=100000 loops=1) Buffers: shared hit=743 -> Seq Scan on a_stale (actual time=0.018..13.053 rows=100000 loops=1) Buffers: shared hit=443 -> Memoize (actual time=0.000..0.000 rows=1 loops=100000) Cache Key: a_stale.id Cache Mode: logical Hits: 99900 Misses: 100 Evictions: 0 Memory Usage: 12kB Buffers: shared hit=300 -> Index Scan using b_target_pkey on b_target (loops=100) Index Searches: 100 Buffers: shared hit=300 Execution Time: 105.418 ms✅ 统计信息准确,优化器正确判断:高重复率 → 触发 Memoize → 100 次真实访问。
实验 3-B:换成全 distinct 数据,但故意不 ANALYZE
-- 换数据,但不 ANALYZE! TRUNCATE a_stale; INSERT INTO a_stale SELECT i FROM generate_series(1, 100000) s(i); -- 注意:故意不执行 ANALYZE EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM a_stale JOIN b_target ON a_stale.id = b_target.id;Nested Loop (actual time=0.077..459.954 rows=10000 loops=1) Buffers: shared hit=210443 -> Seq Scan on a_stale (actual time=0.046..22.151 rows=100000 loops=1) Buffers: shared hit=443 -> Memoize (actual time=0.004..0.004 rows=0.10 loops=100000) Cache Key: a_stale.id Cache Mode: logical Hits: 0 Misses: 100000 Evictions: 98073 Memory Usage: 129kB Buffers: shared hit=210000 -> Index Scan using b_target_pkey on b_target (loops=100000) Index Searches: 100000 Buffers: shared hit=210000 Execution Time: 461.122 ms⚠️ Memoize 触发了,但完全在摆烂。
仔细看数据:
Hits: 0 Misses: 100000 Evictions: 98073命中率:0%。
Evictions 接近 Misses,缓存在疯狂写入、疯狂驱逐,然后又重新查询。
Memoize 节点存在,但实际上比不存在还糟糕——它在白白消耗管理开销。
这就是"被骗了"的状态:统计信息说"这张表有 100 个 distinct 值"。
优化器信了,插入了 Memoize。
实际上是 100000 个 distinct 值,命中率为零。
实验 3-C:ANALYZE 之后
ANALYZE a_stale; EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM a_stale JOIN b_target ON a_stale.id = b_target.id;✅ ANALYZE 之后,优化器重新正确判断:这张表 distinct 值很高,Memoize 没有收益,放弃插入。
虽然执行时间还是 320ms(因为数据本身就是全 distinct,NL 本来就不适合),但至少执行计划是正确的,没有在做无用功。
三个阶段对比:
最危险的是第二种:Memoize 触发了,但在做无用功,执行计划看起来"有优化",实际上比没有还拖速度。
从 105ms 到 461ms,慢了 4.4 倍,甚至比正确放弃 Memoize 的 321ms 还慢。
罢工现场四:多列联合 join key——组合爆炸
核心原理
Memoize 的 Cache Key 是 join 条件里所有列的组合。
单列 join key:
Cache Key = a.id
100 个 distinct 值 → 最多 100 个缓存条目
双列 join key:
Cache Key = (a.id1, a.id2)
每一列各 100 个 distinct 值 → 组合最多 100 × 100 = 10000 个缓存条目
列数越多,distinct 组合数爆炸式增长,缓存空间压力急剧上升。
当组合数过高,优化器会直接放弃插入 Memoize。
02
实验准备
-- 被驱动表:100×100 = 10000 行,双列主键 CREATE TABLE b_multi ( id1 int, id2 int, payload text, PRIMARY KEY (id1, id2) ); INSERT INTO b_multi SELECT i, j, 'payload' FROM generate_series(1,100) i, generate_series(1,100) j;实验 4-A:双列,各 10 个 distinct(组合约 100)
CREATE TABLE a_multi_low (id1 int, id2 int); INSERT INTO a_multi_low SELECT (random()*9)::int+1, (random()*9)::int+1 FROM generate_series(1, 100000); ANALYZE a_multi_low; EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM a_multi_low JOIN b_multi ON a_multi_low.id1 = b_multi.id1 AND a_multi_low.id2 = b_multi.id2;Nested Loop (actual time=0.058..139.030 rows=100000 loops=1) Buffers: shared hit=743 -> Seq Scan on a_multi_low (actual time=0.026..14.475 rows=100000 loops=1) Buffers: shared hit=443 -> Memoize (actual time=0.001..0.001 rows=1 loops=100000) Cache Key: a_multi_low.id1, a_multi_low.id2 ← 注意:双列组合 key Cache Mode: logical Hits: 99900 Misses: 100 Evictions: 0 Memory Usage: 12kB Buffers: shared hit=300 -> Index Scan using b_multi_pkey on b_multi (loops=100) Index Cond: ((id1 = a_multi_low.id1) AND (id2 = a_multi_low.id2)) Index Searches: 100 Buffers: shared hit=300 Execution Time: 145.238 ms✅ Memoize 正常工作。
双列 key,但组合数只有约 100 个,命中率 99.9%,和单列 100 distinct 的效果一样好。
实验 4-B:双列,各 100 个 distinct(组合约 10000)
CREATE TABLE a_multi (id1 int, id2 int); INSERT INTO a_multi SELECT (random()*99)::int+1, (random()*99)::int+1 FROM generate_series(1, 100000); ANALYZE a_multi; EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM a_multi JOIN b_multi ON a_multi.id1 = b_multi.id1 AND a_multi.id2 = b_multi.id2;Nested Loop (actual time=0.039..594.925 rows=100000 loops=1) Buffers: shared hit=300443 -> Seq Scan on a_multi (actual time=0.018..14.214 rows=100000 loops=1) Buffers: shared hit=443 -> Index Scan using b_multi_pkey on b_multi (loops=100000) Index Cond: ((id1 = a_multi.id1) AND (id2 = a_multi.id2)) Index Searches: 100000 Buffers: shared hit=300000 Execution Time: 601.815 ms❌ Memoize 节点直接消失。
双列各 100 个 distinct,组合约 10000 种,优化器评估后认为:
缓存空间开销大
命中率不够高(10 万行输入,约 1 万个 distinct 组合,预期每个 key 平均只命中 10 次)
收益不足以覆盖管理成本
直接放弃插入 Memoize,退化为 100000 次索引查询。
两个实验对比:
组合数从 100 爆炸到 10000,Memoize 直接放弃,执行时间从 145ms 暴涨到 601ms,慢了 4 倍多。
真正的杀手:三列、四列 join key
如果再加一列:
ON a.id1 = b.id1 AND a.id2 = b.id2 AND a.id3 = b.id3假设各 100 个 distinct:100 × 100 × 100 = 100 万个组合。
Memoize 想都不想,直接弃疗。
四个罢工现场,汇总成一张表
最重要的一个排查动作
每次看到 Nested Loop,先找 Memoize 节点,再看这四个数字:
Memoize Hits: ??? Misses: ??? Evictions: ??? Memory Usage: ???kB判断标准:
Hits >> Misses, Evictions = 0 → Memoize 工作正常,很健康 Hits << Misses, Evictions 很多 → 内存不足或统计过期,Memoize 在摆烂 无 Memoize 节点 → 优化器放弃了,检查 distinct 值或 join 列数03
最后,一个最容易被忽略的事实
很多人发现 NL 慢,第一反应是:"我去,开一下 Memoize。"
但你现在知道了:SET enable_memoize = on 不代表 Memoize 真的在工作。
它只是告诉优化器"你可以考虑用 Memoize"。
至于用不用,怎么用,用了有没有效——全都要去执行计划里亲眼确认。
写在最后
Memoize 不是开关,是优化器在特定条件下的一个选择。
**distinct 值太多、内存不够、统计信息过期、多列组合爆炸——**任何一个条件不满足,它就会悄悄消失,或者假装在工作。
所有执行计划均在 PostgreSQL 16 真实环境下运行。
下一篇:当 Memoize 和 Hash Join 都不是最优解的时候,该怎么办。
作者介绍
大家好,我是刘峰,安丫科技创始人 & 数据库技术高级讲师,专注于PostgreSQL、国产数据库运维与迁移、数据库性能优化等方向。
作为 PG中国分会官方授权讲师、PostgreSQL ACE 讲师认证专家,我长期活跃在****一线项目实战中,拥有10年以上大型数据库管理与优化经验,曾深度参与电信、金融、政务等多个行业的数据库性能调优与迁移项目。
欢迎关注我,一起深入探索数据库的无限可能,技术交流不设限!
📌 觉得有收获的话,记得点赞、收藏、转发支持一下哦,别忘了关注我获取更多数据库干货~
安呀智数据坊|我们能做什么
无论你是业务系统的技术负责人,还是数据部门的第一响应人,我们都能为你提供可靠的支持:
- 数据库类型支持
Oracle / MySQL / PostgreSQL / SQL Server 等主流数据库
- 核心服务内容
性能优化 / 故障处理 / 数据迁移 / 备份恢复 / 版本升级 / 补丁管理
- 系统性支持
深度巡检 / 高可用架构设计 / 应用层兼容评估 / 运维工具集成
- 专项能力补充
定制课程培训 / 甲方团队辅导 / 复杂问题协作排查 / 紧急救援支持
原文链接:https://mp.weixin.qq.com/s/johZiHUKs8iT54Sb78EKFA