适用场景
本文适用于 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_id与status的相关性; - 低基数列分布倾斜,优化器按平均值估算热点值;
- 采样页数过少,超大表或局部热点使估算波动;
- 不同实例的统计信息更新时间和采样结果不一致;
- 索引虽然存在,但列顺序与过滤、排序需求不匹配;
- 参数值差异很大,只观察一个样本就误判为统一的计划问题。
排查思路
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 在代码和索引未变时突然变慢,常见根因不是索引“失效”,而是优化器看到的数据画像已经过期或过于粗糙。排查时先比较估算行数与实际行数,再核对统计更新时间、采样质量和数据倾斜;修复则按刷新统计、补充直方图、优化复合索引和调整采样精度逐级推进。最终必须以真实读取行数、延迟和多参数回归结果验证,而不是只看一次执行计划。
Discussion
评论