PostgreSQL 查询计划里有两个容易被混在一起看的节点:Materialize 和 Memoize。它们都像是在“把结果先存起来,避免重复干活”,但机制完全不同:Materialize 会无条件缓冲一批行,Memoize 则按 key 缓存查询结果。对应的两个 GUC 开关 enable_material 和 enable_memoize,也不是简单的“开了更快、关了更慢”。理解它们的差异,能帮你更准确地读懂 EXPLAIN,也能避免在调优时误伤好计划。
Materialize:把行先攒起来,之后反复读
Materialize 节点的核心动作很直接:它把子计划产出的行缓冲起来,后续如果上层节点需要再次读取,就不用重新执行子计划。
典型场景包括:
- Nested Loop 内侧需要被反复扫描;
- 子计划结果不大,但重新执行成本高;
- 上层执行器需要可回退、可重复读取的数据流;
- 某些计划形状要求输入不是“一次性流”。
它的特点是“无条件”:只要计划选择了 Materialize,它就会缓存子计划输出的行,而不是根据某个参数 key 判断是否命中。
这意味着它可能很有用,也可能浪费内存或临时文件 I/O。结果集小的时候,Materialize 往往很便宜;结果集大且只读一次时,它就可能变成额外负担。
enable_material 的作用是影响优化器是否倾向使用 Materialize 计划节点。不过要注意:有些情况下 PostgreSQL 为了保证正确执行,仍然可能使用 Materialization 行为。也就是说,这个开关更像是“别主动偏好它”,不是“系统里彻底禁止它”。
Memoize:按参数 key 缓存,命中才赚
Memoize 更像一个小型查询结果缓存。它常见于参数化 Nested Loop:外层表给出一个 key,内层根据这个 key 查数据。如果外层出现大量重复 key,内层查询结果就可以复用。
它的逻辑可以粗略理解成:
for outer_row in outer_relation:
key = outer_row.join_key
if key in memoize_cache:
return cached_inner_rows
else:
rows = run_inner_plan(key)
cache[key] = rows
return rows
这和 Materialize 的区别很关键:
| 节点 | 缓存依据 | 适合场景 | 主要风险 |
|---|---|---|---|
Materialize |
不按 key,缓存子计划输出行 | 同一批行要被重复读取 | 缓存了其实不需要重复读的数据 |
Memoize |
按参数 key 缓存结果 | 外层 key 重复率高的参数化查询 | key 几乎不重复时,缓存命中低且有额外开销 |
所以 Memoize 的收益依赖数据分布。外层 join key 重复越多,它越可能表现亮眼;key 基本唯一时,它可能只是多了一层缓存管理成本。
enable_memoize 则控制优化器是否考虑 Memoize 节点。调试 Nested Loop 计划时,这个开关非常适合用来做 A/B 对比。
可以这样实践:用 EXPLAIN 对比两个开关
下面是一组可以在本地 PostgreSQL 里改造运行的 SQL。它构造了一个“外层有大量重复 key、内层按 key 查询”的场景,用来观察 Memoize 是否出现。不同 PostgreSQL 版本、统计信息和配置下,计划可能不完全一样;重点是观察开关变化对计划形状和执行时间的影响。
运行前建议使用 PostgreSQL 14 或更新版本,因为 Memoize 是较新的执行计划节点。
-- 可以直接在 psql 中执行
DROP TABLE IF EXISTS orders_demo;
DROP TABLE IF EXISTS customer_demo;
CREATE TABLE customer_demo (
id integer PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE orders_demo (
id bigserial PRIMARY KEY,
customer_id integer NOT NULL,
amount numeric NOT NULL
);
-- 1 万个客户
INSERT INTO customer_demo (id, name)
SELECT i, 'customer-' || i
FROM generate_series(1, 10000) AS s(i);
-- 20 万订单,但 customer_id 被压到 1..1000,制造大量重复 key
INSERT INTO orders_demo (customer_id, amount)
SELECT (random() * 999)::int + 1, (random() * 1000)::numeric
FROM generate_series(1, 200000);
CREATE INDEX idx_orders_demo_customer_id ON orders_demo(customer_id);
ANALYZE customer_demo;
ANALYZE orders_demo;
-- 更容易观察 Nested Loop / Memoize 的影响。
-- 注意:这是实验设置,不建议在生产会话里长期这样配置。
SET enable_hashjoin = off;
SET enable_mergejoin = off;
SET enable_memoize = on;
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, c.name
FROM orders_demo o
JOIN customer_demo c ON c.id = o.customer_id
WHERE o.customer_id BETWEEN 1 AND 100;
SET enable_memoize = off;
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, c.name
FROM orders_demo o
JOIN customer_demo c ON c.id = o.customer_id
WHERE o.customer_id BETWEEN 1 AND 100;
-- 恢复当前会话设置
RESET enable_hashjoin;
RESET enable_mergejoin;
RESET enable_memoize;
你要看的不是某个单一数字,而是几件事:
- 计划中是否出现
Memoize; EXPLAIN ANALYZE里是否有 cache hits/misses 相关信息;- 关闭
enable_memoize后,内层索引扫描是否被重复执行更多次; - 总执行时间和 buffer 访问是否明显变化。
如果你想观察 Materialize,可以用类似方法对比 enable_material:
SET enable_material = on;
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM generate_series(1, 1000) g
JOIN customer_demo c ON c.id = (g % 1000) + 1;
SET enable_material = off;
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM generate_series(1, 1000) g
JOIN customer_demo c ON c.id = (g % 1000) + 1;
RESET enable_material;
这段不保证一定出现 Materialize,因为优化器会结合统计信息、成本参数和版本行为选择计划。实际排查时,可以把它作为模板:固定 SQL、固定会话、只改一个 GUC,然后比较计划节点、loops、buffers 和时间。
调优时别把 GUC 当锤子
enable_material 和 enable_memoize 最适合做诊断,而不是作为长期“性能开关”到处关闭。
更稳妥的工作流是:
- 用
EXPLAIN (ANALYZE, BUFFERS)找出慢查询里的Materialize或Memoize; - 在同一个会话里只切换一个 GUC,比较计划和执行结果;
- 判断根因是统计信息、索引、SQL 写法,还是数据分布;
- 优先修正表统计、索引设计、join 条件和过滤条件;
- 只有在非常明确的情况下,才考虑对特定会话或特定工作负载设置 GUC。
例如,Memoize 命中率低,可能不是 PostgreSQL “选错了缓存”,而是外层 key 本身高度离散;Materialize 看起来碍眼,也可能是在保护一个需要重复读取的内层结果。
一个简短判断清单
看到这两个节点时,可以快速问自己:
- 这批数据是否真的会被重复读取?如果是,
Materialize可能合理; - join key 是否大量重复?如果是,
Memoize可能很赚; - 缓存的数据量是否可能超过
work_mem,导致临时文件 I/O? - 关闭对应 GUC 后,计划是更简单了,还是只是把成本转移到了重复扫描上?
- 统计信息是否新鲜?是否需要
ANALYZE或更高的 statistics target?
一句话总结:Materialize 是“把一段结果流存下来”,Memoize 是“按 key 记住上次查过什么”。它们目标相近,但路径相反。真正的调优不是看见缓存节点就关掉,而是确认这次缓存到底有没有命中现实中的数据模式。