EXPLAIN 描述 PostgreSQL 打算执行的计划,EXPLAIN ANALYZE 则真正执行它并补充测量结果。比较这两组数字之前,必须先理解每个数字究竟在数什么。表面上的估计偏差,既可能来自统计过时,也可能来自节点重复执行,或者父节点提前停止取行。行数吻合只能增强我们对模型某一部分的信任,不能证明这个计划已经是所有可能方案里最快的一个。

把节点放回执行上下文

估计的 rows 是节点输出行数,不一定是它实际检查的全部元组数。一个扫描可以检查几千条记录,过滤后只交出十条。启动成本与总成本使用规划器自己的单位,不是毫秒。父节点的成本已经包含子节点工作,因此把整棵树所有成本相加会重复计算。实际时间也通常包含子节点耗时,不能把它当成彼此独立的一组费用来求和。

对于重复执行的节点,实际行数和耗时按每次执行的平均值报告,执行次数由 loops 表示。一个实际输出两行、循环三次的节点,总共提供六行。过滤掉的行数同样按每轮平均值展示,四舍五入可能影响小数字。应当从底部扫描向上追踪,找到实际数据规模第一次明显偏离估计的位置,同时遵守这些执行边界,而不是挑出整棵树中最显眼的一对数字就下结论。

LIMIT、EXISTS 等上层操作还可能提前停止请求子节点。此时子节点的估计描述完整执行时可能产生的输出,实际值却只记录父节点真正取走的部分。这不自动意味着统计错误。排序也可能已经读完整个输入,却只向上交出所需的前几行;“输出少了”与“底层工作少了”不是一回事。

坏掉的样子

给写语句加上 EXPLAIN ANALYZE 会真的执行写入。外层事务回滚可以保护事务性的表变更,但不能撤销所有副作用,例如序列已经发出去的号码,或函数调用触发的外部行为。在执行测量前,应使用可丢弃的夹具,或者明确语句及其函数、触发器的完整副作用。不能因为输出长得像报告,就把它视为只读操作。

另一个常见误判是仅凭 BUFFERS 诊断磁盘。共享缓冲区命中表示没有发生 PostgreSQL 缓冲区未命中;报告中的读取仍可能由操作系统缓存满足,不能直接等同于设备读取次数,访问计数也不是不同数据块的数量。下面的临时表使用 local 缓冲区;计划中的 temp 块则指排序溢写等工作文件,不能因两者都叫“临时”就混为一谈。即使估计行数完全准确,仍可能存在锁等待、内存不足引发的临时文件、配置问题,或没有被选中的更合适访问路径。

快速验证

在 PostgreSQL 18 的 psql 中执行下面事务。它建立三个父记录、三十个子记录,分别观察重复的横向子查询和单独的前三名查询。数据全部放在临时表中,最后回滚清理,避免把演示对象留在业务表里。

\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE parents (id integer PRIMARY KEY);
CREATE TEMP TABLE children (parent_id integer,value integer);
INSERT INTO parents VALUES (1),(2),(3);
INSERT INTO children SELECT 1+(g-1)%3,g FROM generate_series(1,30) AS g;
ANALYZE parents;
ANALYZE children;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT p.id,c.value FROM parents p CROSS JOIN LATERAL (
  SELECT value FROM children WHERE parent_id=p.id ORDER BY value DESC LIMIT 2
) c;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT value FROM children ORDER BY value DESC LIMIT 3;
SELECT p.id,c.value FROM parents p CROSS JOIN LATERAL (
  SELECT value FROM children WHERE parent_id=p.id ORDER BY value DESC LIMIT 2
) c ORDER BY p.id,c.value DESC;
DO $$ BEGIN
  IF (SELECT array_agg(value ORDER BY parent_id,value DESC) FROM (
       SELECT p.id AS parent_id,c.value FROM parents p CROSS JOIN LATERAL (
         SELECT value FROM children WHERE parent_id=p.id ORDER BY value DESC LIMIT 2
       ) c
     ) s) IS DISTINCT FROM ARRAY[28,25,29,26,30,27] THEN RAISE EXCEPTION 'wrong lateral result'; END IF;
END $$;
ROLLBACK;

PostgreSQL 18.6 实测的横向查询采用嵌套循环。内层 Limit 显示实际行数为二、loops=3,因此整个连接输出六行。其下的扫描每轮返回十个符合条件的孩子,并过滤掉二十个,所以它在三轮里每轮都检查了三十条子记录。把这些数字放在一起读,才不会误以为整个查询只看过十行。

最终按父编号和数值倒序输出的结果是:父记录一得到二十八、二十五,父记录二得到二十九、二十六,父记录三得到三十、二十七。断言核对这些具体成员,不依赖执行计划长什么样。即使别的版本选择了不同扫描方式,返回集合与排序要求仍须保持正确,不能用计划变化掩盖结果变化。

单独查询前三名时,排序节点估计可以输出三十行,但实际上只交出三行,因为上层已经停止取数。底下扫描仍然读取三十行。实验刚刚分析过数据,这里的差异展示的是节点消费语义,而不是需要再跑一次分析的证据。如果忽视提前停止,就会把健康计划误诊成统计失真。

针对真正的问题选择测量

TIMING OFF 保留实际行数和总体执行时间,同时避免每个节点反复读取时钟的开销;BUFFERS 补充页面访问情况,结构化的 JSON 输出则便于系统比较。这些选项都不能让测量值自动等于应用请求延迟:客户端传输、页面渲染、连接建立和并发负载仍需要另外观察。比较计划时应保留相同参数及相关会话设置,把变化解释为处理行数、循环次数、内存溢写或等待的变化,而不是只凭节点名称换了就认定发生回归。

来源