我经常在技术群里看到这类求助:有人贴一段EXPLAIN结果,说相关列明明加了索引,SQL却还是慢得离谱。每次遇到这种问题,我基本都会先反问一句:你确认过EXPLAIN里type走到了哪一级吗?key_len算过没有?Extra那一栏有没有出现Using filesort?多数人回复我的都是沉默——因为大家习惯了"EXPLAIN看一下",却很少真正把这张表读完整。
这篇文章想做的,就是把EXPLAIN这件事讲透:每个字段背后对应什么执行逻辑,索引优化到底在优化什么,以及当一条SQL从3秒优化到30毫秒时,中间经历了怎样的排查链路。适合有SQL基础、但还没有系统啃过执行计划的人,也适合那些被慢查询折腾过却始终不得其解的开发者。读完你就能具备一个基本能力:拿到任何一条慢SQL,知道先看什么、再判断什么、最后改什么。
1. 一条“走了索引还是很慢”的SQL,揭开EXPLAIN的真正价值
1.1 先复现一个让人上火的场景
假设有一张订单表,600万行数据。业务侧反馈:用户端"我的订单"页面打开特别慢。后来DBA抓到一条慢SQL:
SELECT order_no, amount, status, create_time FROM t_user_order WHERE user_id = 1024 ORDER BY create_time DESC LIMIT 20;这条SQL有索引吗?有。表上明明建了idx_user_id(user_id),EXPLAIN的结果也显示type=ref,key=idx_user_id,看起来一切正常。但实际执行就要800多毫秒。
你看,问题恰恰出在这里:索引只是解决了"怎么找到user_id=1024的数据",却没有解决"怎么按create_time排序"。最终优化器只能从索引里抓出这几千条记录,再丢到排序缓冲区里做一次filesort。一个看似走了索引的查询,其实在索引之外还偷偷干了一大堆活。
1.2 EXPLAIN到底在做什么
EXPLAIN的本质,是MySQL优化器对这条SQL生成的一份"执行计划说明书"。优化器会基于表统计信息、索引结构、ref选择方式等,估算出若干种执行路径,然后选择一个它认为成本最低的方案。EXPLAIN给出的,就是这个最终方案的拆解。
所以你在EXPLAIN里看到的不是"SQL执行后的真实结果",而是"优化器认为这条SQL该以怎样的步骤执行"的预估描述。这个区别很重要——因为它是预估值,所以存在失真可能;也因为它是过程描述,所以能暴露大量执行细节。真正要理解EXPLAIN,就要理解优化器的决策逻辑:它为什么选这个索引?它为什么预估要扫那么多行?它为什么宁可全表扫也不走索引?
1.3 怎么学EXPLAIN才不走弯路
很多教程把EXPLAIN讲成了字典,让你记住"type有几种、Extra有几种"。真遇到问题的时候,光记住这些名词没用。我自己的经验是,读EXPLAIN要带着三个问题:
- 这条SQL的访问路径是什么?从哪个表开始,每个表用没用索引,用什么方式访问。
- 索引用到了什么程度?是只用了索引的第一个列,还是完整用上了联合索引。
- Extra里有没有额外的操作?排序、临时表、回表,这些才是性能杀手。
带着这三个问题去读,EXPLAIN就不再是一张死表格,而是一条有逻辑链的执行叙述。接下来我就按这个思路把字段逐个拆开。
2. EXPLAIN字段全拆解:type、key_len、rows、Extra里的诊断线索
2.1 id与select_type:你的查询被拆成了几步
先看id。它标识的是执行计划中表的读取顺序。id越大越先执行,相同id则从上往下执行。碰到子查询、联合查询时,id能帮你理清执行嵌套关系。
select_type主要有SIMPLE、PRIMARY、SUBQUERY、DERIVED、UNION等。其中DERIVED值得留意——如果你的查询里出现了派生表(FROM子句的子查询),MySQL一般会先把它物化成临时表,再用临时表参与JOIN。这往往是一个性能隐患信号。
EXPLAIN SELECT u.user_name, tmp.total_amount FROM t_user u JOIN ( SELECT user_id, SUM(amount) AS total_amount FROM t_user_order GROUP BY user_id ) tmp ON u.id = tmp.user_id;这种写法常见但性价比很差。遇到应该考虑用JOIN改写或加合适索引消除派生表物化。
2.2 type性能阶梯:从ALL到const,每一级代表什么
type是整个EXPLAIN里最值得看的字段。从好到差依次是:
| type | 含义 | 典型场景 | 说明 |
|---|---|---|---|
| system | 表只有一行 | 系统表 | 基本见不到 |
| const | 最多匹配一行 | 主键或唯一索引等值查询 | 最优 |
| eq_ref | 每次驱动表行只匹配被驱动表一行 | 被驱动表用主键或唯一索引连接 | JOIN场景最优 |
| ref | 非唯一索引等值匹配 | 普通索引等值查询 | 常见的最优 |
| range | 索引范围扫描 | BETWEEN、IN、>、< 等 | 可控范围查询 |
| index | 全索引扫描 | 覆盖索引扫全表 | 比ALL好一点,但要小心 |
| ALL | 全表扫描 | 无可用索引 | 需要重点优化 |
判断原则很直接:type至少到range,最好是ref或const。看到ALL基本等于全表扫描,index则要确认是不是覆盖索引捞数据,如果是覆盖索引倒不算太差,毕竟比ALL少了一次回表。
2.3 key_len与ref:联合索引到底用到了哪几列
这两个字段是联合索引诊断的核心。
key显示的是优化器最终选中的索引名。possible_keys会列出所有可能用到的索引,key只表示实际选的。如果你的possible_keys是空的,说明这条SQL根本没有任何索引可走;如果possible_keys有值但key为空,说明优化器认为即使有索引也不会更快,多半是要全表扫。
key_len则是被选中索引的字节长度。它的计算规则有固定套路:INT是4字节,BIGINT是8字节,DATETIME在5.6.4之后一般是5字节,VARCHAR(50)在utf8mb4下是50×4+2=202字节,如果列允许NULL还要再加1字节。可空性、字符集、变长字段头都会影响最终值。
为什么要费劲去算key_len?因为它能精确告诉你联合索引到底用到了哪几列。比如联合索引(a,b,c),key_len如果等于len(a),说明只用到了第一列;如果等于len(a)+len(b),说明用到了前两列。尤其是排查"为什么索引没完全生效"时,key_len是唯一的硬证据。
ref这个字段则与key配合,告诉你索引列是用什么做匹配的。常见的是const(等值常量)、某个列名(与另一列等值连接),或者func(函数结果),看到func要警惕,通常意味着索引利用不充分。
2.4 rows与filtered:读懂优化器的成本估算
rows是优化器估算的需要读取的行数。注意!它只是一个估算值,不是实际扫描行数。但这个数字对判断问题严重程度非常有价值。一条SQL如果rows预估在几十万,即使type=ref,也说明筛选出的记录很多,后面一定还有大量回表和过滤操作。
filtered表示经过SQL条件过滤后剩余记录的比例,单位是百分比。它和rows相乘,才是真正要返回给上层操作的数据量。比如rows=10000,filtered=10.00,意味着大约有1000行会被保留,参与后续操作。
这里有个小技巧:当你发现rows和实际执行耗时严重不成比例时,往往是小部分数据均匀度出了问题,或者统计信息过期了。遇到这种情况先执行ANALYZE TABLE更新统计信息,再重新EXPLAIN看看。
2.5 Extra关键标记:隐藏的操作开销都写在这里
Extra里出现的字眼,往往直接决定这条SQL快不快。需要格外留意几个:
- Using index:走了覆盖索引,查询所需字段全部在索引里,不需要回表。这是非常好的信号。
- Using where:在存储引擎返回记录后,Server层再次过滤条件。看到它说明有一部分筛选没有完全下推到索引层面。
- Using filesort:排序操作无法使用索引顺序,必须额外排序。这是性能杀手,后面会有专门解读。
- Using temporary:使用了临时表,常见于GROUP BY或DISTINCT处理,也是大开销信号。
- Using index condition(ICP):MySQL 5.6引入的索引下推优化,表示部分WHERE条件被下推到索引层面提前过滤。这是好信号。
这几个标记组合起来,能还原一条SQL的大部分行为。下一节我们重点聊聊索引失效的常见场景,这些场景多与Extra和type的异常表现挂钩。
3. 索引失效的六大典型场景与优化器背后的逻辑
3.1 对索引列做函数运算
最经典的场景:
SELECT * FROM t_user_order WHERE YEAR(create_time) = 2024;如果create_time上有索引,这条SQL基本走不上。原因在于B+树索引存储的是原始列值,索引的有序性是建立在原始值之上的。你把YEAR(create_time)当条件时,优化器无法直接利用create_time的原始排序去定位区间,只能把每一行的create_time都取出来计算YEAR值,再判断是否等于2024。这等于把索引的快速定位功能完全绕开了。
正确写法是:
SELECT * FROM t_user_order WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01';改写成范围条件后,优化器可以直接用B+树上的有序性做区间扫描,type=range。
类似还有DATE(create_time)=...、DATE_FORMAT(col,...)=...、LENGTH(col)=...这类写法,都要警惕。
3.2 隐式类型转换
这个坑比想象中更容易踩。最常见的是电话号码、身份证号这类被设计成VARCHAR的列:
SELECT * FROM t_user WHERE mobile = 13800138000;mobile定义是varchar(11),右边却传了一个整数。MySQL在比较时会把字符串转成数字再比,相当于对索引列做了CAST(mobile AS SIGNED),结果又回到函数运算的问题上:索引失效。
这类问题在EXPLAIN上看不到特别明显的标志,type可能还是ref,但rows会异常地大。排查时要仔细观察字段定义和传入参数类型是否一致。简单粗暴的解决办法就是写SQL时用引号包起来:WHERE mobile = '13800138000'。
3.3 联合索引最左前缀的边界
联合索引(a,b,c),查询条件是WHERE b=1 AND c=2,这样a没出现在条件里,整个索引基本废掉。这是最左前缀原则的核心约束:索引的有序性是先按a排,再按b排,最后按c排。跳过了a,后面的b、c就无法参与连续的索引定位。
在MySQL 8.0里推出了Skip Scan优化,特定条件(比如a的区分度非常低)下可以跳跃扫描来部分利用索引,但这不是银弹。宁可理解为:设计联合索引时,要预判查询条件里会稳定出现哪些列,把最常等值匹配的列放在最前面。
另外有些开发会问:WHERE a=1 AND b=2 和 WHERE b=2 AND a=1 效果一样吗?一样的。优化器会做条件重排,最左前缀关心的是「哪些列条件存在」,不关心它们在SQL里写的先后顺序。
3.4 LIKE前置通配符与%位置
SELECT * FROM t_user WHERE user_name LIKE '%张%';这种需求很常见,但索引真的无能为力。原因和函数运算类似:B+树索引只能按前缀匹配定位,'%张%'意味着你要匹配的位置不确定,无法利用有序性做区间扫描。
如果确实要支持"包含"查询,有几个方案:一是考虑全文索引或倒排索引;二是如果查询模式是'张%'这种前缀匹配,那索引可以正常用;三是引入外部搜索引擎。至少要知道:LIKE右侧通配符能走索引,左侧通配符不能。
3.5 OR条件与索引合并
SELECT * FROM t_user_order WHERE user_id = 1024 OR order_no = 'NO20240601001';假设user_id和order_no各自有单列索引,这条SQL可能走不上任何一个,因为你用OR把两个条件合并了。MySQL确实有Index Merge优化,能让OR两边都走索引再合并结果,但两个条件必须都有可用的索引,且优化器评估合并成本更划算才会选。
常见陷阱是:一边条件有索引另一边没有,优化器只能全表扫描。遇到OR查询,更稳的做法是拆成两条SQL用UNION ALL合并,或者改写IN。注意IN在合适情况下可以走range访问,这和OR是完全不同的执行路径。
3.6 排序、分组与回表成本的权衡
ORDER BY create_time LIMIT 10这种查询,单独看create_time有索引,但如果你还要SELECT其他字段,优化器会面临一个选择:是按索引顺序读取并回表10行就结束,还是先扫全表找出符合条件的数据再排序。多数情况下,如果WHERE过滤出来的行数较多,且返回行数不固定,优化器宁可放弃索引排序。
这一类问题在Extra里会看到Using filesort。我的判断思路是:优先把排序字段和等值条件组合成联合索引。等值条件放前面,排序字段放后面,这样既能过滤又能排序,彻底消灭filesort。
4. 联合索引设计实战:从最左前缀到覆盖索引的取舍
4.1 联合索引的列顺序决策
联合索引是最常用也最容易设计错的索引。设计时核心就一句话:等值条件优先,范围条件靠后,排序字段最后面(或与范围条件互换考虑)。
举个例子。查询经常是:
SELECT * FROM t_user_order WHERE status = 1 AND pay_time BETWEEN '2024-06-01' AND '2024-06-30' ORDER BY pay_time;此时设计索引(status, pay_time)就比单列索引(status)好很多。因为status=1先过滤出目标子集,pay_time再在这个子集里做范围扫描,同时排序也能顺带用上同一棵索引树。
如果你把顺序反了,索引(pay_time, status),那结果是:先按pay_time范围查出大量数据,再在这些数据里过滤status。范围查询切断了很多索引匹配的可能,后续的status只能一层层回表过滤,效率低。
4.2 覆盖索引:让Extra直接出现Using index
覆盖索引是最被低估的优化手段。它指的是查询涉及的所有列都包含在同一个索引里,这样查询直接遍历索引就能拿到全部数据,无需回表。
还是用前面的例子:
SELECT order_no, amount, status, create_time FROM t_user_order WHERE user_id = 1024 ORDER BY create_time DESC LIMIT 20;如果只有idx_user_id(user_id),那么定位到user_id=1024的所有记录后,每一条都要根据主键回表,才能取到order_no、amount、status这些字段。一次回表约等于一次随机读,如果这个用户的订单有几千条,回表开销就很可观。
但如果把索引设计成idx_user_order_cover(user_id, create_time, order_no, amount, status),查询结果全部可以从索引树里拿到,Extra会显示Using index,成本立刻降下来。
不过覆盖索引也有代价:索引列越多,索引树越大,写入和更新时的维护成本越高。通常只在高频核心查询上做覆盖索引,不要每个查询都堆字段。
4.3 ICP索引下推:5.6引入的隐性优化
MySQL 5.6之后,优化器多了一个索引下推(Index Condition Pushdown)能力。它在遍历索引时,会把部分WHERE条件下推到存储引擎层,在索引内提前过滤,减少回表次数。
比如联合索引(zipcode, lastname),查询:
SELECT * FROM t_user WHERE zipcode = '100000' AND lastname LIKE '张%';不使用ICP时,存储引擎按zipcode='100000'取出所有索引项,再逐条回表,回表后再过滤lastname。使用ICP后,在遍历索引的同一棵树上,lastname LIKE '张%'的条件在索引内部就被判断,不满足的直接不回表。
你在EXPLAIN的Extra里会看到Using index condition。这个优化是自动发生的,不需要改写SQL。但理解它的存在,能帮你解释一个现象:有些联合索引看起来违反最左前缀,却能部分生效,这正是ICP在起作用。
4.4 索引基数与前缀索引:低区分度列怎么处理
索引有一个重要指标叫基数(Cardinality),表示索引列去重后的值个数。区分度越低(比如status只有0、1、2三种值),索引选择性就越差。优化器评估时,如果觉得用这个索引过滤后还是要回大量行,倒不如直接全表扫更快。
所以不要给gender、status这类低区分度的列单独建索引,收益极小而浪费空间。如果业务必须用这类列过滤,优先考虑将它与高区分度列组合成联合索引,让高区分度列在前面引导扫描。
字符串前缀索引也是一个常用技巧。比如存储用户邮箱email,可以在email前10个字符上建索引:
ALTER TABLE t_user ADD INDEX idx_email(email(10));注意这会导致一些查询无法利用覆盖索引,但能显著缩小索引体积。取舍方法:先试几个长度值,对比区分度变化。
5. 完整调优案例复盘:一条3秒查询如何一步步降到30毫秒
5.1 业务背景与慢SQL
我们有个订单宽表t_order_info,约600万行。业务方反馈"按用户查最近订单"的接口超时严重。拿到的核心SQL是:
SELECT order_no, amount, status, create_time FROM t_order_info WHERE user_id = 1024 ORDER BY create_time DESC LIMIT 20;初始表结构如下:
CREATE TABLE t_order_info ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, order_no VARCHAR(32) NOT NULL, status TINYINT NOT NULL DEFAULT 0, amount DECIMAL(10,2) NOT NULL, create_time DATETIME NOT NULL, KEY idx_user_id(user_id), KEY idx_create_time(create_time) ) ENGINE=InnoDB;user_id和create_time各有一个单列索引,看起来挺齐全,实测却要3秒。
5.2 第一轮EXPLAIN:全表扫描的诊断
直接跑EXPLAIN:
EXPLAIN SELECT order_no, amount, status, create_time FROM t_order_info WHERE user_id = 1024 ORDER BY create_time DESC LIMIT 20;关键输出:
| 字段 | 值 |
|---|---|
| type | ref |
| key | idx_user_id |
| key_len | 4 |
| rows | 3520 |
| Extra | Using filesort |
问题很清楚:虽然用上了idx_user_id,但user_id=1024这一个用户就有3520条订单,然后还要对这3520条记录做filesort,回表取字段。整体耗时大部分花在排序和回表上。
这里我判断,直接在现有两个单列索引之间做文章已经无效,需要联合索引同时覆盖过滤和排序。
5.3 联合索引调整与验证
我把两个单列索引改为组合索引(user_id, create_time):
ALTER TABLE t_order_info DROP INDEX idx_user_id, DROP INDEX idx_create_time, ADD INDEX idx_user_time(user_id, create_time);再次EXPLAIN:
| 字段 | 值 |
|---|---|
| type | ref |
| key | idx_user_time |
| key_len | 8 |
| rows | 3520 |
| Extra | (空) |
key_len从4变成8,说明联合索引的两个列都被用到了。Extra里Using filesort消失了,因为索引本身就是按user_id再按create_time排序的,ORDER BY create_time直接利用索引顺序,无需额外排序。
实际执行时间从约800毫秒降到约40毫秒。已经能用了,但我还想着能不能再进一步。
5.4 覆盖索引的进阶尝试与成本权衡
40毫秒对多数场景已经及格。但这张表是订单核心宽表,查询频率极高,我决定做一次覆盖索引尝试:
ALTER TABLE t_order_info ADD INDEX idx_user_time_cover(user_id, create_time, order_no, amount, status);此时SQL不需要再回表取order_no、amount、status这些字段,EXPLAIN的Extra直接变成Using index。
实测耗时降到约30毫秒以内,且因为不需要回表,IO开销大幅下降。这个优化也有代价:表中现有600万行,新增这么大的联合索引会增加不少存储空间和写入开销。我把这个方案上线前和业务团队确认过:读多写多?如果订单表写入频繁,建议保留上一版(user_id, create_time)联合索引,覆盖索引只作为特别高频接口的补充方案。
最后实际保留的索引策略是:一个(user_id, create_time)联合索引配合业务侧限流,核心接口单独做了覆盖索引。整体查询从优化前的3秒,降到了稳定30毫秒级别。
5.5 EXPLAIN ANALYZE:真实执行才能看到的细节
MySQL 8.0.18开始提供了EXPLAIN ANALYZE,它不只给估算,而是真实执行后返回实际耗时和行数:
EXPLAIN ANALYZE SELECT order_no, amount, status, create_time FROM t_order_info WHERE user_id = 1024 ORDER BY create_time DESC LIMIT 20;输出里能看到每个步骤的实际时间,比如"actual time=0.123..0.532 rows=20"。这个工具最大价值是帮你判断EXPLAIN里的rows估算是否离谱。如果估算行数是3万,实际扫了50万,那统计信息多半过时了,先ANALYZE TABLE刷新再优化。
6. 把EXPLAIN变成肌肉记忆:我总结的几条索引维护经验
用EXPLAIN排查慢SQL这件事,熟练之后会形成一套固定的判断节奏。最后分享几条实践经验,都是踩坑换来的。
第一,索引不是越多越好。每次新增索引前,先用sys.schema_unused_indexes看看有没有长期没被用到的索引,该删就删。冗余索引不仅占空间,还会拖慢写入。
第二,读EXPLAIN时要留意统计信息是否过期。rows如果长期对不上实际行数,执行ANALYZE TABLE,往往比调整索引更立竿见影。
第三,上线新SQL之前养成习惯跑一次EXPLAIN。很多慢SQL问题其实在开发环境就该发现,类型不匹配、忘记带过滤条件、误用OR,这些EXPLAIN一眼就能看出来,等线上出问题再去救火成本高得多。
第四,EXPLAIN输出的是优化器评估的不是实际结果。遇到极端场景,用EXPLAIN ANALYZE拿到真实执行数据,才知道预估和实际差距在哪里。
第五,也是最想强调的一点:索引优化不是把每个查询都优化到完美,而是找到业务高频路径和核心交易的平衡点。覆盖索引很香,但索引体积膨胀后的写入开销同样真实存在。做取舍时,先看业务吞吐比例,再决定索引设计的激进程度。