前年冬天,订单系统的列表页开始变慢。数据量从三百万行涨到一千二百万行,P95 从两百毫秒爬到两秒三。运营在群里说页面一直转圈,我下意识想去加索引,被老陈按住了。他说先别动,把慢查询日志开两天再说。
两天攒下十一万条记录,按语句指纹去重之后只剩七类。真正拖时间的是一条按状态加时间范围翻页的查询,占了总耗时的六成;剩下六类合起来不到一成,加索引也救不回来。那次我们只动了一个联合索引,P95 回到三百毫秒,磁盘多了不到两百兆。
后来我把顺序固定下来:先看日志,再看执行计划,再决定加不加。中间跳一步,通常就是给自己挖坑。
一、凭感觉加索引的三种错法
1.1 见慢就给单列加索引
订单表有个状态字段,取值只有六种。有人给它建了单列索引,查询反而更慢。待付款那一档占了三成行,走索引要回表三成数据,优化器算完成本直接放弃,索引白占几百兆。
低基数字段的单列索引,在选择性差的场景里收益接近零。它有价值的位置是联合索引中的一个列,不该单独站着。
1.2 一个查询配一个索引
上线半年后,我们表上有十九个索引,其中十一个是"某个查询上线时顺手加的"。这些索引彼此重叠,前缀一样的有三组。写入时要维护十九棵 B 树,一次插入的开销里四成花在索引上。
判断重叠的办法是看列的前缀。(a, b) 能覆盖 (a) 的全部用途,两个都留着没有意义。我们清掉十一个之后,批量导入从五十分钟降到二十八分钟,查询计划一条没变。
1.3 把索引当缓存用
有同事故意给报表查询建宽索引,想让大范围扫描走索引。结果计划里出现 index only scan,但读的块数比全表扫还多。索引不减少数据量,它只换访问路径,把它当缓存用会失望。
判断标准很朴素:如果查询要返回表里一成以上的行,顺序扫通常更快。索引适合的是"选出少量行",不是"筛掉少量行"。
这三种错法有个共同点:都想用一个索引解决一个现象,而没有先问这个现象值多少钱。加上去之后没人敢删,一年下来表上堆一堆,写入越来越慢,回头又得花两周做清理。
二、日志要看的三样东西
2.1 语句指纹与总耗时占比
PostgreSQL 的 pg_stat_statements 会把常量参数化,同一条 SQL 的不同参数算作一行。这正好用来找"哪类语句在吃时间"。看占比,不看单次耗时:一条五十毫秒的语句被调用十万次,比一条两秒的语句调用三次更值得处理。
先把占比排前二十的语句拉出来,再逐条判断是索引问题还是写法问题。我们那次二十条里有十一条是 N+1 循环,只有三条是缺索引。
2.2 调用次数与单次耗时分开看
总耗时等于次数乘均值。次数高的语句多半是循环里查库,加索引只能把均值降一点,把 N+1 改成批量才是十倍收益。均值高的语句才是索引的候选。
我习惯导一张两列的清单:横轴调用次数,纵轴单次耗时。右上角先处理,左上角去改代码,右下角的记录进观察名单,左下角不看。
2.3 日志留多久、采样怎么设
log_min_duration_statement 设成两百毫秒,日志按天切分,保留十四天。低峰全采,高峰按十分之一采样,否则日志写入本身会拖慢库。
十四天够覆盖一次月结和一次大促。留太短会漏掉周期性语句,留太长磁盘吃不住,还要额外做归档清理。
2.4 日志抓不到的两类慢
一类是偶发,比如每月初的结账查询,两周的窗口可能一次都碰不上。另一类是锁等待,语句本身很快,等锁等了八百毫秒,日志记的是执行时间,不含等待。这两类得靠 log_lock_waits 和业务侧的定时任务清单补。
我的做法是把月结、日结、批量导出这些任务单独列一份,按业务日历在预发环境压一遍,看它们的计划。日志负责找意料之外的,清单负责覆盖意料之中的。
三、从日志到执行计划
3.1 估算行数与实际行数
EXPLAIN ANALYZE 出来,先看估算和实际差多少。差三个数量级,说明统计信息过期,得先 ANALYZE 再谈索引。我们遇到过一次,估算两百行实际四十万行,原因是批量导入之后没跑统计,加了索引也没用。
估算偏差还会带偏连接顺序。三张表关联时,中间结果被估小了,优化器会选嵌套循环,实际跑成几十万次循环,耗时全在这里。
3.2 联合索引的列序
等值条件在前,范围条件在后,排序列跟在末尾。这条经验在多数场景成立,但不是定律。如果范围条件的选择性特别高,把它放前面也可能更好,得用真实数据试。
我们试列序的办法是建两个索引,各跑一遍 EXPLAIN ANALYZE,看 buffer 读多少块。测完把效果差的那个删掉,不要两个都留着等以后再说。
3.3 覆盖索引省下的回表
把查询要的列都放进索引,能省掉回表这一次随机读。代价是索引变宽,写入更慢。这条只适合高频、小结果集的查询,比如按单号取状态,三列以内。
下面这段是当时用来定位问题索引的 SQL,在 PG 上跑,三个结果分别对应"哪条语句费时间""哪个索引没人用""哪些索引白占空间"。
sql
-- 1. 按总耗时找出前二十条语句指纹
SELECT queryid,
calls,
round(total_exec_time::numeric, 1) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms,
round((100 * total_exec_time
/ sum(total_exec_time) OVER ())::numeric, 2) AS pct,
rows,
left(query, 80) AS sample
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
AND query NOT ILIKE '%pg_stat_statements%'
ORDER BY total_exec_time DESC
LIMIT 20;
-- 2. 某张表上各索引的扫描次数,次数低的是清理候选
SELECT relname AS table_name,
indexrelname AS index_name,
idx_scan,
idx_tup_read,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'order_main'
ORDER BY idx_scan ASC;
-- 3. 从未被扫描、体积又超过 8MB 的非唯一索引
SELECT s.relname,
s.indexrelname,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0
AND NOT i.indisunique
AND NOT i.indisprimary
AND pg_relation_size(s.indexrelid) > 8 * 1024 * 1024
ORDER BY pg_relation_size(s.indexrelid) DESC;
第三段查询要留意时间窗口。pg_stat_user_indexes 的计数从实例启动开始累计,重启一次就归零。刚重启的库直接跑,所有索引都是零次,按这个结果去删会把热索引删掉。
3.4 计划里的三个信号
一是 rows 的估算偏差,二是 loops 的循环次数,三是 Buffers: shared read 的块数。前者看统计信息,中者看连接方式,后者看真实 IO 量。
这三个数比总耗时更能说明问题。总耗时受机器负载影响,同一语句两次跑能差一倍,块数基本稳定。
四、上线与回收
4.1 并发建索引
PG 用 CREATE INDEX CONCURRENTLY,不阻塞写入,但耗时是普通建索引的两三倍,失败还会留下 invalid 索引。建完要查 pg_index.indisvalid,为假就删掉重来。
千万级表上建索引,我放在业务低峰,同时盯复制延迟。主库建完,从库回放要时间,这段时间只读查询的计划可能抖。
还有一点容易被忽略:并发建索引会扫两遍表。头一遍建,后一遍等旧事务结束。如果库上有长事务,后一遍可能等几十分钟,期间这个索引一直是 invalid 状态,占着空间不能用。建之前先查一遍超过五分钟的活跃事务。
4.2 无效索引的清理
每周跑一次未使用索引的清单,连续四周 idx_scan 为零、且不是唯一索引也不是主键的,进候选。删之前先在预发环境删一遍,跑一周确认没有计划回退。
删索引的风险比加索引高,因为回退要重建。所以每次删之前把建索引语句存进变更单,写清楚哪天删的、为什么删。
4.3 写放大的账
每个索引都是一次额外写。批量导入一百万行,十九个索引意味着多写一千九百万次索引项。导入任务从二十分钟涨到一小时,就是这笔账在收利息。
我的经验是把索引数量控制在"每个高频查询一条可用索引"的量级,剩下的交给改写 SQL。真要堆索引,先算清楚写入侧能不能承受。
4.4 回滚预案
索引上线不需要灰度,但需要留退路。建索引前记下当前计划,建完对比同一语句的块数,块数上涨超过两成就要考虑撤。撤的动作就是 DROP INDEX CONCURRENTLY,两分钟能完成。
比索引更该留退路的是 SQL 改写。改写往往一次动三五个查询,出问题不好定位。我们的规矩是一次只改一条,改完观察一天,再动下一条。
常见问题
问:慢查询日志里查不到,但接口还是慢,怎么定位?
答:先分层。应用层打点看耗时分布,时间花在序列化或网络上的,加索引没有意义。数据库侧看 pg_stat_activity 的等待事件,锁等待、IO 等待、客户端等待,处理方式完全不同。
问:一条 SQL 走了索引反而更慢,是什么原因?
答:多半是回表比例太高。走索引读三千行再回表三千次,不如顺序扫一遍。可以让它走覆盖索引,或者用 LIMIT 把结果集压小,也可以临时关掉索引扫描,对比两次的实际块数。
问:联合索引的列序能不能事后调整?
答:能,但要重建。可以建新索引再删旧索引,中间有一段时间两个索引并存,写放大翻倍。调整前先确认新列序确实能让目标语句少读块,否则不值得折腾。
问:多久复查一次索引使用情况?
答:数据分布变化快就一个月一次,稳定就一个季度一次。大版本升级、批量导入、字段类型变更之后要单独跑一次,这几种情况都会让原来的计划失效。