主题
查询优化与执行计划:把慢查询拆成可验证的假设
一句话:执行计划是优化器对「读多少页、做多少次比较、用多少内存」的估算;优化的动作,是让估算和实际对得上。
适用 PostgreSQL 16/17。示例执行计划为示意输出,成本和时间随硬件、数据分布变化。
为什么需要
慢查询的原因通常不在 SQL 写法本身,而在三件事:扫了太多行、连接顺序不对、排序/哈希落盘。只看「执行了 3 秒」无法判断是哪一种;EXPLAIN (ANALYZE, BUFFERS) 把每一层做了多少工作摊开,才能定位。
核心机制
一条 SQL 的路径
Parser 解析语法 → Rewriter 展开视图和规则 → Planner 用统计信息估算成本、选计划 → Executor 执行。优化器不是「找最优」,而是在有限时间内找一个足够便宜的计划。
成本单位不是毫秒,而是以页读取为基准的相对值:seq_page_cost=1.0、random_page_cost=4.0(PostgreSQL 16/17 默认)、cpu_tuple_cost=0.01。所以 cost=0.43..12.45 只能用于比较计划,不能换算成时间。真正的耗时看 actual time。
统计信息决定估算
ANALYZE 把列的值分布写进 pg_statistic,优化器据此估算 rows。估算偏差的常见来源:
- 表刚大批量导入,没跑
ANALYZE。 - 多列之间有相关性,单列统计看不到。例如
city和postcode强相关,用CREATE STATISTICS ... (dependencies)补充。 - 表达式/函数条件没有对应统计。
- 参数化查询使用通用计划(generic plan),看不到具体参数的选择性。
扫描与连接
Seq Scan:顺序读全表。小表或返回大部分行时是合理选择,不一定是「坏计划」。Index Scan:走索引定位,再回表取其他列。Index Only Scan:只读索引;但仍要检查可见性映射,可能产生Heap Fetches。Bitmap Index Scan+Bitmap Heap Scan:先把多个索引的候选行做成位图,再按物理页顺序回表,把随机 IO 变成更有序的 IO。它不是 PostgreSQL 独有;Oracle 的 bitmap index 是另一种实现,这里说的是 PostgreSQL 的计划节点。Nested Loop:小表驱动大表,适合内层有索引的点查。Hash Join:等值连接,先把小表建哈希表;内存不够会分批。- 并行计划用
Gather汇总 worker;小查询开并行反而增加启动开销。
排序与内存
work_mem=4MB(PostgreSQL 16/17 默认)是每个排序/哈希操作的上限,不是每个查询。一个查询有多个 Sort/Hash,就各占一份。内存不够时计划里会出现:
Sort Method: external merge Disk: 32768kB这表示排序落盘了。调大 work_mem 可以缓解,但要按并发数估算总内存。
最小上手
准备一张 100 万行的订单表(与 01 篇相同的建表语句可复用):
sql
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL,
amount numeric(12,2) NOT NULL
);
INSERT INTO orders (user_id, status, created_at, amount)
SELECT (random() * 100000)::bigint,
(ARRAY['paid','pending','cancelled'])[1 + (random() * 2)::int],
now() - (random() * 365)::int * interval '1 day',
(random() * 1000)::numeric(12,2)
FROM generate_series(1, 1000000);
ANALYZE orders;没有复合索引时:
sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, amount
FROM orders
WHERE user_id = 42 AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;示意输出(数值随环境变化):
Limit (cost=41235.67..41235.72 rows=20 width=24) (actual time=182.431..182.436 rows=20 loops=1)
Buffers: shared hit=1250 read=9200
-> Sort (cost=41235.67..41238.17 rows=1000 width=24) (actual time=182.428..182.430 rows=20 loops=1)
Sort Key: created_at DESC
Sort Method: top-N heapsort Memory: 27kB
-> Seq Scan on orders (cost=0.00..41210.00 rows=1000 width=24) (actual time=0.015..180.221 rows=1024 loops=1)
Filter: ((user_id = 42) AND (status = 'paid'::text))
Rows Removed by Filter: 998976
Buffers: shared hit=1250 read=9200
Planning Time: 0.180 ms
Execution Time: 182.500 ms建复合索引后:
sql
CREATE INDEX CONCURRENTLY idx_orders_user_status_created
ON orders (user_id, status, created_at DESC);
ANALYZE orders;再次执行同一条 EXPLAIN,示意输出:
Limit (cost=0.43..9.21 rows=20 width=24) (actual time=0.024..0.061 rows=20 loops=1)
Buffers: shared hit=12 read=3
-> Index Scan using idx_orders_user_status_created on orders
(cost=0.43..438.21 rows=1000 width=24) (actual time=0.022..0.056 rows=20 loops=1)
Index Cond: ((user_id = 42) AND (status = 'paid'::text))
Buffers: shared hit=12 read=3
Planning Time: 0.095 ms
Execution Time: 0.080 ms逐行读:
Limit的actual time=0.024..0.061:第一行在 0.024ms 返回,读完 20 行共 0.061ms。cost=0.43..9.21:启动成本 0.43,总成本 9.21,单位是相对成本,不是毫秒。rows=1000是优化器估算,actual ... rows=20是实际;两者差距大时先怀疑统计信息。loops=1:该节点执行一次。嵌套循环内层节点的actual time要乘以loops才是总耗时。Buffers: shared hit=12 read=3:命中缓存 12 页、磁盘读 3 页。read高说明缓存不足或数据冷。Rows Removed by Filter: 998976:扫描后过滤掉的行数。这个数字接近表行数时,说明索引没有把条件变成Index Cond。Planning Time/Execution Time:计划时间和执行时间;优化看执行,计划时间异常高可能是分区/表太多。
生产动作
- 先找 SQL,再看计划:
pg_stat_statements按total_exec_time排序;需要shared_preload_libraries='pg_stat_statements'并重启。 - 自动记录慢计划:
auto_explain配auto_explain.log_min_duration=1000、log_analyze=on、log_buffers=on;log_min_duration_statement=1000(默认-1,即关闭)记录慢 SQL。 EXPLAIN ANALYZE会真的执行:对UPDATE/DELETE要先BEGIN再ROLLBACK,避免改数据。- 估算偏差处理:
rows与actual rows差 10 倍以上,先ANALYZE;仍偏就建扩展统计,或检查条件里的隐式类型转换。 - 排序落盘:看到
external merge,先确认work_mem(默认 4MB)是否够;调大后观察temp文件和内存,不要全局拉满。 - 并行查询:
max_parallel_workers_per_gather=2(PostgreSQL 16/17 默认);小查询可以SET max_parallel_workers_per_gather=0对比,确认并行是否真的更快。 - JIT:
jit=on(PostgreSQL 16/17 默认),jit_above_cost=100000;短查询开 JIT 可能因编译开销变慢,可用SET jit=off做对比。 - 排障路径:现象「查询突然变慢」→
EXPLAIN (ANALYZE, BUFFERS)→ 看Seq Scan行数、Rows Removed by Filter、Sort Method、Buffers read→ 分别对应统计信息/索引、条件选择性、work_mem、缓存或 IO。
边界
- 成本不是时间:
cost只用于比较计划;换硬件、换random_page_cost后,同一计划的成本数字会变。 - 计划会随数据变:今天的好计划,数据分布变了可能变坏;不要把一个计划当成永久结论。
EXPLAIN ANALYZE有副作用:它执行语句;对写语句务必包在回滚事务里。- 通用计划与定制计划:参数化查询可能选通用计划,具体参数下不是最优;可用
plan_cache_mode=force_custom_plan对比,但会增加每次解析成本。 - Bitmap Scan 不是万能:返回行很多时,位图本身也很大,仍可能退化为顺序扫描;它也不是 PostgreSQL 独有。
- 并行不是越多越好:并行有启动和汇总成本,小表、短查询、高并发下可能更慢。
- 索引不是唯一解:统计信息、SQL 改写、分区裁剪、物化视图,都可能比加索引更合适。
讲清楚
执行计划回答的是「数据库打算怎么读」。EXPLAIN (ANALYZE, BUFFERS) 把估算和实际放在一起:rows 对不上,先修统计信息;Rows Removed by Filter 很大,说明索引没把条件吃进去;Sort Method: external merge 说明 work_mem 不够;Buffers read 高说明缓存或 IO 有问题。成本单位是相对页成本,不是毫秒,所以只看 cost 会误判。优化的顺序应该是:先用 pg_stat_statements 找到真正耗时的 SQL,再用计划验证假设,最后才动索引或参数。Bitmap Scan 只是 PostgreSQL 的一种计划节点,不要把「多个索引位图合并」当成它独有的能力。