PostgreSQL 查询计划入门:读懂 EXPLAIN 的艺术
PostgreSQL 对每一个收到的 SQL 查询都会生成一个查询计划。选择合适的计划,是决定查询性能的关键因素之一。PostgreSQL 内置了一个复杂的查询优化器(planner),负责在众多可行计划中挑选"代价最低"的一个。
要查看优化器为某个查询生成的计划,可以使用 EXPLAIN 命令。读懂查询计划是一项需要经验的技能,本文将带你了解 EXPLAIN 的基础用法和常见计划节点的含义。
本文示例基于 PostgreSQL 9.3 开发版的回归测试数据库,且已事先执行过 VACUUM ANALYZE。你在自己环境中运行时,代价和行数估计可能会略有不同,这是正常现象。
一、EXPLAIN 基础
查询计划本质上是一棵"计划节点树"。
- 最底层通常是扫描节点:负责从表中读取原始数据,如顺序扫描、索引扫描、位图索引扫描等。
- 上层节点则负责对数据进行进一步处理:连接(join)、排序(sort)、聚集(aggregate)、过滤(filter)等。
EXPLAIN 会为每个节点输出一行,显示节点类型和优化器对该节点执行代价的估计。最上面一行是整个计划的总代价估计,优化器的目标就是最小化这个值。
1. 最简单的例子
sql
EXPLAIN SELECT * FROM tenk1;
典型输出:
ini
Seq Scan on tenk1 (cost=0.00..458.00 rows=10000 width=244)
这表示优化器选择了**顺序扫描(Seq Scan)**整个表。括号中的四个数字含义是:
- 启动代价(start-up cost) :在开始输出第一行之前需要花费的代价,比如排序节点需要先完成排序。
- 总代价(total cost) :假设该节点执行到完成(读取所有可用行)的总代价。
- 输出行数(rows) :预计该节点会输出的行数。
- 行宽度(width) :预计每行的平均宽度(字节)。
代价单位是 PostgreSQL 内部的"虚构单位",默认以"磁盘页面读取"为基准:seq_page_cost = 1.0,其他代价参数都相对它来设定。
2. 代价是如何计算的?
以 tenk1 为例,假设:
ini
SELECT relpages, reltuples FROM pg_class WHERE relname = 'tenk1';
得到:
relpages = 358(表占用 358 个磁盘页面)reltuples = 10000(表中有 10000 行)
顺序扫描的代价计算公式大致为:
总代价 ≈ 页面读取代价 + 处理每行的 CPU 代价
= relpages * seq_page_cost + reltuples * cpu_tuple_cost
使用默认参数(seq_page_cost = 1.0, cpu_tuple_cost = 0.01):
358 * 1.0 + 10000 * 0.01 = 358 + 100 = 458
这与 EXPLAIN 输出中的 cost=0.00..458.00 相吻合。
二、WHERE 条件与过滤
当查询中加入 WHERE 条件时,计划会发生变化。
1. 简单过滤:仍然顺序扫描
sql
EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 7000;
输出:
css
Seq Scan on tenk1 (cost=0.00..483.00 rows=7001 width=244)
Filter: (unique1 < 7000)
Filter: (unique1 < 7000)表示:顺序扫描每一行,然后用这个条件过滤。- 预计输出行数从 10000 降到 7001。
- 总代价略有上升(从 458 到 483),因为需要额外的 CPU 时间来检查过滤条件。
2. 更严格的条件:使用索引
当条件更严格时,优化器可能会选择索引扫描:
sql
EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 100;
输出:
arduino
Bitmap Heap Scan on tenk1 (cost=5.07..229.20 rows=101 width=244)
Recheck Cond: (unique1 < 100)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..5.04 rows=101 width=0)
Index Cond: (unique1 < 100)
这里使用了位图索引扫描 + 位图堆扫描的两步计划:
- 子节点
Bitmap Index Scan:在索引tenk1_unique1上找到满足unique1 < 100的行的位置,生成一个位图。 - 父节点
Bitmap Heap Scan:根据位图去表中抓取对应的行,并再次检查条件(Recheck Cond)。
虽然随机访问表行比顺序扫描更昂贵,但因为只访问少量页面,总体代价仍然低于全表扫描。
3. 多条件:索引条件 vs 过滤条件
ini
EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 100 AND stringu1 = 'xxx';
输出:
ini
Bitmap Heap Scan on tenk1 (cost=5.04..229.43 rows=1 width=244)
Recheck Cond: (unique1 < 100)
Filter: (stringu1 = 'xxx'::name)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..5.04 rows=101 width=0)
Index Cond: (unique1 < 100)
unique1 < 100是索引条件(Index Cond) ,可以通过索引快速过滤。stringu1 = 'xxx'不是索引的一部分,只能作为过滤条件(Filter) ,在从表中取出行后再检查。- 预计输出行数进一步减少到 1,但代价变化不大,因为仍然需要访问相同的一组行,只是多了一次过滤。
三、索引扫描 vs 位图扫描
PostgreSQL 有多种索引访问方式,常见的有:
1. 索引扫描(Index Scan)
ini
EXPLAIN SELECT * FROM tenk1 WHERE unique1 = 42;
输出:
arduino
Index Scan using tenk1_unique1 on tenk1 (cost=0.29..8.30 rows=1 width=244)
Index Cond: (unique1 = 42)
- 直接通过唯一索引定位到单行,适合返回少量行的查询。
- 如果查询有
ORDER BY unique1,且索引顺序与排序顺序一致,优化器可能会选择索引扫描来避免额外排序。
2. 位图扫描(Bitmap Scan)
位图扫描适合需要返回中等数量行的场景:
- 先通过索引构建位图(满足条件的行号集合)。
- 再按物理位置排序后批量访问表数据,减少随机 I/O。
一般来说:
- 返回行数很少 → 倾向
Index Scan - 返回行数中等 → 倾向
Bitmap Heap Scan + Bitmap Index Scan - 返回行数很多 → 倾向
Seq Scan(全表扫描)
四、排序与 LIMIT
1. 显式排序
vbnet
EXPLAIN SELECT * FROM tenk1 ORDER BY unique1;
输出:
ini
Sort (cost=1109.39..1134.39 rows=10000 width=244)
Sort Key: unique1
-> Seq Scan on tenk1 (cost=0.00..445.00 rows=10000 width=244)
- 优化器选择先全表扫描,再进行排序。
Sort节点的代价主要来自内存排序的 CPU 开销。
2. 增量排序(Incremental Sort)
如果排序键有前缀已经有序,PostgreSQL 可能使用增量排序:
vbnet
EXPLAIN SELECT * FROM tenk1 ORDER BY four, ten LIMIT 100;
输出:
ini
Limit (cost=521.06..538.05 rows=100 width=244)
-> Incremental Sort (cost=521.06..2220.95 rows=10000 width=244)
Sort Key: four, ten
Presorted Key: four
-> Index Scan using index_tenk1_on_four on tenk1 (cost=0.29..1510.08 rows=10000 width=244)
Presorted Key: four表示four列已经通过索引有序。- 增量排序只需对
ten列进行"分段排序",适合有LIMIT的场景,可以提前返回部分结果。
3. LIMIT 的影响
sql
EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 100 AND unique2 > 9000 LIMIT 2;
输出:
ini
Limit (cost=0.29..14.48 rows=2 width=244)
-> Index Scan using tenk1_unique2 on tenk1 (cost=0.29..71.27 rows=10 width=244)
Index Cond: (unique2 > 9000)
Filter: (unique1 < 100)
- 没有
LIMIT时,优化器可能选择位图扫描(Bitmap Scan)。 - 有了
LIMIT 2,优化器改为使用Index Scan,因为一旦找到 2 行就可以停止,避免位图扫描的启动代价。
注意:
- 索引扫描节点的
cost和rows仍然是按"执行到完成"来估计的。 - 实际执行时,
Limit节点会提前终止,因此真实执行时间会远低于估计的总代价。
五、连接(Join)计划
连接是复杂查询中最关键的部分之一,PostgreSQL 支持多种连接算法:嵌套循环(Nested Loop)、哈希连接(Hash Join)、归并连接(Merge Join)。
1. 嵌套循环连接(Nested Loop)
ini
EXPLAIN SELECT *
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 10 AND t1.unique2 = t2.unique2;
输出:
ini
Nested Loop (cost=4.65..118.62 rows=10 width=488)
-> Bitmap Heap Scan on tenk1 t1 (cost=4.36..39.47 rows=10 width=244)
Recheck Cond: (unique1 < 10)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..4.36 rows=10 width=0)
Index Cond: (unique1 < 10)
-> Index Scan using tenk2_unique2 on tenk2 t2 (cost=0.29..7.91 rows=1 width=244)
Index Cond: (unique2 = t1.unique2)
结构说明:
- 外层(outer):
tenk1 t1的位图扫描,返回约 10 行。 - 内层(inner):对
tenk2 t2的索引扫描,每次使用外层行的t1.unique2作为条件。 - 嵌套循环的总代价 ≈ 外层代价 + 外层行数 × 内层单次代价 + 连接处理的 CPU 代价。
适合场景:
- 外层结果集很小。
- 内层可以通过索引快速查找匹配行。
2. 哈希连接(Hash Join)
当内层表较大时,哈希连接往往更高效:
ini
EXPLAIN SELECT *
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2;
输出:
ini
Hash Join (cost=230.47..713.98 rows=101 width=488)
Hash Cond: (t2.unique2 = t1.unique2)
-> Seq Scan on tenk2 t2 (cost=0.00..445.00 rows=10000 width=244)
-> Hash (cost=229.20..229.20 rows=101 width=244)
-> Bitmap Heap Scan on tenk1 t1 (cost=5.07..229.20 rows=101 width=244)
Recheck Cond: (unique1 < 100)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..5.04 rows=101 width=0)
Index Cond: (unique1 < 100)
执行步骤:
- 构建阶段:扫描
tenk1 t1,构建以unique2为键的哈希表。 - 探查阶段:扫描
tenk2 t2,对每一行在哈希表中查找匹配的unique2。
适合场景:
- 内层表可以放入内存。
- 连接条件是等值连接(
=)。
3. 归并连接(Merge Join)
归并连接要求两个输入都按连接键排序:
ini
EXPLAIN SELECT *
FROM tenk1 t1, onek t2
WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2;
输出:
ini
Merge Join (cost=198.11..268.19 rows=10 width=488)
Merge Cond: (t1.unique2 = t2.unique2)
-> Index Scan using tenk1_unique2 on tenk1 t1 (cost=0.29..656.28 rows=101 width=244)
Filter: (unique1 < 100)
-> Sort (cost=197.83..200.33 rows=1000 width=244)
Sort Key: t2.unique2
-> Seq Scan on onek t2 (cost=0.00..148.00 rows=1000 width=244)
tenk1 t1通过索引扫描自然按unique2排序。onek t2先顺序扫描,再排序。- 两个已排序的结果按连接键"合并",类似归并排序的合并阶段。
适合场景:
- 连接键上有索引,或结果已经有序。
- 连接条件是等值连接。
六、EXPLAIN ANALYZE:查看真实执行情况
EXPLAIN ANALYZE 会实际执行查询,并在计划中加入真实执行时间和行数:
ini
EXPLAIN ANALYZE SELECT *
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 10 AND t1.unique2 = t2.unique2;
典型输出片段:
sql
Nested Loop (cost=4.65..118.62 rows=10 width=488) (actual time=0.128..0.377 rows=10 loops=1)
-> Bitmap Heap Scan on tenk1 t1 (cost=4.36..39.47 rows=10 width=244) (actual time=0.057..0.121 rows=10 loops=1)
Recheck Cond: (unique1 < 10)
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..4.36 rows=10 width=0) (actual time=0.024..0.024 rows=10 loops=1)
Index Cond: (unique1 < 10)
-> Index Scan using tenk2_unique2 on tenk2 t2 (cost=0.29..7.91 rows=1 width=244) (actual time=0.021..0.022 rows=1 loops=10)
Index Cond: (unique2 = t1.unique2)
Planning time: 0.181 ms
Execution time: 0.501 ms
新增字段说明:
actual time:实际执行时间(毫秒),分为启动时间和总时间。rows:实际输出行数。loops:该节点被执行的次数。
对于被多次执行的节点(如嵌套循环的内层):
actual time和rows是每次执行的平均值。- 总时间 ≈
actual time×loops。
1. 额外统计信息
某些节点会输出额外信息,例如排序和哈希:
vbnet
EXPLAIN ANALYZE SELECT *
FROM tenk1 t1, tenk2 t2
WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2 ORDER BY t1.fivethous;
排序节点输出:
sql
Sort (cost=717.34..717.59 rows=101 width=488) (actual time=7.761..7.774 rows=100 loops=1)
Sort Key: t1.fivethous
Sort Method: quicksort Memory: 77kB
哈希节点输出:
sql
Hash (cost=229.20..229.20 rows=101 width=244) (actual time=0.659..0.659 rows=100 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 28kB
这些信息有助于判断:
- 排序是否在内存中完成(是否溢出到磁盘)。
- 哈希表是否过大,是否需要分批次(Batches > 1)。
2. 过滤与索引重检查
sql
EXPLAIN ANALYZE SELECT * FROM tenk1 WHERE ten < 7;
输出:
sql
Seq Scan on tenk1 (cost=0.00..483.00 rows=7000 width=244) (actual time=0.016..5.107 rows=7000 loops=1)
Filter: (ten < 7)
Rows Removed by Filter: 3000
Rows Removed by Filter:被过滤条件丢弃的行数。
对于 GiST 等"有损"索引:
sql
EXPLAIN ANALYZE SELECT * FROM polygon_tbl WHERE f1 @> polygon '(0.5,2.0)';
可能输出:
sql
Index Scan using gpolygonind on polygon_tbl (cost=0.13..8.15 rows=1 width=32) (actual time=0.062..0.062 rows=0 loops=1)
Index Cond: (f1 @> '((0.5,2))'::polygon)
Rows Removed by Index Recheck: 1
- 索引先返回候选行。
- 再通过"索引重检查"(Index Recheck)进行精确判断,不满足的会被丢弃。
3. BUFFERS 选项
EXPLAIN (ANALYZE, BUFFERS) 可以查看 I/O 相关统计:
sql
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM tenk1 WHERE unique1 < 100 AND unique2 > 9000;
输出中会包含:
ini
Buffers: shared hit=15
shared hit:从共享缓冲区(内存)中命中的页面数。shared read:从磁盘读取的页面数(未命中缓存)。
这有助于判断查询的性能瓶颈是否在磁盘 I/O。
七、数据修改语句的 EXPLAIN ANALYZE
EXPLAIN ANALYZE 也适用于 INSERT / UPDATE / DELETE / MERGE:
ini
BEGIN;
EXPLAIN ANALYZE UPDATE tenk1 SET hundred = hundred + 1 WHERE unique1 < 100;
ROLLBACK;
输出结构:
sql
Update on tenk1 (cost=5.08..230.08 rows=0 width=0) (actual time=3.791..3.792 rows=0 loops=1)
-> Bitmap Heap Scan on tenk1 (cost=5.08..230.08 rows=102 width=10) (actual time=0.069..0.513 rows=100 loops=1)
Recheck Cond: (unique1 < 100)
Heap Blocks: exact=90
-> Bitmap Index Scan on tenk1_unique1 (cost=0.00..5.05 rows=102 width=0) (actual time=0.036..0.037 rows=300 loops=1)
Index Cond: (unique1 < 100)
- 顶层是
Update节点,负责实际写入数据。 - 子节点负责查找需要更新的行。
rows=0表示更新节点本身不输出任何行(只修改数据)。
注意:
EXPLAIN ANALYZE会真正执行数据修改,因此通常需要在事务中运行并回滚。- 触发器(尤其是
AFTER触发器)的执行时间可能不会完全体现在计划节点时间中。
八、使用注意事项与常见误区
-
EXPLAIN ANALYZE 会真正执行查询
- 对于
SELECT,结果被丢弃,但仍会产生副作用(如调用函数的副作用)。 - 对于
INSERT / UPDATE / DELETE,会修改数据,需要谨慎使用(通常配合事务 + ROLLBACK)。
- 对于
-
计时开销可能影响结果
EXPLAIN ANALYZE会增加额外的计时开销。- 在某些系统上,
gettimeofday()很慢,会导致执行时间看起来比实际更长。 - 可以用
pg_test_timing工具评估系统计时开销。
-
估计值 vs 实际值
- 优化器的代价和行数估计基于统计信息,是近似值。
- 当计划被
LIMIT或连接算法提前终止时,实际行数和时间会远小于估计值,这不是"错误",而是显示方式的问题。
-
小表上的计划不具有代表性
- 小表通常会选择顺序扫描,即使有索引。
- 因为读取一个页面的索引 + 一个页面的表,可能比直接顺序扫描一个页面更贵。
-
分区表与 Subplans Removed
- 扫描分区表时,某些分区可能在运行时被判定为不可能包含数据。
- 这些子计划会被省略,并显示
Subplans Removed: N。
九、总结
读懂 EXPLAIN 是优化 PostgreSQL 查询性能的基础能力。本文介绍了:
EXPLAIN的基本输出格式和代价含义。- 常见扫描方式:顺序扫描、索引扫描、位图扫描。
- 排序、过滤、LIMIT 对计划的影响。
- 三种主要连接算法:嵌套循环、哈希连接、归并连接。
EXPLAIN ANALYZE的使用方法和真实执行信息的解读。- 使用
EXPLAIN时的注意事项和常见误区。