接口突然慢了。
昨天还在几十毫秒,今天 P95 已经蹿到一秒多。日志里最显眼的是一条查询:
SELECT id, user_id, status, created_at, total_amount
FROM orders
WHERE status = 1
ORDER BY created_at DESC
LIMIT 100;
status 上明明有索引。
这种时候很容易做两件事:再补一个索引,或者开始怀疑 PostgreSQL “没走索引”。更稳的做法是先看执行计划。
因为索引不是一个“加上就生效”的开关。它更像是给数据库多修了一条路。规划器看完路况、距离、要搬多少东西之后,可能觉得走大路反而更便宜。
先看计划,别猜
PostgreSQL 里最常用的命令之一是:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, user_id, status, created_at, total_amount
FROM orders
WHERE status = 1
ORDER BY created_at DESC
LIMIT 100;
先提醒一句:ANALYZE 不是“多分析一点”的意思,它会真的执行这条 SQL。对 SELECT 一般没什么,对 UPDATE、DELETE、INSERT 就别顺手敲了。需要看写语句的实际计划时,至少先把事务和回滚想清楚。
PostgreSQL 18 在 EXPLAIN ANALYZE 中已经会自动带上 buffer 信息,我还是建议把 BUFFERS 明写出来。一是老版本也能看,二是过几个月回头看命令,不用猜当时到底想观察什么。
拿到执行计划后,先别急着盯那个最大的 cost。更值得先看的通常是这些:
Seq Scan / Index Scan / Bitmap Heap Scan
rows=...
actual rows=...
loops=...
Buffers: shared hit=... read=...
cost 不是毫秒。它是规划器内部拿来比较不同方案的成本单位。
rows 更值得留意。计划里的估算行数如果是 200,实际却跑出了 20 万行,那后面选错 Join、选错扫描方式都不奇怪。数据库不是“突然变笨”,它只是拿着一张不准的地图在做决定。
还有 Buffers。shared hit 很高,说明很多块直接从 PostgreSQL 的共享缓冲区拿到了;read 很高,说明需要从共享缓冲区之外读取。只盯执行时间,经常会把“缓存刚好热了”误判成“SQL 已经优化好了”。
执行计划是树。看文本格式时,通常从缩进最深的节点往上读:下面的节点先产出数据,再交给上面的节点继续处理。
全表扫描不一定是坏事
先看一个很常见的表:
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
status SMALLINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
total_amount NUMERIC(12, 2) NOT NULL
);
CREATE INDEX idx_orders_status ON orders(status);
假设 status 一共就几个值:待处理、处理中、已完成、已关闭。
现在查“已完成”的订单。如果一张表里 80% 甚至 90% 都已经完成,status = 已完成 的索引还能不能用?当然能。
但“能用”和“值得用”不是一回事。
普通 B-tree 索引扫描大致要做两件事:先在索引里找到匹配项,再根据索引里保存的位置去表的 heap 里取真正的行。匹配十几行,这很划算。匹配几十万、几百万行时,大量回表访问可能比直接把表顺着扫一遍还贵。
于是你可能看到:
Seq Scan on orders
Filter: (status = 3)
而不是:
Index Scan using idx_orders_status on orders
这不一定是优化器犯错。

