MySQL 查询变慢时,看到 type=ALL 就立即加索引,和看到“数据库 CPU 高”就扩大实例一样,都缺少证据。全表扫描在小表或返回大部分数据时可能合理;真正需要判断的是:生产慢查询对应哪条规范化 SQL、优化器预计扫描多少行、实际扫描多少行、过滤后留下多少行,以及候选索引是否匹配查询条件和排序。
适用范围与结论
本文适用于 MySQL 8.0/8.4 的 InnoDB 查询诊断。V2CE 实验使用 MySQL 8.4.10,在 100,000 行固定数据上执行租户加时间范围查询。建索引前实际扫描 100,000 行;添加 (tenant_id, created_at) 后变为覆盖索引范围扫描,实际读取 51 行,查询结果保持 51。
推荐顺序:
- 从慢查询日志、Performance Schema 或 APM 确认真实 SQL、频率、耗时和业务影响;
- 用普通
EXPLAIN查看计划,不先执行未知重查询; - 在受控环境或确认安全的只读查询上使用
EXPLAIN ANALYZE获取实际行数; - 根据等值、范围、排序和选择性设计最小索引;
- 评估建索引对写入、磁盘、复制和 DDL 锁的影响,再进入生产变更。
第一步:先确认是哪一类 SQL 变慢
不要只复制一次应用日志里的完整 SQL。应聚合相同 digest,记录执行次数、总耗时、平均/高分位耗时和检查行数。一个偶发 2 秒查询与每秒执行 500 次的 20 毫秒查询,优化优先级可能完全不同。
查看当前版本、表结构、行数估计与索引:
SELECT VERSION();
SHOW CREATE TABLE events\G
SHOW INDEX FROM events;
SHOW TABLE STATUS LIKE 'events'\G
保存参数值而不是只保存参数化 SQL,因为选择性会改变计划。例如热门租户与普通租户可能命中完全不同的数据比例。
第二步:用 EXPLAIN 查看预计执行计划
EXPLAIN FORMAT=TREE
SELECT COUNT(*)
FROM events
WHERE tenant_id = 777
AND created_at >= '2025-07-01 00:00:00';
重点检查:
- 访问方式是表扫描、索引扫描、范围扫描还是单点查找;
- 候选索引与实际 chosen index;
- 预计读取行数和过滤比例;
- 是否出现临时表、排序、重复嵌套循环或代价很高的子计划;
- 联合查询的驱动顺序和每层循环次数。
预计行数来自统计信息,不是实际运行结果。估计严重偏离时,可能需要更新统计信息、检查数据分布和直方图,而不是盲目强制索引。
第三步:谨慎使用 EXPLAIN ANALYZE
EXPLAIN ANALYZE 会实际执行语句并记录迭代器的时间、实际行数和循环次数。它不是纯只读的“看计划”按钮。对未知成本、可能锁表或会产生写入副作用的语句,不要直接在生产高峰运行。
EXPLAIN ANALYZE FORMAT=TREE
SELECT COUNT(*)
FROM events
WHERE tenant_id = 777
AND created_at >= '2025-07-01 00:00:00';
把每一层的 estimated rows 与 actual rows、loops 对照。嵌套循环中,上层返回行数乘以下层循环次数可能形成巨量工作。EXPLAIN ANALYZE 本身有测量开销,适合比较访问路径和数量级,不应把输出中的单次毫秒数当成严格基准测试。
第四步:根据查询形状设计复合索引
示例查询对 tenant_id 使用等值条件,对 created_at 使用范围条件,因此候选索引是:
CREATE INDEX idx_events_tenant_created
ON events (tenant_id, created_at);
该顺序允许先定位单个租户,再在其记录中执行时间范围扫描。复合索引遵循最左前缀规则;把范围列放在前面,通常会削弱后续列用于进一步定位的能力。但列顺序不能只靠“选择性最高优先”口号决定,还要同时考虑等值/范围、排序、分组和现有查询组合。
本例只返回 COUNT(*),索引包含过滤所需列,因此优化器可以使用 covering index,不必回表读取 payload。真实业务若需要大量其他列,是否覆盖、回表成本和索引体积都要重新评估。
第五步:检查索引是否重复、是否值得维护
每个二级索引都会占用磁盘,并增加 INSERT、UPDATE、DELETE、页分裂、缓存和备份成本。建索引前检查:
SHOW INDEX FROM events;
SELECT *
FROM sys.schema_redundant_indexes
WHERE table_schema = DATABASE()
AND table_name = 'events';
不要为每条慢 SQL 创建一个宽索引。合并索引时要验证所有重要查询,防止改善一条查询却让另一条失去合适前缀。对低选择性的布尔字段,单列索引经常无法减少足够扫描量。
第六步:生产建索引前评估 DDL 风险
在与生产版本、表结构和数据量接近的环境测量建索引耗时、额外磁盘、redo/binlog、复制延迟和并发写影响。MySQL 对不同 DDL、数据类型和版本可使用的 online/in-place/instant 能力不同,不能默认 CREATE INDEX 完全不阻塞。
准备变更窗口、空间余量、终止条件和回滚方案。索引创建失败或被取消也可能需要清理临时空间。大表应使用组织批准的 online schema change 流程,并监控副本与业务延迟。
复核与回滚
索引完成后重新执行 SHOW INDEX、普通 EXPLAIN 和受控的 EXPLAIN ANALYZE。确认:
- 查询结果与变更前一致;
- 实际访问路径使用目标索引;
- 扫描行数和循环次数按预期下降;
- 应用 P95/P99、数据库 CPU、buffer pool 和写入延迟没有退化;
- 复制延迟与磁盘使用保持安全。
回滚索引前同样评估 DDL 风险,并确认没有其他查询已经依赖它。仅因一次执行计划没有使用某索引,不足以证明它可以删除。
V2CE 复现实验记录
实验表包含 100,000 行、1,000 个租户和确定性日期分布。初始只有主键。查询 tenant_id=777 且日期不早于 2025-07-01 时返回 51。建索引前 EXPLAIN ANALYZE 明确显示 Table scan on events,实际扫描 100,000 行,过滤后留下 51 行;该次实验执行约 115 毫秒。
添加 (tenant_id, created_at) 并更新统计信息后,同一查询仍返回 51。执行计划变为 Covering index range scan,实际读取 51 行;该次实验约 10.2 毫秒。时间数字只代表这台隔离环境的一次运行,但访问路径从 100,000 行缩小到 51 行是可复核的结构性变化。