注本文为安丫科技·刘峰原创。 转载请注明出处严禁抄袭。⚠️ 风险提示文中代码仅供测试环境学习。生产环境复杂严禁盲目套用后果自负。如遇紧急故障或需兜底支持请联系安丫专家团队。导****语上一篇文章我们做了一个实验。结论是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 查缓存有没有 id5 的结果 → 没有Miss→ 真正去查 b拿到结果存入缓存 → 返回结果给上层 a 表又来一行id 5 → Memoize 查缓存有没有 id5 的结果 → 有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); -- 驱动表 110万行100 个 distinct 值重复率 1000x CREATE TABLE a_low (id int); INSERT INTO a_low SELECT (random() * 99)::int 1 FROM generate_series(1, 100000); -- 驱动表 210万行5000 个 distinct 值重复率 20x CREATE TABLE a_mid (id int); INSERT INTO a_mid SELECT (random() * 4999)::int 1 FROM generate_series(1, 100000); -- 驱动表 310万行全部 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 distinctEXPLAIN (ANALYZE, BUFFERS) SELECT * FROM a_low JOIN b_target ON a_low.id b_target.id;Nested Loop (actual time0.037..101.820 rows100000 loops1) Buffers: shared hit743 - Seq Scan on a_low (actual time0.014..11.487 rows100000 loops1) Buffers: shared hit443 - Memoize (actual time0.000..0.000 rows1 loops100000) Cache Key: a_low.id Cache Mode: logical Hits: 99900 Misses: 100 Evictions: 0 Memory Usage: 12kB Buffers: shared hit300 - Index Scan using b_target_pkey on b_target (loops100) Index Searches: 100 Buffers: shared hit300 Execution Time: 107.277 ms✅Memoize 正常工作。100 个 distinct 值命中率 99.9%Evictions 为 0非常理想。实验 1-B中等重复率5000 distinctEXPLAIN (ANALYZE, BUFFERS) SELECT * FROM a_mid JOIN b_target ON a_mid.id b_target.id;Nested Loop (actual time0.047..135.644 rows100000 loops1) Buffers: shared hit15443 - Seq Scan on a_mid (actual time0.015..11.635 rows100000 loops1) Buffers: shared hit443 - Memoize (actual time0.001..0.001 rows1 loops100000) Cache Key: a_mid.id Cache Mode: logical Hits: 95000 Misses: 5000 Evictions: 0 Memory Usage: 571kB Buffers: shared hit15000 - Index Scan using b_target_pkey on b_target (loops5000) Index Searches: 5000 Buffers: shared hit15000 Execution Time: 141.923 ms⚠️ Memoize 还在但收益在下降。5000 个 distinct 值命中率 95%内存消耗从 12kB 涨到了 571kB。这是一个临界区间——Memoize 还撑着但已经有点吃力了。实验 1-C零重复100000 distinctEXPLAIN (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 数量接近 MissesMemoize 几乎等于摆设。实验压缩 work_mem观察 EvictionsSET work_mem 64kB; EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM a_mid JOIN b_target ON a_mid.id b_target.id;正常内存下的 a_midHits: 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 接近 MissesMemoize 已经在陪跑了。此时的解法1 适当调大 work_mem2或者考虑让优化器改用 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 time0.039..100.108 rows100000 loops1) Buffers: shared hit743 - Seq Scan on a_stale (actual time0.018..13.053 rows100000 loops1) Buffers: shared hit443 - Memoize (actual time0.000..0.000 rows1 loops100000) Cache Key: a_stale.id Cache Mode: logical Hits: 99900 Misses: 100 Evictions: 0 Memory Usage: 12kB Buffers: shared hit300 - Index Scan using b_target_pkey on b_target (loops100) Index Searches: 100 Buffers: shared hit300 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 time0.077..459.954 rows10000 loops1) Buffers: shared hit210443 - Seq Scan on a_stale (actual time0.046..22.151 rows100000 loops1) Buffers: shared hit443 - Memoize (actual time0.004..0.004 rows0.10 loops100000) Cache Key: a_stale.id Cache Mode: logical Hits: 0 Misses: 100000 Evictions: 98073 Memory Usage: 129kB Buffers: shared hit210000 - Index Scan using b_target_pkey on b_target (loops100000) Index Searches: 100000 Buffers: shared hit210000 Execution Time: 461.122 ms⚠️ Memoize 触发了但完全在摆烂。仔细看数据Hits: 0 Misses: 100000 Evictions: 98073命中率0%。Evictions 接近 Misses缓存在疯狂写入、疯狂驱逐然后又重新查询。Memoize 节点存在但实际上比不存在还糟糕——它在白白消耗管理开销。这就是被骗了的状态统计信息说这张表有 100 个 distinct 值。优化器信了插入了 Memoize。实际上是 100000 个 distinct 值命中率为零。实验 3-CANALYZE 之后ANALYZE a_stale; EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM a_stale JOIN b_target ON a_stale.id b_target.id;✅ ANALYZE 之后优化器重新正确判断这张表 distinct 值很高Memoize 没有收益放弃插入。虽然执行时间还是 320ms因为数据本身就是全 distinctNL 本来就不适合但至少执行计划是正确的没有在做无用功。三个阶段对比最危险的是第二种Memoize 触发了但在做无用功执行计划看起来有优化实际上比没有还拖速度。从 105ms 到 461ms慢了 4.4 倍甚至比正确放弃 Memoize 的 321ms 还慢。罢工现场四多列联合 join key——组合爆炸核心原理Memoize 的 Cache Key 是 join 条件里所有列的组合。单列 join keyCache Key a.id100 个 distinct 值 → 最多 100 个缓存条目双列 join keyCache 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组合约 100CREATE TABLE a_multi_low (id1 int, id2 int); INSERT INTO a_multi_low SELECT (random()*9)::int1, (random()*9)::int1 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 time0.058..139.030 rows100000 loops1) Buffers: shared hit743 - Seq Scan on a_multi_low (actual time0.026..14.475 rows100000 loops1) Buffers: shared hit443 - Memoize (actual time0.001..0.001 rows1 loops100000) 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 hit300 - Index Scan using b_multi_pkey on b_multi (loops100) Index Cond: ((id1 a_multi_low.id1) AND (id2 a_multi_low.id2)) Index Searches: 100 Buffers: shared hit300 Execution Time: 145.238 ms✅ Memoize 正常工作。双列 key但组合数只有约 100 个命中率 99.9%和单列 100 distinct 的效果一样好。实验 4-B双列各 100 个 distinct组合约 10000CREATE TABLE a_multi (id1 int, id2 int); INSERT INTO a_multi SELECT (random()*99)::int1, (random()*99)::int1 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 time0.039..594.925 rows100000 loops1) Buffers: shared hit300443 - Seq Scan on a_multi (actual time0.018..14.214 rows100000 loops1) Buffers: shared hit443 - Index Scan using b_multi_pkey on b_multi (loops100000) Index Cond: ((id1 a_multi.id1) AND (id2 a_multi.id2)) Index Searches: 100000 Buffers: shared hit300000 Execution Time: 601.815 ms❌ Memoize 节点直接消失。双列各 100 个 distinct组合约 10000 种优化器评估后认为缓存空间开销大命中率不够高10 万行输入约 1 万个 distinct 组合预期每个 key 平均只命中 10 次收益不足以覆盖管理成本直接放弃插入 Memoize退化为 100000 次索引查询。两个实验对比组合数从 100 爆炸到 10000Memoize 直接放弃执行时间从 145ms 暴涨到 601ms慢了 4 倍多。真正的杀手三列、四列 join key如果再加一列ON a.id1 b.id1 AND a.id2 b.id2 AND a.id3 b.id3假设各 100 个 distinct100 × 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