凌晨两点,我被一通电话从床上拽了起来。线上核心库CPU打到99%,所有订单查询接口全部超时。登录跳板机,show processlist一刷,满屏都是同一条SQL在跑,单次执行就要8秒多。当时的我盯着那条SQL愣了好几秒——联表、索引、字段类型看着都没毛病,怎么就慢成这样了?
那段日子我几乎把市面上能搜到的“SQL优化最佳实践”翻了个底朝天,也在生产环境里踩了无数个或深或浅的坑。后来才慢慢意识到,所谓优化,本质上是在跟数据库的“读数据方式”做博弈。你写的每条SQL,optimizer都要绞尽脑汁猜你想干嘛,猜对了吃索引,猜错了就全表扫描,机器等着,业务卡着,用户骂着。
这篇文章我不想罗列那种“不要用select *”“记得加索引”的正确废话。我会用几个真实折腾过的案例,从执行计划、隐式转换、深分页、并行执行这些角度,讲讲一条慢SQL从发现到彻底治好的完整过程,以及那些你在文档里翻不到、只有被生产毒打之后才懂的细节。不管你是刚接手公司SQL优化任务的初级DBA,还是被慢查询日志折磨到秃头的后端开发,这篇文章应该都能帮你少走点弯路。
1. 一条“看似正常”的SQL为什么会慢得离谱
先还原一下当晚的情况。业务逻辑很简单,查某个时间段内的订单列表,再关联用户表拿昵称。SQL长这样:
sql复制SELECT
o.order_id,
o.amount,
u.nickname
FROM
t_order o
INNER JOIN t_user u ON o.user_id = u.id
WHERE
o.pay_time >= '2024-06-01 00:00:00'
AND o.pay_time < '2024-06-08 00:00:00'
ORDER BY
o.pay_time DESC
LIMIT 20;
这写法挺规矩的,条件字段pay_time上有索引,关联字段user_id也有索引,分页就取20条,怎么看都不像能跑8秒的样子。可现实就是跑了8秒,还拖着整个库一起遭殃。我当时第一反应是索引失效,结果EXPLAIN一看,type是range,key也正常用了idx_pay_time,索引没毛病。
真正拖垮查询的,是在ORDER BY o.pay_time DESC LIMIT 20这一步。怎么理解呢:
- MySQL先从
idx_pay_time索引找到符合条件的行,这个范围挺大的——一周的订单,2000多万行表里筛出200多万行。 - 每找到一行,就要根据主键回表查一次完整行数据。
- 回表完了,还要再拿
user_id去t_user表走一遍关联,拿用户昵称。 - 拿到全部200多万行结果后,按
pay_time排序,最后才取前20条返回。
也就是说,你是从200多万行的数据集里找出最大的20个,但为了这20个,前面200多万行的回表、关联、排序一次没落下。这个场景特别典型,我后来把它总结成一句话:索引帮你圈定了范围,但范围不等于答案。范围有多大,你要付出的代价就有多大。
1.1 回表不是问题,大面积回表才是问题
很多人一听到回表就紧张,觉得回表是万恶之源。其实单次回表走主键聚簇索引,速度非常快,1毫秒都用不了,纯粹按主键随机读的成本可以接受。真正的性能杀手是回表的行数。当你需要回表200万行的时候,就算每次只用1毫秒,也要2000秒的理论时间——当然InnoDB有预读、有缓冲池,实际不至于这么夸张,但性能掉到秒级甚至十秒级,太正常了。
所以优化的核心思路就一条:能不能让排序或者取值直接死在索引里,别回表?
针对那个订单查询,最简单的改动就是建一个覆盖索引:
sql复制ALTER TABLE t_order
ADD INDEX idx_pay_time_user_id (pay_time, user_id);
你可能会说,这不就是普通联合索引吗?对,就是联合索引,但加完之后效果是质变级的。因为pay_time和user_id都进了索引,查询条件走索引判断,获取user_id不需要回表,直接从索引叶子节点拿。这个就叫索引覆盖。做不做,差别非常直观:
| 优化手段 | 扫描行数 | 是否回表 | 实测耗时 |
|---|---|---|---|
| 原始SQL(仅单列索引) | 200万+ | 是 | 8.2s |
| 加覆盖索引后 | 200万+ | 否 | 1.9s |
注意,扫描行数没变,还是200多万行,但少了200多万次回表,时间直接从8秒降到2秒以内。这只是第一步,真正的质变在后面。
1.2 再快一点:延迟关联和预排序
覆盖索引把查询从8秒拉到2秒,2秒对OLTP系统来说还是太慢。接下来要对ORDER BY下手。
还记得MySQL的排序逻辑吗?拿到关联结果后需要排序,排序需要临时表。如果数据量大到超过内存排序阈值,还要在磁盘上用文件排序(filesort),这又是一次灾难。所以我当时的做法是:先单独从t_order表查出要的20个订单ID,再拿着这20个ID去关联用户表和订单表拿完整数据。
sql复制SELECT
