“SQL 很慢,加个索引”是最常见、也最危险的性能优化方式。真实问题往往不是缺少某一个索引,而是查询形态、数据分布、排序分页和优化器成本估算共同作用的结果。

案例背景:一个包含多表关联、子查询、排序和深分页的列表查询,在数据量增长后从秒级恶化到约 49 秒。优化后稳定在约 0.9 秒。以下只保留可复用的技术结构,不包含业务敏感信息。

一、先定义现象:慢在哪里,而不是“整个 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. 排序:过滤索引和排序索引不能同时高效利用

WHEREORDER 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 排查清单

  1. 确认慢的是数据库执行,而不是连接池或序列化。
  2. 固定参数和数据快照,保存优化前基线。
  3. 比较预估行数与实际行数,定位优化器误判。
  4. 检查回表、排序、临时表、关联放大和深分页。
  5. 让过滤、排序、分页尽量在窄索引上完成。
  6. 用真实并发和多组参数回归,观察尾延迟。
  7. 记录为什么有效,而不是只记录最终 SQL。
性能优化的本质不是写出更“炫”的 SQL,而是减少数据库真正需要完成的工作量。