Skip to content

PG17:EXPLAIN (MEMORY) 增强 ​

EXPLAIN 是 PostgreSQL 性能调优的第一工具,但长期以来它有一个观测盲区:计划器(planner/optimizer)自己用了多少内存。查询的执行阶段有 BUFFERS、actual time 等完善的观测手段,而计划阶段的内存消耗一直是黑盒。遇到「一条复杂 SQL 让会话内存暴涨」「连接池里大量计划内存无法归位」这类问题,DBA 只能靠日志猜。

PostgreSQL 17 补上了这块拼图:新增 EXPLAIN 的 MEMORY 选项(新增于 PG17),在计划输出中直接报告计划器各阶段的内存使用。同时这一版还有一系列 EXPLAIN 相关增强:SERIALIZE 选项报告数据序列化为网络传输格式的成本、BUFFERS 输出增加本地 I/O 块读写的计时统计、SubPlan 节点与 JIT 相关的显示改进。这些增强让 EXPLAIN 从「看计划」的工具升级为「看计划阶段的资源画像」的工具。

本页围绕 EXPLAIN (MEMORY) 展开:它报告什么、怎么读、配合哪些场景使用,并顺带覆盖 PG17 的其他 EXPLAIN 增强。

前置知识

建议先阅读:PG16:VACUUM 改进 与 EXPLAIN ANALYZE。

基础概念 ​

概念英文 / 记法说明
计划器planner / optimizer把 SQL 翻译成执行计划的组件
计划内存planner memory计划阶段在内存中分配的数据结构(路径树、连接信息等)
MEMORY 选项EXPLAIN (MEMORY)新增于 PG17,报告计划器内存使用
SERIALIZE 选项EXPLAIN (SERIALIZE)新增于 PG17,报告结果序列化为网络格式的实际开销
执行内存executor memory执行阶段的内存:排序、哈希、物化节点的工作内存
work_mem—单个排序/哈希操作可用的内存上限
BUFFERS—计划与执行阶段的缓冲区读写统计
JITjust-in-time compilation对表达式做机器码编译的执行加速机制

计划阶段的内存用在哪 ​

一条 SQL 到达后、执行开始前,计划器要做的事情远比「挑个索引」复杂:

  1. 展开与改写:视图展开、子查询提升、常量折叠,生成的 Query 树都常驻内存;
  2. 路径枚举:每个表可走 SeqScan/IndexScan/BitmapScan……多表连接还要枚举连接顺序与算法,候选路径(Path 树)全部在内存中比较与保留;
  3. 代价计算:选择率估算、统计信息读取、代价模型计算;
  4. 计划定型:最终计划树与各节点参数序列化,准备交给执行器。

绝大多数查询的计划内存是 KB 到 MB 级,无需关心。但三类查询会让计划内存显著膨胀:超长 IN 列表 / 大量 OR 条件、几百个分区的大表查询(每个分区都要做规划)、极端复杂的表达式与多层嵌套视图。这些场景下 EXPLAIN (MEMORY) 让内存消耗第一次变得可见、可比、可优化。

对连接池用户还有一个隐蔽放大器:计划内存发生在会话(连接)里。每个后端进程的计划内存生命周期与连接绑定,长连接 + 高频复杂查询的组合会让计划内存成为连接池常驻成本的一部分——「连接数 × 单查询计划内存」的乘积是容量规划里容易被忽略的一项,这也是本页反复强调「控制查询复杂度」的深层原因。

PG17 的 EXPLAIN 增强清单 ​

按官方 release notes,PG17 在 EXPLAIN 上共有五项增强(均新增于 PG17):

增强说明典型用途
MEMORY 选项报告计划器(优化器)内存使用诊断计划阶段内存膨胀
SERIALIZE 选项实际执行序列化,报告数据转换为网络传输格式的成本诊断「计划快执行也快但传输慢」
BUFFERS 本地 I/O 计时在 BUFFERS 输出中增加本地块读/写的计时统计细分 I/O 时间来源
SubPlan 显示改进更清晰地显示子计划节点与输出参数读懂相关子查询计划
JIT deform_counter报告 JIT 元组变形(deform)相关计数定位 JIT 开销

这五项里,MEMORY 与 SERIALIZE 是新增观测面,BUFFERS 计时是既有观测面加细粒度,SubPlan/JIT 是可读性改进。本页前半部分讲前两类怎么用,后半部分把五项放进日常排障的工作流里。

语法与用法 ​

EXPLAIN (MEMORY) 基本用法 ​

sql
EXPLAIN (MEMORY)
SELECT count(*)
FROM orders
WHERE status = 'paid' AND created_at >= '2026-09-01';
text
Aggregate  (cost=234.56..234.57 rows=1 width=8)
  ->  Index Scan using orders_created_at_idx on orders
        Index Cond: (created_at >= '2026-09-01 00:00:00+00')
        Filter: (status = 'paid'::text)
