调MySQL慢查询,第一件事不是看索引、不是改配置,而是先跑一条EXPLAIN把SQL的“执行计划”摊开看。这东西在面试里是高频考点,在性能排查里是最基础的debug手段。EXPLAIN能告诉你一个查询走了哪个索引、扫描了多少行、有没有文件排序、有没有临时表,甚至能看出你写的SQL是不是在“硬扫全表”。本文就围绕EXPLAIN的完整输出,结合实际SQL例子和优化场景,把每个列、每个关键字讲透,顺便把EXPLAIN ANALYZE、FORMAT=JSON这些进阶用法也一并整理。
1. EXPLAIN是什么:把SQL执行计划摊开来看
1.1 为什么要先看执行计划
很多朋友在SQL变慢之后的第一反应是“加索引”,这思路没错,但加索引之前必须搞清楚一件事:当前SQL到底卡在哪。有些慢是因为没走索引,有些慢是走了索引但索引选错了,还有些慢是因为排序、分组、关联搞出了临时表和文件排序。这些情况光看SQL本身很难判断,但执行计划会把优化器的决策过程全部暴露出来。
EXPLAIN就是MySQL用来查看“优化器如何执行这条SQL”的命令。它不会真的去跑数据,而是基于表结构、索引、统计信息,估算出一条SQL的成本,并告诉你它打算怎么查:先读哪张表、用哪个索引、预估扫多少行、是否要额外排序。这就好比出门前看地图,先定路线再上路,而不是开出去堵死在半路再掉头。
1.2 最基本的用法
在任意一条SELECT前面加上EXPLAIN即可:
EXPLAIN SELECT order_no, status FROM orders WHERE user_id = 123;MySQL 5.7及之前,EXPLAIN会返回一列简化信息;8.0里默认输出的列更完整,还支持FORMAT=JSON、FORMAT=TREE以及EXPLAIN ANALYZE。日常调试最常用的就是默认的表格形式。
它的输出每一行代表一个查询步骤。复杂SQL可能有多行,比如关联查询、子查询、UNION,都会拆成多个步骤,每一行告诉你这一步怎么执行。读的顺序通常是从上往下,但遇到关联查询时要结合id列一起看。下面先把每个列逐个说清楚,这是理解执行计划的基础。
2. EXPLAIN输出列全解读:从id到Extra
2.1 核心输出列速查表
默认输出包含大约12列,根据MySQL版本不同略有差异。把它们一次性记住有点难,但可以分三组来看:第一组是“执行顺序与语句类型”(id、select_type、table、partitions),第二组是“索引使用情况”(type、possible_keys、key、key_len、ref),第三组是“代价评估与额外信息”(rows、filtered、Extra)。
| 列名 | 含义 | 典型的坑 |
|---|---|---|
| id | 查询步骤编号,id越大越先执行;id相同则从上往下执行 | 关联查询中id相同,顺序不代表优先级 |
| select_type | 查询类型,是简单查询还是子查询、联合查询等 | DEPENDENT SUBQUERY往往说明子查询在“逐行执行” |
| table | 当前步骤访问的表名,也可能是派生表别名 | 看到<derivedN>说明中间有派生表 |
| partitions | 命中的分区(分区表才显示) | 优化时希望它越少越好 |
| type | 访问类型,从system到ALL,好坏一眼看出来 | ALL是性能杀手,必须重点排查 |
| possible_keys | 可能用到的索引,注意只是“可能” | 列出多个索引不代表都用得上 |
| key | 优化器最终选用的索引 | NULL表示没走索引 |
| key_len | 使用的索引字节长度 | 可以反推SQL使用了联合索引的哪几列 |
| ref | 使用索引等值匹配时,参考的列或常量 | 和key配合判断匹配方式 |
| rows | 优化器预估需要扫描的行数 | 预估不是实际值,但和实际偏差过大说明统计信息过期 |
| filtered | 存储引擎返回后,经过WHERE过滤后剩余行的百分比 | 关联查询中过滤比例低会导致驱动表膨胀 |
| Extra | 附加信息,包含排序、临时表、覆盖索引等关键标记 | Using filesort / Using temporary 出现了要警惕 |
2.2 读懂id和select_type
先说id。每遇到一个SELECT关键字,MySQL就会给它分配一个id。规则有点反直觉:id数字越大,越先执行。比如SELECT * FROM a WHERE id IN (SELECT id FROM b),子查询的id是2,外层是1,实际执行时先执行id=2的部分。而普通的JOIN查询,两张表的id相同,比如都是1,这时候从上往下读,第一行是驱动表,第二行是被驱动表。
select_type里最需要留意的是DEPENDENT SUBQUERY。普通的SUBQUERY只会被子查询执行一次,和主查询结果无关;但DEPENDENT SUBQUERY表示子查询依赖外层查询的值,MySQL优化的不好时,相当于外层每扫一行就执行一次子查询,代价极高。
EXPLAIN SELECT * FROM user u WHERE EXISTS (SELECT 1 FROM order o WHERE o.user_id = u.id);这条SQL里子查询的select_type会显示为DEPENDENT SUBQUERY,如果o.user_id没有索引,那就成了经典的N+1问题,慢到怀疑人生。看到这个标记,第一反应就是检查关联字段有没有索引,或者干脆改成JOIN。
2.3 type列决定SQL“吃饭”的方式
type列是判断查询质量的第一指标,它描述了MySQL找到所需数据行的方式,从好到坏大致是:
system > const > eq_ref > ref > range > index > ALL只要看到ALL,就意味着全表扫描。如果表比较大,这个SQL十有八九就是慢查询元凶。这几种类型的具体含义在下一节展开,这里想先强调:type是面试和排查时最常被问到的点,你得能背下来并且能举例子。
2.4 Extra列里的信号
Extra列是信息量最大的一个,很多优化点都藏在这里。最值得优先关注的是两个:Using filesort(文件排序)和Using temporary(使用临时表)。这两个东西都意味着MySQL额外做了内存或磁盘层面的操作,数据量一大就非常容易拖慢查询。它们经常出现在ORDER BY、GROUP BY、DISTINCT这些操作上。
另一个好消息是Using index,它表示“覆盖索引”,也就是查询要的列已经全部在索引里,不需要回表。这个标记出现得越多,说明索引设计越贴合业务查询。同样的SQL,从Using filesort优化成Using index,性能可能差一个数量级。
3. type访问类型详解:从system到ALL
3.1 每种类型实际长什么样
type的排序已经告诉了你质量的优劣,但光记住顺序没用,关键是要在实际SQL里认出每种类型。我用一套简单的表结构来演示:
CREATE TABLE user_info ( id INT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(32) NOT NULL, age TINYINT NOT NULL, email VARCHAR(64), UNIQUE KEY uk_email (email), KEY idx_user_name (user_name), KEY idx_age (age) ) ENGINE=InnoDB;system:表中只有一行数据(系统表),或者查询恰好命中一张只有一行数据的表。平时业务SQL基本见不到,只有SELECT * FROM (SELECT 1) t这种或者系统字典表才可能出现。
const:用主键或唯一索引等值匹配,最多返回一行。比如:
EXPLAIN SELECT * FROM user_info WHERE id = 100; EXPLAIN SELECT * FROM user_info WHERE email = 'tom@example.com';对id = 100或唯一索引email等值查询,MySQL能直接定位到那一行,所以type是const。这是效率最高的访问之一,你写的SQL应该尽可能做到这种级别。
eq_ref:出现在关联查询中,被驱动表通过主键或唯一索引等值匹配。含义是“我知道关联列上最多只有一行匹配”,每次驱动表来一个值,被驱动表查一次就能确定命中的那行。
EXPLAIN SELECT * FROM user_info u JOIN order_info o ON u.id = o.user_id;如果o.user_id是主键或唯一索引,被驱动表的type就是eq_ref。这个类型也很快,常见于JOIN的“一边一条”关联。
ref:普通二级索引等值匹配,或者使用了联合索引的最左前缀。和const的区别在于,它可能匹配到多行。比如:
EXPLAIN SELECT * FROM user_info WHERE user_name = 'Tom';user_name上有普通索引idx_user_name,所以这里 type =ref。它效率很高,但可能返回多行,实际代价要看匹配的行数。
range:索引范围扫描。比如WHERE id > 100、WHERE age BETWEEN 20 AND 30、WHERE user_name LIKE 'Tom%',都能通过索引快速定位范围内数据。这个类型表示“用到了索引,但不是一个值,而是一个范围”。
index:全索引扫描,也就是遍历整棵索引树。它比全表扫描好一点,因为它不需要回表,但要读的索引数据量仍然很大。出现这个类型时,往往是因为查询需要覆盖索引,但索引范围太大,或者WHERE条件没法走索引前缀。
EXPLAIN SELECT age FROM user_info;如果age有索引,这条查询可能走index,因为索引比表小,而且age列就在索引里。
ALL:全表扫描,从头到尾把表读一遍。只要条件列没索引、或者用了函数包裹条件列、或者查询条件本身无法使用索引,就会变成ALL。对一张大表做全表扫描,那基本就是灾难现场。
3.2 为什么“能走索引”也可能走成ALL
这里有个很常见的误区:以为给字段建了索引,查询就一定能用上。实际上,下面这些情况索引会失效,type直接从range掉成ALL:
- 对索引列使用了函数:
WHERE DATE(created_at) = '2025-01-01',除非建了函数索引(8.0.13+支持),否则索引报废; - 隐式类型转换:
WHERE mobile = 13800138000,mobile是varchar,数字和字符串比较触发类型转换,索引失效; - 前导模糊匹配:
WHERE user_name LIKE '%Tom%',无法使用索引。只有Tom%这种后缀模糊能走range; - OR条件连接非索引列:
WHERE id = 1 OR status = 0,如果status没有索引,整条查询可能退化成ALL; - 联合索引不满足最左前缀:索引是
(user_id, created_at),但你只写了created_at条件,用不上。
排查慢SQL时,如果type是ALL,先按这个清单过一遍,基本能找出索引失效的原因。我在实际排查中发现,隐式类型转换是最隐蔽的,表面上条件写得很正常,但字段类型对不上,索引就悄悄没了。
4. Extra列深度解析:这些关键字决定了查询的下限
4.1 Using where 和 Using index 的区别
很多初学者会把这两个搞混。简单来说:
Using where表示存储引擎返回了数据后,MySQL还要在Server层再过滤一遍。最常见的情况是:索引只帮你定位了一部分行,剩下条件需要回表后逐行判断。比如联合索引(user_id, created_at),查询条件是user_id = 123 AND status = 1,status不在索引里,MySQL用索引定位user_id之后,还要对每行status再做过滤,Extra就会出现Using where。Using index表示所有需要的数据都能从索引里取得,不需要回表。比如索引是(user_id, created_at),查询SELECT created_at FROM orders WHERE user_id = 123,那created_at和user_id都在索引里,直接用索引就够了。
最理想的情况是Using index,不仅查询快,连回表都省了。这就是覆盖索引的威力。
4.2 Using filesort 和 Using temporary 是性能黑洞
Using filesort不是真的用磁盘“文件”排序,而是表示MySQL没法直接用索引的排列顺序返回数据,需要额外做一次排序。排序可能在内存也可能在磁盘,但都是额外开销。出现它的典型场景是ORDER BY的列和WHERE条件用的索引对不上。
举个例子:
EXPLAIN SELECT order_no, status FROM orders WHERE user_id = 123 ORDER BY created_at DESC;如果只有idx_user_id索引,MySQL会先用user_id定位到数据,再对created_at做一次文件排序。数据量小时没什么感觉,几万行以上排序开销就明显了。优化方案是建联合索引(user_id, created_at),让索引天然按user_id和created_at排好,MySQL按顺序读出来就是ORDER BY的结果,Using filesort就会消失。
Using temporary更是重量级,它表示MySQL为了完成查询创建了临时表。常见于GROUP BY、DISTINCT、UNION和某些子查询。比如:
EXPLAIN SELECT status, COUNT(*) FROM orders GROUP BY status;如果status没有索引,MySQL可能需要创建临时表来做分组统计。分组列加索引往往能消除临时表。注意一点:临时表分为内存临时表和磁盘临时表,如果临时表太大,MySQL会自动转成磁盘临时表,性能会断崖式下跌。
4.3 其他值得留意的Extra标记
| Extra标记 | 含义 | 应对思路 |
|---|---|---|
| Using index condition | 使用了索引条件下推(ICP),部分过滤条件下推到存储引擎 | 一般是好事,表示索引利用率高 |
| Using join buffer | 关联查询没有用索引,需要把驱动表结果放入join buffer去匹配被驱动表 | 检查关联条件的索引 |
| Impossible WHERE | WHERE条件恒为假,MySQL直接说“查不出来” | 说明条件写错了,比如1=0 |
| No tables used | 没有涉及表,比如SELECT 1 | 正常现象 |
| Select tables optimized away | 查询被优化到不需要访问任何表,比如只查COUNT或MIN | 性能极佳,不用管 |
| Distinct | 正在去重 | 检查是否可以用索引消除去重 |
5. rows、key_len、select_type:从预估到印证
5.1 rows 是估算值,但有参考意义
rows是优化器预估“为了找到目标行,需要读多少行”。它来源于表的统计信息,不是精确值。数据更新频繁时,如果统计信息不准,rows会和实际情况偏差很大。但即便不准,它也是判断执行计划好坏的重要参考:如果type=ALL,rows接近全表总行数,那这条SQL肯定有问题;如果type=ref,rows只有十几行,那大概率是高效的。
有一点要提醒:rows小,不代表SQL整体执行快。因为有时优化器为了减少读取行数,选了某个索引,但实际还需要回表处理大量数据。所以rows要结合Extra和key一起看,别单独迷信某一行数字。
5.2 key_len 能反推联合索引用了哪几列
key_len表示MySQL在索引中使用了的字节数。它能帮你判断联合索引到底用到了几列。计算规则是:
上文说的(user_id, created_at)联合索引,如果user_id是INT NOT NULL,created_at是DATETIME NOT NULL,查询条件是WHERE user_id = 123 AND created_at > '2025-01-01':
INT占4字节;DATETIME占8字节(MySQL 5.6.4之前是8字节,之后实际还是8字节存储,但有的版本会额外有小数秒存储,一般按8字节算);- 这张表没有NULL标记位耗尽的话,
user_id部分的key_len就是4,加上created_at部分(8)就是12。
如果key_len只有4,说明只用了联合索引的第一列user_id;如果key_len=12,说明两列都用了。这是一个非常实用的诊断技巧,能避免你误以为联合索引里的列都生效了。字符串类型还要注意字符集的字节数:utf8mb4一个字符最多4字节,varchar还需额外2字节记录长度,NULL字段再加1字节。
5.3 select_type里的 DEPENDENT 和 DERIVED
DERIVED表示派生表,也就是FROM子句里的子查询。比如:
SELECT * FROM (SELECT user_id, COUNT(*) cnt FROM orders GROUP BY user_id) t WHERE cnt > 10;MySQL 5.7以前会把这个子查询结果物化成临时表,再和外部查询关联,在某些情况效率不高。8.0优化器可以做derived_merge,很多时候能把派生表合并到主查询,减少一次物化。
MATERIALIZED表示物化子查询,主要出现在IN (SELECT ...)的场景。MySQL会把子查询的结果物化成一张临时表,再去关联外层查询。这通常比DEPENDENT SUBQUERY好,因为它只执行一次。
6. 实战案例:一条慢SQL从EXPLAIN到优化
6.1 复现慢查询
假设有订单表:
CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, order_no VARCHAR(32) NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, KEY idx_user_id (user_id), KEY idx_created_at (created_at) ) ENGINE=InnoDB;线上反馈这个查询很慢:
SELECT order_no, status FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 10;6.2 第一轮EXPLAIN定位问题
EXPLAIN SELECT order_no, status FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 10;关键输出是:
| type | key | rows | Extra |
|---|---|---|---|
| ref | idx_user_id | 85 | Using filesort |
看到Using filesort基本可以断定:user_id条件走了索引,但created_at排序用不上索引,MySQL只能先把85行结果捞出来再额外排序。数据量大时,这个排序就是瓶颈。而且如果这张表的user_id分布不均匀,某个用户有上万订单时,排序成本直线上升。
6.3 优化方案与二次验证
直接把idx_user_id改成联合索引(user_id, created_at):
ALTER TABLE orders DROP INDEX idx_user_id; ALTER TABLE orders ADD INDEX idx_user_id_created_at (user_id, created_at);再跑一次:
EXPLAIN SELECT order_no, status FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 10;输出变成:
| type | key | rows | Extra |
|---|---|---|---|
| ref | idx_user_id_created_at | 85 | (空) |
Using filesort消失了。因为InnoDB索引本身就是按(user_id, created_at)排序的B+Tree,MySQL直接从索引末尾往前读,天然就是created_at倒序。这个优化在订单详情、用户操作记录等“按用户查最近记录”的场景里特别常用。
6.4 如果还能再进一步:覆盖索引
上面的例子,查询列是order_no, status,它们并不在联合索引里。所以MySQL用索引定位后,还要回表拿这两列数据。如果想彻底避免回表,可以建一个覆盖索引:
ALTER TABLE orders ADD INDEX idx_user_created_order (user_id, created_at, order_no, status);但注意:覆盖索引覆盖的列越多,索引体积越大,写入性能越差。不要为了消除回表盲目堆列。一般我在实际中,只有当回表比例很高、SQL高频执行时才考虑覆盖索引,而且必须权衡写入场景。
7. 常见坑与进阶工具
7.1 EXPLAIN不是真实执行,别被预估骗了
EXPLAIN是基于统计信息的估算,存在两个风险:一是统计信息过期,rows和实际情况严重不符;二是优化器在某些情况下会估算错误,选错索引。遇到后者,可以先ANALYZE TABLE更新统计信息,实在不行再用FORCE INDEX强制指定索引,但这只是临时手段,根因往往是索引设计不合理或SQL写法有问题。
在MySQL 8.0里,EXPLAIN FORMAT=JSON能看到更详细的成本计算信息,包括cost_info、used_columns、attached_condition等。排查复杂问题的正确姿势是:先跑一个EXPLAIN眼见为实,再结合FORMAT=JSON看优化器成本评估,别瞎猜。
7.2 用 SHOW WARNINGS 看优化器改写的SQL
一个很容易被忽略的技巧:执行EXPLAIN后,紧接着执行SHOW WARNINGS,能看到MySQL优化器对SQL的改写结果。有时候你会惊讶地发现,优化器把你写的子查询改写成了JOIN,或者把IN改写成了EXISTS,了解改写逻辑能帮你理解为什么实际执行计划和你想的不一样。
EXPLAIN SELECT * FROM user_info WHERE id IN (SELECT user_id FROM orders WHERE status = 1); SHOW WARNINGS;Message列里可能出现 “/* select#2 */ ...”。它能直观反映优化器怎么处理你的SQL,对判断索引为何没生效非常有帮助。
7.3 8.0新特性:EXPLAIN ANALYZE 实测
MySQL 8.0.18及以上版本支持EXPLAIN ANALYZE,它和EXPLAIN最大的区别是:EXPLAIN只给估算,EXPLAIN ANALYZE是真实执行,并返回每个步骤的实际耗时、实际行数。比如:
EXPLAIN ANALYZE SELECT order_no, status FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 10;输出类似:
-> Limit: 10 rows (actual time=0.02..0.03 rows=10 loops=1) -> Sort: orders.created_at DESC (actual time=0.02..0.02 rows=10 loops=1) -> Index lookup on orders using idx_user_id (user_id=123) (actual time=0.01..0.01 rows=85 loops=1)注意Sort这个节点,它对应上面的Using filesort。通过EXPLAIN ANALYZE能看到每一步实际花了多少时间、处理了多少行,比单纯的EXPLAIN更贴近真实性能。生产环境执行时要注意:它会真实执行SQL,读操作没问题,但如果是INSERT/UPDATE/DELETE配合它,一定要谨慎,建议只在测试环境或针对SELECT操作使用。
我在实际排查中个人最依赖的组合是:EXPLAIN快速定位问题列,EXPLAIN ANALYZE确认真实耗时。两者结合,绝大多数慢查询都能在几分钟内找到根因,而且这套方法对于任何MySQL版本、任何业务场景都通用。最后再分享一个小技巧:如果你遇到一条SQL怎么调都走不上理想的索引,先SHOW INDEX FROM 表名看看索引基数,再确认会不会是统计信息太久没更新,直接ANALYZE TABLE刷新一下,很多“索引失效”问题其实只是统计信息老了。