SQL慢查询调优实战:从全表扫描到索引覆盖的完整复盘

说实话,干了这么多年数据库开发,最怕的不是写复杂SQL,而是半夜两点被运维电话叫醒,告诉你生产环境一个核心接口崩了。上个月我就经历了这么一次------用户中心的订单查询接口,从200毫秒直接飙到30秒,整个页面白屏,用户投诉电话打爆了客服。我一看SQL,写得挺"规矩"的,但就是慢得离谱。后来花了一个小时排查,发现问题出在一个谁都容易忽略的地方。今天就把这次调优的全过程掰开了揉碎了讲给你听,保证你看完之后再碰到类似问题,心里门儿清。
**数据库工程与SQL优化:**一个订单查询从30秒到0.03秒的调优复盘

一、出事了:一个看似正常的SQL拖垮了整个接口
我们系统里有一张 orders 订单表,数据量在800万行左右。业务那边有个需求,查询最近7天已完成的订单列表,返回前20条。原始SQL长这样:
sql
SELECT
o.id, o.order_no, o.user_id, o.amount, o.status, o.created_at,
u.nickname, u.avatar
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE o.status = 'COMPLETED'
AND o.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
ORDER BY o.created_at DESC
LIMIT 20;
乍一看,这SQL写得挺规范的------有WHERE过滤,有JOIN关联,有ORDER BY排序,有LIMIT分页。但就是这么一条"看起来没毛病"的语句,直接把接口干趴了。响应时间从200毫秒飙到30秒以上,用户体验基本归零。

二、先别慌,用EXPLAIN把真相挖出来
碰到慢查询,很多人第一反应是改SQL或者加索引,但这是瞎搞。正确的做法是先看执行计划,用 EXPLAIN 把MySQL到底怎么执行这条语句的过程看清楚。
优化前的执行计划如下:
id select_type table type possible_keys key key_len ref rows Extra
1 SIMPLE o ALL idx_status,idx_created_at NULL NULL NULL 8123456 Using where; Using filesort
1 SIMPLE u eq_ref PRIMARY PRIMARY 8 db.o.user_id 1 NULL
这张表一出来,问题就清清楚楚了:
1、orders 表走的是 type = ALL,也就是全表扫描,整整扫了812万行数据。
2、Extra 列里出现了 Using filesort,说明排序操作没有用到索引,MySQL得把所有符合条件的数据先捞出来,然后在内存里再排一遍序,这在大数据量下就是噩梦。
3、users 表走的是主键等值查找,这部分其实没啥问题,瓶颈全在 orders 表上。
**再看索引情况:**possible_keys 显示有 idx_status 和 idx_created_at 两个单列索引,但 key 列是 NULL,说明优化器压根没用上任何一个。为什么?因为WHERE条件里同时有 status = 'COMPLETED' 和 created_at >= 某时间 两个条件,而这两个条件分别对应不同的单列索引,MySQL优化器一看,觉得用哪个都不划算,干脆全表扫描得了。

三、深挖根因:三重打击叠在一起
把问题拆开来看,这条慢查询其实是被三个问题同时击中了:
1、索引失效导致全表扫描:现有的 idx_status 和 idx_created_at 都是单列索引,无法同时满足两个过滤条件。MySQL在这种情况下,往往选择放弃索引,直接全表扫描。你可以理解为:你有两把钥匙,但锁有两道,一把钥匙开不了两道锁,最后干脆把门拆了。
2、排序触发文件排序:ORDER BY o.created_at DESC 这个排序,因为 created_at 的单列索引没法和 status 条件配合使用,所以MySQL无法利用索引完成排序,只能额外开一块内存区域做 filesort。800万行数据的排序,光这一步就够喝一壶的。
3、大量回表操作:查询要返回 id, order_no, user_id, amount, status, created_at 这些字段,但现有的单列索引都不包含这些字段,所以每匹配到一行就得回表去聚簇索引里查一遍完整数据。800万行全表扫描,意味着800万次回表,I/O开销直接爆炸。
**总结一句话:**索引没建对,全表扫描、文件排序、大量回表三重打击叠加,不慢才怪。

四、动手优化:一个联合索引解决所有问题
分析清楚了根因,优化方案其实就呼之欲出了------建一个联合索引,把过滤条件和排序字段都涵盖进去,同时尽量做成覆盖索引。
1、建立联合索引
sql
ALTER TABLE orders ADD INDEX idx_status_created (status, created_at);
为什么 status 放前面,created_at 放后面?这里面有讲究。联合索引遵循最左前缀原则,等值查询的字段应该放在最左边。status = 'COMPLETED' 是等值条件,放在索引第一位,MySQL可以快速定位到所有 COMPLETED 状态的数据。而 created_at 是范围条件,放在后面,在 status 定位好的数据范围内,created_at 本身已经是有序的了,ORDER BY created_at DESC 就可以直接利用索引顺序,不需要额外排序。
2、进阶:做成覆盖索引
如果想把性能再往上提一档,可以把SELECT需要的字段也加到索引里:
sql
ALTER TABLE orders ADD INDEX idx_status_created_cover
(status, created_at, id, order_no, user_id, amount);
这样一来,所有需要的字段都在索引树上了,MySQL从头到尾只需要读索引,连回表都省了。当然,覆盖索引的代价是索引会变大,写入时的开销也会增加,所以要根据业务的读写比例来权衡。像我们这个场景,读多写少,覆盖索引完全合适。

