news 2026/7/10 1:23:36

PostgreSQL开了 Memoize 就万事大吉?我被它坑了四次

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PostgreSQL开了 Memoize 就万事大吉?我被它坑了四次

注:本文为安丫科技·刘峰原创。 转载请注明出处,严禁抄袭。

⚠️ 风险提示:

文中代码仅供测试环境学习。

生产环境复杂,严禁盲目套用,后果自负。

如遇紧急故障或需兜底支持,请联系安丫专家团队。

导****语

上一篇文章,我们做了一个实验。

结论是: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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/10 1:21:12

六十四卦编码方案:从二进制到 Unicode,不同的表达不同的代价

六十四卦编码方案&#xff1a;从二进制到 Unicode&#xff0c;不同的表达不同的代价 一、同一个卦象&#xff0c;四种编码方式占用的内存差 100 倍 在做一个传统文化数据项目时&#xff0c;遇到了一个最基本但却被忽略的问题&#xff1a;怎么在计算机中表示一个卦&#xff1f…

作者头像 李华
网站建设 2026/7/10 1:18:02

硬件 CRC 翻车实录:你以为的 CRC-32,其实是错的

一句话结论&#xff1a;MCU 硬件 CRC 外设算出来的"CRC-32"&#xff0c;几乎从来不等于标准 ZLIB/PKZIP 的 CRC-32。 把它当成标准 CRC 用&#xff0c;对端校验时必然翻车。一次真实的翻车现场 某个 OTA 升级项目&#xff0c;链路是这样的&#xff1a; PC 打包工具 (…

作者头像 李华