图:Seq Scan、Index Scan 与 Bitmap Index Scan 的关系示意。来源:墨天轮《PostgreSQL技术内幕(七)索引扫描》。图片托管在国内 OSS。
Seq Scan 做的事情很朴素:从头到尾读表,符合条件的留下。
Index Scan 是从索引找到 TID,再去 heap 取行。数据很少时很舒服,数据散得很开、又要拿很多行时,随机访问会开始变贵。
中间还有一个很有意思的方案:Bitmap Index Scan + Bitmap Heap Scan。它不是找到一条就立刻回表,而是先把可能命中的位置收集起来,按 heap page 组织好,再批量去读表。可以把它理解成 PostgreSQL 在“逐条回表”和“整张表扫一遍”之间找了个折中。
所以看到 Bitmap Heap Scan 别先皱眉。很多范围查询、命中行不算少的查询,它本来就是合理选择。
索引里找到位置之后,事情还没结束
很多“索引为什么不快”的误解,来自把索引想成了 Excel 的另一份排序表。
PostgreSQL 的 B-tree 叶子页里保存 key 和指向 heap tuple 的 TID。索引和表的数据区是分开的。普通 Index Scan 命中索引后,还得去 heap 找真正的行。
图:PostgreSQL B-tree 从 meta page、root page、branch page 到 leaf page 的结构。来源:阿里云开发者社区《使用 pageinspect 深入解析 PostgreSQL B-Tree 索引结构》。图片位于阿里云 CDN。
这也是为什么 SELECT * 有时候特别碍事。
比如页面只需要最近 20 条订单的时间、状态和金额:
SELECT created_at, status, total_amount
FROM orders
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 20;
可以考虑:
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at DESC)
INCLUDE (status, total_amount);
user_id 和 created_at 是真正参与查找、排序的 key,status、total_amount 只是顺手放进索引里的 payload。
条件合适时,PostgreSQL 可以走 Index Only Scan,直接从索引拿查询需要的数据,不必为每一行都去 heap。
不过名字里的 “Only” 也别理解得太绝对。PostgreSQL 还要确认这行对当前 MVCC 快照是否可见。可见性信息主要在 heap,只有对应 heap page 在 visibility map 里已经标记为 all-visible 时,才真正能省掉那次 heap 访问。所以一张更新特别频繁的热表,即便执行计划写着 Index Only Scan,也可能看到不少 Heap Fetches。
这时候继续往 INCLUDE 里塞列,未必还能换来什么。
status 这种字段,问题往往不是“有没有索引”
status、enabled、deleted、gender 这类字段经常被单独建索引,也经常让人失望。
原因不神秘:值太少,很多值对应了表里很大一片数据。
真正要问的是:这次查询到底会拿走表里的多少行。
假设订单分布是这样:
| status | 含义 | 占比 |
|---|---|---|
| 0 | 已完成 | 86% |
| 1 | 待处理 | 3% |
| 2 | 处理中 | 8% |
| 3 | 已关闭 | 3% |
查 status = 0 时,顺序扫描很可能完全合理。
但如果后台工作台每天最常查的是“待处理订单”,而待处理只有 3%,事情就不一样了。甚至可以不索引所有状态,只索引真正关心的那一小撮:
CREATE INDEX idx_orders_pending_created
ON orders (created_at DESC)
WHERE status = 1;
然后:
SELECT id, user_id, created_at, total_amount
FROM orders
WHERE status = 1
ORDER BY created_at DESC
LIMIT 100;
这种 partial index 往往比一个覆盖全部订单的 status 索引更小,也更贴合真实查询。
当然前提是查询条件能让规划器判断出它确实满足这个 partial index 的谓词。索引不是装饰品,必须和真正发出去的 SQL 对得上。
联合索引别按“字段出现次数”排
再看一条更像业务代码里的 SQL:
SELECT id, status, created_at, total_amount
FROM orders
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 20;
更自然的索引是:
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at DESC);
因为它和查询本身的动作是对齐的:先把某个用户的订单范围缩出来,再直接按需要的时间顺序往下扫,拿够 20 条就停。
如果你只有:
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_created_at ON orders(created_at);
PostgreSQL 有能力组合多个索引,有时候会生成 Bitmap 计划。但“能组合”不代表等价于一个合适的联合索引。尤其查询还带着 ORDER BY ... LIMIT 时,顺序本身就是成本的一部分。
这里还有一个容易把人带沟里的老口号:最左前缀。
它作为入门记忆没问题,但不要背成“第一列没有条件,后面的列永远用不了”。PostgreSQL 18 的多列 B-tree 已经能在一些情况下做 skip scan。更准确的理解是:前导列上的约束通常决定这个索引到底能省掉多少扫描工作;没有理想的前导条件时,后面的列并不是绝对不可用,只是成本可能完全不同。
先看执行计划,比背一句规则靠谱。
SQL 写法和索引表达式也要对得上
下面这条查询很常见:
SELECT id, email
FROM users
WHERE lower(email) = 'someone@example.com';
如果只有:
CREATE INDEX idx_users_email ON users(email);
别想当然地认为 lower(email) 一定能直接利用这个普通索引。
PostgreSQL 支持 expression index:
CREATE INDEX idx_users_lower_email
ON users (lower(email));
查询和索引表达式对应起来,规划器才有合适的访问路径。
同样的坑还有各种不必要的 cast、函数包装和 ORM 自动生成的表达式。线上看到“明明有索引”,第一件事应该把实际发到数据库的 SQL 和参数类型拿出来,而不是只看实体类上有没有 @Index。
真正麻烦的是:规划器估错了
排慢 SQL 时最值得警惕的一种情况不是 Seq Scan,而是这种:
rows=120
actual rows=185432
差几十倍、几百倍。
扫描方式和 Join 算法都是建立在“会有多少行”这个估算上的。第一步就估歪了,后面再聪明也很难选对。
PostgreSQL 依赖统计信息判断数据分布。大批量导入、短时间内大量更新,或者数据分布发生明显变化之后,先看看统计信息是不是还靠谱。
最简单的动作:
ANALYZE orders;
再看看规划器掌握了什么:
SELECT
attname,
n_distinct,
most_common_vals,
most_common_freqs
FROM pg_stats
WHERE schemaname = 'public'
AND tablename = 'orders';
还有一种更隐蔽的情况:单列统计都没错,但列和列之间强相关。
比如:
WHERE tenant_id = 42
AND campus_id = 7
如果某个 campus_id 几乎只会出现在固定 tenant 下面,把两个条件当作彼此独立来估算,结果就可能很离谱。
这种场景 PostgreSQL 可以建扩展统计:
CREATE STATISTICS orders_tenant_campus_stats
(dependencies, mcv)
ON tenant_id, campus_id
FROM orders;
ANALYZE orders;
PostgreSQL 不会自动把所有列组合的扩展统计全建出来——组合数量太夸张了。哪些列真的相关,还是得根据业务和执行计划判断。

