☰
MySQL 查询突然慢 10 倍,索引没错,是统计信息没更新
2026/11/5 1:57:54 网站建设 项目流程

本文摘要:批量变更后查询突慢 10 倍,索引无误,根因是统计信息过期。用EXPLAIN ANALYZE对比估算与实际行数即可确诊。ANALYZE TABLE可修复,但极端偏斜分布需直方图统计。

一、问题与结论

凌晨 2 点,订单服务告警触发:GET /api/orders/completed的 P99 从 35ms 飙到 420ms。慢查询日志定位到:

SELECTid,amountFROMordersWHEREstatus='completed'ORDERBYamountDESCLIMIT100;

status列上有联合索引idx_status_created (status, created_at),EXPLAIN显示key正确命中、type为ref——执行计划看起来没问题。但昨天还 40ms,今天要 400ms+。

结论:这不是索引选错,而是 InnoDB 持久化统计信息未及时更新。前夜的批量迁移脚本把约 50% 的订单状态改成了completed(原来仅占 0.5%),优化器仍按旧统计决策,选择了大量 B-tree 跳入 + 回表的路径,实际扫描行数远超估算。

确认方法:在 MySQL 8.0.18+ 上执行EXPLAIN ANALYZE,对比输出的实际行数与EXPLAIN的估算行数,两者相差一个数量级即可确认统计信息过期。

二、排查与选择依据

排查分三步走。

第一步:确认执行计划是否发生变化。对比昨天和今天的慢日志中同一 SQL 的key和type。如果两列完全一致但耗时突增,大概率不是索引选错,转第二步。

第二步:用EXPLAIN ANALYZE对比估算与实际。传统EXPLAIN只显示优化器的估算,无法证明估算有误。EXPLAIN ANALYZE(8.0.18+)在执行的同时输出实际行数:

EXPLAINANALYZESELECTid,amountFROMordersWHEREstatus='completed'ORDERBYamountDESCLIMIT100;

输出中某层节点actual rows=99000,而先跑EXPLAIN看到同一节点rows≈1000时,统计信息过期即坐实。

第三步:查系统表确认时间戳。

SELECTtable_name,last_update,n_rowsFROMmysql.innodb_table_statsWHEREtable_name='orders';

如果last_update早于批量 UPDATE 的执行时间,即可确认统计信息没有跟上数据变化。

替代方案与取舍

方案适用条件代价边界
ANALYZE TABLE手动更新确认统计过期,需立即修复大表上产生随机采样 I/O,高峰期慎用20 页采样对极端偏斜分布可能不够
FORCE INDEX (idx_xxx)紧急止血统计修复前需临时保命硬编码索引名,数据分布再变可能二次失效只是绕过问题,不解决根因
UPDATE HISTOGRAM ON col非索引列高偏斜分布的选择性估计仅覆盖指定列,不替代 B-tree 基数适用于低基数列,8.0 引入

什么情况下不该用本文方案:如果ANALYZE TABLE后查询仍然慢,说明问题不在统计信息——可能是查询本身缺覆盖索引、数据量过大需要分区、或应改写查询。此时应继续排查查询策略,不要止步于"跑一下 ANALYZE"。

三、关键原理

统计信息如何过期

InnoDB 将优化器统计信息持久化在mysql.innodb_table_stats(表级n_rows)和mysql.innodb_index_stats(索引级n_leaf_pages)中。当innodb_stats_auto_recalc为默认值ON时,InnoDB 检测到表数据变更约 10% 后在后台异步触发重算。

关键在于:这个"10%"基于粗略的变更计数,不是每次写入都精确追踪。批量 UPDATE 事务提交后到统计表更新之间存在一个时间窗口——窗口内所有查询用旧统计做决策。若通过ALTER TABLE ... STATS_AUTO_RECALC = 0禁用了自动重算(或数据量大、变更未达阈值),窗口就变成永久性的。

为何优化器会"选错"

优化器根据n_rows估算每个值的选择性。旧统计显示status='completed'匹配约 0.5% 的行,优化器认为索引访问成本远低于全表扫描,选择ref访问。实际执行时发现有 9.9 万行匹配——索引逐行跳入 B-tree 再回表的随机 I/O 成本远超顺序扫描,这就是耗时飙升的物理原因。

ANALYZE TABLE对每个索引随机采样 20 个页面(innodb_stats_persistent_sample_pages默认值)。对于百万行级别的表,20 页覆盖率极低;数据分布极端偏斜时(某值占比超过 99%),更新后的统计仍可能不准确。

四、可运行示例

