EXPLAIN 输出解读(成本 / 行数 / 时间)
拿到一份执行计划,如何逐行读懂它?本节把 EXPLAIN 文本输出的每一个组成部分拆开:缩进与 -> 的结构含义、cost=0.00..35.50 的代价格式、rows=1000 width=36 的行数与行宽、Filter 与 Index Cond 等条件字段的差别、Sort Method 与 Batches 的性能信号,最后用一个完整的真实计划做逐行中文注解。
本节是前两节的「字典」,也是后续「扫描类型」与「连接算法」两节的基础。
前置知识
阅读本节前,建议先了解:EXPLAIN ANALYZE 实际执行
基础概念
| 中文 | 英文 | 说明 |
|---|---|---|
| 节点类型 | Node Type | 该节点的算子名,如 Seq Scan |
| 启动代价 | startup cost | 返回第一行之前的代价 |
| 总代价 | total cost | 返回全部行的代价 |
| 估算行数 | Plan Rows | 节点假设跑完时输出的行数 |
| 行宽 | Plan Width | 每行输出的平均字节数(估算) |
| 输出列表 | Output | 节点输出的列(VERBOSE 才显示) |
| 过滤条件 | Filter | 在节点内逐行判断的条件 |
| 索引条件 | Index Cond | 用于定位索引扫描范围的条件 |
| 重检条件 | Recheck Cond | 位图扫描回表后重新校验的条件 |
| 哈希条件 | Hash Cond | 哈希连接使用的等值条件 |
| 连接过滤 | Join Filter | 连接完成后额外判断的条件 |
| 归并条件 | Merge Cond | 归并连接的等值条件 |
| 排序键 | Sort Key | Sort 节点的排序表达式 |
| 分组键 | Group Key | 聚合节点的分组表达式 |
| 排序方法 | Sort Method | quicksort / top-N heapsort / external merge |
| 批次数 | Batches | 哈希表分几批处理,1 表示不落盘 |
计划树的空间结构(缩进与箭头):
Hash Join (cost=..., rows=...) ← 缩进 0,根节点
Hash Cond: (o.customer_id = c.id) ← 节点属性行(无 ->)
-> Seq Scan on orders o (cost=..., rows=...) ← 缩进 2,子节点(带 ->)
Filter: (status = 'paid'::text) ← 该子节点的属性行
-> Hash (cost=..., rows=...) ← 缩进 2,另一个子节点
-> Seq Scan on customers c (cost=...) ← 缩进 6,孙节点规则:
- 带
->的行是节点首行(summary line),描述节点类型 + 估算/实际数据; - 不带
->的缩进行是属性行(条件、排序键、统计等),归属其上方的节点; - 缩进深度 = 树中层级;相同缩进的多个
->是兄弟节点。
语法与用法
决定输出内容多少的选项:
-- 最简:只有节点名与估算
EXPLAIN SELECT * FROM orders;
-- 加实际数据
EXPLAIN ANALYZE SELECT * FROM orders;
-- 加输出列清单、schema 限定名、触发器名、Query Identifier
EXPLAIN (VERBOSE) SELECT * FROM orders;
-- 加缓冲区与 WAL
EXPLAIN (ANALYZE, BUFFERS, WAL) SELECT * FROM orders;
-- 机器可读
EXPLAIN (FORMAT JSON) SELECT * FROM orders;VERBOSE 带来的输出差异:
EXPLAIN (VERBOSE, COSTS OFF)
SELECT o.order_no, c.name FROM orders o JOIN customers c ON c.id = o.customer_id WHERE c.city = 'Beijing'; QUERY PLAN
----------------------------------------------------------------------
Hash Join
Output: o.order_no, c.name
Hash Cond: (o.customer_id = c.id)
-> Seq Scan on public.orders o
Output: o.id, o.customer_id, o.order_no, o.amount, o.status, o.created_at
-> Hash
Output: c.name, c.id
-> Seq Scan on public.customers c
Output: c.id, c.name, c.city, c.created_at
Filter: (c.city = 'Beijing'::text)可以看到 public. schema 前缀被加上,每个节点都输出了 Output:。
详细说明
节点类型全景
PostgreSQL 的节点类型可分为四大类:扫描节点、连接节点、处理节点、汇总/分发节点。
扫描节点(scan nodes)
| 节点 | 含义 |
|---|---|
Seq Scan | 顺序扫描整表 |
Index Scan | 走索引取 TID,再回表逐行取数据 |
Index Only Scan | 所需列全部在索引中,无需回表 |
Bitmap Index Scan | 只走索引,构建 TID 位图 |
Bitmap Heap Scan | 按位图指出的物理顺序批量回表 |
Tid Scan | 按物理行号(ctid)直接访问 |
Tid Range Scan | 按 ctid 范围扫描(PostgreSQL 14+) |
CTE Scan | 扫描一个已物化的 CTE 结果 |
WorkTable Scan | 扫描递归 CTE 的工作表 |
Function Scan | 扫描 FROM 中的集合返回函数 |
Values Scan | 扫描 VALUES 列表 |
Subquery Scan | 扫描一个子查询结果 |
连接节点(join nodes)
| 节点 | 含义 |
|---|---|
Nested Loop | 嵌套循环连接 |
Hash Join | 哈希连接 |
Merge Join | 归并连接 |
处理节点(processing nodes)
| 节点 | 含义 |
|---|---|
Sort | 显式排序 |
Incremental Sort | 增量排序(PostgreSQL 13+) |
HashAggregate | 哈希聚合(GROUP BY 走哈希表) |
GroupAggregate | 排序后分组聚合 |
Group | 仅做分组(无聚合函数) |
Unique | 去重(常用于 DISTINCT) |
WindowAgg | 窗口函数计算 |
Limit | 限制行数(LIMIT / OFFSET) |
Materialize | 物化子节点结果,供多次读取 |
Memoize | 缓存内层扫描结果(PostgreSQL 14+) |
Result | 常量表达式求值 / One-Time Filter 场景 |
SetOp | INTERSECT / EXCEPT 集合运算 |
LockRows | 行锁(FOR UPDATE / FOR SHARE) |
ModifyTable | 写操作的统一入口(INSERT/UPDATE/DELETE/MERGE) |
汇总与分发节点(gather / append nodes)
| 节点 | 含义 |
|---|---|
Gather | 汇总并行 worker 的结果 |
Gather Merge | 汇总并保持有序 |
Append | 依次追加多个子计划结果 |
MergeAppend | 有序追加(要求子计划有序) |
Parallel Append | 并行追加(PostgreSQL 10+) |
Recursive Union | 递归 CTE 的迭代合并 |
cost=0.00..35.50 的格式
代价部分格式固定为:
cost=启动代价..总代价 Index Scan using idx_orders_customer_id on orders (cost=0.42..312.30 rows=98 width=44)
└───┬───┘ └───┬───┘ └───┬──┘ └─┬──┘
启动 总代价 估算行数 行宽- 启动代价(startup cost):返回第一行之前必须付出的代价。
Sort节点的启动代价很高,因为它必须读完所有输入才能输出第一行;而Seq Scan的启动代价几乎是 0。 - 总代价(total cost):假设该节点跑到完成、返回所有行的代价。
- 规划器的选择目标:绝大多数情况下最小化总代价。但在
EXISTS子查询、LIMIT等场景下,会倾向于最小化启动代价,因为执行器会提前停止。
代价单位是任意(arbitrary)的抽象刻度,默认以「顺序读一个磁盘页」为 1.0:
seq_page_cost = 1.0 # 基准
random_page_cost = 4.0 # 随机读一页 = 4 倍顺序读
cpu_tuple_cost = 0.01 # 处理一行
cpu_index_tuple_cost = 0.005
cpu_operator_cost = 0.0025 # 每个算子/函数调用举例,tenk1 有 345 个页、10000 行、无 WHERE,那么 Seq Scan 的代价为:
345 × seq_page_cost(1.0) + 10000 × cpu_tuple_cost(0.01) = 345 + 100 = 445这与官方文档示例中的 cost=0.00..445.00 完全对应。
关键提醒:代价不是毫秒,不同机器、不同版本的代价可比性有限。它的唯一用途是在同一个实例内比较不同方案的优劣。
rows=N width=W 的含义
rows=1000 width=36rows=1000:规划器估计该节点输出 1000 行。注意是输出而非扫描。一个Seq Scan可能扫描 10000 行,但通过Filter后只输出 1000 行,则rows=1000。width=36:估计每行输出的平均字节数。它影响内存分配估算(排序是否落盘、哈希表是否分批),也是判断「是否传输了过多列」的依据。
width 的一个重要用途是识别 SELECT * 的代价:
EXPLAIN (COSTS OFF) SELECT * FROM orders WHERE customer_id = 888; QUERY PLAN
--------------------------------------------------
Index Scan using idx_orders_customer_id on orders
Index Cond: (customer_id = 888)对比 VERBOSE 下的 width:
EXPLAIN SELECT * FROM orders WHERE customer_id = 888;
EXPLAIN SELECT id FROM orders WHERE customer_id = 888;-- SELECT * 的 width 更大
Index Scan using idx_orders_customer_id on orders (cost=0.42..312.30 rows=98 width=44)
-- 只查 id,width 更小,还可能走 Index Only Scan
Index Only Scan using idx_orders_customer_id on orders (cost=0.42..12.30 rows=98 width=8)条件字段:Filter / Index Cond / Recheck Cond / Hash Cond / Join Filter / Merge Cond
这六个字段经常被混淆,它们的执行位置和性能含义完全不同。
| 字段 | 出现位置 | 含义 | 性能影响 |
|---|---|---|---|
Index Cond | Index Scan / Bitmap Index Scan | 下推到索引层的条件,直接缩小索引范围 | 最好,参与索引定位 |
Recheck Cond | Bitmap Heap Scan | 位图可能含「假阳性」行,回表后重新校验 | 位图扫描的必要成本 |
Filter | 各类扫描节点 | 取出行后逐行判断,丢弃不合格行 | 越少越好,多说明条件无法下推 |
Hash Cond | Hash Join | 哈希连接的等值条件 | 由哈希表结构完成,效率高 |
Merge Cond | Merge Join | 归并连接的等值条件 | 由归并过程完成 |
Join Filter | Nested Loop / Merge Join | 连接完成后再判断的条件 | 越少越好,多说明连接后还要大量丢弃 |
来看一组对比,同一个查询在不同筛选强度下的条件归属:
-- 情况 1:条件可以走索引 → Index Cond
EXPLAIN SELECT * FROM orders WHERE customer_id = 888; Index Scan using idx_orders_customer_id on orders (cost=0.42..312.30 rows=98 width=44)
Index Cond: (customer_id = 888)-- 情况 2:索引只覆盖部分条件,另一部分只能做 Filter
EXPLAIN SELECT * FROM orders WHERE customer_id = 888 AND status = 'paid'; Index Scan using idx_orders_customer_id on orders (cost=0.42..312.55 rows=25 width=44)
Index Cond: (customer_id = 888)
Filter: (status = 'paid'::text)-- 情况 3:走位图扫描,出现 Recheck Cond
EXPLAIN SELECT * FROM orders WHERE customer_id < 100; Bitmap Heap Scan on orders (cost=25.07..612.40 rows=2900 width=44)
Recheck Cond: (customer_id < 100)
-> Bitmap Index Scan on idx_orders_customer_id (cost=0.00..24.35 rows=2900 width=0)
Index Cond: (customer_id < 100)Filter 与 Index Cond 的关键区别:Index Cond 在索引层生效,扫描的行数就已经被限制;Filter 在取出行之后生效,被丢弃的行已经付出了 I/O 代价。所以把条件变成 Index Cond 永远是优化方向。
Bitmap Heap Scan 为什么要 Recheck Cond?因为位图是有损(lossy)的:当索引条目太多,无法为每个 TID 都保留在精确位图中时,位图会退化成「按数据页标记」,导致同一页上不符合条件的行也被一并取出,所以必须回表后再校验一次。
Sort Key / Group Key / Output
Sort (cost=1834.72..1835.22 rows=100 width=44)
Sort Key: created_at DESCSort Key 是排序表达式,可以有多个键,逗号分隔:
Sort Key: o.amount DESC, o.created_at ASC NULLS LAST HashAggregate (cost=1850.32..1852.11 rows=5 width=36)
Group Key: c.cityGroup Key 是分组表达式。如果 GROUP BY 有多个列,会全部列出。
Output 只在 VERBOSE 下显示,列出节点输出的列:
Result
Output: (count(*)), (sum(o.amount))Sort Method:内存还是磁盘
Sort 节点的关键属性行:
Sort Method: quicksort Memory: 2512kBSort Method | 含义 | 性能 |
|---|---|---|
quicksort | 全部数据在内存中快速排序 | 好 |
top-N heapsort | 配合 LIMIT,只维护最大的 N 个 | 好,内存占用小 |
external merge | 内存不足,分成多段落盘后归并 | 差,出现 Disk: N kB |
external merge 的完整输出形式:
Sort Method: external merge Disk: 12576kB有时会带附加信息:
Sort Method: external sort Disk: 8192kBIncremental Sort(PostgreSQL 13+)会额外显示已排序的前缀键:
Incremental Sort (cost=19.35..2033.39 rows=10000 width=244)
Sort Key: hundred, ten
Presorted Key: hundred
Full-sort Groups: 100 Sort Method: quicksort Average Memory: 27kB Peak Memory: 27kBPresorted Key: hundred 说明输入已经按 hundred 有序,只需在每个 hundred 分组内按 ten 排序,代价远低于全量排序。
Hash 属性:Batches / Buckets / Memory Usage
哈希相关节点(Hash、HashAggregate、Hash Join 的构建侧)会输出版本信息:
Hash (cost=518.75..518.75 rows=100 width=32) (actual time=24.310..24.310 rows=2000 loops=1)
Buckets: 2048 Batches: 1 Memory Usage: 4097kB| 字段 | 含义 | 判读 |
|---|---|---|
Buckets | 哈希桶数量 | 与行数匹配即可,一般不必干预 |
Batches | 分批数 | = 1 表示全部在内存;> 1 表示分批落盘,性能显著变差 |
Memory Usage | 哈希表占用的内存 | 与 work_mem × hash_mem_multiplier 对照 |
Batches: 8 表示哈希表被分成了 8 批,写出 7 批到临时文件,然后逐批探测。这是 Hash Join 最典型的性能退化信号,修复方式是提高 work_mem。
HashAggregate 同理:
HashAggregate (cost=1832.00..1852.00 rows=200 width=36) (actual time=1850.32..1852.11 rows=5 loops=1)
Group Key: c.city
Batches: 1 Memory Usage: 105kB
Planned Partitions: 4Planned Partitions: 4 出现在估算分组数很多的情况下,表示规划器预先计划了分区聚合。
Buckets / Rows Removed by Filter / Heap Fetches
Rows Removed by Filter 出现在有 Filter 的扫描节点:
Seq Scan on orders (actual time=0.018..92.140 rows=203 loops=1)
Filter: (order_no ~~ '%8888'::text)
Rows Removed by Filter: 199797Rows Removed by Join Filter 出现在连接节点:
Nested Loop (actual time=0.062..1520.400 rows=12 loops=1)
Join Filter: ((o.amount)::numeric > ((c.id * 100))::numeric)
Rows Removed by Join Filter: 399988Heap Fetches 出现在 Index Only Scan:
Index Only Scan using idx_orders_customer_created on orders (actual time=0.031..12.480 rows=10012 loops=1)
Index Cond: ((customer_id >= 100) AND (customer_id <= 200))
Heap Fetches: 10012还有一个不常见但有用的字段 Rows Removed by Index Recheck,出现在位图扫描因有损位图而重新校验时:
Bitmap Heap Scan on orders (actual time=0.180..42.400 rows=2900 loops=1)
Recheck Cond: (customer_id < 100)
Rows Removed by Index Recheck: 812
Heap Blocks: exact=300 lossy=120lossy=120 表示有 120 个数据页的位图是「有损」的(只记录了页号,没记录具体行),因此需要重新校验,并产生了 812 行的额外丢弃。
估算偏差诊断清单
把上面这些字段组合起来,就形成了一套可执行的诊断流程:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
rows < actual rows 10 倍以上 | 统计信息不足 / 列相关性 | ANALYZE、提高 SET STATISTICS、建扩展统计 |
rows > actual rows 10 倍以上 | 统计信息过时 / 选择率估算过乐观 | ANALYZE,检查是否有稀疏值 |
Rows Removed by Filter 远大于 actual rows | 条件无法走索引 | 建索引、改写查询使条件下推 |
Rows Removed by Join Filter 很大 | 连接条件太弱 | 重构查询、增加连接条件的选择性 |
Sort Method: external merge | work_mem 不足 | 提高 work_mem 或减少排序数据量 |
Batches > 1 | 哈希表超出内存 | 提高 work_mem / hash_mem_multiplier |
Heap Fetches ≈ actual rows | 可见性映射未更新 | VACUUM 该表 |
loops 很大且单次耗时不可忽略 | 嵌套循环内层昂贵 | 换索引、改连接方式 |
shared read 很大 | 缓存未命中 | 增 shared_buffers、优化索引 |
Planning Time > Execution Time | 规划复杂度过高 | 简化查询、调 join_collapse_limit |
实战示例
场景一:完整的真实计划逐行注解
下面是一个电商后台「按城市统计已支付订单金额」的查询,我们对其计划逐行注解。
EXPLAIN (ANALYZE, BUFFERS)
SELECT c.city, count(*) AS order_cnt, sum(o.amount) AS total_amount
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'paid'
AND o.created_at >= now() - interval '30 days'
GROUP BY c.city
ORDER BY total_amount DESC
LIMIT 10; QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=2456.20..2456.22 rows=10 width=44) (actual time=612.480..612.485 rows=5 loops=1)
Buffers: shared hit=1268 read=12
-> Sort (cost=2456.20..2456.32 rows=50 width=44) (actual time=612.480..612.483 rows=5 loops=1)
Sort Key: (sum(o.amount)) DESC
Sort Method: quicksort Memory: 25kB
Buffers: shared hit=1268 read=12
-> HashAggregate (cost=2454.20..2455.20 rows=50 width=44) (actual time=612.400..612.460 rows=5 loops=1)
Group Key: c.city
Batches: 1 Memory Usage: 24kB
Buffers: shared hit=1268 read=12
-> Hash Join (cost=546.00..2354.20 rows=20000 width=40) (actual time=32.150..548.300 rows=24810 loops=1)
Hash Cond: (o.customer_id = c.id)
Buffers: shared hit=1268 read=12
-> Bitmap Heap Scan on orders o (cost=22.40..1420.00 rows=20000 width=28) (actual time=14.200..342.800 rows=24810 loops=1)
Recheck Cond: (created_at >= (now() - '30 days'::interval))
Filter: (status = 'paid'::text)
Rows Removed by Filter: 12450
Heap Blocks: exact=1150
Buffers: shared hit=1240 read=12
-> Bitmap Index Scan on idx_orders_created_at (cost=0.00..17.40 rows=37215 width=0) (actual time=12.400..12.401 rows=37260 loops=1)
Index Cond: (created_at >= (now() - '30 days'::interval))
Buffers: shared hit=28
-> Hash (cost=518.75..518.75 rows=2000 width=36) (actual time=17.900..17.901 rows=2000 loops=1)
Buckets: 2048 Batches: 1 Memory Usage: 145kB
Buffers: shared hit=16
-> Seq Scan on customers c (cost=0.00..518.75 rows=2000 width=36) (actual time=0.012..9.400 rows=2000 loops=1)
Buffers: shared hit=16
Planning Time: 0.395 ms
Execution Time: 613.020 ms
(28 rows)逐行注解:
Limit (cost=2456.20..2456.22 rows=10 width=44) (actual time=612.480..612.485 rows=5 loops=1)
↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑
节点名 启动 总代价 估算 行宽 实际启动 实际总 每循环 循环 真实输出
代价 行数 耗时 耗时 行数 次数 5 行(只有 5 个城市)cost的启动与总代价几乎相等(2456.20 vs 2456.22),这是Limit的典型特征:它只需从子节点取前 10 行就可以停。rows=10是LIMIT 10的要求;actual rows=5说明实际只有 5 个城市,比预期少。actual time的启动值与总值接近,因为只取了很少的行。
-> Sort (cost=2456.20..2456.32 rows=50 width=44) (actual time=612.480..612.483 rows=5 loops=1)
Sort Key: (sum(o.amount)) DESC
Sort Method: quicksort Memory: 25kBSort Key: (sum(o.amount)) DESC排序的是聚合后的结果,不是原始行,所以只有 5 行,Memory: 25kB很小。Sort Method: quicksort说明没落盘。rows=50估算有 50 个城市分组,实际 5 个——估算偏高 10 倍,但因为数据量小,无实际影响。
-> HashAggregate (cost=2454.20..2455.20 rows=50 width=44) (actual time=612.400..612.460 rows=5 loops=1)
Group Key: c.city
Batches: 1 Memory Usage: 24kBGroup Key: c.city:按城市分组。Batches: 1:分组数少,完全在内存中完成,未落盘。Memory Usage: 24kB与work_mem(默认 4MB)相比极小,无需调整。
-> Hash Join (cost=546.00..2354.20 rows=20000 width=40) (actual time=32.150..548.300 rows=24810 loops=1)
Hash Cond: (o.customer_id = c.id)Hash Cond是连接条件,用的是等值连接,所以能走哈希。- 估算
rows=20000,实际rows=24810,偏差仅 1.24 倍,估算准确。 actual time的启动 32.15ms 包含构建哈希表的时间(Hash节点耗时 17.9ms)。
-> Bitmap Heap Scan on orders o (cost=22.40..1420.00 rows=20000 width=28) (actual time=14.200..342.800 rows=24810 loops=1)
Recheck Cond: (created_at >= (now() - '30 days'::interval))
Filter: (status = 'paid'::text)
Rows Removed by Filter: 12450
Heap Blocks: exact=1150Recheck Cond说明这是位图扫描,回表后需要重新校验时间条件。Filter: (status = 'paid')说明status条件无法走索引(索引建在created_at上),只能在取出行之后过滤。Rows Removed by Filter: 12450表示 37260 行中有 12450 行因status != 'paid'被丢弃,过滤效率约 66%,还算可接受。Heap Blocks: exact=1150表示访问了 1150 个数据页,且位图是精确的(非 lossy)。
-> Bitmap Index Scan on idx_orders_created_at (cost=0.00..17.40 rows=37215 width=0) (actual time=12.400..12.401 rows=37260 loops=1)
Index Cond: (created_at >= (now() - '30 days'::interval))- 只走索引构建 TID 位图,
width=0是这一节点的固定特征(不输出实际列)。 - 估算
rows=37215,实际rows=37260,估算极准。
-> Hash (cost=518.75..518.75 rows=2000 width=36) (actual time=17.900..17.901 rows=2000 loops=1)
Buckets: 2048 Batches: 1 Memory Usage: 145kB
-> Seq Scan on customers c (cost=0.00..518.75 rows=2000 width=36) (actual time=0.012..9.400 rows=2000 loops=1)Hash的启动代价 = 总代价(518.75..518.75),因为它不做过滤、只把输入全部读进哈希表。Batches: 1表示 2000 行完全装进内存,Memory Usage: 145kB很小。- 小表
customers只有 2000 行,走Seq Scan合理。
Planning Time: 0.395 ms
Execution Time: 613.020 ms- 规划几乎不耗时,执行 613ms 是本次查询的主要成本。
结论:这个查询的执行时间主要花在 Bitmap Heap Scan(342.8ms 中去除子节点约 330ms)。优化方向是建一个覆盖 (created_at, status) 或 (status, created_at) 的复合索引,把 status 条件也变成 Index Cond,消除 Rows Removed by Filter: 12450。
-- 优化:让时间和状态都能走索引
CREATE INDEX CONCURRENTLY idx_orders_status_created
ON orders (status, created_at);
ANALYZE orders;场景二:识别估算偏差并修正
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT * FROM orders WHERE order_no = 'ORD-000123456'; QUERY PLAN
--------------------------------------------------------------------------------
Seq Scan on orders (actual time=0.018..88.240 rows=1 loops=1)
Filter: (order_no = 'ORD-000123456'::text)
Rows Removed by Filter: 199999
Buffers: shared hit=8 read=1216
Planning Time: 0.150 ms
Execution Time: 88.420 ms计划里 看不到 rows= 估算值(因为 COSTS OFF 关掉了)。要看估算偏差,必须保留 COSTS:
EXPLAIN (ANALYZE) SELECT * FROM orders WHERE order_no = 'ORD-000123456'; QUERY PLAN
--------------------------------------------------------------------------------
Seq Scan on orders (cost=0.00..2032.00 rows=1 width=44) (actual time=0.018..88.240 rows=1 loops=1)
Filter: (order_no = 'ORD-000123456'::text)
Rows Removed by Filter: 199999
Planning Time: 0.150 ms
Execution Time: 88.420 ms估算 rows=1,实际 rows=1,估算非常准。那为什么还是 88ms?因为 没有 order_no 索引,规划器只能用 Seq Scan,扫了 20 万行。
这个案例说明:估算准 ≠ 计划好。没有索引时,规划器再怎么正确地估算也无计可施。修复方式是:
CREATE UNIQUE INDEX CONCURRENTLY idx_orders_order_no ON orders (order_no);
ANALYZE orders;重建后:
Index Scan using idx_orders_order_no on orders (cost=0.42..8.44 rows=1 width=44) (actual time=0.031..0.032 rows=1 loops=1)
Index Cond: (order_no = 'ORD-000123456'::text)
Planning Time: 0.180 ms
Execution Time: 0.055 ms耗时从 88ms 降到 0.055ms,且 Filter 变成了 Index Cond。
注意事项
不要只看节点名,要读属性行。 两个都是
Seq Scan的计划,一个带Filter一个不带,性能含义完全不同。sql-- 前者输出 20 万行(无 Filter),后者输出 3 行(带 Filter,丢 199997 行) -- 只看节点名会误判为「一样」Bitmap Index Scan的width=0是正常的,不是错误。 它只输出 TID,不输出业务列。textBitmap Index Scan on idx_orders_created_at (cost=0.00..17.40 rows=37215 width=0)Recheck Cond不是多余的重复工作,而是位图扫描的正确性要求。 有损位图必须重检。Batches > 1一定要当回事。 它意味着磁盘临时文件,性能可能下降数倍。textBuckets: 2048 Batches: 8 Memory Usage: 4096kB -- 不推荐,应提高 work_memexternal merge是最差的排序方式。 宁可加大work_mem,也不要让排序落盘。textSort Method: external merge Disk: 12576kB -- 不推荐不要忽略
Heap Fetches。 它大于 0 且接近actual rows时,Index Only Scan名不副实。textIndex Only Scan ... Heap Fetches: 10012 -- 需要 VACUUMFilter与Index Cond的差别是优化的核心杠杆。 看到Filter就要思考「能否变成Index Cond」。sql-- 改前:status 只能做 Filter -- WHERE created_at >= X AND status = 'paid' -- 改后:建 (status, created_at) 复合索引,两个条件都进 Index CondSort Key显示的是表达式,注意括号。 聚合结果排序会显示为(sum(o.amount)) DESC,不要误读为列名。width变大意味着数据传输与内存开销上升。 避免SELECT *。text-- 不推荐:width=244 Seq Scan on orders (cost=0.00..445.00 rows=10000 width=244)Gather/Gather Merge下面的子树才是真正的并行部分。 它们之上的节点在 leader 进程中串行执行。textFinalize Aggregate ← leader 进程 -> Gather ← 并行边界 -> Partial Aggregate ← worker 进程
最佳实践
建立「先看结构、再看条件、再看数值」的三步阅读法。 结构决定执行顺序,条件决定过滤效率,数值决定瓶颈位置。
text第 1 步:看缩进与 -> 理清节点树 第 2 步:看 Filter / Index Cond / Join Filter 判断条件是否下推 第 3 步:看 rows vs actual rows、actual time、Buffers 定位瓶颈遇到
Seq Scan不要条件反射地认为要加索引。 大范围查询、小表、宽表返回大量列时,Seq Scan反而是最优选择。sql-- 合理:取回全表 40% 的行,Seq Scan 比索引回表更快 EXPLAIN SELECT * FROM orders WHERE status <> 'void';把
Filter改造成Index Cond作为首要优化目标。 建复合索引让多个条件一起走索引。sqlCREATE INDEX CONCURRENTLY idx_orders_status_created ON orders (status, created_at);Sort Method与Batches出现磁盘信号时,先评估是否真的需要全量排序或大哈希表。 加LIMIT、缩小列宽、提前过滤都能减少内存需求。sql-- 先过滤再排序,减少排序输入量 SELECT id, order_no FROM orders WHERE status = 'paid' ORDER BY created_at DESC LIMIT 20;Heap Fetches高时先VACUUM再下结论。 可见性映射的更新是VACUUM的职责,不能靠改索引解决。sqlVACUUM (ANALYZE, VERBOSE) orders;用
EXPLAIN (VERBOSE, COSTS OFF)查看Output列表,检查是否有多余列。 这能直观看出SELECT *的成本。sqlEXPLAIN (VERBOSE, COSTS OFF) SELECT * FROM orders WHERE customer_id = 1;把
Format JSON的输出保存为基线,用于计划回归测试。 发版后对比Plan Rows与Total Cost变化。sqlEXPLAIN (FORMAT JSON, ANALYZE, BUFFERS) SELECT ...;读懂
cost的相对大小而非绝对大小。 同一个查询的两个候选计划的 cost 才具备可比性。text计划 A 的 total cost=1200,计划 B 的 total cost=800 → 选 B(同一实例内比较)Limit节点的 cost 有两个数值且很接近时,代表它提前停止。 不要据此认为子节点的总代价就是查询代价。textLimit (cost=0.29..14.28 rows=2 width=244)Bitmap Heap Scan的Heap Blocks: exact=N lossy=M中,lossy > 0需要关注。 有损位图意味着额外的重检成本,可通过提高work_mem缓解。textHeap Blocks: exact=300 lossy=120 -- 存在有损页,重检成本上升Subquery Scan通常是可消除的中间层。 若它只是简单转发,考虑把子查询展开(PG 通常会自动展开,未展开说明有LIMIT、OFFSET或其他阻断因素)。sql-- 若出现 Subquery Scan,检查子查询是否含 LIMIT / OFFSET / volatile 函数节点属性行的顺序是固定的,可以把习惯固化下来。 一般顺序为:
Output→ 条件(Index Cond/Filter等)→Rows Removed by ...→ 算法属性(Sort Method/Batches)→Buffers。
下一节