图:围绕 EXPLAIN、扫描节点、成本与运行时信息做 SQL 诊断的一种流程。来源:腾讯云开发者社区《SQL执行计划及优化策略》。图片托管于腾讯云。
一条 SQL 20ms,接口照样可能 800ms
还有个很现实的问题:有时候数据库根本没你想象中那么慢。
在 psql 里跑:
Execution Time: 18.7 ms
接口监控里却是 700ms。
这时继续研究索引,方向已经偏了。
接下来该看的是:连接池是不是在排队;事务是不是等锁;一次请求是不是偷偷发了几十条 SQL;ORM 有没有 N+1;结果集是不是太大;应用层反序列化和对象装配花了多少时间;数据库和应用之间的网络是不是有问题。
EXPLAIN ANALYZE 测的是服务器执行计划,不会把真实的客户端网络传输算进去。PostgreSQL 17 起提供的 SERIALIZE 选项可以把结果转成文本或二进制的序列化成本也测进去,PostgreSQL 18 仍然支持它;但它依然不会把结果真的发给客户端,所以网络时间还是要从应用和链路侧看。
这也是为什么“数据库执行只要 10ms”不能直接证明“这个接口的 SQL 没问题”,反过来也一样。
一套不太容易走偏的排查顺序
先拿到真的那条 SQL 和真的参数。不要看仓库里的模板,不要猜 ORM 最后生成了什么。
然后跑执行计划。对安全的读查询,可以直接看 EXPLAIN (ANALYZE, BUFFERS);线上特别重的查询要更谨慎,先 EXPLAIN,或者在可控副本上重放。
接着看估算行数和实际行数差多少。如果差得离谱,先查统计信息,而不是先造索引。
估算差不多,再看扫描方式:它为什么选 Seq Scan?命中比例是不是本来就很高?如果是 Index Scan,heap 访问是不是反而成了主要成本?如果是 Bitmap Heap Scan,返回的数据量是不是已经到了“逐条回表不划算”的区间?
然后才看索引形状:过滤条件、排序、LIMIT、返回列能不能形成一条顺畅的访问路径;有没有必要做联合索引、表达式索引、partial index 或 INCLUDE。
最后才去查数据库之外的东西:锁等待、连接池、N+1、网络、应用层处理。
这个顺序不一定适合所有故障,但至少能避免一种特别常见的维护方式:
SQL 慢了,加索引;还慢,再加一个;半年以后表上十几个索引,写入越来越慢,谁也不敢删。
索引当然重要。PostgreSQL 官方文档也直接提醒:索引可以显著加速数据检索,但它也会给整个数据库系统带来额外开销。
所以更合适的理解是:索引是一条有成本的访问路径。
慢 SQL 真正值得解决的,不是“怎么让它走索引”,而是:
为什么规划器认为当前这条路最便宜,它依据的数据对不对,而我们能不能给它一条真正更便宜的路。
MySQL 怎么看
这篇主要拿 PostgreSQL 举例,但思路并不只适用于 PostgreSQL。
MySQL 8.4 同样应该先看 EXPLAIN / EXPLAIN ANALYZE。传统输出里常看的有 type、key、rows、Extra;Using index 通常表示覆盖索引可以直接提供需要的列,Using index condition 则涉及 Index Condition Pushdown。
但不要把两家的术语硬套。PostgreSQL 的 Bitmap Heap Scan、visibility map、MVCC 下的 Index Only Scan 行为都有自己的实现细节。方法论可以共用,执行计划要按各自数据库的文档读。
参考资料
- PostgreSQL 18:Using EXPLAIN
- PostgreSQL 18:EXPLAIN
- PostgreSQL 18:Indexes
- PostgreSQL 18:Multicolumn Indexes
- PostgreSQL 18:Index-Only Scans and Covering Indexes
- PostgreSQL 18:Indexes on Expressions
- PostgreSQL 18:Statistics Used by the Planner
- PostgreSQL 18:ANALYZE
- PostgreSQL 17:Release 17(SERIALIZE)
- MySQL 8.4:Optimization and Indexes
- MySQL 8.4:EXPLAIN Output Format
评论