Planning Time: 0.452 ms
Planning Memory: 8123 bytes used in total
  peak: 8123 bytes in Plan Queries and Plans
  peak: 0 bytes in Optimizer Objects
JIT:
  Functions: 4
  Options: Inlining false, Optimization false, Expressions true, Deforming true
  Timing: Generation 0.301 ms, Inlining 0.000 ms, Optimization 0.102 ms, Emission 0.602 ms, Total 1.005 ms

输出在原有 Planning Time 之后新增 Planning Memory 部分:总量(bytes used in total)与分组峰值。具体分项的名称与粒度以你所用版本的输出为准——本页示例基于 PG17 的典型输出格式,重点是学会用它对比而不是记每个字段。

与其他选项组合 ​

MEMORY 与常用选项自由组合:

sql
-- 完整画像:执行统计 + 缓冲区 + 计划内存
EXPLAIN (ANALYZE, BUFFERS, MEMORY)
SELECT o.id, c.name
FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.created_at >= now() - interval '1 day';

-- 序列化成本:模拟把结果行转成传输格式的开销(ANALYZE 才有实际数据)
EXPLAIN (ANALYZE, SERIALIZE, BUFFERS)
SELECT * FROM wide_table LIMIT 1000;

SERIALIZE 选项会让 EXPLAIN ANALYZE 真的把结果行序列化一遍(但不发送给客户端),从而报告序列化耗时——它能暴露「行宽太大、TOAST 反压缩、格式转换」这类被 execution time 掩盖的成本。注意官方要求 SERIALIZE 必须与 ANALYZE 同用;SERIALIZE 省略参数时默认按 TEXT 处理。

psql 端的自动触发 ​

更常用的是 auto_explain 扩展在慢查询日志里自动记录计划:

sql
LOAD 'auto_explain';
SET auto_explain.log_min_duration = '500ms';
SET auto_explain.log_analyze = on;
SET auto_explain.log_buffers = on;

慢查询日志会自动附上计划与执行统计,事后排障不再需要复现。(auto_explain 的可用参数以官方文档为准,不同版本有所增减。)

EXPLAIN ANALYZE 对写语句的安全用法 ​

ANALYZE 会真实执行语句:SELECT 无副作用,INSERT/UPDATE/DELETE/MERGE 会真的改数据。官方文档给出的保护写法:

sql
BEGIN;
EXPLAIN ANALYZE UPDATE orders SET status = 'paid' WHERE id = 42;
ROLLBACK;

事务回滚让「测量」与「变更」解耦——执行走了完整路径(含触发器、约束检查、WAL 生成),提交时全部丢弃。两点例外要心里有数:事务外有副作用的触发器(发消息、调外部接口)不会被回滚;序列(sequence)的推进也不随 ROLLBACK 回退。测试环境的写语句 EXPLAIN 也应默认带这层保护。

GENERIC_PLAN 与 SETTINGS:两个常被忽视的选项 ​

同属「读懂计划」的工具箱,与 MEMORY 搭配使用价值很高:

sql
-- GENERIC_PLAN:参数化语句的通用计划(PG16+),不执行、不绑定实参
EXPLAIN (GENERIC_PLAN)
SELECT * FROM orders WHERE customer_id = $1 AND status = $2;

-- SETTINGS:在计划尾部列出「与内置默认值不同」的计划相关 GUC
EXPLAIN (SETTINGS)
SELECT count(*) FROM orders;
text
...
Settings: effective_cache_size = '24GB', work_mem = '64MB'
Planning Time: 0.418 ms

两个选项解决两类高频困惑:「同一条 SQL 为什么开发库快、生产慢」——SETTINGS 直接列出生产会话里被改过的参数,环境差异不再靠翻配置文件;「prepared statement 的通用计划长什么样」——GENERIC_PLAN 无需真实参数就能看计划形状。注意 GENERIC_PLAN 与 ANALYZE 互斥;SETTINGS 可与任意选项组合叠加。

FORMAT JSON:让计划进入自动化工具链 ​

FORMAT { TEXT | XML | JSON | YAML } 选择输出的机器可读格式。人读 TEXT,程序读 JSON:

sql
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT ...;

JSON 计划的价值在自动化链路:CI 里对关键 SQL 跑计划回归(比对节点结构变化、报警「Seq Scan 替换了 Index Scan」);把 JSON 贴进可视化工具(如 explain.dalibo.com)得到带行宽着色的执行树图,评审时比纯文本直观得多。MEMORY/SERIALIZE 等新选项的输出同样包含在 JSON 结构里,自动化巡检可以直接解析。

常用选项组合速查 ​

