ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL EXPLAIN执行计划详解:从慢查询定位到索引优化实战

MySQL EXPLAIN执行计划详解:从慢查询定位到索引优化实战

MySQL的EXPLAIN命令,大概是所有做数据库性能优化的人最先接触也最常用到的工具。一句EXPLAIN加在SQL前面,就能看到优化器打算怎么执行这条查询:先读哪张表、走没走索引、预估扫多少行、要不要排序和建临时表。恰恰是这些信息,决定了线上SQL是几十毫秒返回还是拖到超时告警。我前阵子处理一个订单查询慢的问题,表里不到三十万行数据,接口稳定在1.8秒,EXPLAIN一发出来问题就摆在脸上:WHERE条件里对索引字段做了函数运算,type直接降到ALL。类似场景几乎每个月都会遇到。

这篇文章想把自己实际用EXPLAIN的经验做一次完整梳理,从命令怎么用、输出每一列怎么读,到真实案例的调优过程和常见坑点,尽量覆盖日常排查慢查询会碰到的绝大多数情况。适合手里已经能写SQL、开始被线上性能问题折磨的开发者,也适合需要定期Review慢日志的DBA或后端负责人。我会尽量用实际场景来讲,因为执行计划这东西,光背列名没有意义,得结合真实SQL和索引设计去理解才有感觉。

1. EXPLAIN怎么用:基础命令与输出全貌

1.1 一条EXPLAIN命令,到底能告诉我们什么

EXPLAIN的用法简单粗暴,在SELECT、UPDATE、DELETE、INSERT语句前面加上EXPLAIN关键字就可以了。MySQL 5.6之后对UPDATE和DELETE也支持EXPLAIN,这点比早期版本方便不少,很多改数脚本的慢查询排查都靠它。

执行后返回的是一张结果表,每一行代表查询计划中的一个步骤。多行结果就说明这个SQL被拆成了多个执行步骤,比如子查询、UNION、多表JOIN都会出现多行。每一行里包含了访问哪张表、用什么访问方式、预计读取多少行、Extra列还带着排序、临时表等额外信息。

需要特别强调的一点:默认的EXPLAIN并不会真正执行SQL,它只做优化器层面的计划生成和成本估算,所以线上库可以直接跑,不会对业务数据产生写操作,也不会真的把慢查询完整执行一遍。但后面要讲的EXPLAIN ANALYZE是例外,它会真实执行查询,使用时要谨慎。

新版本里EXPLAIN还支持几种输出格式,常见的包括:

  • EXPLAIN FORMAT=TREE SELECT ...:树形结构展示执行计划,层次感更强,MySQL 8.0.16起默认支持。
  • EXPLAIN FORMAT=JSON SELECT ...:返回JSON结构,里面包含更详细的成本估算数据,是我们理解优化器决策的重要线索。
  • EXPLAIN ANALYZE SELECT ...:MySQL 8.0.18引入,会实际执行SQL并输出每一步的真实耗时、真实返回行数、循环次数,属于性能分析利器。

日常我们先看默认表格输出,遇到复杂SQL再用FORMAT=JSON或ANALYZE做深入分析。这个顺序能保证大多数问题在五分钟内定位。

1.2 输出列一眼看懂:重点关注哪些字段

不同MySQL版本输出的列略有差异,但关键列是稳定的。常见的列大致如下:

列名含义关注度
id查询步骤的编号,id越大越先执行,相同id从上往下执行中
select_type查询类型,比如SIMPLE、PRIMARY、SUBQUERY、DERIVED、UNION等中
table当前步骤访问的表名或衍生表名低
partitions命中的分区(如果是分区表)低
type访问类型,从system到ALL的性能阶梯,是调优的第一观察点高
possible_keys优化器认为可能用到的索引中
key实际选择的索引高
key_len实际使用的索引字节长度,能看出复合索引用了几个字段高
ref与索引比较的列或常量低
rows优化器预估要读取的行数高
filtered预估被WHERE条件过滤后剩余行数的百分比中
Extra附加信息,包含是否排序、是否用临时表、是否覆盖索引等高

四张表里最需要盯紧的就是type、key、rows、Extra。type决定访问数据的方式,key决定走了哪条索引,rows反应了优化器对这个方案的成本预估,Extra则透露了查询中隐藏的排序、临时表、回表代价。

