3. 执行计划阅读基础

在 PostgreSQL 中,SQL 只是一份“意图声明”,真正决定性能的是优化器选出的执行计划。阅读执行计划,是复杂查询性能治理的第一项基本功。本节不讨论如何改 SQL,而是先解决一个更前置的问题:拿到一段 EXPLAIN 输出后,如何按结构把它读明白,并识别出扫描、连接、聚合、排序四类核心节点以及代价估算信息。

一、EXPLAIN 与 EXPLAIN ANALYZE 的本质区别

EXPLAIN 只做计划生成,不真正执行语句。它返回的是优化器基于统计信息给出的估算计划,包含节点类型、访问方式、估算行数(rows)、估算代价(cost)和估算宽度(width)。因为不执行,所以它很快、无副作用,适合在写 SQL 阶段反复查看。

EXPLAIN ANALYZE 会真正执行语句,并在估算信息之外追加实际运行数据:实际耗时(actual time)、实际行数(actual rows)、实际循环次数(loops)。它还会在末尾给出 Planning Time 与 Execution Time。代价是语句真的会跑,写操作会真实修改数据,因此对 UPDATE/DELETE 要谨慎,通常放在事务中回滚。

判断估算是否可信,最直接的方法就是对比同一节点的 rows=估算值 与 rows=实际值。两者数量级接近,说明统计信息可用;相差几十倍甚至上百倍,往往意味着统计信息过期、列相关性未被捕获,或存在函数包裹导致选择率估算失真。

二、执行计划是一棵自底向上的节点树

EXPLAIN 输出采用缩进表示层级:缩进越深,越靠近叶子,越先被执行;缩进越浅,越靠近根,越晚执行。数据从叶子节点向上流动,父节点消费子节点的输出。理解这一点,才能正确判断“谁先跑、谁在等谁”。

每个节点行通常形如:

Seq Scan on orders  (cost=0.00..18334.00 rows=1000000 width=37)
                    (actual time=0.015..142.330 rows=1000000 loops=1)

其中 cost=0.00..18334.00 是启动代价与总代价,单位是抽象的代价单位,不是毫秒;rows 是估算返回行数;width 是估算行平均字节宽度;actual time 是首行返回时间与全部返回时间;loops 是该节点被执行的次数。当节点位于 Nestloop 内侧时,loops 会大于 1,此时 actual time 与 rows 都是单次循环的平均值,总耗时需乘以 loops 才是真实开销。

执行计划节点树:数据自底向上流动 缩进越深越先执行 · 父节点消费子节点输出 Sort 排序节点 cost=… rows=… width=… Hash Join 连接节点 Hash Cond: o.cid = c.id HashAggregate 聚合节点 Group Key: o.cid Seq Scan 顺序扫描 on orders · Filter: status='paid' Index Scan 索引扫描 on customers · Index: pk_id 数据上行 数据上行 节点类别: 扫描节点 连接节点 聚合节点 排序节点 关键字段:cost(估算代价)· rows(估算/实际行数)· width(行宽)· actual time(实际耗时)· loops(循环次数)

三、四类核心节点的识别要点

扫描节点决定数据从哪里取。常见的有 Seq Scan(全表顺序扫描)、Index Scan(索引扫描后回表)、Index Only Scan(仅索引扫描,无需回表)、Bitmap Index Scan + Bitmap Heap Scan(位图扫描,适合中等选择率)。看到 Seq Scan 不一定就是问题:当查询需要返回表中大部分行时,顺序扫描反而比索引更划算。真正需要警惕的是“大表 + 高选择率过滤条件 + 仍然走 Seq Scan”,这通常意味着缺索引或统计信息失真。

连接节点决定两张表如何拼接。Nested Loop 适合外表小、内表有高效索引的场景,其代价随外表行数线性增长;Hash Join 适合等值连接且数据量较大,会先构建哈希表再探测;Merge Join 适合两侧都已按连接键有序的情况。判断连接方式是否合理,要看两侧的估算行数与实际行数是否匹配,以及 Hash Join 是否出现 Batches > 1(说明哈希表溢出到磁盘,需要调大 work_mem)。

聚合节点决定分组统计如何完成。HashAggregate 先建哈希表再聚合,适合分组数适中;GroupAggregate 要求输入已排序,适合分组数很大或已有排序;Plain Aggregate 用于无 GROUP BY 的全局聚合。若 HashAggregate 出现 Disk Usage,说明分组数过多导致内存不足。

排序节点决定 ORDER BY、DISTINCT、Merge Join 前的排序如何完成。Sort 节点若显示 Sort Method: external merge Disk: …kB,说明排序落盘,是明显的性能信号;quicksort Memory 则表示在内存中完成。若排序键上已有合适索引,优化器可能直接走 Index Scan 避免显式 Sort。

四、代价估算信息怎么读

代价由 seq_page_cost、random_page_cost、cpu_tuple_cost 等参数共同决定,默认值下顺序读一页代价为 1.0,随机读一页为 4.0。因此 cost 只是相对值,不能直接换算成毫秒。它的用途是比较同一查询的不同计划,而不是跨查询比较。

真正需要重点核对的是估算行数与实际行数的偏差。偏差会沿着节点树向上放大:叶子节点估算少了一倍,上层连接可能选错连接方式,再上层聚合可能选错算法。定位偏差时,从最深的叶子节点开始逐层向上看,找到第一个 rows 估算与实际明显不符的节点,那里通常就是问题源头。

使用 EXPLAIN ANALYZE 分析写操作时,务必包在事务中并回滚,例如 BEGIN; EXPLAIN ANALYZE UPDATE …; ROLLBACK;,否则会真实修改数据。另外,EXPLAIN ANALYZE 本身有测量开销,对极短查询的 actual time 会有一定干扰,判断时应结合多次执行结果。

五、一份可复用的阅读顺序

  1. 先看根节点,确认最终输出是什么(排序、聚合还是连接)。
  2. 沿缩进找到最深的叶子节点,确认数据来源与过滤条件。
  3. 逐层向上,核对每个节点的估算 rows 与实际 rows 是否接近。
  4. 标记四类节点中代价最高、行数偏差最大的那一个,作为重点分析对象。
  5. 最后看 Planning Time 与 Execution Time,判断瓶颈在计划生成还是计划执行。