目的命令备注
快速看计划结构EXPLAIN纯估算,不执行
验证真实执行EXPLAIN (ANALYZE)会真的执行语句
I/O 画像EXPLAIN (ANALYZE, BUFFERS)PG17 起含 I/O 计时
计划内存EXPLAIN (MEMORY)新增于 PG17
传输成本EXPLAIN (ANALYZE, SERIALIZE)新增于 PG17,须与 ANALYZE 同用
环境差异排查EXPLAIN (SETTINGS)列出与默认值不同的 GUC
参数化语句EXPLAIN (GENERIC_PLAN)PG16+,不执行
机器可读输出FORMAT JSON工具链 / CI 回归

记忆锚点:MEMORY 管计划内存、BUFFERS 管执行 I/O、SERIALIZE 管输出传输——三个资源维度各有一个选项;ANALYZE 是让后两者产生真实数字的总开关。

详细说明 ​

MEMORY 报告的分项含义 ​

PG17 的计划内存报告把内存按「用途」分组。以典型输出为例:

text
Planning Memory: 92156 bytes used in total
  peak: 52844 bytes in Plan Queries and Plans
  peak: 39312 bytes in Optimizer Objects

各分项的解读:

  • total:计划阶段累计分配的内存量(含可复用部分)。
  • Plan Queries and Plans:解析后的查询树与最终计划树占用——它随查询规模线性增长,超长 SQL 是主要膨胀源。
  • Optimizer Objects:计划器内部的路径对象、估算信息——多表连接与多分区查询的主要膨胀源。
  • 两者的杠杆点不同:Plan Queries and Plans 的解药是「SQL 别太长」(子查询提升、视图展开都会复制查询结构);Optimizer Objects 的解药是「别让连接组合爆炸」(控制 JOIN 数、用好分区裁剪减少候选路径)。

注意输出粒度随小版本演进

Planning Memory 的具体分项名称与层级在 PG17 的开发过程中有过调整,不同小版本的输出可能略有差异。使用时以同一版本的输出做对比为准则,不要跨版本硬比数字。

读报告时的实用口径:把 bytes used in total 与该会话的 work_mem 配置对比——计划内存接近甚至超过 work_mem 时,说明这条 SQL 的复杂度已经到了「调参救不回来」的量级,改写比调参有效。

什么时候 MEMORY 有用:三个典型场景 ​

场景一:巨型 IN 列表

sql
EXPLAIN (MEMORY)
SELECT * FROM users WHERE id = ANY (ARRAY[ /* 10 万个 id */ ]);

IN/ANY 列表的长度直接进入计划内存的线性项。10 万元素的列表可能让 Planning Memory 达到几十 MB——连接池里几十个这样的会话就是 GB 级常驻。MEMORY 报告让这个量级从猜测变成观测,优化的方向是把「传 10 万个 id」改成「传过滤条件」或临时表 JOIN。识别这类 SQL 的红旗信号:代码评审看到 implode(',', $ids) 拼 SQL、ORM 的 whereIn() 接收未截断的数组、慢日志里出现 MB 级 SQL 文本——任一出现,就该跑 MEMORY 看看账单了。

场景二:几千个分区的大表

分区表的查询规划要枚举每个分区的访问路径。几千个分区的表上,即使查询命中少数分区(运行时裁剪),计划器仍可能为整个分区集付规划成本。MEMORY 报告里 Optimizer Objects 的异常膨胀是「分区规划成本」的直接证据,对应的治理手段是控制分区数、拆表或升级到按需初始化的分区裁剪行为。

场景三:多层嵌套视图

业务视图套业务视图,一条 SQL 展开成几百行的关系代数。Plan Queries and Plans 的峰值能定位「哪一层视图贡献了内存」,为视图瘦身提供依据。

场景四:动态拼接的连接爆炸

ORM 按请求参数动态拼 JOIN,一个「高级筛选」页面能让 SQL 从 5 个 JOIN 膨胀到 30 个。候选路径的组合数随连接数指数增长(计划器超过 geqo_threshold 会切基因算法控制规划时间,但内存水位仍可能陡增)。MEMORY 报告对这类 SQL 的价值是给评审提供证据:把「这条动态 SQL 计划内存 45MB、每秒 20 次」写进工单,比「感觉它很重」有说服力得多。

计划内存 vs 执行内存:两条独立的账 ​

初学者容易把 Planning Memory 与 work_mem 混为一谈。它们是两条完全独立的账:

维度计划内存执行内存
发生阶段计划阶段(Planning)执行阶段(Execution)
受控参数无直接 GUC(随查询复杂度)work_mem、hash_mem_multiplier、maintenance_work_mem
观测手段EXPLAIN (MEMORY)(PG17+)EXPLAIN ANALYZE 的 Sort/Hash 节点、pg_stat_activity
典型问题超长 SQL、大分区表、巨型 IN 列表大排序、大哈希落盘

EXPLAIN (ANALYZE, BUFFERS, MEMORY) 一条命令把两条账同时呈现:Planning Memory 看计划阶段,Sort Method: external merge Disk: 512MB 看执行阶段。

