PostgreSQL 里的 Materialize 与 Memoize:两个相似目标、相反路径的开关

2026-07-01 35 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:9 分钟

PostgreSQL 查询计划里有两个容易被混在一起看的节点:MaterializeMemoize。它们都像是在“把结果先存起来,避免重复干活”,但机制完全不同:Materialize 会无条件缓冲一批行,Memoize 则按 key 缓存查询结果。对应的两个 GUC 开关 enable_materialenable_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_materialenable_memoize 最适合做诊断,而不是作为长期“性能开关”到处关闭。

更稳妥的工作流是:

  1. EXPLAIN (ANALYZE, BUFFERS) 找出慢查询里的 MaterializeMemoize
  2. 在同一个会话里只切换一个 GUC,比较计划和执行结果;
  3. 判断根因是统计信息、索引、SQL 写法,还是数据分布;
  4. 优先修正表统计、索引设计、join 条件和过滤条件;
  5. 只有在非常明确的情况下,才考虑对特定会话或特定工作负载设置 GUC。

例如,Memoize 命中率低,可能不是 PostgreSQL “选错了缓存”,而是外层 key 本身高度离散;Materialize 看起来碍眼,也可能是在保护一个需要重复读取的内层结果。

一个简短判断清单

看到这两个节点时,可以快速问自己:

  • 这批数据是否真的会被重复读取?如果是,Materialize 可能合理;
  • join key 是否大量重复?如果是,Memoize 可能很赚;
  • 缓存的数据量是否可能超过 work_mem,导致临时文件 I/O?
  • 关闭对应 GUC 后,计划是更简单了,还是只是把成本转移到了重复扫描上?
  • 统计信息是否新鲜?是否需要 ANALYZE 或更高的 statistics target?

一句话总结:Materialize 是“把一段结果流存下来”,Memoize 是“按 key 记住上次查过什么”。它们目标相近,但路径相反。真正的调优不是看见缓存节点就关掉,而是确认这次缓存到底有没有命中现实中的数据模式。


相关推荐