数据库加索引的时机:慢查询日志说话

前年冬天,订单系统的列表页开始变慢。数据量从三百万行涨到一千二百万行,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 把结果集压小,也可以临时关掉索引扫描,对比两次的实际块数。

问:联合索引的列序能不能事后调整?

答:能,但要重建。可以建新索引再删旧索引,中间有一段时间两个索引并存,写放大翻倍。调整前先确认新列序确实能让目标语句少读块,否则不值得折腾。

问:多久复查一次索引使用情况?

答:数据分布变化快就一个月一次,稳定就一个季度一次。大版本升级、批量导入、字段类型变更之后要单独跑一次,这几种情况都会让原来的计划失效。

相关推荐
咖啡八杯1 小时前
Redis 工具封装:RedisCache 对 Operations 体系的二次封装
java·数据库·redis·缓存·框架·springboot·若依
PaperData1 小时前
2000-2025年地级市科技支行设立数据
数据库·科技
实泽有之,无泽虚之1 小时前
ORA-12514:TNS:监听程序当前无法识别连接描述符中请求的服务问题原因及解决办法
数据库·oracle
小鹿学程序1 小时前
市赛python学习
python
牧瀬クリスだ1 小时前
Redis Hash实战:高效存储对象与购物车
java·数据库·redis·算法·哈希算法
高洁011 小时前
知识图谱:结构化表达实体与关系的知识组织技术
人工智能·python·深度学习·机器学习·迁移学习
A小码哥1 小时前
【外】水星3号实测 1068 token 每秒靠谱吗
数据库
骊山道童2 小时前
第 1 周 LLM 原理与高效微调知识精讲手册
python
qq_452396232 小时前
第六篇:《SQL 优化的艺术:从慢查询到毫秒级响应》
数据库·sql