会话内存的全景还要加上第三笔账——后端常驻部分:目录缓存、prepared statement 缓存等,pg_backend_memory_contexts(PG16+)能看到的全部门类。三笔账的关系是:会话总内存 ≈ 常驻 + 计划峰值 + 执行峰值(不完全严格相加,分配器会复用已释放的空间),排查「会话内存高」时按常驻 → 计划 → 执行的顺序逐层拆解,MEMORY 报告只负责中间那一层。

SERIALIZE 选项:补上「传输成本」的观测 ​

PG17 的 SERIALIZE 解决的是一个长期被忽视的问题:为什么同一查询「执行很快、客户端收到很慢」?

sql
EXPLAIN (ANALYZE, SERIALIZE)
SELECT * FROM documents;
text
Seq Scan on documents (actual time=0.02..512.20 rows=100000 loops=1)
Planning Time: 0.103 ms
Serialization: time=823.441 ms
Execution Time: 1341.132 ms

这个例子中扫描只花了约 0.5 秒,序列化却花了 0.8 秒:真正的大头不是扫描,而是把 10 万行宽行数据转成传输格式。这个成本的解法是投影裁剪(只 SELECT 需要的列)、服务端聚合、分页——而不是继续优化扫描。PG17 之前这个环节完全不可见,只能靠客户端压测间接推断。

两个使用细节:SERIALIZE BINARY 按 binary 格式序列化(对应扩展协议的二进制行),与 TEXT 的耗时对比能帮你判断「换二进制协议传输值不值」;官方文档还明确 TIMING off 时序列化只做转换不计时,需要精确时间就保持 TIMING 默认开启。

适用性自查:结果集万行以上、行宽大(TOAST 列多)、客户端反馈「DB 快应用慢」——三条命中任意两条就值得跑一次 SERIALIZE;反之点查场景(返回几十行)序列化成本可忽略。

BUFFERS 的本地 I/O 计时 ​

PG17 之前,EXPLAIN (ANALYZE, BUFFERS) 只有读写的块数统计;PG17 增加了本地块读写的计时统计(本地 I/O block read/write timing)。它让「I/O 花了多少时间」从推断(块数 × 经验延迟)变成直接测量,对区分「缓存命中但 CPU 忙」与「真的在等磁盘」非常有用。结合 pg_stat_io(PG16 引入的 I/O 统计视图)可以画出完整的 I/O 时间画像。

对照两者的观察口径:pg_stat_io 是累计值(自统计重置以来),EXPLAIN 是单次执行。对比时用两次 pg_stat_io 快照的差值与 EXPLAIN 对齐时间窗,否则结论会被历史值稀释。

一个实用的落地习惯:把 EXPLAIN (ANALYZE, BUFFERS) 的 I/O 计时行固化进慢查询工单。以前「这条查询读了多少块」只有块数、判断不了 I/O 好坏;现在 read/write 计时直接回答「I/O 花了几毫秒、占执行时间比例多少」,冷读、随机读、写放大三类问题的甄别不再依赖经验猜。

SubPlan 显示与 JIT 计数改进 ​

两个小而实用的改进:

  1. SubPlan 显示改进:相关子查询(correlated subquery)生成的 SubPlan/InitPlan 节点,现在会显示其输出参数与引用关系,读「每行都执行一次的子查询」计划时不再靠猜。
  2. JIT deform_counter:JIT 编译的元组变形(把磁盘格式行拆成内存格式)是 JIT 的主要开销来源之一,PG17 把 deform 相关计数加入 EXPLAIN 的 JIT 报告,判断「这条查询开 JIT 是赚是赔」有了更直接的依据。JIT 排查仍以 Timing 各段为主,新计数是辅助线索。

BUFFERS 计时与 pg_stat_io 的互相印证 ​

PG17 的 BUFFERS 计时让单条查询的 I/O 画像完整了,它与系统级视图 pg_stat_io 互为印证:

层级视角工具
单查询这条 SQL 的 I/O 时间分布EXPLAIN (ANALYZE, BUFFERS)(PG17 起含计时)
会话/系统全库 I/O 吞吐与延迟pg_stat_io、pg_stat_bgwriter
硬件磁盘设备级延迟操作系统监控

典型用法:track_io_timing = on 开启后,EXPLAIN BUFFERS 的 read/write 计时与 pg_stat_io 的 read_time/write_time 同口径可比——单查询 I/O 占比高且系统级延迟也高,是存储瓶颈;单查询占比高而系统级不异常,多半是这条 SQL 读的冷数据特别多(或分区裁剪失效扫了太多块)。先看系统级基线再解读单查询,是避免「把正常冷读当故障」的关键顺序。

auto_explain 与线上观测 ​

把 PG17 的 EXPLAIN 增强接入生产监控的推荐配置:

