大家好,我是数据库小学妹👋 我踩过的坑,你别再踩。
同一句 SQL,周二跑 50 毫秒,周三跑 5 秒。SQL 我一行没改,表结构没动,数据量也没涨多少。我第一反应是有人偷偷改了数据。翻完两天的变更单和发布记录,什么都没有。后来我把两次 EXPLAIN 并排一放,才发现是数据库自己改了主意。
两天的 EXPLAIN,只差三列
其余列都一样,只有这三列变了。
-- 周二
-- type=ref key=idx_status rows=1200
-- 周三
-- type=ALL key=NULL rows=2100000
type 从 ref 变成 ALL,说明它放弃索引改成了全表扫。key 变成 NULL,说明它一个索引都没用上。rows 从 1200 涨到 210 万,说明它以为这个条件要命中两百多万行。这张表总共才两百多万行,实际符合条件的只有一万多条。它凭什么觉得这个条件要命中几乎整张表?
它是估的,不是数的
InnoDB 不维护精确的行数,它靠采样。默认配置下,它随机抽 20 个索引页。用这 20 页的分布去推算整张表。20 页,每页 16KB,加起来 320KB。对一张几个 G 的表来说,这就是个小样本。抽样本来就会有偏差,数据一倾斜,偏差更大。status 这列,大部分行挤在同一个值上。落进采样页的比例稍微偏一点,推出来的基数就能差出好几倍。
那为什么是周三才飘,周二不飘?因为统计信息不是每次都重算。InnoDB 会盯着表的变更量。改动超过大约 10% 的行,就自动触发一次重采样。周二到周三那次日结,刚好越过了这个阈值。重采样就是再随机抽一遍页。抽的页不一样,估出来的基数也不一样。表里的数据其实没怎么变,计划却变了。
这里还有个参数值得记住。innodb_stats_persistent_sample_pages 控制采样页数,8.0 默认是 20。页数越小,两次重采样之间的波动就越大,计划也越容易飘。同一句 SQL,统计信息刷一次就换一次计划,根子就在这。
代价是这么算出来的
基数估错,为什么就要换路?因为优化器比的是代价。它手里有两套单价,存在 mysql.server_cost 和 mysql.engine_cost 里。顺序读一页和随机读一页,价差着几倍。
关键在于,回表的代价是按行数乘出来的。它估你要回表 210 万次,就按 210 万次随机读计费。而且不去重,同一页被读一百遍,它就记一百遍的钱。乘下来,代价直接爆表。
再看全表扫。它按表的总页数算钱,顺序读完,每页只读一次。这张表的数据页撑死几万页。几万对两百多万,差了几十倍,它当然选全表扫。它没做错什么。它只是拿了一个不准的估算,做了个诚实的判断。
光看那三列还不够,EXPLAIN 还有个 JSON 格式能翻出账本。
EXPLAIN FORMAT=JSON
SELECT ... FROM orders WHERE status = 3;
输出里每条 table 节点都带着 cost_info。走索引那条会给出回表行数和预估成本,全表扫那条写的是按总页数算的成本。两边的数字一对比,你就能看见它为什么选错。我第一次看到 rows_examined_per_scan 是 210 万的时候,才明白它不是抽风,是算错了。
直方图,给倾斜的列补上分布
MySQL 8.0 开始支持给单列建直方图。这个东西值得记。
-- 给倾斜的列建直方图,32 个桶
ANALYZE TABLE orders UPDATE HISTOGRAM ON status WITH 32 BUCKETS;
-- 看它记了什么
SELECT COLUMN_NAME, HISTOGRAM
FROM information_schema.column_statistics
WHERE TABLE_NAME = 'orders'\G
原来的统计信息只有一个基数,只能告诉优化器这列有 5 个不同的值。它不知道其中某一个值占了八成行。直方图不一样,它把取值范围切成若干个桶,每个桶记区间和频次分布。值集中得厉害时,桶会退化成单值桶,一个桶只装一个值,这时候估得最准。这样 WHERE status = 3 的命中行数就能贴近真实。
它也有边界。只支持单列,多列组合的倾斜管不了。唯一性很高的列建了基本没用,桶里就一个值,等于退化成基数。建完还额外占空间。所以我只给倾斜严重的状态位、类型位建。
先止血,再谈治本
当时业务还等着出报表,我先做了两件事止血。一件是重采样。
ANALYZE TABLE orders;
重采样能立刻刷新统计信息,多数情况下计划就回来了。但它只是这一刻准了,数据再变还可能再飘。另一件是索引提示。在 SQL 里加 USE INDEX 或者 FORCE INDEX,把计划强行掰回原样。它能立刻见效,代价我也写在下面。
| 手段 | 治什么 | 局限 |
|---|---|---|
| ANALYZE 重建统计信息 | 采样数据过期 | 数据再变还会飘 |
| 提高采样页数 | 样本太小 | IO 开销跟着变大 |
| 直方图 | 单列数据倾斜 | 只支持单列,高基数列无效 |
| 索引提示 | 立刻恢复执行计划 | 治标不治本,索引变更直接报错 |
避坑清单
大表 ANALYZE TABLE 别在高峰期跑,它会触发索引页采样。你要是把采样页数调大了,IO 压力会更明显。我们现在的做法是把它排进低峰运维窗口,跟备份错开。
FORCE INDEX 千万别当长期方案。它把一个动态的决定,写死在了 SQL 里。索引一旦改名或被删,SQL 直接报错。更麻烦的是,它会把真正的问题盖住。统计信息一直不准下去,谁都不会再去看它。
最后一条我踩得最狠。innodb_stats_persistent 这个参数如果没打开,统计信息就不落盘。重启之后它会重新采样,执行计划可能在重启后整体变样。我们有一次大版本维护之后,第二天一批 SQL 的计划全变了。查了整整一天,最后才定位到这个开关。现在它在我们的基线配置里是必须开的,也只有开了它,前面说的采样页数才管得住。
写在最后
执行计划漂移不是数据库在抽风。它是拿着一个不准的估算,做了一次诚实的决定。所以治它要分两层。
第一层是让估算更准,采样页数和直方图都在这一层。一个治样本量太小,一个治数据倾斜。第二层是让漂移能被发现,把核心 SQL 的执行计划纳入监控,计划一变就告警。第二层我们做得很晚,吃过好几次亏。往往是业务先感知到慢,我们才知道。
别用索引提示把问题压住。 压住的是症状,统计信息该不准还是不准。我现在的顺序是先 ANALYZE 看能不能恢复。恢复了就去查它为什么会飘,是数据倾斜还是采样太少,把根因解决掉。索引提示只留给那些实在来不及治、但又必须马上恢复的核心 SQL。
你遇到过执行计划莫名其妙变差吗?最后是怎么定位的?评论区聊聊。
我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