排查慢SQL的时候,我见过太多人盯着 EXPLAIN 结果里的 key 字段看一眼,发现不是 NULL,就长舒一口气:“索引走了,稳了。” 结果数据量一上来,接口照样超时,数据库 CPU 照样被打满。说实话,只盯着 key 判断索引有没有生效,是新手最容易踩的坑。MySQL 的 EXPLAIN 执行计划,是一张能直接告诉你 SQL 到底怎么跑的表,但它不是只靠一列就能读懂的。这篇文章,我想带你系统地过一遍 EXPLAIN 里的关键列,重点讲清楚一个核心问题:索引为什么失效,以及我们到底该怎么从执行计划里找到真实原因。
这篇文章适合谁看?刚接触索引优化的开发同学,被慢查询折磨过几次的后端工程师,以及想理清执行计划各个字段含义的人。我会结合具体的 SQL 现场和真实排查思路,把那些文档里写得不痛不痒、实际却特别要命的细节都讲透。
1. 先搞清楚 EXPLAIN 到底在“解释”什么
1.1 为什么说“别只盯着 key”
我刚开始用 EXPLAIN 的时候,和很多人一样,只关心 key 这一列。后来遇到一个典型的线上问题:一个订单查询接口,where 条件里有状态字段,也建了联合索引,EXPLAIN 输出里 key 确实显示了那个索引,但查询依然慢得离谱。
后来仔细看才发现,type 显示的是 index,不是 ref,也不是 range。这是什么意思?type=index 表示这个查询扫描了整个索引树,而不是通过索引精确定位到某几行。相当于你有一本字典,不去按拼音查目录,而是把整本字典从头到尾读了一遍,虽然碰巧读的是“目录页”而不是“正文页”,但代价依然很大。问题就出在 where 条件里用了联合索引的非最左列,优化器只能选择全索引扫描来“硬扛”。如果我当时只看 key,这问题永远排查不出来。
所以“别只盯着 key”不是一句口号,而是执行计划分析的第一步:key 只是冰山一角,type、key_len、rows、filtered、Extra 组合起来,才能还原一条 SQL 的真实执行路径。
1.2 EXPLAIN 输出字段速查
EXPLAIN 的结果有很多列,每一列都有自己的诊断价值。我平时最关注下面几张表格里的字段。
| 字段 | 含义 | 判断要点 |
|---|---|---|
| id | 查询中每个 SELECT 子句的序号 | 多表连接时,id 相同从上往下执行;子查询时 id 递增,越大的越先执行 |
| select_type | SELECT 的类型 | SIMPLE 是简单查询,SUBQUERY、DERIVED、UNION 等需要留意 |
| table | 当前行访问的是哪张表 | 多表查询时看访问顺序 |
| partitions | 命中的分区 | 分区表时需要关注 |
| type | 访问类型 | 核心中的核心,后面详细讲 |
| possible_keys | 可能用到的索引 | 只是“可能”,不代表一定用 |
| key | 实际选用的索引 | 只看这一列最容易误判 |
| key_len | 实际使用的索引长度 | 判断联合索引用到了几列,很有用 |
| ref | 与索引比较的列或常量 | 显示 const 表示等值常量匹配 |
| rows | 预估扫描行数 | 估算值,不是实际值 |
| filtered | 满足条件的行数百分比 | 5.7+ 才有,配合 rows 估算最终返回行数 |
| Extra | 额外的执行信息 | 这里藏着 Using where、Using index、Using filesort 等关键线索 |
不要一上来就背字段,而是先建立一个大印象:type 告诉你访问方式,rows 告诉你预估代价,Extra 告诉你有没有额外的排序、临时表、回表操作。三个配合,才能判断一个索引到底用得好不好。
2. type 列:判断索引是否真正生效的关键
2.1 type 取值从好到差怎么排
type 是执行计划里最有信息量的一列,它直接反映 MySQL 访问表数据的方式。按性能从好到差,常见值如下:
| type 值 | 含义 | 典型场景 |
|---|---|---|
| system | 表只有一行 | 系统表的特殊场景,业务基本见不到 |
| const | 主键或唯一索引等值查询,最多返回一行 | where 主键 = 1 |
| eq_ref | 被驱动表通过唯一索引或主键等值匹配 | 多表 join 中,驱动表每行对应被驱动表一行 |
| ref | 普通索引等值查询,返回多行 | where user_id = 100,user_id 有普通索引 |
| range | 索引范围扫描 | between、<、>、like 'abc%' |
| index | 扫描整个索引树 | 联合索引非最左前缀查找 |
| ALL | 全表扫描 | 基本等于噩梦 |
日常优化目标,至少要让 SQL 达到 range 级别,最好能到 ref。如果看到 index 或 ALL,就要警惕,哪怕 key 字段有值。我见过不少 case,key 写着 idx_xxx,慢查询日志里也一样,type 却是 ALL,说明索引根本没被利用起来,可能是函数计算、类型隐式转换、统计信息偏差等问题。
2.2 key_len 与 rows:看懂索引到底“用了几分力”
key_len 是判断联合索引使用程度的好帮手。举个例子,你建了一个联合索引 idx_user_status(user_id, status),其中 user_id 是 BIGINT(8 字节),status 是 TINYINT(1 字节),两个字段都 NOT NULL。那么:
- 只用到 user_id 一列时,key_len 大约是 8。
- 两列都用上时,key_len 大约是 9。
也就是说,通过 key_len 的变化,你能精准推断出优化器到底用了联合索引的哪几个字段。如果一个联合索引有三四列,但 EXPLAIN 显示 key_len 只覆盖了第一列,那就得想想 where 条件里是不是没带全。
rows 是优化器估算的扫描行数,filtered 表示根据 where 条件过滤后剩余的比例。两者配合,可以粗估真实返回行数:rows × filtered / 100。如果 rows 很大,filtered 又很低,说明查询扫描了大量行才筛出极少结果,这种情况即使 key 有值,效率也好不到哪去。这一点在慢查询优化里特别常见。
3. 索引失效的常见场景与现场还原
3.1 对索引列做函数运算,优化器直接放弃索引
最典型的失效场景,就是对索引列使用函数。比如一条统计语句:
SELECT * FROM order_info WHERE DATE(create_time) = '2024-01-15';create_time 明明建了索引,但执行计划通常会显示:
| 字段 | 值 |
|---|---|
| type | ALL |
| key | NULL |
| rows | 约等于全表行数 |
| Extra | Using where |
原因很直接:索引是按原始值排序存储的,DATE(create_time)已经对列里的每个值做了一次函数变换,原本的索引顺序完全打乱了,优化器没法通过 B+ 树直接定位。这就像你把所有人的生日都格式化成“今天是星期几”,然后让系统按星期几去查原始生日,索引根本帮不上忙。
正确写法是改成范围查询:
SELECT * FROM order_info WHERE create_time >= '2024-01-15 00:00:00' AND create_time < '2024-01-16 00:00:00';这样 create_time 本身没有发生任何运算,优化器就能正常走索引范围扫描。
3.2 隐式类型转换,索引“假装没看见”
隐式类型转换是另一种很容易踩的坑。假设 order_no 是 VARCHAR 类型,查询条件写成:
SELECT * FROM order_info WHERE order_no = 123456;MySQL 会把字符串列和数字进行比较时,尝试把字符串转成数字,或者把数字转成字符串。关键在于:如果转换发生在索引列这一侧,索引就失效了。执行计划会从 key=uk_order_no 变成 key=NULL,type=ALL。
这也是为什么我经常建议:写 SQL 的时候,参数类型一定要和字段类型保持一致。ORM 框架有时候会自动处理类型,但处理不完全可靠,尤其在拼接原生 SQL 时,类型问题只能靠人眼盯出来。排查的时候,用 SHOW WARNINGS 能看到优化器重写后的查询,那里面有非常明显的结果。
提示:在 EXPLAIN 之后执行 SHOW WARNINGS,可以看到 MySQL 优化后的 SQL 文本,能帮你快速发现隐式转换这类问题。
3.3 联合索引不满足最左前缀,等于白建
联合索引是业务里最常用也最容易被理解错的索引。假设我建了 idx_user_status(user_id, status),那么查询:
SELECT * FROM order_info WHERE status = 1;大概率走不了这个索引——因为联合索引的排序规则是先按 user_id 排,再按 status 排。没有 user_id 的条件,SQL 根本无法快速定位到指定 status 所在的索引区间。之前的实操里,这种查询 EXPLAIN 后可能出现 type=index、Extra 带 Using where;如果查询列恰好都在索引里,甚至会出现 Using index,但它仍然是整个索引树全扫。
理解最左前缀,只需要记住一个类比:联合索引像一本先按“姓氏”排、再按“名字”排的通讯录。你想找所有名字叫“伟”的人,却不让系统先用姓氏定位,就得把整本通讯录翻一遍。
3.4 模糊查询、OR、范围条件等特殊场景
除了函数运算和隐式类型转换,索引失效还高发在这些场景:
| 场景 | 失效原因 | 处理建议 |
|---|---|---|
| LIKE '%关键词' | 前导通配符导致无法按索引前缀匹配 | 改成 LIKE '关键词%',或考虑全文索引 |
| OR 连接非索引列 | OR 两侧条件无法合并扫描路径 | 改写为 UNION,或给 OR 两侧都建索引 |
| 范围查询之后接等值条件 | 联合索引中 range 后续列无法用于过滤 | 把范围字段放在联合索引最后 |
| IS NOT NULL / NOT IN | 优化器往往认为走索引回表代价太高 | 结合数据分布判断,必要时改写法 |
这里面'%关键词'的问题我反复强调:B+ 树索引天然支持前缀匹配,不支持中缀和后缀匹配。好比字典的拼音目录,你能按“bei”快速翻到“北京”,却不能直接按“京”字翻出所有含“京”的词条,除非另建一个倒排索引。
4. 实操演示:从建表到执行计划全解读
4.1 建表与造数
纸上谈兵没用,我们实际建一张表,把上面说的现象都复现一遍。表结构如下:
CREATE TABLE order_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL, KEY idx_user_status (user_id, status), KEY idx_create_time (create_time), UNIQUE KEY uk_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;为了模拟真实场景,我写了一个存储过程批量插入约 50 万行数据,数据和统计信息都不算太干净,这更贴近线上实际情况。夜深人静的时候跑一下,大概几十秒。
DELIMITER $$ CREATE PROCEDURE init_order_info() BEGIN DECLARE i INT DEFAULT 1; WHILE i <= 500000 DO INSERT INTO order_info(order_no, user_id, goods_id, status, create_time) VALUES ( CONCAT('NO', LPAD(i, 10, '0')), i % 5000, i % 1000, i % 3, DATE_SUB(NOW(), INTERVAL (i % 365) DAY) ); SET i = i + 1; END WHILE; END$$ DELIMITER ; CALL init_order_info();4.2 四条 SQL 的 EXPLAIN 结果解读
SQL 1:联合索引正常命中
EXPLAIN SELECT * FROM order_info WHERE user_id = 12345 AND status = 1;预期输出:
| type | key | key_len | rows | Extra |
|---|---|---|---|---|
| ref | idx_user_status | 9 | 约 30 | 无临时表,无额外排序 |
这里 key_len=9,说明 user_id(8 字节)和 status(1 字节)两列都用到了,属于联合索引的完整使用方式。ref 列会显示 const,const。这个 SQL 是模范生。
SQL 2:函数导致索引失效
EXPLAIN SELECT * FROM order_info WHERE DATE(create_time) = '2024-01-15';预期输出:
| type | key | key_len | rows | Extra |
|---|---|---|---|---|
| ALL | NULL | NULL | 约 50 万 | Using where |
这个例子就是典型的“看着字段有索引,实际全表扫描”。只要 create_time 被 DATE() 包裹,索引就形同虚设。修法前面说过了,用区间查询替代函数。
SQL 3:联合索引不满足最左前缀
EXPLAIN SELECT user_id, status FROM order_info WHERE status = 1;预期输出:
| type | key | key_len | rows | Extra |
|---|---|---|---|---|
| index | idx_user_status | 9 | 约 50 万 | Using where; Using index |
这里很有意思:key 有值、key_len 还很完整,甚至 Extra 出现了 Using index,看起来是很完美的执行计划。但 type=index,意思是扫描了整棵索引树。因为我查询列恰好只有 user_id 和 status,都在联合索引里,所以不需要回表,可代价依然是全索引扫描。如果这张表有千万级数据,这种 SQL 同样能把资源耗尽。这就是“别只盯着 key”的最有力实证。
SQL 4:优化器放弃索引
EXPLAIN SELECT * FROM order_info WHERE user_id > 0;预期输出:
| type | key | key_len | rows | Extra |
|---|---|---|---|---|
| ALL | NULL | NULL | 约 50 万 | Using where |
这不是写错了,而是优化器故意选择全表扫描。user_id > 0 几乎能命中全表所有记录,如果走 idx_user_status 索引再回表,代价比全表扫描高得多。执行计划的生成依据是成本模型,遇到这样的条件,索引确实“没被选择”,但本质上不是索引失效,而是优化器认为“用了更亏”。这种场景需要结合业务实际考虑,比如按 user_id 分页是否合理、是否需要加其他过滤条件。
4.3 EXPLAIN ANALYZE 进一步验证
传统 EXPLAIN 输出的是估算值,不是实际执行数据。MySQL 8.0.18+ 提供了 EXPLAIN ANALYZE,可以直接看真实执行时间和循环次数:
EXPLAIN ANALYZE SELECT * FROM order_info WHERE user_id = 12345 AND status = 1;输出会带着 actual time、rows、loops 等真实数据。我在定位慢 SQL 时,经常先用 EXPLAIN 看结构,再用 EXPLAIN ANALYZE 确认实际行数和耗时,两者差距大时,多半是统计信息不准,需要 ANALYZE TABLE 刷新。
5. 常见误判与排查技巧实录
5.1 三个最常见的误判
第一个误判:key 是 NULL 就认为没救了。其实 key 为 NULL 只是说明这步执行没选索引,不代表没有可用索引,可能是函数运算、类型隐式转换,也可能是优化器觉得全表扫描更划算。此时应该先看 type 和 Extra。
第二个误判:key 有值就认为索引生效了。这个前面反复说过,type=index 就是反例。还有一种情况,联合索引只用了前缀部分,key_len 很短,说明后面的字段没用上,索引生效程度也要打个问号。
第三个误判:rows 小就认为快。rows 是优化器的估算值,不是精确值,而且它没算上回表代价。如果 Extra 里没有 Using index,说明还要回表,回表次数多了,即使 rows 看起来不大,实际查询也很慢。
我把几个关键结论整理成速查表:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| key=NULL + type=ALL | 函数运算、隐式转换、统计信息偏差 | SHOW WARNINGS,调整 SQL |
| key有值 + type=index | 整树扫描,联合索引最左前缀缺失 | 调整查询条件或索引顺序 |
| key_len 异常偏短 | 联合索引只用到前缀列 | 检查 where 条件是否包含后续列 |
| rows 很大 + filtered 很低 | 先大范围筛选再小比例过滤 | 考虑增加更精准的过滤列 |
| Extra 出现 Using filesort | 排序字段未能使用索引顺序 | 让 order by 字段与索引顺序一致 |
5.2 排查索引失效的实操流程
我在线上排查慢查询时,有一套相对固定的流程,分享出来供参考。
第一步,先拿到慢 SQL,用 EXPLAIN 看 type 级别。如果 type 是 ALL 或 index,进入第二步;如果是 range 或更好,再看 Extra 里的其他信息。
第二步,看 key 和 key_len。key 是否为 NULL,key_len 是否符合预期,判断联合索引是否用全。这里要熟悉自己的表结构和索引设计,能通过 key_len 快速判断。
第三步,看 Extra 里的额外操作。Using filesort 意味着排序没走索引,需要检查 order by 字段是否在索引中;Using temporary 意味着临时表参与运算,对大结果集非常致命;Using index condition 则说明用上了索引条件下推,通常不算坏消息。
第四步,用 SHOW WARNINGS 看优化器重写后的 SQL,确认有没有隐式类型转换、子查询改写之类的隐性操作。
第五步,如果 SQL 看着没问题,但优化器就是选了全表扫描,可以考虑刷新统计信息:
ANALYZE TABLE order_info;如果刷新后依然如此,可能是数据分布太偏,优化器基于成本模型做出的判断确实是有道理的,此时别死磕索引,应该考虑改写 SQL 或调整索引设计。
5.3 平时容易忽略的细节
有几个细节,属于平时很容易忽略、但坑很深的点。
一是字符集和排序规则不一致。如果两张表 join 的字段一个是 utf8mb4,一个是 gbk,MySQL 需要做字符集转换,这个转换会阻止索引使用。建表和设计字段时就要统一字符集。
二是统计信息过期。大批量删改数据后,统计信息不会实时更新,优化器可能拿着旧的估算值做出错误决策。定期 ANALYZE TABLE 是非常有必要的。
三是范围查询在联合索引中的位置。联合索引里,范围条件后面的字段无法参与索引过滤。比如 idx(a, b, c),where a=1 and b>10 and c=2,c 的过滤条件在索引定位时用不上,只能通过回表后的 where 再过滤。设计联合索引时,尽量把范围查询的字段放在靠后的位置。
四是关于 order by 的隐藏索引需求。order by 字段如果不在索引中,就会出现 Using filesort。有时候查询本身不慢,但加上 order by 之后突然变慢,多半是排序没有索引可用。解决方式之一是让 order by 字段与现有联合索引顺序匹配。
我个人在实际操作中的体会是:EXPLAIN 不是用来“应付面试”的,而是用来做决策的。拿到一条慢 SQL,先把执行计划打印出来,逐列看一遍,再对照索引定义推演一遍 B+ 树的查找路径,基本就能找到问题症结。这种方法论,比记住一百个“索引失效例子”有用得多。
最后再分享一个实用习惯:每次优化完 SQL,我会对比优化前后两次 EXPLAIN 的结果,重点看 type 是否提升、rows 是否明显下降、Extra 是否少了 filesort。如果这三项没有明显好转,那这个优化方案多半是无效的,不要自我安慰。慢 SQL 优化这条路,说到底就是不断用执行计划验证假设的过程,坚持下来,你也会形成自己的排查直觉。