conf
# postgresql.conf(或按会话 LOAD)
shared_preload_libraries = 'auto_explain'   # 或会话级 LOAD 'auto_explain'
auto_explain.log_min_duration = 1s          # 只记录慢查询
auto_explain.log_analyze = on
auto_explain.log_buffers = on
auto_explain.log_timing = off               # 高频场景关闭逐节点计时,降低开销

log_timing = off 值得强调:ANALYZE 的逐节点计时本身有可测的开销,高频线上采样时关闭它、保留结构与内存信息,是开销与信息量的平衡点。另外两个值得知道的开关:log_verbose = on 记录 VERBOSE 级计划(含输出列清单,日志解析更友好);log_wal = on 把 WAL 生成量一并记录(PG13+,DML 问题画像更完整)。可用参数以官方 auto_explain 文档为准。

auto_explain 与手工 EXPLAIN 的分工要说清:auto_explain 负责「抓住现场」——慢查询发生那一刻的计划与统计,事后无法复现时它是唯一证据;手工 EXPLAIN 负责「深入取证」——任意选项组合、反复对比。两者输出格式一致,工单里可以混排。唯一要警惕的是 log_analyze = on 的常驻开销:逐节点计时会让被记录的查询变慢(只影响被记录的慢查询本身),采样型观测可用 log_timing = off 抵消,或把 log_min_duration 设高只抓真正的长尾。

从 pg_stat_statements 到 EXPLAIN 的排查闭环 ​

MEMORY/执行画像与 pg_stat_statements 是上下游关系:后者负责「发现问题」,前者负责「解释问题」:

  1. 发现:pg_stat_statements 按 total_exec_time 排序,找出最耗时的查询指纹;
  2. 定位:取该指纹的一条实际 SQL(query 列),跑 EXPLAIN (ANALYZE, BUFFERS, MEMORY);
  3. 归因:计划阶段看 Planning Time / Planning Memory,执行阶段看各节点 actual time 与 Buffers,传输阶段看 SERIALIZE;
  4. 验证:改写后同一命令复跑,前后对比留档。
sql
-- 第 1 步:Top 5 耗时查询(pg_stat_statements 扩展)
SELECT left(query, 80) AS query, calls,
       round(total_exec_time) AS total_ms,
       round(mean_exec_time, 2) AS mean_ms
FROM pg_stat_statements
ORDER BY total_exec_time DESC LIMIT 5;

把这套四步写进团队排障 SOP,EXPLAIN (ANALYZE, BUFFERS, MEMORY) 就是其中的标准取证命令——比「凭感觉猜」的排障效率高一个数量级。

MEMORY 巡检的落地模式 ​

MEMORY 不只是排障工具,还能做周期性巡检:每周对 Top SQL 采集一次计划内存基线,固化成基线表后,两类异常自动浮出——

  • 某条 SQL 的计划内存环比突增:多半是上游 SQL 被改成了巨型 IN 列表或 JOIN 数暴增,在用户感知到慢之前就拦截;
  • 全库计划内存分布的长尾:尾部几条占掉绝大多数内存,治理优先级一目了然。

基线对比要在同一 PG 小版本内进行:跨版本的分项结构与数值都不可比,升级后按最佳实践重建基线。巡检频率建议周级——计划内存的恶化通常随发布引入(新 SQL、新视图),周级足够拦住;执行画像的异常则交给日级实时告警。

巡检产出的基线表至少记录四列:采集时间、PG 小版本、查询指纹(pg_stat_statements 的 queryid 或 SQL 指纹)、Planning Memory 数值。基线表用一张普通表存放即可,配上「超过基线 2 倍且绝对值超 1MB 则告警」的简单规则,就是一套可持续运行的计划内存治理——它的价值不在单次数据,而在趋势:一条 SQL 的计划内存曲线,就是它复杂度演化的历史。

实战示例 ​

示例一:定位巨型 IN 列表的计划内存 ​

sql
-- 用 generate_series 模拟 10 万元素的 IN 列表
EXPLAIN (MEMORY)
SELECT * FROM users
WHERE id = ANY (SELECT generate_series(1, 100000));

对比直接内联 10 万元素数组:

sql
-- 数组内联版本(计划内存随数组长度线性增长)
EXPLAIN (MEMORY)
SELECT * FROM users
WHERE id = ANY (ARRAY(SELECT generate_series(1, 100000)));

两者的对比清晰呈现「子查询形式」与「内联数组形式」的计划内存差异:子查询形式把元素生成交给执行器,计划内存恒定;内联数组把全部元素进计划,内存线性膨胀。结论:超大 ID 集合永远用子查询/临时表,不要内联。

补充第三种形态——临时表 JOIN 的对照:

sql
CREATE TEMP TABLE id_filter (id int PRIMARY KEY);
INSERT INTO id_filter SELECT generate_series(1, 100000);

EXPLAIN (ANALYZE, BUFFERS, MEMORY)
SELECT u.* FROM users u JOIN id_filter f ON f.id = u.id;