五、效果验证:数据不会说谎
加上联合索引之后,再跑一遍 EXPLAIN:
id select_type table type possible_keys key key_len ref rows Extra
1 SIMPLE o range idx_status_created idx_status_created 52 NULL 1523 Using index condition; Using where
1 SIMPLE u eq_ref PRIMARY PRIMARY 8 db.o.user_id 1 NULL
变化一目了然:
1、type 从 ALL 变成了 range,说明走了索引范围扫描,不再全表扫描了。
2、扫描行数从812万行降到了1523行,降了5000多倍。
3、Extra 里的 Using filesort 消失了,排序直接利用索引完成。
4、如果用了覆盖索引,连 Using index 都会出现在Extra里,表示全程只读索引。
实际执行时间呢?从原来的 30秒+ 直接降到了 0.03秒,提升了将近1000倍。接口恢复正常,老板终于不催了。

六、几个容易踩的坑,提前帮你避掉
☆ 1、联合索引的字段顺序极其关键。等值查询字段放左边,范围查询字段放右边。如果你把 created_at 放在 status 前面,那 status 条件就用不上索引了,等于白建。记住一句话:等值在前,范围在后。
☆ 2、ORDER BY 的方向要和索引一致。如果你建了 (status, created_at) 联合索引,索引里 created_at 默认是升序排列的。你的SQL写的是 ORDER BY created_at DESC(降序),MySQL在某些版本下可以做索引逆序扫描,但不是所有情况都支持。最稳妥的做法是SQL里写 ORDER BY created_at ASC,或者在建索引时指定降序(MySQL 8.0支持)。
☆ 3、LIMIT 不能拯救烂SQL。很多人觉得加个 LIMIT 20 就万事大吉了,但 LIMIT 只是减少返回行数,不减少扫描行数。MySQL可能还是要扫描几十万行才能找到那20行。优化的核心永远是减少扫描行数,别指望 LIMIT 救你。
☆ 4、WHERE条件里别放函数。像 DATE_SUB(NOW(), INTERVAL 7 DAY) 这种写法,会导致索引失效,因为MySQL没法对函数结果建索引。正确的做法是把计算放到应用层,传具体的时间值进去:
sql
-- 不推荐
WHERE o.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
-- 推荐
WHERE o.created_at >= '2025-05-10 00:00:00'
☆ 5、定期维护索引和统计信息。索引不是建完就完事了,数据不断增删改,索引会产生碎片,统计信息会过期,优化器可能做出错误判断。建议定期执行 ANALYZE TABLE orders; 更新统计信息,必要时做 OPTIMIZE TABLE orders; 整理碎片。

七、再往深了聊:更复杂场景怎么处理
上面讲的是单表查询优化,实际工作中你还会碰到更复杂的场景,这里再分享几个实战思路:
1、多表JOIN的优化原则:让驱动表(EXPLAIN结果第一行的表)的扫描行数尽可能少。基本原则就是小表驱动大表。如果两个表都很大,那就考虑先用子查询或临时表把数据集缩小,再做关联。
2、子查询和JOIN怎么选:在MySQL 5.7之前,子查询的性能通常不如JOIN。但MySQL 8.0之后,优化器对子查询的处理已经好了很多。如果子查询能命中索引且返回结果集很小,有时候反而比JOIN更快。别死记结论,具体情况具体分析。
3、分区表的使用:如果数据量真的到了千万级甚至亿级,单靠索引可能已经不够了。可以考虑按时间范围做分区,比如按月分区。查询近7天数据时,MySQL只需要扫描最近一两个分区,直接把数据量砍掉一个数量级。
4、EXPLAIN ANALYZE的妙用:MySQL 8.0之后支持 EXPLAIN ANALYZE,它不仅显示执行计划,还会真正执行这条SQL并给出实际的耗时和行数。比普通 EXPLAIN 更靠谱,因为普通EXPLAIN的 rows 只是估算值,不一定准。

八、写在最后
SQL优化这事,说难也难,说简单也简单。难的是每个业务场景都不一样,没有银弹;简单的是方法论是通用的------先用EXPLAIN看执行计划,找到瓶颈在哪,然后针对性地建索引、改写法,最后用数据对比验证效果。
把"看计划→找瓶颈→定方案"这三步养成习惯,你就是团队里的SQL调优高手。
希望这篇复盘对你有帮助,如果你也遇到过类似的慢查询噩梦,欢迎在评论区聊聊你的调优经历,咱们一起涨经验。

💡注意:本文所介绍的软件及功能均基于公开信息整理,仅供用户参考。在使用任何软件时,请务必遵守相关法律法规及软件使用协议。同时,本文不涉及任何商业推广或引流行为,仅为用户提供一个了解和使用该工具的渠道。
你在生活中时遇到了哪些问题?你是如何解决的?欢迎在评论区分享你的经验和心得!
希望这篇文章能够满足您的需求,如果您有任何修改意见或需要进一步的帮助,请随时告诉我!
感谢各位支持,可以关注我的个人主页,找到你所需要的宝贝。
博文入口:https://blog.csdn.net/Start_mswin 复制到【浏览器】打开即可,宝贝入口:https://pan.quark.cn/s/b42958e1c3c0 宝贝:https://pan.quark.cn/s/1eb92d021d17
作者郑重声明,本文内容为本人原创文章,纯净无利益纠葛,如有不妥之处,请及时联系修改或删除。诚邀各位读者秉持理性态度交流,共筑和谐讨论氛围~
📋 复制整篇文章