很多新人一上来喜欢死抠select_type和ref的含义,但实际排查慢查询时,这两个字段提供的信息没有前面四个那么直接。我的建议是先抓住主要矛盾,理解type和Extra,再看key_len和rows做深入确认,等遇到子查询和复杂JOIN时,再回头研究select_type也不迟。

2. 访问类型与索引细节:type、key_len、rows怎么配合着看

2.1 type列:从system到ALL的完整性能阶梯

type列是执行计划里最有信息量的字段,它描述了MySQL如何从表中读取数据。从最优到最差,常见类型大致有这么一档:

type典型场景性能含义
system表中只有一行记录,系统表级别常数级,几乎无成本
const根据主键或唯一索引等值查询,最多返回一行常数级,优化器把结果当成常量处理
eq_refJOIN关联时,被驱动表用主键或唯一索引做等值匹配每次只匹配一行,联表查询中最理想的情况
ref使用普通二级索引做等值查询,可能返回多行比ALL好很多,但回表次数取决于匹配行数
range使用索引做范围查询,比如BETWEEN、>、<、IN有界扫描,数据量大时也能接受
index扫描整棵二级索引树,和ALL类似但数据量更小如果没有回表,某些场景还能接受
ALL全表扫描,优化器决定把整张表读一遍性能杀手,线上大表出现这个基本就要介入

const和eq_ref是执行计划里最赏心悦目的两种形态。比如主键等值查询,或者JOIN时被驱动表通过主键关联,这类查询无论数据量多大,单次读取成本都基本固定。ref则是普通索引等值匹配,比如WHERE status = 1且status上建了二级索引,这个级别通常已经算健康。

range是我个人设的一个及格线。线上业务SQL,如果过滤条件不走索引且没有覆盖索引兜底,type掉到ALL通常意味着查询成本线性增长。而index级别是个容易迷惑人的状态——它没有全表扫描那么吓人,但本质上是把整棵二级索引树扫了一遍,如果还需要大量回表,实际性能未必比ALL好到哪里去。

需要补充的是,MySQL在5.6以后支持index_merge,某些特殊条件下优化器会把两个索引合并使用,type显示为index_merge。另外子查询中还有unique_subquery、index_subquery等类型,这些相对少见,遇到时单独看官方文档确认即可。

2.2 key_len:索引到底吃了几斤几两

key_len是很多人在读执行计划时会忽略的列,但它恰恰是判断复合索引利用率的核心依据。它表示MySQL在本次查询中实际使用的索引前缀长度,单位是字节。对于单列索引,key_len通常固定;对于复合索引,比如(a, b, c)三个字段,如果key_len只等于a字段的长度,说明只用到了索引的最左前缀,b和c没有参与匹配。

key_len的计算规则不复杂,但非常容易算错。以utf8mb4字符集下常见的varchar(50) NOT NULL字段为例,utf8mb4字符集每个字符最多占4字节,所以这个字段的理论最大字节数是50×4=200字节。由于是变长字段,MySQL还需要额外2字节记录实际长度,允许NULL时还要再加1字节。如果字段加上NOT NULL约束,那就没有这1字节了。

举一个我常用的例子:

CREATE TABLE users ( id INT NOT NULL, name VARCHAR(50) NOT NULL, age INT NOT NULL, KEY idx_name_age (name, age) );

在这个表上执行:

EXPLAIN SELECT * FROM users WHERE name = 'abc'; EXPLAIN SELECT * FROM users WHERE name = 'abc' AND age = 20;

第一条语句的key_len是202,第二条是206。计算逻辑是:name在utf8mb4下是50×4+2=202字节,age是INT占4字节,加起来206。如果看到第一条的key_len是202、第二条是206,就说明复合索引只用到了name一个字段,或者name+age都用上了。

学会看key_len之后,你能做一件很实用的事:不用猜优化器的意图,直接通过字节数验证复合索引的利用程度。比如索引建的是(category_id, create_time),执行计划里key_len如果只等于category_id的长度,说明排序列create_time没有参与索引匹配,这时候出现Using filesort就可以理解了。

2.3 rows与filtered:估算值背后的判断逻辑

