同一条 SQL 在测试库 10ms、生产库 3 秒:执行计划被统计信息带偏的完整排查

举报
yd_273983069 发表于 2026/11/01 10:10:22 2026/11/01
【摘要】 一条走了索引的查询,测试库毫秒返回,生产库却要三秒。不是数据量的问题,是优化器选错了索引。本文给出一套可复现的排查顺序:看真实的执行计划、看优化器为什么这么选、用索引提示或直方图纠正,以及怎么避免它下次再变回去。

现象很典型:一条按时间范围和状态过滤的列表查询,测试库上 explain 出来 type=ref,扫描几十行,10ms 返回。上线到生产,同样的 SQL、同样的索引,接口耗时 3 秒。第一反应一般是"生产数据多",但一查行数只差两倍,解释不了 300 倍的差距。


先排除掉不是原因的几件事


不要一上来就加索引。先把下面这几个常见误判排掉:


- 慢查询日志里 Rows_examined 很大,不代表索引没走,可能走的是另一个索引,扫的行多但过滤比低。

- 网络和连接池很容易背锅。同一个连接上连续跑两次,第二次还是慢,基本可以排除冷启动和建连开销。

- 缓存没命中也会慢,但缓存没命中的特征是第一次慢后续快,而这里的现象是稳定地慢。


判断依据只有一个:拿生产的真实参数值去 explain,而不是拿测试库的参数值。


第一步:看优化器实际做了什么


explain 要加 format=json,普通格式看不到 cost 和选索引的依据。


  EXPLAIN FORMAT=JSON SELECT ... WHERE created_at > '2026-10-01' AND status = 2 ORDER BY id DESC LIMIT 20;


重点看三处:key(实际用的索引)、rows_examined_per_scan、以及 attached_condition。如果 key 用的是一个高选择性的索引(比如 user_id),但 attached_condition 里挂着 status 的过滤,那说明优化器是在索引里回表之后再过滤,扫的行数远超预期。


另一个更直接的证据是 EXPLAIN ANALYZE(MySQL 8.0.18+),它会给出真实的执行耗时和实际行数,能直接看到估算行数和实际行数差了几个数量级。差一个数量级以上,就是统计信息的问题。


第二步:优化器为什么会选错


MySQL 的优化器基于 cost 模型,cost 的来源是索引统计信息,也就是 innodb_index_stats 里的那几张表。它靠采样的方式估算索引的区分度(cardinality)。


  SELECT index_name, stat_name, stat_value, sample_size

  FROM mysql.innodb_index_stats

  WHERE database_name = 'yourdb' AND table_name = 'yourtable';


如果 status 这一列的 cardinality 被估成了 2(因为采样时只碰到两三种值),优化器就会认为"这个条件过滤不掉什么",于是不愿意为它付出索引代价,转而去走一个看起来更"便宜"的索引。实际生产中 status=2 可能只占 1% 的行,差距就在这里。


统计信息为什么会不准?三种情况:表刚经历大批量导入/删除,采样没跟上;开了 innodb_stats_persistent 但 innodb_stats_auto_recalc 关闭,统计信息一直停在旧值;或者数据分布本身就极端偏斜(比如 95% 的行都是 status=1)。


补一句:MySQL 8.0 之前没有直方图,对偏斜列的估算天然不准。


第三步:三种纠正手段,按侵入性排序


1)更新统计信息


  ANALYZE TABLE yourtable;


这一步最轻,很多情况下跑完就恢复了。但如果是数据分布本身偏斜,ANALYZE 之后估算仍然会错——它只是采样更准,改变不了"均匀分布"这个假设。


2)建直方图(MySQL 8.0+)


  ANALYZE TABLE yourtable UPDATE HISTOGRAM ON status WITH 64 BUCKETS;


直方图记录的是真实的值分布,不是均匀假设。对 status 这种枚举型、且分布严重偏斜的列,效果非常明显。查看:


  SELECT * FROM information_schema.COLUMN_STATISTICS WHERE TABLE_NAME='yourtable';


注意直方图不会被自动更新,数据变化大之后要手动重跑,这一点很容易忘。


3)用索引提示固化执行计划


如果统计信息反复漂移,或者这张表的数据分布每天都在剧烈变化,那就别再指望优化器猜对:


  SELECT ... FROM yourtable FORCE INDEX (idx_status_created) WHERE ...;


代价是把维护责任转移到了 SQL 上:以后索引改名、或者数据分布反过来变化,这个 hint 就变成负资产。所以 hint 是止血,不是治本。


更稳的做法是让索引本身"无可争议"——建一个覆盖查询条件的联合索引,把选择性高的列和范围列的顺序排对。这里有个常被搞反的点:等值条件列在前,范围条件列在后。也就是 (status, created_at) 而不是 (created_at, status)。前者能用上索引做范围扫描,后者在 created_at 上开了范围之后,status 就用不上了。


第四步:确认它不会再变回去


改完之后别只看一次 explain 就收工。把这条 SQL 的 explain 结果做一次基线记录(key、rows、cost),放进监控或者巡检脚本里,每天比一次。执行计划突变是很安静的故障,没人告警的时候通常要等到接口超时才被发现。


如果想更主动一点,可以用 performance_schema 抓执行计划的历史变化:


  SELECT * FROM performance_schema.events_statements_summary_by_digest

  ORDER BY SUM_TIMER_WAIT DESC LIMIT 10;


按 digest 聚合,能看出哪条 SQL 的平均耗时在最近一段时间变差了,比等业务方来报障要早得多。


小结


测试快生产慢、SQL 和索引都一样的时候,先怀疑统计信息,不要先怀疑数据量。排查顺序是:format=json 的 explain 看真实选择 → EXPLAIN ANALYZE 看估算与实际行数的偏差 → 查 innodb_index_stats 的 cardinality → ANALYZE 或建直方图 → 实在不稳再用 FORCE INDEX 止血,同时把联合索引的列顺序排对。最后一定要做基线监控,执行计划漂移不会自己报警。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。