临时表方案的计划内存与子查询形式同量级(恒定),且元素集可复用、主键能让连接走更优路径;代价是一次落盘与统计成本。三形态的选择排序:一次性小集合 → 子查询;复用或超 10 万元素 → 临时表;永不内联。

验证这个结论只需要一分钟:把 10 万改成 100,三种形态的计划内存几乎相同;改回 10 万,内联形式的 Planning Memory 一骑绝尘。让团队新人在自己的开发库里跑一遍这个实验,比十条编码规范都管用。

示例二:SERIALIZE 暴露宽行传输问题 ​

sql
-- 宽表(含大文本/JSONB 列)全量拉取
EXPLAIN (ANALYZE, SERIALIZE, BUFFERS)
SELECT * FROM events LIMIT 10000;
text
Limit (actual time=0.014..45.201 rows=10000 loops=1)
  ->  Seq Scan on events (actual time=0.013..40.100 rows=10000 loops=1)
Planning Time: 0.089 ms
Serialization: time=312.997 ms
Execution Time: 362.101 ms

扫描 45ms,序列化 313ms——「查询慢」的真凶是传输。改写为只取业务列 + 服务端过滤:

sql
EXPLAIN (ANALYZE, SERIALIZE, BUFFERS)
SELECT id, created_at, payload->>'type' AS type
FROM events
WHERE created_at >= now() - interval '1 hour'
LIMIT 100;

同一命令复测,Serialization: time= 从 313ms 降到 1ms 以内——「查询慢」的假象消除,收益直接可见。这类「传输瓶颈」在业务侧的典型症状是:DBA 在 pg_stat_activity 里找不到慢查询、APM 显示 DB 时间占比不高、但用户就是觉得慢——瓶颈在应用进程的接收与反序列化,SERIALIZE 让这一段在数据库侧有了对照数字。

示例三:分区表规划内存画像 ​

sql
-- 一张 2000 分区的表:对比全分区扫描与裁剪后的计划内存
EXPLAIN (MEMORY)
SELECT count(*) FROM metrics
WHERE time >= '2026-10-01' AND time < '2026-10-02';

Optimizer Objects 的峰值在分区数多的表上远高于普通表。若同一查询在分区数从 200 增至 2000 的过程中 Planning Memory 呈数十倍增长而 Planning Time 同步恶化,就是「分区规划成本」的确凿证据——治理手段(控制分区数、分区粒度调粗、必要时应用侧分表)由此有了量化依据。

复现这个对比不需要真建 2000 分区的表:建一个 200 分区的分区表,把同一条查询分别跑在「命中 1 个分区」与「跨全部分区」两种时间条件下,MEMORY 报告的差异就是分区规划成本的直观教材。团队评审「分区数要不要继续增长」时,这个实验比争论更有说服力。

如果只能记住一个数字,记住「计划内存 ÷ 命中分区数」:它把规划成本归一到单分区口径,跨表、跨时间的比较才有共同基准——分区治理的目标就是让这个数字不随分区数漂移。

示例四:auto_explain 采集慢查询计划 ​

bash
# 会话内加载并模拟线上采样
psql <<'SQL'
LOAD 'auto_explain';
SET auto_explain.log_min_duration = '500ms';
SET auto_explain.log_analyze = on;
SET auto_explain.log_buffers = on;

-- 触发一条慢查询
SELECT count(*) FROM generate_series(1, 5000000) g
JOIN generate_series(1, 5000000) h ON g % 1000 = h % 1000;
SQL

日志中的记录(节选):

text
LOG:  duration: 1832.004 ms  plan:
      Query Text: SELECT count(*) FROM generate_series(1, 5000000) g ...
      Aggregate  (cost=..rows=1 width=8) (actual time=1831.9..1832.0 rows=1 loops=1)
      Planning Time: 0.190 ms
      Planning Memory: 24610 bytes used in total
        peak: 24610 bytes in Plan Queries and Plans
      JIT:
        Functions: 6
        Timing: Generation 0.5 ms, Total 3.2 ms
      Execution Time: 1834.104 ms

慢查询的「计划耗时 + 计划内存 + 执行画像」一并落日志,事后排障不需要复现。

示例五:标准化的 EXPLAIN 工单样例 ​

团队协作里,贴 EXPLAIN 结果要「可读、可比、可追溯」。推荐的工单格式:

sql
-- ① 元信息:版本与参数上下文
SELECT version();
SHOW work_mem;
SHOW shared_buffers;
SHOW max_parallel_workers_per_gather;

-- ② 标准取证命令(多选项组合)
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, SERIALIZE)
SELECT ...;   -- 完整 SQL 原文

工单正文按五段式组织:

