慢查询排查实录:从全表扫描到毫秒响应,我只改了一个索引
凌晨三点,生产环境订单接口突然告警,响应时间从200ms直接飙到47秒。我盯着监控大屏上飘红的告警曲线,手心全是汗。就这么一条看似人畜无害的查询语句,差点让整个交易系统瘫痪。接下来三天,我把这条SQL翻来覆去地拆解,索引策略从头捋了一遍,最终把耗时从47秒压到了0.03秒。这篇文章,就是我这次排查的全部心得,从踩坑到方法论,一个字不藏,全掏给你。
一、案发现场:一条查询是怎么把数据库拖垮的
我们先还原一下出问题的那张表。这是一张订单表,数据量大概1200万行,表结构长这样:
sql
CREATE TABLE t_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL COMMENT '订单号',
user_id BIGINT NOT NULL COMMENT '用户ID',
shop_id BIGINT NOT NULL COMMENT '店铺ID',
status TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态:0待支付 1已支付 2已发货 3已完成',
total_amount DECIMAL(10,2) NOT NULL COMMENT '订单金额',
create_time DATETIME NOT NULL COMMENT '创建时间',
pay_time DATETIME DEFAULT NULL COMMENT '支付时间',
remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
INDEX idx_user_id (user_id),
INDEX idx_create_time (create_time),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
慢查询日志里抓到的那条SQL是这样的:
sql
SELECT *
FROM t_order
WHERE user_id = 102345
AND status = 2
AND create_time >= '2025-01-01 00:00:00'
ORDER BY create_time DESC
LIMIT 20;
跑一遍EXPLAIN,结果让人头皮发麻:
id select_type table type possible_keys key key_len rows Extra
1 SIMPLE t_order ALL idx_user_id,idx_status,idx_create_time NULL NULL 11876543 Using where; Using filesort
type列显示的是ALL,说明全表扫描;key列是NULL,一个索引都没用上;Extra里明明白白写着Using filesort,排序在内存里硬扛。1200万行数据,逐行过滤再手动排序,47秒一点都不冤。
二、排查过程:三个单列索引为什么集体罢工
1、我第一反应是检查索引有没有建。翻了建表语句,user_id、status、create_time三个字段各有一个单列索引,看起来挺齐全的。但问题恰恰出在这里——MySQL的优化器面对多个单列索引时,在5.7之前只能选其中一个来用,这就是所谓的"索引合并"机制。而索引合并的效率,跟联合索引比起来,差的不是一点半点。
2、我先用FORCE INDEX挨个试了一遍。强制走idx_user_id,扫描行数从1200万降到了8万多,type变成了ref,但还是慢,因为后面还有两个条件要过滤。强制走idx_create_time更惨,时间范围太大,直接扫描了600多万行。单独靠任何一个索引,都接不住这条查询。
3、关键的转折出现在我重新审视WHERE条件的时候。三个条件里,user_id = 102345是等值查询,status = 2也是等值查询,只有create_time >= '2025-01-01'是范围查询。根据联合索引的最左匹配原则,等值条件必须放在最前面,范围条件放在最后。而且查询里还有ORDER BY create_time DESC,这个排序字段也得考虑进去。
4、于是我做了一个改动,把三个单列索引全部删掉,建了一个联合索引:
sql
ALTER TABLE t_order DROP INDEX idx_user_id;
ALTER TABLE t_order DROP INDEX idx_status;
ALTER TABLE t_order DROP INDEX idx_create_time;
ALTER TABLE t_order ADD INDEX idx_user_status_time (user_id, status, create_time);
5、重新EXPLAIN,结果变成了这样:
id select_type table type possible_keys key key_len rows Extra
1 SIMPLE t_order ref idx_user_status_time idx_user_status_time 17 342 Using index condition
type从ALL变成了ref,扫描行数从1200万骤降到342行,key_len是17个字节(user_id占8字节,status占1字节,create_time占8字节),Extra里的Using index condition说明索引下推(ICP)也生效了。查询耗时从47秒直接掉到了0.03秒,我盯着这个数字看了好几遍,确认不是眼花。
三、深入理解:联合索引背后的B+树逻辑
1、为什么联合索引能起飞?核心原因在于B+树的排序特性。联合索引(user_id, status, create_time)在B+树里是按照这三个字段的字典序排列的:先按user_id排,user_id相同的再按status排,status也相同的再按create_time排。所以当查询条件是user_id = 102345 AND status = 2时,MySQL可以直接在B+树里定位到这个精确的起点,然后沿着create_time的有序链表往后扫。因为索引本身就是按create_time升序排列的,而查询要的是DESC,MySQL会从起点往后扫描到满足时间范围的最后一条记录,再倒序返回,代价非常小。
2、为什么原来的三个单列索引不行?因为每个单列索引只能帮你过滤掉一部分数据。idx_user_id能快速找到这个用户的所有订单,大概8万条,但这8万条里还要再过滤status = 2和create_time的条件,而且最后还要手动排序。三个索引如果走Index Merge,MySQL需要分别扫描三个索引拿到结果集,再做交集运算,这个过程比直接走联合索引慢得多,尤其是在LIMIT 20这种需要快速返回前几条的场景下,联合索引的优势是碾压级的。
3、还有一个很多人容易忽略的细节:索引字段的顺序不是随便排的。我总结了一个口诀——等值在前,范围在后,排序字段紧跟范围条件。这条查询里,user_id和status都是等值,必须放最前面;create_time是范围,放最后;同时因为要按create_time排序,所以它必须出现在索引的最后一位,这样排序才能利用索引的有序性,否则又会出现Using filesort。
4、关于排序方向,MySQL 8.0开始支持显式指定索引的升降序了。如果你用的是8.0以上的版本,可以这样建索引:
sql
ALTER TABLE t_order ADD INDEX idx_user_status_time_desc (user_id, status, create_time DESC);
这样一来,ORDER BY create_time DESC就完全命中索引的排序方向,连倒序扫描的开销都省了。如果是5.7及以下版本,虽然不支持显式降序,但实际效果差别不大,因为MySQL会在索引扫描时做一个小的方向调整,代价可以忽略。
四、实战对比:不同查询场景下的索引策略选择
光说理论不够,我在测试环境里用同样的1200万条数据,模拟了几种常见的查询场景,把不同索引方案的效果做了对比。
1、场景一:只按用户ID查,不带状态和时间
sql
SELECT * FROM t_order WHERE user_id = 102345 LIMIT 20;
联合索引(user_id, status, create_time)依然能用,因为最左匹配只要求从最左边开始连续使用,用到user_id就够了。扫描行数大约200行,耗时0.01秒。
2、场景二:只按状态查,不带用户ID
sql
SELECT * FROM t_order WHERE status = 2 LIMIT 20;
这时候联合索引就完全用不上了,因为跳过了最左边的user_id。如果这种查询很频繁,就需要单独给status建一个索引。我加了INDEX idx_status (status)之后,扫描行数从1200万降到了380万,耗时0.18秒。虽然不如联合索引快,但比全表扫描好太多了。
3、场景三:只按时间范围查
sql
SELECT * FROM t_order WHERE create_time >= '2025-06-01' LIMIT 20;
同样的道理,联合索引用不上。单独加create_time索引后,扫描行数降到15万,耗时0.09秒。
4、我把几种方案的对比数据整理成了表格:
索引方案 场景一耗时 场景二耗时 场景三耗时 索引总数 写入开销评估
三个单列索引 0.02s 0.18s 0.09s 3个 中等
仅联合索引 0.01s 12.3s 11.7s 1个 低
联合索引+单列status 0.01s 0.15s 11.7s 2个 中低
联合索引+单列time 0.01s 12.3s 0.08s 2个 中低
联合索引+status+time 0.01s 0.15s 0.08s 3个 中等
从这张表能看出来,没有万能的方案。如果场景二和场景三的查询频率都很低,那就只保留联合索引,写入开销最小;如果场景三(按时间查)也很高频,那就联合索引加一个create_time单列索引,总共两个索引,读取和写入都能接受。索引设计本质上是一个权衡的艺术,你得根据实际的业务查询模式来做取舍,不能拍脑袋。
五、那些年我踩过的坑,每一个都是血泪教训
1、坑一:索引不是越多越好。我之前接手过一个项目,有张表建了11个索引,INSERT性能直接崩盘。每次插入一行数据,MySQL要同时维护11棵B+树,你想想那个开销有多恐怖。我的经验是,单表索引控制在5个以内比较安全,具体还得看读写比。如果是读多写少的表,可以稍微多一点;如果写入很频繁,每个索引都是负担。
2、坑二:SELECT *是性能的隐形杀手。回到最开始那条慢查询,如果把SELECT *改成只查需要的字段,比如SELECT id, order_no, total_amount, create_time,而且这四个字段恰好都在联合索引里,那MySQL就可以直接走"覆盖索引",连回表都省了。EXPLAIN的Extra列会显示Using index,性能还能再提升一个档次。很多人写SQL图省事,上来就SELECT *,在小数据量下没感觉,数据量一上来,回表的IO开销能把你拖死。
3、坑三:模糊查询的前置通配符会让索引直接报废。WHERE remark LIKE '%退款%'这种写法,B+树根本没法用,只能全表扫描。我见过有人建了索引,跑EXPLAIN发现没走索引,还以为是MySQL出bug了,其实就是查询条件里藏了一个%。如果业务确实需要这种模糊匹配,建议走Elasticsearch,别在MySQL里硬扛。
4、坑四:隐式类型转换是最隐蔽的索引杀手。有一次排查慢查询,发现user_id字段是BIGINT类型,但代码里传参的时候不小心传成了字符串'102345'。MySQL在比较的时候,会把索引列的值隐式转换成字符串,这相当于在索引上套了一个CAST()函数,索引直接失效。这种问题EXPLAIN看不出来,只能靠肉眼比对查询条件和字段类型。我后来养成了一个习惯:写SQL之前先看一眼字段类型,传参的时候严格匹配类型,这个小习惯帮我避免了好几次线上事故。
5、坑五:ORDER BY的字段如果不在索引里,LIMIT再小也救不了你。比如WHERE user_id = 102345 ORDER BY pay_time DESC LIMIT 20,如果索引是(user_id, create_time),pay_time不在索引里,MySQL还是得先把这个用户的所有订单找出来,再按pay_time排序,最后取前20条。如果这个用户有10万条订单,那前面99980条全是白查的。所以设计联合索引的时候,一定要把ORDER BY的字段考虑进去。
六、总结:一套可以复用的索引调优方法论
1、拿到慢查询,第一件事不是改SQL,而是先跑EXPLAIN。重点盯四个字段:type列看是不是全表扫描,key列看有没有走索引,rows列看扫描了多少行,Extra列看有没有filesort和Using temporary。这四个字段能帮你快速定位问题出在哪。
2、分析WHERE条件里的字段组合,按照"等值在前、范围在后、排序字段跟在范围后面"的原则设计联合索引。如果有多个等值条件,区分度高的放前面,比如user_id的区分度肯定比status高,所以user_id放status前面。
3、用EXPLAIN验证新索引的效果,重点对比扫描行数。如果从百万级降到千级甚至百级,基本就对了。如果降得不明显,回去重新审视字段顺序和索引覆盖。
4、检查SELECT的字段列表,能不用SELECT *就别用。尽量让查询走覆盖索引,Extra列显示Using index的时候,性能是最优的。
5、最后别忘了在测试环境跑一下写入性能测试。索引调优不能只看读,写的代价也得算进去。我一般会用sysbench跑一批并发插入,对比加索引前后的TPS变化,确保写入性能在可接受的范围内。
这次从47秒到0.03秒的经历,让我真正体会到了一句话:数据库调优不是靠感觉,是靠方法论。 索引策略看起来简单,但字段顺序、覆盖范围、读写权衡,每一步都有讲究。希望这篇文章能帮你少走几步弯路,下次遇到慢查询的时候,心里有底,手上有招。
💡注意:本文所介绍的软件及功能均基于公开信息整理,仅供用户参考。在使用任何软件时,请务必遵守相关法律法规及软件使用协议。同时,本文不涉及任何商业推广或引流行为,仅为用户提供一个了解和使用该工具的渠道。
你在生活中时遇到了哪些问题?你是如何解决的?欢迎在评论区分享你的经验和心得!
希望这篇文章能够满足您的需求,如果您有任何修改意见或需要进一步的帮助,请随时告诉我!
感谢各位支持,可以关注我的个人主页,找到你所需要的宝贝。
博文入口:https://blog.csdn.net/Start_mswin 复制到【浏览器】打开即可,宝贝入口:https://pan.quark.cn/s/b42958e1c3c0 宝贝:https://pan.quark.cn/s/1eb92d021d17
作者郑重声明,本文内容为本人原创文章,纯净无利益纠葛,如有不妥之处,请及时联系修改或删除。诚邀各位读者秉持理性态度交流,共筑和谐讨论氛围~