适用场景

本文适用于 MySQL 8.0 使用 InnoDB 的生产环境。典型场景是 SQL 和索引近期都没有修改,但一次批量导入、归档删除或数据分布变化后,原本几十毫秒的查询突然变成数秒;EXPLAIN 显示优化器改走了低选择性索引、错误的连接顺序,甚至全表扫描。

这类问题容易被误判为“数据库负载太高”。真正需要回答的是:优化器估算了多少行、实际读取了多少行,以及统计信息为什么没有反映当前数据分布。

现象描述

以订单查询为例:

SELECT id, user_id, status, created_at
FROM orders
WHERE tenant_id = 42
  AND status = 'PAID'
  AND created_at >= '2026-09-01 00:00:00'
ORDER BY created_at DESC
LIMIT 100;

表上同时存在 idx_status(status)idx_tenant_status_created(tenant_id, status, created_at)。历史上 PAID 只占少数,后来一次数据迁移让租户 42 的大部分订单都变成 PAID,但优化器仍按旧分布估算,可能错误选择 idx_status,扫描大量其他租户的数据。

常见伴随现象包括:

  • 慢查询集中出现在某个租户、状态或时间段;
  • Rows_examined 远大于最终返回行数;
  • 重启实例、切换只读副本或执行 ANALYZE TABLE 后性能暂时恢复;
  • 相同 SQL 在不同实例上执行计划不同;
  • 总 CPU、磁盘延迟并未先升高,而是慢 SQL 增多后才被拖高。

可能原因

  • 大批量插入、删除或更新改变了列值分布,持久化统计信息尚未刷新;
  • 单列统计信息无法表达 tenant_idstatus 的相关性;
  • 低基数列分布倾斜,优化器按平均值估算热点值;
  • 采样页数过少,超大表或局部热点使估算波动;
  • 不同实例的统计信息更新时间和采样结果不一致;
  • 索引虽然存在,但列顺序与过滤、排序需求不匹配;
  • 参数值差异很大,只观察一个样本就误判为统一的计划问题。

排查思路

1. 先保留慢 SQL 现场

从慢查询日志或性能平台记录 SQL 摘要、参数范围、耗时、检查行数和返回行数。不要只保存脱敏后的 SQL 模板,因为本问题往往与具体参数的数据倾斜有关。

SELECT DIGEST_TEXT,
       COUNT_STAR,
       ROUND(AVG_TIMER_WAIT / 1000000000, 2) AS avg_ms,
       SUM_ROWS_EXAMINED,
       SUM_ROWS_SENT
FROM performance_schema.events_statements_summary_by_digest
WHERE DIGEST_TEXT LIKE 'SELECT%FROM `orders`%'
ORDER BY AVG_TIMER_WAIT DESC
LIMIT 10;

SUM_ROWS_EXAMINED / SUM_ROWS_SENT 持续很高,说明数据库为了返回少量结果读取了大量候选行。该视图是累计摘要,比较前应确认采集窗口,避免把历史数据误当成当前状态。

2. 对比估算行数与实际行数

先用普通 EXPLAIN 查看候选计划,再在可控环境或确认查询为只读后使用 EXPLAIN ANALYZE

EXPLAIN FORMAT=TREE
SELECT id, user_id, status, created_at
FROM orders
WHERE tenant_id = 42
  AND status = 'PAID'
  AND created_at >= '2026-09-01 00:00:00'
ORDER BY created_at DESC
LIMIT 100;

EXPLAIN ANALYZE
SELECT id, user_id, status, created_at
FROM orders
WHERE tenant_id = 42
  AND status = 'PAID'
  AND created_at >= '2026-09-01 00:00:00'
ORDER BY created_at DESC
LIMIT 100;

重点比较每个节点的估算 rows 与实际 rows,同时关注 loops。如果估算只有数百行,实际却读取数十万行,优先调查统计信息和列相关性。EXPLAIN ANALYZE 会真实执行语句,不要对写语句使用,也不要在高峰期直接分析未知成本的查询。

3. 检查索引基数与统计更新时间

SHOW INDEX FROM orders;

SELECT database_name,
       table_name,
       index_name,
       last_update,
       stat_name,
       stat_value,
       sample_size
FROM mysql.innodb_index_stats
WHERE database_name = DATABASE()
  AND table_name = 'orders'
ORDER BY index_name, stat_name;

SHOW INDEX 中的 Cardinality 是估算值,不是精确计数。last_update 明显早于批量变更,或不同副本的值差异很大,都是重要线索。不要为了得到精确数字在生产大表上直接执行无条件 COUNT(DISTINCT ...)

4. 验证数据是否倾斜

先限定租户或时间范围做聚合,避免一次扫描整张大表:

SELECT status, COUNT(*) AS row_count
FROM orders
WHERE tenant_id = 42
  AND created_at >= '2026-09-01 00:00:00'
GROUP BY status
ORDER BY row_count DESC;

如果热点值占比远高于全表平均值,单列基数无法准确描述组合条件。此时只刷新统计信息可能短期有效,但数据再次变化后计划仍可能波动。

5. 用不可见索引或提示做验证,不直接固化结论

在测试环境可以通过索引提示验证复合索引是否显著减少读取行数:

EXPLAIN ANALYZE
SELECT id, user_id, status, created_at
FROM orders FORCE INDEX (idx_tenant_status_created)
WHERE tenant_id = 42
  AND status = 'PAID'
  AND created_at >= '2026-09-01 00:00:00'
ORDER BY created_at DESC
LIMIT 100;

如果提示后的实际读取行数和耗时明显下降,说明索引具备价值,但不代表应永久保留 FORCE INDEX。提示会绕开优化器未来的改进,也可能让其他参数值变慢,应把它作为定位手段或短期止血措施。

修复方案

方案一:在维护窗口刷新统计信息

ANALYZE TABLE orders;

执行后重新获取 EXPLAIN 和真实耗时,并检查受影响的其他核心 SQL。ANALYZE TABLE 不是无风险按钮:大表执行时间、元数据锁影响和版本差异都需要先在同等数据量环境验证。

方案二:为倾斜列建立直方图

当过滤列没有合适索引,或者优化器需要更准确地理解热点值分布时,可评估直方图:

ANALYZE TABLE orders
UPDATE HISTOGRAM ON status WITH 64 BUCKETS;

SELECT TABLE_NAME,
       COLUMN_NAME,
       JSON_EXTRACT(HISTOGRAM, '$."number-of-buckets-specified"') AS buckets
FROM information_schema.column_statistics
WHERE SCHEMA_NAME = DATABASE()
  AND TABLE_NAME = 'orders';

桶数不是越多越好;它会增加统计成本,也不能替代合理索引。上线前用主要参数样本对比计划,确认热点值和普通值都没有明显退化。若验证无效,可移除:

ANALYZE TABLE orders DROP HISTOGRAM ON status;

方案三:建立匹配访问路径的复合索引

本例经常按租户、状态过滤,并按时间倒序取最近记录,可评估:

CREATE INDEX idx_tenant_status_created
ON orders (tenant_id, status, created_at DESC);

设计时先确认查询模式,而不是机械地把所有条件塞进索引。还要评估写放大、磁盘占用、重复索引和其他查询的收益。生产建索引应预估执行时间、锁影响、复制延迟和回滚方式。

方案四:调整持久化统计采样精度

如果超大表的采样结果频繁波动,可只针对目标表逐步提高采样页数:

ALTER TABLE orders
STATS_PERSISTENT = 1,
STATS_SAMPLE_PAGES = 128;

ANALYZE TABLE orders;

提高采样页数会增加分析时间和 I/O,必须通过前后多次计划对比证明收益,不应全局盲目调大。

定位与验证示例

一次有效的闭环应记录同一组参数在修复前后的数据:

指标 修复前 修复后
优化器估算行数 320 86,000
实际读取行数 128,400 112
返回行数 100 100
执行耗时 3.8 秒 24 毫秒
使用索引 idx_status idx_tenant_status_created

修复完成的标准不是“执行了一次 ANALYZE TABLE”,而是计划选择正确、实际读取行数下降、主要参数样本稳定,并且监控窗口内没有其他查询回退。

预防措施

  • 把批量导入、历史归档和大范围状态更新视为可能改变统计分布的发布事件;
  • 对核心 SQL 保存计划摘要、延迟分位数和检查行数,发生突变时能够对比;
  • 在主库和只读副本分别检查计划,避免统计差异造成流量切换后抖动;
  • 变更索引、直方图或采样页数前准备回滚语句,并保留基准数据;
  • 用多个典型参数做回归测试,至少覆盖热点值、普通值、空结果和大时间范围;
  • 不把 FORCE INDEX 当成永久默认方案,确需使用时记录适用条件和移除计划;
  • 只在有证据时刷新统计信息,避免定时对所有大表执行 ANALYZE TABLE 造成额外负载。

总结

SQL 在代码和索引未变时突然变慢,常见根因不是索引“失效”,而是优化器看到的数据画像已经过期或过于粗糙。排查时先比较估算行数与实际行数,再核对统计更新时间、采样质量和数据倾斜;修复则按刷新统计、补充直方图、优化复合索引和调整采样精度逐级推进。最终必须以真实读取行数、延迟和多参数回归结果验证,而不是只看一次执行计划。