“SQL 很慢,加个索引”是最常见、也最危险的性能优化方式。真实问题往往不是缺少某一个索引,而是查询形态、数据分布、排序分页和优化器成本估算共同作用的结果。
一、先定义现象:慢在哪里,而不是“整个 SQL 很慢”
第一步不是改 SQL,而是把请求拆成四段:网关与网络、应用等待、数据库执行、结果集传输。只有数据库执行时间占大头,才进入 SQL 优化。应用日志应记录 SQL 指纹、参数规模、返回行数和连接池等待时间,避免把连接池耗尽误判成数据库慢。
request latency
├── gateway / network
├── connection pool wait
├── database execution
└── result mapping / serialization
随后固定一组具有代表性的参数,在相同数据快照上重复测试。只看一次耗时没有意义,因为缓存预热、并发和参数选择性都会改变结果。
二、读执行计划:重点不是有没有索引,而是实际做了多少工作
执行计划至少关注:驱动表、访问类型、预估行数、实际行数、过滤比例、回表次数、临时表与文件排序。MySQL 8 可以优先使用 EXPLAIN ANALYZE,把“优化器估计”与“真实执行”放在一起看。
EXPLAIN ANALYZE
SELECT ...
FROM order_main o
LEFT JOIN tenant t ON t.id = o.tenant_id
WHERE o.status = ?
ORDER BY o.created_at DESC
LIMIT 100000, 20;
最有价值的信号通常是“预估 100 行,实际扫描 100 万行”。这意味着统计信息、字段相关性或索引选择性让优化器做了错误判断。此时单纯新增索引可能不会被选中。
三、定位四类主要成本
1. 回表:索引命中了,但每一行都要再取整行
二级索引保存索引列和主键。查询列不在索引中时,需要通过主键回到聚簇索引。小结果集问题不大,但深分页先扫描十万条再丢弃时,十万次回表会成为主成本。
2. 排序:过滤索引和排序索引不能同时高效利用
当 WHERE 与 ORDER BY 分别依赖不同索引,优化器需要在“先少扫行再排序”和“按顺序扫描再过滤”之间选择。数据分布变化后,原来正确的选择可能失效。
3. 关联放大:一对多表让中间结果爆炸
多个 LEFT JOIN 并不只是“多查几张表”。如果一个主表行在两张明细表各匹配 5 行,中间结果可能放大到 25 行,再进行排序和去重。应先确认关联基数,再判断是否可以先聚合、后关联。
4. 深分页:OFFSET 的成本不会因为只返回 20 行而消失
LIMIT 100000, 20 仍需找到并丢弃前 100000 行。可使用游标分页;若产品必须支持跳页,可先在覆盖索引中取主键,再回表关联详情。
SELECT detail.*
FROM order_main detail
JOIN (
SELECT id
FROM order_main
WHERE status = ?
ORDER BY created_at DESC, id DESC
LIMIT 100000, 20
) page ON page.id = detail.id
ORDER BY detail.created_at DESC, detail.id DESC;
四、重写方案:让昂贵操作发生在更小的数据集上
案例最终采用的核心原则是:先利用窄索引完成过滤、排序和分页,只保留主键;再把 20 个主键关联回主表与维表。这样排序和 OFFSET 都在更小的索引页上完成,昂贵的回表与多表关联只执行几十次。
| 动作 | 优化前 | 优化后 |
|---|---|---|
| 扫描 | 宽行 + 多表中间结果 | 窄覆盖索引 |
| 回表 | 大量候选行逐行回表 | 只对分页后的主键回表 |
| 排序 | 可能产生临时表与文件排序 | 复合索引提供稳定顺序 |
| 耗时 | 约 49 秒 | 约 0.9 秒 |
验证不能只看平均值。至少比较 P50/P95、逻辑读、扫描行数、返回行数、临时表、CPU 和并发下的连接占用;同时测试不同状态值与不同页码,防止只优化了某一组参数。
五、可复用的 SQL 排查清单
- 确认慢的是数据库执行,而不是连接池或序列化。
- 固定参数和数据快照,保存优化前基线。
- 比较预估行数与实际行数,定位优化器误判。
- 检查回表、排序、临时表、关联放大和深分页。
- 让过滤、排序、分页尽量在窄索引上完成。
- 用真实并发和多组参数回归,观察尾延迟。
- 记录为什么有效,而不是只记录最终 SQL。
性能优化的本质不是写出更“炫”的 SQL,而是减少数据库真正需要完成的工作量。