原始执行计划不是给人顺着读的文章。更好的做法是先把 plan 当成一组 relational tables:节点、边、条件、估算值、真实执行值和 buffer 统计。然后“top 3 估算误差”“最贵节点”“数据流叙事”都只是建在这些表上的 view。

PostgreSQL 的 EXPLAIN 输出很诚实,但不总是友好。一个复杂查询的 plan 会很快变成几十层节点:Nested Loop、Hash Join、Bitmap Heap Scan、Sort、Aggregate 互相嵌套。如果只从第一行往下看,很容易看见很多节点,却没看懂真正的问题在哪里。

我比较喜欢的展开方式是:先把 plan 当成机器产生的结构化数据,再把它装进一组关系表。之后,给人看的报告不是一段手写解释,而是若干个可复用的 view。这些 view 要回答四个问题:

  1. 数据从哪里来,每一步变多了还是变少了?
  2. 优化器以为会有多少行,实际有多少行?
  3. 主要过滤条件、连接条件、索引条件分别在哪里发生?
  4. 真正昂贵的节点是哪个,风险信号是什么?

核心观点:一个 plan 应该也是一组 relational tables

PostgreSQL 的 plan 表面上是一棵树,但分析它的时候,我们真正需要的是可查询的数据模型。一旦把 plan 拆成表,很多“解释”就不再是特殊逻辑,而是普通 SQL。

plan_node(
  plan_id,
  node_id,
  parent_id,
  node_type,
  relation_name,
  alias,
  startup_cost,
  total_cost,
  plan_rows,
  plan_width,
  actual_startup_time,
  actual_total_time,
  actual_rows,
  actual_loops
)

plan_condition(
  plan_id,
  node_id,
  condition_kind, -- Index Cond, Filter, Hash Cond, Join Filter, Recheck Cond
  expression
)

plan_buffer(
  plan_id,
  node_id,
  shared_hit,
  shared_read,
  shared_dirtied,
  shared_written,
  temp_read,
  temp_written
)

有了这层模型,读 plan 的方式会变得非常自然:不是从缩进文本里肉眼搜索,而是直接问这组表。

-- top 3 估算误差
create view plan_top_row_error as
select
  node_id,
  node_type,
  relation_name,
  plan_rows,
  actual_rows * actual_loops as actual_total_rows,
  greatest(
    (actual_rows * actual_loops) / nullif(plan_rows, 0),
    plan_rows / nullif(actual_rows * actual_loops, 0)
  ) as row_error
from plan_node
order by row_error desc nulls last
limit 3;

-- 最贵节点
create view plan_most_expensive as
select
  node_id,
  node_type,
  relation_name,
  actual_total_time * actual_loops as total_time
from plan_node
order by total_time desc
limit 10;

这也是为什么“可读 plan”不应该只是漂亮 HTML。漂亮 HTML 是展示层;真正有价值的是中间那层关系模型。有了它,同一份 plan 可以有很多 view:性能 view、估算误差 view、join view、scan view、buffer view、条件下推 view。

JSON 是输入格式,关系表才是分析模型

EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT JSON) 是一个合适的入口,因为它保留了节点字段和树结构。但 JSON 仍然只是输入格式。直接围着 JSON 写一堆遍历逻辑,最后很容易得到一个只能回答固定问题的 formatter。

更好的边界是:JSON 负责承载原始 plan,relational model 负责承载分析对象。一旦节点、边、条件、buffer、JIT、worker 等信息进入表,解释 plan 就变成了查询这些表。页面只是 view 的渲染结果,不应该承载主要分析逻辑。

没有 ANALYZE 时,表里只有优化器估算;有 ANALYZE 时,同一组 view 可以同时展示估算值和真实执行值。这个差值通常比节点名字本身更重要。

View 比手写解释更可靠

估算误差、最贵节点、条件下推、重复执行次数,本质上都不是自然语言问题。它们是关系查询问题。比如估算误差可以从 plan_node 计算,条件下推可以从 plan_condition 判断,buffer 热点可以从 plan_buffer 排序。

View回答的问题依赖的表
plan_top_row_error哪里实际行数和估算行数差得最大plan_node
plan_most_expensive哪个节点贡献了最多执行时间plan_node
plan_filter_after_scan哪些条件是在读出 tuple 后才过滤plan_node, plan_condition
plan_buffer_hotspots哪些节点有明显 I/O 或临时文件压力plan_buffer

这样做还有一个好处:解释可以复查。自然语言说“这个 Nested Loop 可能有问题”,读者很难知道判断从哪里来;view 给出的结果可以直接回到 SQL、字段和原始 JSON。

事实和判断应该分层

plan 工具最容易犯的错误,是把猜测写成事实。看到 Nested Loop 就说慢,看到 Seq Scan 就说缺索引,都是不够严谨的解释。关系模型能把事实层和判断层分开:

  • 事实层:内层节点执行了多少次,输出多少行,读了多少 buffer。
  • 派生层:估算误差是多少,时间占比是多少,哪些条件没有变成索引条件。
  • 判断层:风险可能来自统计信息、访问路径、join order,还是内存/I/O。

这三层不应该混在一起。前两层适合用 SQL view 固化;第三层可以是说明文字,但必须能指回具体 view 的结果。

一个实用的 view 结构

如果我要做一个“人类可读 PG plan”的页面或命令行工具,我不会把所有逻辑写死在渲染层。我会先生成这些 view,再让页面按顺序展示:

  1. plan_summary:一句话总结这个查询主要时间花在哪里。
  2. plan_flow:按数据流顺序列出 5 到 10 个关键步骤。
  3. plan_top_row_error:最大估算误差的 top 3 节点。
  4. plan_most_expensive:按 actual time、loops、buffers 找最贵节点。
  5. plan_conditions:区分索引条件、过滤条件和 join 条件。
  6. plan_raw:最后保留完整 JSON,方便回查。

这样做的目标不是替代 EXPLAIN。真正目标是把 plan 从“一段输出”变成“一个可以查询的数据集”。对数据库工程师来说,plan 仍然是证据;展开后的报告只是建立在这些证据表上的阅读界面。