text
1. 现象:接口 P99 从 120ms 涨到 900ms,起始时间点 xx-xx 14:00
2. 环境:PG 17.2 / 表行数 1.2 亿 / 命中分区 32
3. 取证:EXPLAIN (ANALYZE, BUFFERS, SETTINGS) 输出全文
4. 归因:Sort 节点 external merge 640MB(work_mem 不足);
         Planning Memory 突增源于新增 8 万元素 IN 列表
5. 方案:改写为 EXISTS + 索引;work_mem 会话级调至 32MB;
         附改写前后对比数据

两条格式纪律:第 ③ 段必须贴文本全文而不是截图——文本可 diff、可搜索、可归档;第 ④ 段把「计划膨胀」与「执行落盘」分开归因——两类问题对应完全不同的改法。这份格式让 EXPLAIN 的信息在团队内无损流转。

这套格式沿用越久价值越大:一年后的工单库里,同一条 SQL 的历史 EXPLAIN 就是最诚实的性能档案——「这条查询去年就扫描 800 万行、一直如此」与「上个月才开始劣化」是两个完全不同的处理方向。

示例六:JSON 计划回归(CI 里的守门员) ​

关键 SQL 的计划在 CI 里做回归,防止「发版后索引计划悄悄变全表扫描」:

bash
#!/bin/bash
# plan_regression.sh —— 基线 JSON 与当前计划的结构对比
psql -Atc "EXPLAIN (FORMAT JSON) SELECT ..." > plan_current.json
python3 diff_plan.py plan_baseline.json plan_current.json
text
== plan diff ==
[!] Index Scan using orders_created_at_idx -> Seq Scan on orders
    estimated rows: 1200 -> 8400000
    filter added: (status = 'paid'::text)
== verdict: REGRESSION ==

diff 脚本通常只比较三类信息:节点类型的树形结构(最直接的回归信号)、估算行数量级(统计信息劣化的信号)、是否新增临时文件类节点(work_mem 回归信号)。基线 JSON 随代码库一起版本化,计划结构变化与代码变更出现在同一个 PR 里——「计划回归」从上线后救火前移到合并前拦截。

回归基线要跟着「有意的计划变更」更新:确要换索引、改结构时,随 PR 一起刷新基线 JSON 并在提交说明里写明原因——基线的变更历史本身就是这份关键 SQL 的计划演进史。

示例七:读计划的三遍法 ​

拿到一张几十个节点的大计划不要慌,按三遍法读:

第一遍:只看成本与行数的断层。 自上而下找「父节点 cost ≈ 子节点 cost 之和」被打破的地方——断层处就是异常节点(估算行数差几个量级、或耗时集中点)。

第二遍:对准最慢节点问三个问题。 以 (actual time=0.02..823.44 rows=120000 loops=1) 为例:823ms 花在哪类节点(扫描/连接/排序)?rows=120000 与估算差多少(超 10 倍即统计问题)?loops=1 还是 loops=120000(每行执行一次的子查询要乘 loops 再评估)?

第三遍:BUFFERS 与 MEMORY 收尾。 慢节点 Buffers: shared read=850MB → I/O 问题;Sort Method: external merge → work_mem 问题;Planning Memory 异常 → 查询复杂度问题。

text
(actual time=0.02..823.44 rows=120000 loops=1)
          │        │        │       └ loops=1:外层每 1 行执行 1 次
          │        │        └ 实际行数(与估算 rows 对比)
          │        └ 完成本节点的总时间(ms)
          └ 返回第一行的时间(ms)

三遍法把「看计划」从玄学变成固定动线,配合标准取证命令与工单格式,就是一套完整可教的方法论。

三遍法练熟后还可以反向使用:评审别人的 EXPLAIN 输出时,先问「第几遍发现了什么」——如果三遍都走完仍无结论,通常问题不在计划而在别处(锁等待、客户端处理、网络传输),及早转向比在计划里死磕更有效率。

示例八:会话内存上涨的完整取证 ​

应用反馈「某个批处理会话内存持续上涨」,按计划内存 → 执行内存 → 常驻内存的顺序取证:

sql
-- 1) 计划内存:该会话典型 SQL 的复杂度账单
EXPLAIN (MEMORY)
SELECT /* 批处理中的典型查询 */ ...;

-- 2) 执行内存:看排序/哈希是否落盘
EXPLAIN (ANALYZE, BUFFERS, MEMORY)
SELECT ...;

-- 3) 会话全景:当前各内存上下文的分布(PG16+)
SELECT name,
       pg_size_pretty(total_bytes) AS total
FROM pg_backend_memory_contexts
ORDER BY total_bytes DESC LIMIT 10;
text
        name        |  total
--------------------+----------
 HashBatchContext   | 64 MB    ← 执行内存的大头
 ...
 TopMemoryContext   | 56 kB