rows列表示优化器预估需要读取的行数,这个值是成本模型的核心输入。它不是真实返回行数,只是基于统计信息做的估算。我把一个包含100万行的表做过测试,EXPLAIN显示rows有时会偏差三四倍,尤其在统计信息过期或者条件列数据分布严重倾斜时。所以看到rows不要当成真值,把它理解为优化器心中的“预期工作量”。

filtered列从MySQL 5.7开始出现在EXPLAIN输出中,表示存储引擎返回的数据经过WHERE条件过滤后,预计还剩多少比例的行。比如rows=10000,filtered=10,意味着预计有1000行会进入下一阶段处理。在JOIN场景里,这个值对判断驱动表的过滤效果很有帮助。

读这两列的正确姿势是结合看。如果type=ALL,rows=50万,filtered=1,说明优化器认为虽然要扫全表,但最终只有500行通过过滤条件。这种计划不好看,但并非不可接受,可能因为小表全表扫描比走索引更快。反过来,type=ref,rows=100,filtered=100,说明每条匹配的行都会被保留,过滤条件帮不上忙,那就要思考是否需要在SQL层面增加更 selective 的过滤条件。

实践中我发现一个高频误判:很多人看到rows很小就觉得查询没问题,忽略了filtered可能很低暗示的统计问题。如果在EXPLAIN里看到rows和实际情况明显不符,先执行一次ANALYZE TABLE更新统计信息,再重新看计划,往往会有不一样的结果。

3. 三个实战案例:从看懂执行计划到动手优化

3.1 案例一:无索引条件下的全表扫描订单查询

某次排查一个订单列表接口,业务方反馈最近数据量上来之后越来越慢,高峰期甚至要两秒多。SQL大概是这个形态:

SELECT * FROM order_info WHERE status = 1 ORDER BY create_time DESC LIMIT 20;

EXPLAIN的结果是:

idselect_typetabletypekeyrowsExtra
1SIMPLEorder_infoALLNULL480000Using where; Using filesort

type=ALL,key=NULL,rows显示48万行,Extra里还带着Using filesort。这条SQL涉及两块开销:全表扫描48万行,然后对所有满足status=1的记录做排序取前20。status=1在这个业务里大约占全表的四分之一,也就是说要扫描48万行、排序十几万行,耗时自然压不下来。

优化的方向很明确:建立复合索引让过滤和排序同时走索引。

ALTER TABLE order_info ADD INDEX idx_status_create_time (status, create_time);

建完之后的执行计划变成:

idselect_typetabletypekeykey_lenrowsExtra
1SIMPLEorder_inforefidx_status_create_time212000NULL

type提升到ref,rows降到1.2万,Extra里的Using filesort消失了。为什么排序消失?因为复合索引把status字段放在前面、create_time放在后面,等值匹配status=1之后,索引叶子节点上的create_time天然有序,ORDER BY create_time DESC可以直接反向扫描索引取前20条,完全不需要额外排序。实测这个接口从1.8秒降到30毫秒左右。

这里有个细节值得展开:如果SQL里过滤条件不止status,比如还要带上user_id,那复合索引的设计就得重新考虑最左前缀原则。核心方法是把等值筛选字段放前面,把排序字段放后面,让索引顺序与查询需求对齐。基于这个案例的经验,回看所有“在线列表类查询”,只要满足“等值过滤+排序+分页”的模式,基本都能用同一套思路解决。

3.2 案例二:filesort排序代价被复合索引消灭

商品列表页有这样一个查询,按分类筛选商品,再按销量倒序取前10条:

SELECT id, name, price FROM products WHERE category_id = 5 ORDER BY sold_count DESC LIMIT 10;

原始表上只有category_id单列索引idx_category。EXPLAIN结果如下:

idselect_typetabletypekeyrowsExtra
1SIMPLEproductsrefidx_category8000Using where; Using filesort

这个例子和案例一很像,但有个关键区别:索引上只有category_id,sold_count排在索引之外,所以即使WHERE过滤走索引,ORDER BY还是无法借用索引顺序,必须对所有满足category_id=5的记录做一次文件排序,然后再取前10。当这个分类下有几万商品时,filesort的代价就很可观了。

我给的建议是增加一个覆盖查询需求的复合索引:

ALTER TABLE products ADD INDEX idx_category_sold (category_id, sold_count);

执行计划变为:

idselect_typetabletypekeyrowsExtra
1SIMPLEproductsrefidx_category_sold8000NULL

