同一条 SQL 昨天 20ms 今天 8 秒:统计信息与执行计划漂移
同一条 SQL 没改一个字,昨天 20ms 今天 8 秒。归档触发统计重算,采样偏差把估算行数放大 200 倍,优化器放弃索引改走全表。直方图、提示与索引重排三种修法的取舍。
早上九点二十,监控群里弹出一条告警:订单列表接口 P99 从 210ms 涨到 8.4s。我第一反应是发版引起的,翻了发布记录,最近一次上线在前天下午,昨天一整天这个接口都稳在 20ms 上下。
更奇怪的是慢查询日志。同一条 SQL 昨天 12 万次执行,平均 0.019 秒;今天早上六点开始,同一语句每天执行 3000 多次,每次 7 到 9 秒。SQL 文本一个字都没改,索引也在,表数据量只增长了不到 2%。
我把这条 SQL 单独拎出来跑,确实是 8 秒。加上 FORCE INDEX 之后立刻回到 22ms。到这里可以断定:不是 SQL 写坏了,也不是数据库整体变慢,而是优化器给这条 SQL 选错了执行计划。
一、先分清是「SQL 变慢」还是「等不到资源」
遇到接口变慢,最怕一上来就调参数。我先按代价从低到高排了一遍:
# 1. 有没有锁等待、有没有长事务
mysql -e "SELECT * FROM information_schema.innodb_trx ORDER BY trx_started LIMIT 5;"
# 2. 这个语句当前在干什么
mysql -e "SELECT id,user,time,state,LEFT(info,80) FROM information_schema.processlist
WHERE info LIKE '%t_order%' AND command='Query';"
# 3. 慢日志里这条语句的扫描行数
sudo tail -2000 /var/log/mysql/slow.log | grep -A3 "t_order" | grep -E "Rows_examined|Query_time"
三条都指向同一个结论:没有锁等待,没有长事务,Rows_examined 从 380 涨到了 41 万。扫描行数变成原来的一千倍,说明访问路径变了,不是资源不够。
判断顺序很重要。如果是锁等待,
innodb_trx里会看到长时间的trx_state=RUNNING且trx_rows_locked很大;如果是 IO 瓶颈,Rows_examined不会涨。扫描行数暴涨几乎只有一种解释:执行计划换了。
二、EXPLAIN 前后对比:估算行数差了 200 倍
我把今天的执行计划和一个星期前留档的计划放在一起比:
EXPLAIN FORMAT=TREE
SELECT id, order_no, status, created_at
FROM t_order
WHERE shop_id = 8412
AND status IN (1, 2)
AND created_at >= '2026-11-10 00:00:00'
ORDER BY created_at DESC
LIMIT 20;
| 项 | 一周前 | 今天 |
|---|---|---|
| 访问方式 | index range scan (idx_shop_status_ctime) |
index scan on PRIMARY |
| possible_keys | idx_shop_status_ctime |
idx_shop_status_ctime |
| key | idx_shop_status_ctime |
NULL |
| 估算 rows | 1 240 | 268 000 |
| 实际 Rows_examined | 1 380 | 411 000 |
| Extra | Using index condition | Using where; Using filesort |
possible_keys 里有索引,key 却是 NULL,说明优化器认为走索引不如全表扫。它这么判断的依据只有一个:估算出来符合条件的行数太多,走二级索引还要回表,代价反而更高。
关键在于「估算」。这个查询真实命中只有 1200 行左右,优化器却估成 26.8 万行,偏差 200 倍。估算从哪来?从索引统计信息来。
三、InnoDB 的统计信息是怎么算出来的
MySQL 不会精确统计每个索引有多少行,它靠采样。默认参数下,InnoDB 对每个索引随机读 20 个页(innodb_stats_persistent_sample_pages 默认 20),用这 20 个页里记录的分布去推算整体。表越大、数据分布越不均匀,这个推算就越容易偏。
再看这张表的实际情况:shop_id 的分布极度倾斜。头部三个店铺占了 62% 的订单,shop_id=8412 是其中最大的一个,实际有 47 万行。当采样只覆盖了 20 个页时,样本里 shop_id=8412 的比例完全可能被放大两倍,导致 status 与 created_at 的联合基数算歪。
统计信息什么时候会刷新?两个条件:innodb_stats_auto_recalc=ON 时,表变更行数超过 10% 会触发重算;或者手工 ANALYZE TABLE。这张表最近做了一次批量归档,删了 8 万行历史订单,删除会触发重算,重算的结果就是那份偏得离谱的估算。
-- 看统计信息的刷新时间与采样情况
SELECT table_name, index_name, stat_name, stat_value, last_update
FROM mysql.innodb_index_stats
WHERE table_name = 't_order' AND stat_name IN ('n_leaf_pages','size')
ORDER BY index_name;
SELECT table_name, last_update, n_rows, clustered_index_size
FROM mysql.innodb_table_stats WHERE table_name = 't_order';
last_update 是今天凌晨两点十七分,正好在归档任务跑完之后。到这里因果链闭合了:归档 → 触发统计重算 → 采样偏差 → 估算行数虚高 → 优化器放弃索引 → 全表扫 41 万行。
四、直方图:字段倾斜时的补丁
MySQL 8.0 提供了直方图,专门解决「统计信息只有基数、没有分布」的问题。对倾斜严重的字段建一份等高直方图,优化器就能知道 shop_id=8412 到底占多少。
ANALYZE TABLE t_order UPDATE HISTOGRAM ON shop_id, status WITH 128 BUCKETS;
SELECT schema_name, table_name, column_name, histogram->>'$."number-of-buckets-specified"' AS buckets
FROM information_schema.column_statistics
WHERE table_name = 't_order';
建完直方图再 EXPLAIN,估算从 26.8 万降到 1 460,计划回到 idx_shop_status_ctime,执行时间 19ms。
但要清楚直方图的边界:它只在「等值条件 + 少量值」的场景有效,对范围条件、对多列联合分布作用有限;而且直方图不会自动更新,数据分布变了要重新 ANALYZE。把它当成临时补丁没问题,当成长期方案会踩坑。
五、把计划钉住:三种手段的取舍
永久性的做法有三种,我按侵入性排序:
| 手段 | 写法 | 优点 | 代价 |
|---|---|---|---|
| 优化器提示 | SELECT /*+ INDEX(t_order idx_shop_status_ctime) */ ... |
精确、可回滚 | 写死在 SQL 里,索引改名就失效 |
| FORCE INDEX | FROM t_order FORCE INDEX (idx_shop_status_ctime) |
兼容老版本 | 同上,且会阻止优化器自选更好索引 |
| 改写查询 | 去掉 IN,拆成两次单值查询后归并 |
不依赖提示 | 应用层要改逻辑 |
我选的是第一条,只在最热的那个查询上加提示,其余依靠统计信息。理由很简单:这张表的数据分布短期内不会变,而 IN (1,2) 这种写法优化器本来就估不准,加提示是把已知的结论固化下来,而不是掩盖问题。
真正治本的是让索引更「适合」这个查询。原来的联合索引是 (shop_id, status, created_at),status IN (1,2) 之后 created_at 的有序性被打断,只能靠 Using filesort 排序。改成 (shop_id, created_at, status) 之后,范围条件在第二位,排序可以直接用索引顺序,filesort 消失,Rows_examined 从 1 380 降到 24。
ALTER TABLE t_order ADD INDEX idx_shop_ctime_status (shop_id, created_at, status), ALGORITHM=INPLACE, LOCK=NONE;
六、防复发:把「统计信息年龄」也纳入巡检
这次最难受的地方不是修,而是「昨天还好好的」。没有任何监控盯着统计信息,问题就永远在凌晨爆发。
我补了三件事:
# 每天凌晨检查:统计信息超过 3 天没更新、且表变更量大的,列出来
mysql -N -e "
SELECT t.table_name, s.last_update, s.n_rows
FROM information_schema.tables t
JOIN mysql.innodb_table_stats s ON s.table_name = t.table_name
WHERE t.table_schema = 'order_db'
AND s.last_update < NOW() - INTERVAL 3 DAY
AND t.table_rows > 100000;" | while read tb lu nr; do
echo "stale stats: $tb last=$lu rows=$nr"
done
二是给核心 SQL 建一份 EXPLAIN 基线,每天定时跑一遍,key 或 rows 与基线偏差超过 5 倍就告警。执行计划的漂移往往比 RT 告警早几个小时出现。
三是把慢查询阈值从 1 秒降到 200ms,并且只对 Rows_examined > 10000 的语句告警。这样扫描行数异常能第一时间抓到,而不是等到接口 P99 崩掉。
小结
- 接口变慢先看
Rows_examined。扫描行数暴涨说明访问路径变了,不要先怀疑 IO 和 CPU。 possible_keys有值而key为 NULL,基本就是统计信息估歪了,先ANALYZE TABLE再谈别的。- InnoDB 的统计靠采样,默认 20 个页,字段分布越倾斜越不准;大批量归档、删除之后一定要主动重算。
- 8.0 的直方图能救等值查询的估算,但不会自动更新,只能当补丁用。
- 优化器提示可以钉住计划,但索引结构才是根因。把排序字段放对位置,
filesort和回表一起消失。 - 巡检要盯「统计信息年龄」和执行计划基线,别等 RT 告警才反应。
正在加载评论…