三步各自归因:第 1 步数字异常 → 查询复杂度问题(改写 SQL);第 2 步 Sort Method: external merge 或哈希大对象 → work_mem 问题(调参);两步都正常但会话仍涨 → 常驻缓存问题(prepared statement 数量、目录缓存),用第 3 步的视图逐项核对。MEMORY 报告在这条链路里回答的是「复杂度这一层有没有问题」,避免把所有内存上涨都归咎于 work_mem。

注意事项 ​

EXPLAIN (MEMORY) 报告的是计划阶段内存,不代表会话内存总账。 会话还持有目录缓存、执行内存、连接缓冲;定位会话内存问题要把 MEMORY 报告与 pg_stat_activity、pg_backend_memory_contexts(PG16+)等视图结合。

SERIALIZE 只在 ANALYZE 下产生真实测量。 不带 ANALYZE 的 SERIALIZE 只有估算;且它会真实执行序列化,大结果集的 EXPLAIN 会消耗可观 CPU 与临时内存,不要对超大查询随手开启。

ANALYZE 本身有开销与副作用。 EXPLAIN ANALYZE 会真实执行 DML(配合 BEGIN; ... ROLLBACK; 保护数据),逐节点计时在高频场景可观测地拖慢执行;生产采样建议 auto_explain 的 log_timing = off。

计划内存的绝对值没有跨版本可比性。 分项结构在小版本间有调整,监控与回归要固定在同一 PostgreSQL 版本内做纵向对比。

分区表规划成本仍随分区数增长。 MEMORY 让成本可见,但不会消除它;分区数仍在数千以上时,先做分区治理再谈其他优化。

auto_explain 的各选项独立设置、加载不等于记录。 不设 log_min_duration 就什么都不输出,log_analyze、log_buffers 等各自独立开关,漏配是「日志里没有想要的信息」的常见原因;可用参数随版本增减,以所用版本的官方文档为准。

EXPLAIN 输出要贴文本、别截图。 文本可 diff 可搜索可归档;截图丢掉可对比性,也让后续版本的输出结构变化无从考证。

巡检 Planning Memory 对短查询噪音要有预期。 简单查询的计划内存波动在 KB 级、受统计信息缓存影响,巡检只对超过阈值(如 1MB)的查询告警,避免噪音淹没信号。

对比实验要在相同数据状态下做。 EXPLAIN ANALYZE 执行 DML 会改变统计基线(行数、页面数随之偏移),紧接着的第二次 EXPLAIN 数字可能漂移;改写前后的对比要回滚或重建到同一数据状态。

最佳实践 ​

  1. 把 EXPLAIN (ANALYZE, BUFFERS, MEMORY) 定为团队的标准调优起手式。 一条命令同时拿到执行统计、缓冲区与计划内存,避免只看一半画像就下结论。
  2. 超大 ID 集合一律用子查询/临时表,禁止内联长数组。 用 EXPLAIN (MEMORY) 在代码评审前验证:计划内存应与结果集规模无关,而与集合长度无关才对。
  3. 分区表上线前做规划内存基线。 记录典型查询在不同分区数下的 Planning Memory 与 Planning Time,分区治理(合并、调粒度)以基线数据为依据。
  4. 「计划快执行慢、客户端收得慢」的查询先跑 SERIALIZE。 传输成本不可见时,优化火力经常打错地方;PG17 的 SERIALIZE 让这类问题一眼定位。
  5. auto_explain 常驻线上采集慢查询计划。 配合 log_min_duration 只记录慢查询,log_timing = off 控制开销,让慢查询日志自带完整计划画像,形成线上常态观测。
  6. 跨版本对比计划内存没有意义,监控按版本分段。 升级 PG 大版本后重建内存基线,避免把格式差异误判为性能回归。
  7. 结合 pg_backend_memory_contexts 看会话内存全景。 PG16 引入的该视图列出后端各内存上下文,与 MEMORY 报告互补:一个看计划阶段,一个看会话全景。
  8. JIT 的开关决策用 Timing 数据说话。 记录开/关 JIT 的 EXPLAIN (ANALYZE) 对比,短查询通常关 JIT 更快;deform_counter 等新计数是辅助证据。
  9. 复杂视图的内存贡献用 MEMORY 报告定位瘦身。 逐层展开视图、对比展开前后的 Planning Memory,找到膨胀源优先重构——比凭直觉改视图高效得多。
  10. 把 EXPLAIN 输出纳入工单模板。 性能问题工单强制附 EXPLAIN (ANALYZE, BUFFERS, MEMORY) 输出与 PG 版本号,排障沟通成本立减一半。
  11. MEMORY 巡检对象永远来自真实流量。 用 pg_stat_statements 的耗时排序生成巡检清单,而不是拍脑袋挑 SQL——巡检的价值在覆盖真实的重查询。
  12. 把三遍法教给每一位后端。 「成本断层 → 最慢节点三问 → BUFFERS/MEMORY 收尾」的固定动线,让新人也能系统地读计划,而不是扫一眼就下结论。

下一节

继续学习:PG17:批量加载性能

参考链接 ​