EXPLAIN 执行计划
EXPLAIN 是 PostgreSQL 中最重要也最常用的性能分析工具。它把一条 SQL 交给查询优化器,然后输出优化器选定的「执行计划(query plan)」:用哪些扫描方式读表、如何连接、用到哪些索引、每一步的估算代价与行数。学会读执行计划,是从「会写 SQL」到「能调优 SQL」的分水岭。
前置知识
阅读本节前,建议先了解:psql 元命令(\d / \dt / \du)
基础概念
| 概念 | 术语 | 说明 |
|---|---|---|
| 执行计划 | Query Plan | 优化器为 SQL 选定的执行步骤树 |
| 计划树 | Plan Tree | 由节点组成的树,子节点先执行,结果向上传递 |
| 代价 | Cost | 估算的抽象开销,格式为 启动代价..总代价 |
| 行数估算 | Rows | 优化器预估该节点输出的行数 |
| 宽度估算 | Width | 预估每行平均字节数 |
| 实际执行 | ANALYZE | 真正执行语句并输出实际耗时与行数 |
| 缓冲区统计 | BUFFERS | 输出共享块命中/读盘次数 |
| 顺序扫描 | Seq Scan | 全表顺序读取 |
| 索引扫描 | Index Scan | 通过索引定位再回表 |
| 仅索引扫描 | Index Only Scan | 所需列都在索引中,无需回表 |
| 位图扫描 | Bitmap Scan | 先用索引构建位图再批量回表 |
| 嵌套循环 | Nested Loop | 外层每行驱动内层查找 |
| 哈希连接 | Hash Join | 构建哈希表后探测匹配 |
| 归并连接 | Merge Join | 两侧排序后归并匹配 |
| 估算偏差 | Estimation Error | 估算行数与实际行数差距过大 |
读计划的核心原则:
- 自下而上:最内层(缩进最深)的节点最先执行。
- 同层从左到右:同一缩进层按自上而下、从左到右的顺序执行。
(cost=start..total rows=N width=W):start是返回第一行前的代价,total是返回全部行的代价,比较两个节点时主要看total。
-- 不看计划,先建立直观印象
EXPLAIN SELECT * FROM products WHERE sku = 'SKU-1001'; QUERY PLAN
---------------------------------------------------------------------
Index Scan using products_sku_key on products (cost=0.29..8.30 rows=1 width=68)
Index Cond: (sku = 'SKU-1001'::text)
(2 rows)语法与用法
EXPLAIN [ ( option [, ...] ) ] statement
-- 常用选项
ANALYZE [ boolean ] -- 真正执行并输出实际数据
VERBOSE [ boolean ] -- 输出更多细节(列、schema 前缀)
COSTS [ boolean ] -- 是否显示代价估算,默认 on
BUFFERS [ boolean ] -- 显示缓冲区使用,需配合 ANALYZE
TIMING [ boolean ] -- 显示每节点实际耗时,默认 on
SUMMARY [ boolean ] -- 额外输出规划与执行耗时汇总
FORMAT { TEXT | JSON | YAML | XML } -- 输出格式
WAL [ boolean ] -- 显示 WAL 记录数(PostgreSQL 13+)
SETTINGS [ boolean ] -- 显示影响计划的参数(PostgreSQL 12+)-- 最实用的组合:实际执行 + 缓冲区 + 关闭代价噪声
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT * FROM products WHERE stock > 100;ANALYZE 会真正执行语句
EXPLAIN ANALYZE 会真正执行 SQL。对 INSERT / UPDATE / DELETE 使用它时,数据会被真实修改,务必用事务包裹并回滚。
详细说明
基础用法与计划树结构
EXPLAIN
SELECT p.sku, p.name, o.order_no
FROM products p
JOIN orders o ON o.product_id = p.id
WHERE p.price > 100
ORDER BY o.order_no; QUERY PLAN
------------------------------------------------------------------------------
Sort (cost=25.32..25.34 rows=8 width=72)
Sort Key: o.order_no
-> Hash Join (cost=16.90..25.20 rows=8 width=72)
Hash Cond: (o.product_id = p.id)
-> Seq Scan on orders o (cost=0.00..7.30 rows=230 width=36)
-> Hash (cost=15.60..15.60 rows=104 width=44)
-> Seq Scan on products p (cost=0.00..15.60 rows=104 width=44)
Filter: (price > 100)
(7 rows)解读顺序(自下而上):
- 两个
Seq Scan分别读取orders与products,并对products施加Filter。 Hash节点把products的结果构建为哈希表。Hash Join用orders逐行探测哈希表。Sort对结果排序。
ANALYZE:实际执行数据
ANALYZE 会输出 actual time、rows、loops 三个实际指标。
EXPLAIN (ANALYZE, COSTS OFF)
SELECT * FROM products WHERE stock > 100; QUERY PLAN
---------------------------------------------------------
Seq Scan on products (actual time=0.015..0.021 rows=3 loops=1)
Filter: (stock > 100)
Rows Removed by Filter: 4
Planning Time: 0.120 ms
Execution Time: 0.048 ms
(5 rows)关键字段含义:
| 字段 | 含义 |
|---|---|
actual time=第一行..全部行 | 返回第一行与全部行的实际毫秒数(单位:毫秒) |
rows=N | 该节点实际输出的平均行数(= 总行数 / loops) |
loops=N | 该节点被执行的次数 |
Rows Removed by Filter | 被过滤条件排除的行数(估算偏差的重要线索) |
Planning Time | 生成计划耗时 |
Execution Time | 执行总耗时(含所有子节点) |
注意:上层节点的 actual time 已包含下层节点耗时,做性能归因时应看「本节点耗时 - 子节点耗时」。
BUFFERS:缓冲区与 I/O
BUFFERS 输出各节点的块访问情况,是判断「是否读盘」的关键。
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*) FROM orders; QUERY PLAN
---------------------------------------------------------
Aggregate (actual time=1.120..1.121 rows=1 loops=1)
Buffers: shared hit=12
-> Seq Scan on orders (actual time=0.010..0.800 rows=5000 loops=1)
Buffers: shared hit=12
(3 rows)| 指标 | 含义 |
|---|---|
shared hit | 在共享缓冲区命中,未读盘(快) |
shared read | 从磁盘读取(慢,是 I/O 瓶颈来源) |
shared dirtied | 被修改过的块数 |
shared written | 写出的块数 |
temp read/written | 临时文件读写(排序/哈希溢出到磁盘的信号) |
若 read 很大而 hit 很小,说明缓存未命中,首次执行偏慢属正常;若反复执行仍大量 read,则需考虑索引或增大 shared_buffers。
扫描节点:Seq / Index / Index Only / Bitmap
| 节点 | 适用场景 | 特点 |
|---|---|---|
Seq Scan | 返回行占比大、表很小、无可用索引 | 顺序读全表,大表上代价高 |
Index Scan | 返回行很少、需要回表取列 | 索引定位 + 随机回表 |
Index Only Scan | 所需列都在索引里 | 无需回表,但依赖可见性映射 |
Bitmap Index Scan | 返回行中等、多个索引条件组合 | 先建位图,再按页顺序回表 |
Bitmap Heap Scan | 配合位图索引扫描 | 批量按页访问,减少随机 I/O |
Tid Scan | 直接按物理行号访问 | 少见,用于 ctid 条件 |
-- Seq Scan:无索引时
EXPLAIN (ANALYZE, COSTS OFF)
SELECT * FROM products WHERE name LIKE '%线缆%';
-- Index Only Scan:所需列全在索引中
EXPLAIN (ANALYZE, COSTS OFF)
SELECT sku FROM products WHERE sku = 'SKU-1001';Index Only Scan 依赖「可见性映射(visibility map)」。若表刚更新过、VACUUM 未跟上,会退化为频繁回表,输出中会出现 Heap Fetches: N。
EXPLAIN (ANALYZE, COSTS OFF)
SELECT sku, name FROM products WHERE sku LIKE 'SKU-1%'; QUERY PLAN
---------------------------------------------------------------------------
Index Only Scan using products_sku_key on products
(actual time=0.020..0.035 rows=3 loops=1)
Index Cond: (sku >= 'SKU-1'::text AND sku < 'SKU-2'::text)
Heap Fetches: 0
(3 rows)连接节点:Nested Loop / Hash Join / Merge Join
| 节点 | 算法 | 最佳场景 |
|---|---|---|
Nested Loop | 外层每行驱动内层查找 | 外层结果很小、内层有索引 |
Hash Join | 构建哈希表后探测 | 两表都较大、等值连接 |
Merge Join | 两侧有序后归并 | 两侧已有序或需排序输出 |
-- Nested Loop:外层小、内层走索引
EXPLAIN (ANALYZE, COSTS OFF)
SELECT p.sku, o.order_no
FROM products p
JOIN orders o ON o.product_id = p.id
WHERE p.sku = 'SKU-1001';
-- Hash Join:大表等值连接
EXPLAIN (ANALYZE, COSTS OFF)
SELECT p.sku, count(*)
FROM orders o
JOIN products p ON p.id = o.product_id
GROUP BY p.sku;Hash Join 输出中要重点关注:
Hash Cond:连接条件。Hash子节点:构建侧,通常选较小的表。Batches/Memory Usage:若Batches > 1,说明哈希表溢出到磁盘,应调大work_mem。
Hash Join (actual time=1.2..3.4 rows=5000 loops=1)
Hash Cond: (o.product_id = p.id)
-> Seq Scan on orders o (...)
-> Hash (actual time=0.5..0.9 rows=120 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 24kB排序与聚合节点
需要排序的节点常见的有 Sort、GroupAggregate、Merge Join、Incremental Sort(PostgreSQL 13+)。Sort 节点会显示排序方式与内存使用。
Sort (actual time=5.1..6.2 rows=5000 loops=1)
Sort Key: created_at DESC
Sort Method: quicksort Memory: 512kBSort Method | 含义 |
|---|---|
quicksort | 全部在内存中完成 |
top-N heapsort | 配合 LIMIT 只维护前 N 个 |
external merge | 溢出到磁盘,性能差,需增大 work_mem |
看到 Disk: N kB 说明发生了外排,这是排序性能问题最直接的信号。
并行查询节点
PostgreSQL 9.6+ 支持并行查询,计划中会出现 Gather、Gather Merge、Parallel Seq Scan。
EXPLAIN (ANALYZE, COSTS OFF)
SELECT count(*) FROM orders; QUERY PLAN
----------------------------------------------------------------------
Finalize Aggregate (actual time=15.2..15.2 rows=1 loops=1)
-> Gather (actual time=15.0..15.1 rows=3 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Partial Aggregate (actual time=14.8..14.8 rows=1 loops=1)
-> Parallel Seq Scan on orders o (...)| 字段 | 含义 |
|---|---|
Workers Planned | 计划启用的并行 worker 数 |
Workers Launched | 实际启动的 worker 数(为 0 说明未拿到 worker) |
Gather | 汇总各 worker 结果 |
Gather Merge | 归并有序结果,保持排序 |
若 Workers Launched 少于 Workers Planned,通常是 max_parallel_workers 或并发负载导致的资源不足。
估算偏差:读计划的重点
执行计划分析的真正目的,是找出「估算与实际差距大的节点」。差距大意味着优化器选错了方案。
EXPLAIN (ANALYZE, COSTS OFF)
SELECT * FROM orders WHERE amount > 1000; QUERY PLAN
---------------------------------------------------------------
Seq Scan on orders (actual time=0.02..8.50 rows=2 loops=1)
Filter: (amount > 1000)
Rows Removed by Filter: 4998
Planning Time: 0.100 ms
Execution Time: 8.600 ms
(5 rows)上例中,若估算行数与实际相差数量级,或 Rows Removed by Filter 极大,就说明统计信息不准或条件不可索引。
常见的偏差原因:
- 统计信息过期 → 手动
ANALYZE。 - 条件函数包裹索引列 → 索引不可用。
- 列值相关性未被统计 → 用
CREATE STATISTICS扩展统计。 - 预估代价模型与实际 I/O 特性不符。
格式化输出与 SETTINGS
FORMAT JSON 便于程序解析;SETTINGS 会列出影响本次计划的参数。
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT * FROM products WHERE price > 100;
EXPLAIN (ANALYZE, SETTINGS, COSTS OFF)
SELECT * FROM products WHERE price > 100; QUERY PLAN
---------------------------------------------------------
Seq Scan on products (actual time=0.01..0.02 rows=5 loops=1)
Filter: (price > 100)
Settings: work_mem = '4MB', search_path = 'public'实战示例
步骤 1:建表并造数据
CREATE DATABASE demo;
\c demo
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS products;
CREATE TABLE products (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku text NOT NULL UNIQUE,
name text NOT NULL,
category text,
price numeric(10, 2),
stock integer NOT NULL DEFAULT 0
);
CREATE TABLE orders (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no text NOT NULL,
product_id integer REFERENCES products (id),
qty integer NOT NULL,
amount numeric(10, 2),
created_at timestamp NOT NULL DEFAULT now()
);
CREATE INDEX idx_orders_product_id ON orders (product_id);
CREATE INDEX idx_products_price ON products (price);
-- 造 5000 行订单,其中 amount > 1000 的仅有几行
INSERT INTO products (sku, name, category, price, stock)
SELECT
'SKU-' || lpad(g::text, 4, '0'),
'商品-' || g,
(ARRAY['键盘', '鼠标', '显示器', '线缆', '配件'])[1 + (g % 5)],
(g % 50) * 29.90,
(g % 120)
FROM generate_series(1, 120) AS g;
INSERT INTO orders (order_no, product_id, qty, amount, created_at)
SELECT
'SO-' || lpad(g::text, 5, '0'),
1 + (g % 120),
1 + (g % 4),
round((random() * 200)::numeric, 2),
now() - (g || ' minutes')::interval
FROM generate_series(1, 5000) AS g;
ANALYZE products;
ANALYZE orders;步骤 2:对比索引扫描与顺序扫描
-- 走索引:只取一行
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT * FROM products WHERE sku = 'SKU-0001'; QUERY PLAN
-----------------------------------------------------------
Index Scan using products_sku_key on products
(actual time=0.020..0.022 rows=1 loops=1)
Index Cond: (sku = 'SKU-0001'::text)
Buffers: shared hit=4
Planning Time: 0.140 ms
Execution Time: 0.045 ms
(5 rows)-- 走顺序扫描:函数包裹导致索引失效
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT * FROM products WHERE lower(sku) = 'sku-0001'; QUERY PLAN
-----------------------------------------------------
Seq Scan on products (actual time=0.015..0.300 rows=1 loops=1)
Filter: (lower(sku) = 'sku-0001'::text)
Rows Removed by Filter: 119
Buffers: shared hit=7
Planning Time: 0.150 ms
Execution Time: 0.330 ms
(6 rows)步骤 3:观察连接方式的差异
-- 小结果集 + 索引 → Nested Loop
EXPLAIN (ANALYZE, COSTS OFF)
SELECT p.sku, o.order_no
FROM products p
JOIN orders o ON o.product_id = p.id
WHERE p.sku = 'SKU-0001'; QUERY PLAN
----------------------------------------------------------------------
Nested Loop (actual time=0.060..0.500 rows=42 loops=1)
-> Index Scan using products_sku_key on products p (...)
Index Cond: (sku = 'SKU-0001'::text)
-> Index Scan using idx_orders_product_id on orders o (...)
Index Cond: (product_id = p.id)
(6 rows)-- 全量聚合连接 → Hash Join
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT p.category, count(*)
FROM orders o
JOIN products p ON p.id = o.product_id
GROUP BY p.category; QUERY PLAN
----------------------------------------------------------------------
HashAggregate (actual time=22.5..22.6 rows=5 loops=1)
Group Key: p.category
-> Hash Join (actual time=1.2..18.0 rows=5000 loops=1)
Hash Cond: (o.product_id = p.id)
-> Seq Scan on orders o (...)
-> Hash (cost=...) (actual time=0.3..0.5 rows=120 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 16kB
-> Seq Scan on products p (...)
(9 rows)步骤 4:识别估算偏差
-- 估算与实际的对比
EXPLAIN (ANALYZE, COSTS OFF)
SELECT * FROM orders WHERE amount > 1000; QUERY PLAN
---------------------------------------------------------------
Seq Scan on orders (actual time=0.020..6.500 rows=9 loops=1)
Filter: (amount > 1000)
Rows Removed by Filter: 4991
Planning Time: 0.110 ms
Execution Time: 6.800 ms
(5 rows)-- 改善:建立索引后计划变化
CREATE INDEX idx_orders_amount ON orders (amount);
ANALYZE orders;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT * FROM orders WHERE amount > 1000; QUERY PLAN
---------------------------------------------------------------------
Bitmap Heap Scan on orders (actual time=0.030..0.050 rows=9 loops=1)
Recheck Cond: (amount > 1000.00)
Heap Blocks: exact=5
-> Bitmap Index Scan on idx_orders_amount (...)
Index Cond: (amount > 1000.00)
Buffers: shared hit=8
(7 rows)步骤 5:排序溢出到磁盘
-- 观察外排:先临时调小 work_mem
SET work_mem = '64kB';
EXPLAIN (ANALYZE, COSTS OFF)
SELECT * FROM orders ORDER BY amount DESC LIMIT 10; QUERY PLAN
--------------------------------------------------------------
Limit (actual time=12.0..12.0 rows=10 loops=1)
-> Sort (actual time=12.0..12.0 rows=10 loops=1)
Sort Key: amount DESC
Sort Method: top-N heapsort Memory: 25kB
-> Seq Scan on orders o (...)
(6 rows)-- 全量排序在大数据量下会外排
SET work_mem = '64kB';
EXPLAIN (ANALYZE, COSTS OFF)
SELECT sku, amount FROM orders o JOIN products p ON p.id = o.product_id
ORDER BY o.amount, p.sku; QUERY PLAN
--------------------------------------------------------------
Sort (actual time=45.0..48.0 rows=5000 loops=1)
Sort Key: o.amount, p.sku
Sort Method: external merge Disk: 480kB
-> Hash Join (...)
(5 rows)看到 external merge 与 Disk: 480kB 即说明溢出,应提高 work_mem。
SET work_mem = '16MB';
EXPLAIN (ANALYZE, COSTS OFF)
SELECT sku, amount FROM orders o JOIN products p ON p.id = o.product_id
ORDER BY o.amount, p.sku; QUERY PLAN
--------------------------------------------------------------
Sort (actual time=18.0..19.5 rows=5000 loops=1)
Sort Key: o.amount, p.sku
Sort Method: quicksort Memory: 1024kB
-> Hash Join (...)
(5 rows)步骤 6:安全地对 DML 使用 EXPLAIN ANALYZE
-- DML 一定要包在事务里并回滚
BEGIN;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
UPDATE products SET stock = stock + 1 WHERE category = '配件';
ROLLBACK; QUERY PLAN
----------------------------------------------------------------------
Update on products (actual time=0.500..0.500 rows=24 loops=1)
Buffers: shared hit=10 read=1
-> Seq Scan on products (actual time=0.010..0.100 rows=24 loops=1)
Filter: (category = '配件'::text)
Rows Removed by Filter: 96
(5 rows)注意事项
EXPLAIN ANALYZE会真正执行语句。 对INSERT/UPDATE/DELETE必须包在BEGIN...ROLLBACK中。
-- 错误!会真实删除数据
EXPLAIN ANALYZE DELETE FROM orders WHERE amount < 10;
-- 正确
BEGIN;
EXPLAIN (ANALYZE, COSTS OFF) DELETE FROM orders WHERE amount < 10;
ROLLBACK;不要用
EXPLAIN判断 SQL 是否正确。 它只输出计划,不保证语义,语法错误仍会报错,但逻辑错误不会暴露。cost是相对值,不是毫秒。 不同查询的cost不可横向比较,只能在同一查询的不同计划间比较。上层节点的
actual time包含子节点耗时。 归因时要减去子节点,或直接看Execution Time与耗时最深的叶子节点。首次执行的
actual time不准。 缓存未预热时会偏慢,性能对比应执行多次取稳定值。rows=N是平均值,不是总和。 在Nested Loop中loops很大时,需用rows * loops估算总量。
-> Index Scan on orders (actual time=0.005..0.008 rows=1 loops=5000)上例实际访问了 5000 × 1 = 5000 行。
Buffers不开启时看不到 I/O 信息。 分析磁盘瓶颈必须加BUFFERS且配合ANALYZE。FORMAT JSON的字段名与 TEXT 不同。 用程序解析时以 JSON 字段为准(如Total Cost、Actual Rows)。并行计划不总是更快。 小表上并行带来额外调度开销,
Workers Launched: 0也可能是资源不足而非计划问题。
最佳实践
- 日常分析统一用固定组合:
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF),既看实际数据又看 I/O,且屏蔽代价噪声。
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT * FROM orders WHERE amount > 1000;对比计划时保持环境一致,连续执行多次,
SET work_mem等参数变化要显式声明。优先关注两件事:
Rows Removed by Filter过大、估算行数与实际行数差数量级,这两点指向大部分性能问题。
-- 出现大量 Rows Removed by Filter 就该考虑索引
EXPLAIN (ANALYZE, COSTS OFF) SELECT * FROM orders WHERE amount > 1000;- 看到
external merge就调work_mem,看到Batches > 1就调哈希内存,二者都是内存不足信号。
SET work_mem = '64MB';- 让索引列「裸用」,避免函数或类型转换包裹,必要时建表达式索引。
-- 不推荐
WHERE lower(sku) = 'sku-0001'
-- 推荐:建表达式索引
CREATE INDEX idx_products_sku_lower ON products (lower(sku));- 用
ANALYZE刷新统计信息后再确认计划,很多「优化器选错」其实是统计过期。
ANALYZE orders;- 用
auto_explain扩展采集线上慢查询计划,避免逐个手工复现。
-- postgresql.conf
shared_preload_libraries = 'auto_explain'
auto_explain.log_min_duration = '500ms'
auto_explain.log_analyze = on
auto_explain.log_buffers = on- 用
pg_stat_statements定位谁慢,再用EXPLAIN分析为什么慢,两者配合是最标准的调优流程。
SELECT query, calls, mean_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;- 对 DML 的计划分析一律包事务回滚,把「会改数据」这件事当成纪律而非例外。
BEGIN;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) UPDATE products SET stock = 0 WHERE id = 1;
ROLLBACK;下一节
继续学习:ANALYZE 统计更新