接口突然慢了。

昨天还在几十毫秒,今天 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 一般没什么,对 UPDATEDELETEINSERT 就别顺手敲了。需要看写语句的实际计划时,至少先把事务和回滚想清楚。

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、选错扫描方式都不奇怪。数据库不是“突然变笨”,它只是拿着一张不准的地图在做决定。

还有 Buffersshared 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

这不一定是优化器犯错。

PostgreSQL 中几种常见扫描方式的示意

图: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 页结构示意

图: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_idcreated_at 是真正参与查找、排序的 key,statustotal_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 这种字段,问题往往不是“有没有索引”

statusenableddeletedgender 这类字段经常被单独建索引,也经常让人失望。

原因不神秘:值太少,很多值对应了表里很大一片数据。

真正要问的是:这次查询到底会拿走表里的多少行。

假设订单分布是这样:

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 到性能诊断的流程示意

图:围绕 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。传统输出里常看的有 typekeyrowsExtraUsing index 通常表示覆盖索引可以直接提供需要的列,Using index condition 则涉及 Index Condition Pushdown。

但不要把两家的术语硬套。PostgreSQL 的 Bitmap Heap Scan、visibility map、MVCC 下的 Index Only Scan 行为都有自己的实现细节。方法论可以共用,执行计划要按各自数据库的文档读。

参考资料