慢 SQL是生产数据库最隐蔽的性能杀手。一个看似正常的查询,在流量高峰可能把数据库 CPU 打到 100%,连接池瞬间打满,上游接口全线超时。它不像内存泄漏那样缓慢累积,而是「某个索引缺失 + 一次全表扫描」就足以引爆。本文复盘一次真实的线上排查,从监控告警、慢查询日志到 EXPLAIN 定位,再到加索引与 CI 门禁根治,把完整链路讲透。
一、事故背景:一次平淡无奇的发布之后
某天上午发布了一个订单列表的查询优化,本以为只是去掉几行冗余 JOIN,结果午高峰一过,监控开始连环告警:数据库实例 CPU 从平稳的 30% 一路飙升到 100%,活跃连接数突破上限,应用侧大量请求阻塞在获取数据库连接上,P99 从 80ms 涨到 8s。回滚代码后 CPU 立刻回落——问题就出在这条「优化」出来的 SQL 上。
二、告警先响:用监控锁定是数据库而不是应用
排障第一步是判断瓶颈在哪一层。先登数据库主机看系统负载,再用数据库自带视图看当前在跑什么:
# 1) 主机层:CPU 是否被打满
top -H -p $(pgrep -d, mysqld)
# 2) 数据库层:当前活跃连接与正在执行的语句
mysql> SHOW PROCESSLIST;
# 或 8.0+ 更友好的视图
mysql> SELECT id, time, state, info
FROM information_schema.processlist
WHERE command <> 'Sleep' ORDER BY time DESC;
PROCESSLIST 里出现成百上千条 SELECT ... FROM orders 卡在 Sending data 状态、且 time 持续增长,基本可以断定:某条 SQL 在全表扫描,且不只一条,是并发把它放大了。这和Java 内存泄漏排查里「先看监控曲线、再定位单一根因」的思路完全一致——先缩小范围,别急着改代码。
三、锁定元凶:慢查询日志 + EXPLAIN
确认方向后,打开慢查询日志把「凶手」抓出来。生产环境务必长期开启慢日志(阈值设 1s 足够暴露问题):
# my.cnf 长期开启慢日志
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = 1 # 把全表扫描也记下来
# 用 pt-query-digest 聚合,直接看最耗时的前几条
pt-query-digest /var/log/mysql/slow.log | head -40
拿到嫌疑 SQL 后,用 EXPLAIN 看执行计划。真正要盯的是 type 和 rows 两列:
mysql> EXPLAIN SELECT * FROM orders
WHERE user_id = 1024 AND status = 'PAID'
ORDER BY created_at DESC LIMIT 20;
# 关键列解读
# type=ALL → 全表扫描(最坏,索引完全没用上)
# rows=2000000 → 预估扫描 200 万行,实际只取 20 行
# key=NULL → 实际没用任何索引
# Extra=Using filesort → 排序也无法走索引,内存/磁盘额外开销
type=ALL 且 key=NULL,说明查询条件里的列上没有合适的索引,MySQL 只能逐行扫 200 万行再排序。这就是为什么一条语句就能把 CPU 吃满——它本该靠索引在几毫秒内定位 20 行。MySQL 索引底层原理讲清了 B+ 树如何把随机 IO 变成极少次磁盘访问,缺索引等于退化为顺序遍历。
四、根因:一个缺失的复合索引
原来发布时为了「简化」,把原来的 WHERE user_id = ? 改成了 WHERE user_id = ? AND status = ?,却忘了新建覆盖这两个条件的复合索引。单列 user_id 索引在加了 status 过滤后选择性变差,优化器干脆放弃了索引走全表。按最左前缀原则,正确的复合索引应当把等值条件放前面、范围/排序放后面:
# 修复:等值列在前,排序列放最后,覆盖查询避免回表
CREATE INDEX idx_orders_uid_status_ct
ON orders (user_id, status, created_at);
# 重建后再次 EXPLAIN
# type=ref → 命中索引,范围扫描
# key=idx_orders… → 用上了新索引
# rows=20 → 预估只扫 20 行
# Extra=Using index → 覆盖索引,连回表都省了
加完索引再跑这条 SQL,耗时从 2.3s 降到 3ms,CPU 峰值回落到 35%。但要注意:大表在线加索引会锁表。千万级表请用 ALGORITHM=INPLACE, LOCK=NONE(MySQL 8.0 默认 Online DDL),或用 gh-ost / pt-online-schema-change 这类工具在低峰变更,避免加索引本身变成第二次事故。
五、止血与根治:临时限流 + 加索引 + 改写法
线上恢复分两步:先止血,再根治。止血用连接池上限 + 接口限流挡住雪崩;根治除了加索引,还要改写几条容易触发全表扫描的写法:
# 1) 改写深分页:LIMIT 100000,20 必全扫,改成游标分页
SELECT * FROM orders WHERE id < :last_id
ORDER BY id DESC LIMIT 20;
# 2) 避免 SELECT *:只取需要的列,给覆盖索引让路
SELECT id, amount, status FROM orders WHERE user_id = ?;
# 3) IN 列表过长会失效:拆批或用临时表 JOIN
# 反例:WHERE id IN (1,2,3,...10000)
# 正例:先落临时表再 JOIN,或分批 500/次
同时把数据库最大连接数、应用连接池上限与Linux 内核套接字参数对齐,防止连接风暴把操作系统也拖垮。这三板斧(限流止血、加索引、改写法)配合,事故在 20 分钟内彻底平息。
六、一张表看懂完整排查路径
| 现象 | 用什么看 | 结论 | 动作 |
|---|---|---|---|
| CPU 100% / 接口超时 | top、监控大盘 | 瓶颈在数据库 | 进库排查,先别回滚 |
| 大量连接卡 Sending data | SHOW PROCESSLIST | 某 SQL 全表扫描 | 抓出该 SQL |
| 慢日志 Top1 是它 | pt-query-digest | 确认高频慢 SQL | EXPLAIN 定位 |
| type=ALL, key=NULL | EXPLAIN | 缺复合索引 | 建覆盖索引 |
| 耗时 2.3s→3ms | 压测回放 | 根因消除 | 加 CI 门禁防复发 |
七、事后复盘:把同类问题挡在发布前
救火结束不等于结束。真正值钱的是让「缺索引的 SQL」再也上不了生产。我们把慢 SQL 检查做进 CI:每次 MR 用 pt-query-digest 回放测试库慢日志,超过阈值直接卡合并:
# .github/workflows/sql-review.yml 片段
- name: 慢 SQL 门禁
run: |
pt-query-digest --limit 10 --filter '$event->{Query_time} > 1' \
test-slow.log > suspicious.log
if [ -s suspicious.log ]; then
echo "发现超过 1s 的慢 SQL,禁止合并"; exit 1
fi
此外把「慢日志条数」「全表扫描次数」「活跃连接数」接入告警,阈值一破就通知到人,而不是等用户投诉。这条纪律和GitHub Actions 持续集成的「越早发现问题成本越低」一脉相承。我们还在评审清单里加了一条硬性规则:任何新增 WHERE 条件,必须同步确认是否存在对应索引。
结语
这次事故的根因简单到可笑——一个缺失的复合索引。但它提醒我们:慢 SQL 的破坏力不取决于代码行数,而取决于它是否绕过了索引。排查的钥匙永远是「先监控定位、再 EXPLAIN 看计划、最后加索引与改写」三步法;而真正的工程成熟,是把慢 SQL 门禁做进 CI,让同类问题在合并前就被拦下。把这次复盘沉淀成团队的默认动作,下次流量高峰你睡得会更安稳。




