先问一句:你手上有条SQL跑了三秒,领导让你优化,你第一步干什么?打开客户端敲EXPLAIN,这个动作90%的人都会。但EXPLAIN结果出来之后呢?看到type=ALL,rows十几万,然后呢?然后就没有然后了,只能去翻书、搜文章,最后看了一眼索引就随手加了个索引交差。这就是典型的“EXPLAIN看了,但没看懂”。
我见过太多人栽在这一步。慢SQL定位这件事,SQL本身往往看不出毛病,索引也建了一堆,问题就出在执行计划这一层——你根本没读懂MySQL到底是怎么执行这条SQL的,自然就谈不上精准优化。这讲的内容就是围绕EXPLAIN展开:它的输出每一列到底代表什么、怎么从一堆数字和英文词里读出真正的性能瓶颈、遇到疑似低效SQL时按什么顺序做判断。适合那种已经会用MySQL写业务SQL、但一遇到慢查询就不知道从哪下手的同学。看完之后你可以直接拿线上的慢SQL练手,把执行计划里那几个关键字段对号入座,基本上就能定位出个七七八八。
1. 执行计划到底是什么:EXPLAIN输出的阅读顺序
先说一个我见过的普遍误区:很多人拿到EXPLAIN结果,第一件事就是去找type、key这两列,看到type是ALL就慌,看到key不是NULL就安心。这种只看两个字段的习惯,会让你漏掉大量信息。EXPLAIN输出的每一行,其实都对应优化器选择的一个执行步骤,你首先得搞清楚这几个步骤谁先谁后。
1.1 先读懂id和select_type:执行顺序别搞反
EXPLAIN结果里的id列,是理解整个执行过程的钥匙。id越大,越先执行;id相同,则从上往下执行。这句话看起来简单,但很多人就是记反了。
举一个最常见的例子:两条SQL,一条是带子查询的,一条是带JOIN的。带子查询的EXPLAIN经常会看到id分别1和2,id=2那行会是子查询,它先执行,然后结果才交给id=1的外层查询用。有些人看EXPLAIN就从上往下读,以为外层先执行,然后去分析外层表走了全表扫描,结果分析半天方向全反了。
select_type这一列更是重灾区。SIMPLE、PRIMARY、SUBQUERY、DERIVED这些单词都好理解,但有两个你千万要注意:
- DEPENDENT SUBQUERY:这个单词的意思是“相关子查询”,也就是子查询里引用了外层表的字段。这种子查询的实际执行方式是对外层每一行都执行一次。假如外层返回1000行,子查询就被执行1000次。这时候EXPLAIN看不太出来,但你只要看到DEPENDENT SUBQUERY,就要警惕:这条SQL的表连接方式可能不太健康,能不能改成JOIN或者临时表关联。
- UNCACHEABLE SUBQUERY:意味着子查询结果无法被缓存,执行代价更高。常见于子查询中使用了用户变量或某些随机函数。
还有DERIVED,它在MySQL 5.7之后通常会被优化器尝试物化或合并。你在table列里看到像<derived2>这样的名字,就说明这行操作的是id=2那个步骤生成的派生表,它并不是你原有业务表之一。
1.2 table列里的花活:真正的底层操作对象
table列看起来最简单,就是表名。但除了<derived2>这种派生表标记,你还可能看到<union1,2>之类的,表示UNION合并的结果来自id=1和id=2两个步骤。还有<subquery3>这种,表示物化子查询的结果被临时存成了内部表。
这里想强调一个经验:EXPLAIN结果的行数,就是优化器认为这条SQL需要做“几步”才能完成。执行步骤越多,中间的临时产物越多,性能通常越差。这不绝对,但作为一个初步判断标准非常有效。
1.3 我习惯的浏览优先级
拿到EXPLAIN后,我自己的习惯是:先数行数,再看id顺序,然后逐行扫type和Extra,最后回到key_len、rows去估算量级。
这个顺序的好处是,先建立对“这条SQL整体执行路径”的认识,再落到细节。如果你一上来就盯着单个字段看,被某一行特别好看的type迷惑,很容易漏掉旁边的Using temporary。
2. type列才是优化等级表:从ALL到const逐级拆解
type列是执行计划里最能快速反映性能的一列,但它也最容易被误读。很多人觉得type=index就是“走了索引”,是好事,这完全是把词义理解错了。准确点说,index代表的是“全索引扫描”,不是“索引查找”,它的代价通常仅比全表扫描好一点点。
2.1 type的完整等级梯度
MySQL官方文档里type有很多种取值,按照性能从优到劣排,大概是这么个顺序:
| type取值 | 含义 | 常见出现场景 | 性能等级 |
|---|---|---|---|
| system | 表只有一行,系统表 | 极少见 | 最好 |
| const | 通过主键或唯一索引等值命中一行 | WHERE id = 1 | 极好 |
| eq_ref | 被驱动表通过主键或唯一索引等值关联 | JOIN关联条件的被驱动表 | 很好 |
| ref | 通过普通索引等值匹配,可能返回多行 | WHERE status = 1 | 好 |
| range | 索引范围扫描 | WHERE id BETWEEN 100 AND 200 | 中等 |
| index | 扫描整个索引树 | 覆盖索引但无过滤条件,或某些排序场景 | 较差 |
| ALL | 全表扫描 | 无可用索引或优化器选择放弃索引 | 差 |
这里要特别提一下unique_subquery和index_subquery,它们其实对应子查询中的IN (SELECT ...)场景,前者表示子查询走唯一索引,后者表示走普通索引,性能介于eq_ref和ref之间。遇到IN子查询时,看到这两个type反而是好事,说明子查询本身能被索引高效处理,比DEPENDENT SUBQUERY强太多了。
2.2 ALL和index:两种“扫全”的差别
ALL指的是扫聚集索引(也就是整张表的数据页),而index指的是扫某个二级索引的完整索引树。二级索引树通常比表数据小,因为只包含索引列和主键,所以同样“全扫一遍”,index通常比ALL快一些。但注意,如果你用的是SELECT *,即使用了index扫描二级索引,最终还是要回表取完整行,此时回表代价可能大到拖垮整体性能。
那什么情况下index这种“全索引扫描”会是一个合理选择?比如你要取一列的唯一值:SELECT DISTINCT category_id FROM product,而category_id上有索引,那么优化器直接扫索引树就能拿到全部去重值,不需要回表,这比全表扫描快得多。这种场景EXPLAIN里type=index,Extra里往往还会跟着Using index,那是可接受的。
2.3 range/ref/eq_ref/const:索引到底用到了什么程度
const和system是最理想的情况:WHERE id = 1,主键等值查询,优化器能直接确定返回一行。eq_ref常见于主键关联:SELECT * FROM a JOIN b ON a.id = b.a_id,如果a作为被驱动表并且关联列是主键,a那行type就是eq_ref。ref则对应普通索引等值匹配:比如WHERE user_id = 888,user_id上有非唯一索引,会扫索引树找到一个区间内的多条记录。range就是范围条件:大于、小于、BETWEEN、IN列表,以及LIKE 'prefix%'。出现range大部分时候都算不错,但你要看一下rows列,如果扫描的区间行数非常大,那代价同样可观。
2.4 什么时候ALL不一定是坏消息
这里想替你纠一个偏:看到type=ALL,不要条件反射地觉得必须加索引。如果一张表总共就一两千行,并且查询条件过滤不出什么东西,优化器算来算去觉得全表扫描比走索引回表还划算,那它就会选择ALL。这种时候你强行为了让EXPLAIN好看去加索引,反而可能拖慢写入。
我一个实际体会是:小表上的全表扫描,在秒级内就能完成,真正要警惕的是大表上的ALL。判断“大”还是“小”,可以看rows列,如果rows直接奔着几十万去了,而你的业务表又确实有大几十万行,那基本可以断定这条SQL未来会随着数据量增长越来越慢。
3. key_len、ref、rows:联合起来才能看透索引
type只能告诉你“走没走索引、大概怎么走的”,但真正要回答“索引吃没吃透”,你得看下面三兄弟:key_len、ref、rows。
3.1 key_len的计算方法和意义
key_len表示MySQL在索引中实际使用的字节数。注意这个词——“实际使用”。你建了一个联合索引(a, b, c),但查询条件只用了a,那key_len就只会等于a字段占的字节数,b和c根本没参与索引查找。你只看key列,看到用的是你刚建的idx_a_b_c,觉得挺满意的;但再一看key_len,发现跟a字段单列索引的长度一样,那这个联合索引建了等于白建,后面两列没吃到。
那key_len具体怎么算?我总结一个简化版规则:
| 字段类型 | 字节计算 | 备注 |
|---|---|---|
| INT | 4字节 | BIGINT则为8字节 |
| TINYINT | 1字节 | SMALLINT为2字节 |
| VARCHAR(n) | n × 字符集字节数 + 2 | 2字节为变长长度标识;若字段允许NULL,再加1字节 |
| CHAR(n) | n × 字符集字节数 | 若允许NULL,再加1字节 |
| 可空字段 | 在定长基础上 +1 | 因为索引元组要记录NULL标志 |
举个例子,假设有一张用户表:
CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT, `status` TINYINT NOT NULL DEFAULT 0, `phone` VARCHAR(20) NOT NULL, `created_at` DATETIME NOT NULL, PRIMARY KEY (`id`), KEY `idx_status_phone` (`status`, `phone`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字段字符集是utf8mb4,一个字符最多4字节。那么status这个TINYINT占1字节,phoneVARCHAR(20)占20×4+2=82字节,两者累加,联合索引idx_status_phone理论上最长是1+82=83字节。
现在跑一条查询:
EXPLAIN SELECT * FROM user WHERE status = 1;如果EXPLAIN结果显示key=idx_status_phone,但key_len=1,那就说明只用了联合索引的第一列status。条件里如果用上了phone,即WHERE status = 1 AND phone = '138...',key_len就会变成83,说明联合索引的两个字段都生效了。
这里还有个细节经常让人困惑:为什么不允许NULL的INT还是可能显示5字节?确切讲,MySQL在索引记录中允许NULL字段要有一个额外的NULL标志位,不同版本和引擎实现略有差异,这个1字节额外开销在经验估算中通常按“可空字段加1”来算。严谨起见,你可以拿真实表跑一下EXPLAIN对比验证。我记得最常见的场景是:一个INT NULL的主键关联,look的key_len显示5而不是4,很多人以为建错索引了,其实是因为字段允许NULL。
3.2 ref和rows的匹配关系
ref列显示的是“使用索引查找时,用来与索引比较的列或者常量是什么”。如果是const,说明是常量等值条件;如果是test.user.id这种,说明是用另一个表的id列作为关联条件;如果ref里是空值,那说明这行并没有使用有效的等值匹配来定位索引。
rows则是优化器估算的需要扫描的行数。这里必须强调“估算”二字。它来自统计信息和采样,不等于真实值。但rows是判断SQL量级的核心参考:如果rows是50万,那这条SQL再怎样也快不到哪去。如果rows很小,但实际执行还是慢,就要怀疑是不是统计信息过期了,或者后续有大量回表。
你要养成一个习惯:把key_len、ref、rows三列连在一起读。比如:key是idx_status_phone,key_len=1,ref=const,rows=8000。这说明查询只用了联合索引的status列定位,扫描到了约8000行,phone列没用上。那如果要优化,思路可能是把条件补全、让联合索引吃到第二列;或者单独考虑更贴合查询的索引。
3.3 一个完整的联合索引判断实例
再给你看一个更真实的例子。商品订单表有一张索引KEY idx_user_pay(user_id, pay_status)。执行计划显示:
EXPLAIN SELECT * FROM payment_order WHERE user_id = 9527 AND pay_status = 1 ORDER BY create_time DESC LIMIT 20;执行计划输出(精简后):
| id | table | type | key | key_len | ref | rows | Extra |
|---|---|---|---|---|---|---|---|
| 1 | payment_order | ref | idx_user_pay | 4 | const | 368 | Using filesort |
看到key_len=4(user_id是INT),就知道索引的第二列pay_status没有被用于等值定位,只定位了user_id一个条件,然后扫描368行再排序。但如果你把条件改为不查pay_status,先只看WHERE user_id = 9527 ORDER BY create_time DESC,其实可能更适合建idx_user_create(user_id, create_time),这样连filesort都能省掉。这就是一列一列读EXPLAIN、再反过来审视现有索引设计的过程。
4. Extra列里的警报:filesort、temporary、ICP各自暗示什么
如果说type和key_len告诉你“索引用得怎么样”,那Extra告诉你的是“MySQL在索引之外还额外做了什么”。Extra里出现的很多词,基本都是优化要动手的地方。
4.1 Using filesort:排序没走索引,代价很容易被忽略
Using filesort的意思是:这条SQL需要排序,但没能借助索引的有序性,于是MySQL得自己把结果集放进内存或磁盘排序。它不一定会慢死,但只要数据量上去了,排序本身的开销非常可观。
常见触发场景:ORDER BY create_time DESC,但create_time没有跟WHERE条件里的列组合成合适的联合索引。举个例子,WHERE category_id = 5 ORDER BY create_time DESC,如果只在category_id上建了单列索引,那MySQL会用索引找出所有category_id=5的记录,然后对这些记录按create_time做filesort。要是这个分类下有几千甚至几万条订单,每一次翻页都要全排一遍,性能自然就崩了。
正确做法是建(category_id, create_time)联合索引,让索引天然有序,Extra里就不再出现Using filesort。注意:联合索引的字段顺序必须是“等值条件列在前、排序列在后”,这个顺序写反了,排序还是吃不到索引。
还有一种常见的隐藏filesort是GROUP BY。MySQL里GROUP BY经常附带排序操作,如果不需要排序,可以加ORDER BY NULL来消掉,不过MySQL 8.0里这个优化方式已经不太需要了,优化器更聪明了。
4.2 Using temporary:临时表出现,基本可以判定SQL写得有问题
Using temporary意味着MySQL需要创建内部临时表来完成操作。常见于GROUP BY、DISTINCT、UNION,以及某些带ORDER BY子查询的嵌套场景。临时表如果小还能在内存里扛一扛,一旦数据量超过tmp_table_size或max_heap_table_size,就会落到磁盘上,那性能就是断崖式下跌。
我遇到过一个真实案例:一条统计SQL,对一张月流水千万级的表做SELECT COUNT(*) FROM ... GROUP BY province,EXPLAIN里type=ALL,Extra里既有Using temporary也有Using filesort。这意味着MySQL先把全表扫出来,存进临时表,再对临时表排序分组。那还谈什么性能?后来加了(province, id)的覆盖索引,并且改成子查询先去重再做聚合,Extra里的临时表和filesort都消失了,查询从4秒多降到0.2秒。
4.3 Using index condition / Using where / Using index / Using MRR
这四个词容易混淆,我一并给你说清楚:
- Using index:这是好消息,代表“覆盖索引”,查询所需字段都能从索引树里直接取到,不需要回表。你在优化的最高目标之一就是尽量让查询走到覆盖索引。
- Using index condition:这是ICP(Index Condition Pushdown,索引条件下推),MySQL把部分WHERE条件下推到存储引擎层,让存储引擎在读取索引记录时就过滤掉不满足条件的行,减少回表次数。这个属于正面的优化手段,但要注意它依然可能伴随回表。
- Using where:不算坏消息,也不是好消息。它表示存储引擎返回记录后,Server层还需要进一步过滤。常见于索引无法完全覆盖所有条件,比如
WHERE name = 'xx' AND status = 1,只有name列有索引,status过滤就要在Server层做。 - Using MRR:多范围读优化,存储引擎先收集一批主键,再按主键顺序批量回表,减少随机IO。看到MRR一般说明MySQL在尝试用工程手段缓解回表压力。
我把这些Extra常见值整理成一个速查表,方便你对照:
| Extra值 | 性能影响 | 典型SQL形态 | 你该做什么 |
|---|---|---|---|
| Using filesort | 负面 | ORDER BY字段不在索引中 | 调整/新增联合索引 |
| Using temporary | 负面 | GROUP BY、DISTINCT、UNION | 改写SQL,或建匹配索引 |
| Using index | 正面 | 覆盖索引 | 保持,值得追求 |
| Using index condition | 中性偏正 | 部分条件下推 | 确认回表量是否可控 |
| Using where | 中性 | 索引只覆盖部分条件 | 考虑增加过滤列到索引 |
| Using join buffer | 负面 | JOIN无索引可用 | 给关联字段加索引 |
5. 三个真实慢SQL的执行计划拆解:从输出反推根因
前面讲的是方法论,这一部分我把手头做过优化的三个典型案例po出来,每个都带完整EXPLAIN输出和当时的优化动作,让你看看“反推根因”到底怎么玩。
5.1 案例一:深分页的SELECT为什么会把CPU打满
原SQL长这样:
SELECT * FROM payment_order WHERE status = 1 ORDER BY id DESC LIMIT 100000, 20;payment_order有三百万行,status=1的记录约60万条。EXPLAIN输出如下:
| id | table | type | key | key_len | rows | Extra |
|---|---|---|---|---|---|---|
| 1 | payment_order | ref | idx_status | 1 | 620000 | Using index condition |
乍一看type=ref,索引也用上了,问题在哪?在rows=620000。因为LIMIT 100000, 20意味着MySQL要从这62万条里先按id倒序找出前100020条,然后丢掉前10万条,只返回最后20条。也就是说,索引虽然命中了status=1,但深分页让前面的10万次索引遍历全浪费了。
解决办法是改成“游标分页”写法,把LIMIT offset转换成基于上一页最后一条id的条件:
SELECT * FROM payment_order WHERE status = 1 AND id < 100020 ORDER BY id DESC LIMIT 20;这里把上一页最后一条记录的id(假设是100020)传进来,让MySQL直接从id < 100020开始扫,扫20条就停,整体执行次数从62万变成20。改完之后EXPLAIN的rows直接变成20,查询从1.8秒变成20毫秒级别。
5.2 案例二:关联查询驱动表选错,执行计划排序才是关键
有两条表:用户表user(50万行)、订单表payment_order(300万行)。业务查询是找出某时间段内注册用户和他们的订单数:
SELECT u.id, COUNT(o.id) FROM user u LEFT JOIN payment_order o ON u.id = o.user_id WHERE u.created_at >= '2024-01-01' GROUP BY u.id;当时的EXPLAIN第一行table是payment_order,type=ALL,rows=300万,第二行才是user,type=range。这意味着MySQL选择了payment_order作为驱动表,user作为被驱动表,先把300万行订单全扫出来,然后再去关联用户并做分组聚合。这是一条灾难级别的执行计划。
为什么优化器会做出这种选择?多半是因为统计信息不准,或者我们当时在payment_order.user_id上还没有索引,导致优化器算不清楚怎么关联更划算。解决办法很直接:在payment_order.user_id上加索引,同时把SQL改写为内连接并显式让用户表做驱动:
SELECT u.id, COUNT(o.id) FROM user u JOIN payment_order o ON o.user_id = u.id WHERE u.created_at >= '2024-01-01' GROUP BY u.id;加完索引后EXPLAIN里第一行是user,type=range,rows=8000左右;第二行是payment_order,type=ref,key=idx_user_id,rows=2左右。整个查询从12秒降到1秒以内。这个案例最有价值的点是:你以为SQL写法没问题,实际执行计划里驱动表的选择已经出卖了真正的瓶颈。
5.3 案例三:隐式类型转换让索引静默失效
这是一个特别容易踩的坑。表结构里用户手机号字段是VARCHAR(20),SQL写成:
SELECT * FROM user WHERE phone = 13812345678;注意等号右边是数字,不是字符串。MySQL在比较时会尝试把字符串字段转成数字,这意味着phone列上的索引无法正常使用。EXPLAIN输出:
| id | table | type | key | rows | Extra |
|---|---|---|---|---|---|
| 1 | user | ALL | NULL | 500000 | Using where |
type=ALL,key=NULL,50万行全扫。只要把条件改成phone = '13812345678',让类型一致,EXPLAIN立刻变成type=ref,key=idx_phone,rows=1。这个改动只需要加一对引号,但执行效率差了十万八千里。
类似的坑还有WHERE DATE(created_at) = '2024-01-01',对索引列使用函数导致索引失效。正确写法是范围条件:created_at >= '2024-01-01' AND created_at < '2024-01-02'。EXPLAIN里type会从ALL变成range。
6. 我在真实业务里看EXPLAIN的几条经验
这部分不打算给你讲新概念,就说点实用的工作习惯和踩坑心得。
6.1 先捞慢SQL,再谈EXPLAIN
EXPLAIN的一次输出只针对一条SQL。你要优化的是线上真实的慢查询,那就得先从慢查询日志或者performance_schema里把SQL捞出来,别看到一条SQL跑得慢就想当然地去猜。我习惯按“平均耗时 × 执行次数”来排序,先处理那些虽然单次不算最慢、但高频执行的SQL。一条每秒执行100次的400毫秒SQL,危害远大于一条每天执行一次却跑10秒的SQL。
6.2 不要只依赖传统EXPLAIN,会用TREE和ANALYZE
MySQL 8.0.18之后提供了EXPLAIN ANALYZE,它会真正执行这条SQL,并返回每一步的实际耗时和行数,比传统EXPLAIN的“估算值”靠谱太多。比如:
EXPLAIN ANALYZE SELECT * FROM payment_order WHERE status = 1 LIMIT 1000;输出会带上实际执行时间、返回行数、循环次数等信息。当你对EXPLAIN的rows估算有怀疑时,就用EXPLAIN ANALYZE去验证一下。此外EXPLAIN FORMAT=TREE可以把执行计划以树形结构输出,阅读起来比表格直观得多,特别适合看多表JOIN的嵌套关系。
6.3 审查执行计划时,我固定检查这几个点
每次改完一条SQL后,我都有一个固定checklist:
- type是否从ALL提升到了range/ref/eq_ref?如果没有提升,先别急着想加索引,先问为什么优化器不选。
- key_len是否覆盖了你的所有等值条件?联合索引后面几列到底用没用上,这里一眼就能看出来。
- Extra里是否还有Using filesort或者Using temporary?如果有,优先解决排序和临时表问题。
- rows和真实数据量是否在一个量级?如果rows显示几万,但你觉得应该只有几百,检查统计信息是否过期,或者条件本身是否有隐式类型转换。
还有一条容易被忽略的:EXPLAIN的结果是会变的。同样的SQL,在数据量增长后、加了新索引后、甚至同一条SQL在不同参数值下,执行计划都可能完全不同。所以不要看一次结果就下终身结论,每次大版本发布、大促前,我会把核心查询的EXPLAIN重新过一遍。
说回开头那个场景——如果现在有人再拿一条慢SQL问你,EXPLAIN出来type=ALL、key=NULL、Extra里一堆filesort,你应该能清楚地告诉他:这条SQL要优化的不是一个点,而是从索引设计到SQL写法的一条完整链路。能看懂EXPLAIN里每一列的含义和它们之间的联动关系,才算真正迈进了SQL性能调优的门。我自己这些年优化线上MySQL慢查询,几乎没有一次是脱离执行计划靠猜索引猜出来的。实践出真知,收藏再多执行计划解读表格,都不如你亲手跑一条EXPLAIN,再把慢日志里那些“老朋友”逐一拎出来过一遍。