注意这里Extra里连Using where都不见了,因为条件都通过索引直接完成,排序直接走索引倒序,取10条回表后返回。这种优化本质上是用索引的有序性替代了数据库额外的排序动作,代价是每次写数据时要多维护一棵索引树,所以索引不是越多越好,需要结合查询频率和写放大去权衡。

这里也想纠正一个观点:Using filesort不代表一定慢。MySQL的filesort如果排序数据量小,可能在内存中完成,也可以使用optimized single-pass排序算法,性能未必差。我之前遇到过一个SQL,Extra里一直显示Using filesort,但查询本身只要十几毫秒,完全没必要折腾。判断标准永远是实际耗时和工作集大小,而不是看到某个关键词就动手改。

3.3 案例三:多表JOIN驱动顺序怎么选

联表查询的执行计划里,第一行决定的往往不是SQL里写在FROM后面的第一个表,而是优化器选择的“驱动表”。驱动表会先被读取,然后用它的每一行到被驱动表里去匹配。优化器的目标是让全程代价最小,一般来说是选择过滤后行数更少的表作为驱动表。

看这个例子:

SELECT o.order_id, u.nickname, o.amount FROM order_info o INNER JOIN user_info u ON o.user_id = u.id WHERE u.vip_level = 3;

假设order_info有50万行,user_info有10万行,vip_level=3的用户大约500人。执行计划如下:

idtabletypekeyrowsExtra
1user_inforefidx_vip_level500Using where; Using index condition
2order_inforefidx_user_id1NULL

第一行驱动表是user_info,第二行是被驱动表order_info。优化器先取出500个VIP用户,然后对每个用户去order_info里用user_id索引查找订单,总匹配量是500×平均订单数。相比直接用order_info当驱动表,扫完50万行再逐个去user_info匹配,代价要低得多。

阅读这种执行计划时还有一个很实用的观察点:检查被驱动表的关联列上是否建立了索引。如果被驱动表的type是ALL,而且Extra里出现Using join buffer,说明关联列没有索引,MySQL要把驱动表的批量结果放到JOIN buffer里和被驱动表逐行做全表匹配。这种情况对大数据集是灾难性性能问题。只要给被驱动表的关联列加上合适的索引,type通常会变成ref或者eq_ref,整个查询立刻改观。

关于驱动顺序要不要人工干预,我的经验是不太需要。MySQL的成本模型大多数情况比人拍脑袋准确,与其用STRAIGHT_JOIN强制顺序,不如先把统计信息刷够,把必要的索引建好,让优化器自己选。强制顺序只是临时手段,而且很可能在数据量变化后变得不再最优。

4. 高频问题排查与进阶技巧

4.1 常见执行计划问题速查表

把日常工作中反复遇到的情况整理成一张速查表,遇到问题直接对号入座,效率会高很多。限于篇幅,我列几个出现频率最高的组合:

现象可能原因排查思路
type=ALL,rows很大缺少合适索引;索引条件因函数/隐式转换失效;优化器认为全表扫描成本更低先确认WHERE字段是否有索引,再看有没有对索引列做运算,最后用小表场景验证
有索引但key=NULL查询条件不满足最左前缀;数据量太小,优化器放弃索引检查复合索引字段顺序,或者用ANALYZE TABLE刷新统计信息后重看计划
Extra=Using filesortORDER BY字段不在索引中;复合索引字段顺序与排序方向不匹配分析排序字段能否加入到已有索引,注意等值字段放前、排序字段放后
Extra=Using temporaryGROUP BY或DISTINCT字段与索引顺序不一致;部分聚合操作无法消去临时表调整索引顺序让group by字段走索引,必要时接受合理临时表
key_len小于预期复合索引只用了最左前缀的一部分计算各字段字节长度,判断查没查出索引的能力边界
filtered很低统计信息过期;过滤条件背后的数据分布与均匀假设偏差大执行ANALYZE TABLE后复测,考虑单独统计高频值
Using index condition出现,但rows还是大索引下推确实生效,但匹配范围本身宽尝试用范围更精确的条件,或者重新设计联合索引,让更多行列进索引

排查这类问题有个通用顺序:先看type,判断有没有全表扫描级别的问题;再看key和key_len,确认索引是否被正确使用;接着看rows和filtered,估算代价是否合理;最后根据Extra里的排序、临时表、join buffer等信号定位细节。