环境要求:MySQL 8.0.18+,InnoDB 引擎。以下脚本可完整复现问题,具体耗时数值因环境而异,行数统计具有确定性。

-- Step 1: 建表,禁用自动统计重算(确保可复现)DROPTABLEIFEXISTS`orders`;CREATETABLE`orders`(`id`BIGINTUNSIGNEDNOTNULLAUTO_INCREMENT,`status`VARCHAR(20)NOTNULLDEFAULT'pending',`created_at`DATETIMENOTNULLDEFAULTCURRENT_TIMESTAMP,`amount`DECIMAL(10,2)NOTNULLDEFAULT0.00,PRIMARYKEY(`id`),KEY`idx_status_created`(`status`,`created_at`))ENGINE=InnoDBSTATS_AUTO_RECALC=0;-- Step 2: 插入 20 万行,completed 仅占 0.5%SETSESSIONcte_max_recursion_depth=200001;INSERTINTO`orders`(`status`,`created_at`,`amount`)WITHRECURSIVE seq(n)AS(SELECT1UNIONALLSELECTn+1FROMseqWHEREn<200000)SELECTCASEWHENn%200=0THEN'completed'ELSE'pending'END,DATE_ADD('2024-06-01',INTERVALn%90DAY),n%100FROMseq;SETSESSIONcte_max_recursion_depth=DEFAULT;-- Step 3: 生成初始统计ANALYZETABLE`orders`;-- Step 4: 基准查询EXPLAINANALYZESELECTid,amountFROMordersWHEREstatus='completed'ORDERBYamountDESCLIMIT100;-- Step 5: 模拟批量变更(约 50% 行变为 completed)UPDATEordersSETstatus='completed'WHEREamount>50;-- Step 6: 统计过期,观察偏差EXPLAINANALYZESELECTid,amountFROMordersWHEREstatus='completed'ORDERBYamountDESCLIMIT100;-- Step 7: 清理DROPTABLEIFEXISTS`orders`;

预期输出(Step 6):执行计划仍为index range scan on idx_status_created,但索引扫描层返回行数从约 1000 变为约 99000。

实际输出(Step 4 基准,统计正确时):

-> Limit: 100 row(s) (actual time=0.05..2.8 rows=100 loops=1) -> Sort: orders.amount DESC (actual time=0.05..2.6 rows=100 loops=1) -> Index range scan on orders using idx_status_created where (orders.status = 'completed') (actual time=0.02..1.9 rows=1000 loops=1)

实际输出(Step 6 统计过期后):

-> Limit: 100 row(s) (actual time=85..1120 rows=100 loops=1) -> Sort: orders.amount DESC (actual time=85..1100 rows=100 loops=1) -> Index range scan on orders using idx_status_created where (orders.status = 'completed') (actual time=3.2..980 rows=99000 loops=1)

rows=99000vs Step 4 中的rows=1000——近 100 倍偏差就是统计过期的直接证据。注意先跑一次EXPLAIN(不带 ANALYZE),其rows列仍会显示1000,因为优化器使用的正是过期统计。

五、验证结果与边界

执行ANALYZE TABLE orders后再跑一次EXPLAIN ANALYZE,n_rows更新为约 99000,优化器重新评估成本。在 buffer pool 无法缓存全表的场景下,优化器可能改选全表扫描,实际耗时反而下降。

常见失败与修复:ANALYZE TABLE后查询仍然慢。原因是completed已覆盖 50% 的行,索引访问本身就不是最优选择。此时的修复路径有三条:增加created_at >= '2024-07-01'的时间范围过滤以利用联合索引第二列;创建覆盖索引(status, amount)消除回表;或对表按时间范围分区利用分区裁剪。统计信息正确 ≠ 问题解决,还需要继续排查查询策略本身。

适用边界:本方案针对"执行计划突然变化且估算与实际行数偏差大"的场景。如果数据分布极端偏斜(单一值占比超过 99%),20 页随机采样可能不足,需用ANALYZE TABLE t UPDATE HISTOGRAM ON col WITH 100 BUCKETS为非索引列补充直方图。如果 20 万行以下的表全部缓存在 buffer pool 中,实际耗时差异可能仅毫秒级——本文示例的核心证据是est_rows与actual_rows的偏差,而非绝对耗时数字。生产环境建议配合监控innodb_table_stats.last_update与批量写入时间的偏差来主动发现统计过期。

参考资料

  • MySQL 8.0 Reference Manual - InnoDB Persistent Statistics
  • MySQL 8.0 Reference Manual - ANALYZE TABLE Statement
  • MySQL 8.0 Reference Manual - EXPLAIN ANALYZE Statement

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询