4.2 SHOW WARNINGS和EXPLAIN ANALYZE:纸面判断如何验证

EXPLAIN输出的是优化器计划,但很多时候我们想知道优化器到底对我们写的SQL做了哪些“手脚”。MySQL里有一个被严重低估的命令:在EXPLAIN之后执行SHOW WARNINGS,可以看到优化器重写后的SQL语句。

比如写过一些复杂IN子查询、派生表嵌套时,实际执行的SQL和原始SQL可能已经形态不同。SHOW WARNINGS的Message列会展示优化器改写后的版本,这能帮你理解为什么某个索引被选上、某个子查询被转成了JOIN。以前遇到一个IN子查询的性能问题,原始语句写法是IN (SELECT id FROM ...),EXPLAIN显示有临时表。后来用SHOW WARNINGS一看,优化器已经把IN改写成了EXISTS形态,而且EXISTS子查询有独立的执行计划。顺着这个思路调整了子查询的结构,问题当场清晰很多。

FORMat=JSON同样值得用起来。它会暴露优化器的成本数据,比如read_cost、eval_cost、prefix_cost,这些数值能直观回答“为什么优化器放弃了我觉得更好的索引”。对比两个不同索引方案的JSON成本,比靠猜靠谱得多。

而在MySQL 8.0.18及以上版本中,EXPLAIN ANALYZE把纸面计划变成了真实测量。执行这个命令会真的运行SQL,并输出每个执行步骤的actual time、actual rows和loops。它对定位“优化器估算与实际严重偏离”的问题特别有效。不过要切记:这是会真实执行的,线上大查询需要谨慎使用,最好通过只读实例操作。

我在一次复杂报表SQL的调优中,EXPLAIN显示rows只有几百,但实际执行要好几秒。当时差点去改SQL结构。后来用EXPLAIN ANALYZE跑了一次才发现,其中一个步骤actual rows达到了十几万,与估算差了上百倍,根因是历史统计信息完全没有更新。清理统计信息并重建统计后,执行计划恢复正常。这个经历提醒我,EXPLAIN只是工具,最终要以实测为准。

4.3 关于索引失效的几个经典误区

最后聊几个我在带人和复盘过程中反复遇到的误区,每一个都对应过真实的线上事故。

第一个误区是“索引一定不会失效,失效一定是SQL写法问题”。实际上优化器选择全表扫描,很多时候是因为它计算出全表扫描成本更低。比如一张只有几千行的配置表,就算WHERE字段有索引,优化器也很可能选择ALL,因为顺序读几千行比随机IO回表还要快。这时候看到ALL不用慌,先看数据量再判断。

第二个误区是把“覆盖索引”和“走索引”混为一谈。走索引不等同于不回表。比如SELECT *加上二级索引等值查询,如果查询列不在索引里,MySQL拿到索引中的引用后还要回表取完整行。Extra里没有Using index就说明回表发生了。真正避免回表的方法是设计覆盖索引,让查询需要的字段全部包含在索引中,这时Extra才会出现Using index。

第三个误区是认为出现Using filesort就必须优化。我刚才说过,filesort在小结果集下真的不值一提。不要为了消除一个无伤大雅的filesort去建一堆索引,写库压力会把你坑回来。先看实际耗时,再看数据规模,最后才决定要不要为排序建索引。

第四个误区是忽略统计信息的影响。优化器不是神,它依赖的是表的统计信息。如果一张大表的统计信息长期不更新,EXPLAIN的rows可能偏得离谱,进而导致优化器选错索引。数据库不是出了性能问题才去ANALYZE TABLE,而是应该在批量数据导入、大量更新删除之后主动刷新统计信息。

最后再分享一个排查习惯,这个习惯帮我少踩了很多坑。拿到一条慢SQL,我从来不会只看一次EXPLAIN,而是结合慢日志里的实际执行时间、临时表落盘情况、IO等待指标一起看。EXPLAIN把“优化器打算怎么做”告诉了你,但数据库真正经历什么,还需要靠真实运行时的观察来交叉验证。只有把纸面计划、成本和运行事实放在一起,慢SQL分析才算做得扎实。希望这篇内容能帮你在下一次面对执行计划时,更快找到那条通往正确索引和合理SQL的路径。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进