☰
MySQL EXPLAIN详解:用执行计划定位慢SQL,索引优化不再靠猜
2026/10/20 22:48:00 网站建设 项目流程

MySQL的EXPLAIN用好了,就是一条SQL的体检报告。每次线上接口慢、报表跑不动、分页越翻越卡,我第一个做的动作几乎都是复制SQL出来,前面加个EXPLAIN,看它到底是怎么在表里翻数据的。很多人一说优化就只知道“加索引”,但到底该加在哪一列、为什么加了索引SQL还是走全表扫描、明明用了索引查询还是慢……这些问题光靠猜是不行的,EXPLAIN能直接告诉你答案。

这篇文章完全围绕EXPLAIN展开。从输出列怎么读、type和Extra那些值分别代表什么,到真实慢SQL的优化全过程,再加上FORMAT=JSON、EXPLAIN ANALYZE这些高级玩法,最后把我踩过的坑一并列出来。不管你是刚接触MySQL的新手,还是已经写过不少业务SQL的开发,这套分析思路都能直接套用。

1. 先搞清楚EXPLAIN在分析什么

1.1 优化器在动手之前都想了什么

MySQL执行一条SQL,并不是拿到语句之后直接开干。它先要做词法分析、语法分析,把SQL变成一棵解析树,然后交给优化器。优化器的任务是在多个可能的执行路径里挑一条“成本最低”的方案——你觉得你的SQL就一种写法,但在MySQL眼里,全表扫一遍、走哪个索引、先查哪张表再关联哪张表、排序用不用临时文件,这些都是可选路线。

EXPLAIN就是把这个“选择过程”的结果打印出来。它不会真正执行你的SQL,只是让优化器把选定后的执行计划告诉你。这也是为什么同一个SQL在数据量不同、索引不同、统计信息不同的时候,EXPLAIN的结果可能完全不一样。理解这一点非常重要,因为我见过不少人把EXPLAIN的输出当成一成不变的真理,其实它给出的只是一个基于当前状态的估算。

优化器估算成本时会参考表的行数、索引的区分度、数据页的分布情况等信息。这些信息来自MySQL的统计信息,而统计信息不是实时更新的,所以用EXPLAIN发现一条SQL没走索引后,第一件事是确认统计信息是不是过期了。我遇到过有张表数据删了一半,执行计划还是一直走全表扫描,后来执行了一次ANALYZE TABLE,优化器立刻选了之前一直没用的索引。

1.2 EXPLAIN为什么只能做“预判”

EXPLAIN本身不执行查询,只能基于优化器的成本模型做估算。估算和真实执行结果之间可能有偏差,偏差主要来自几个方面:

  • 统计信息不准确,比如刚大量增删过数据还没重新收集统计信息
  • 索引区分度不高,优化器认为全表扫描成本更低
  • 条件里带有不确定函数,比如NOW()、RAND()之类的
  • 嵌套子查询里的依赖关系比较复杂,优化器没法精确推算每层返回多少行

所以我的习惯是:先用EXPLAIN看执行计划,判断瓶颈方向,再用真实查询确认返回行数和耗时。如果EXPLAIN显示估算扫描行数是10万,实际查询结果只有100行,那说明过滤条件生效了,问题可能出在排序或者关联阶段;如果估算和实际差距巨大,优先考虑统计信息的问题。

提示:EXPLAIN适合判断“路径”,不适合判断“耗时”。路径选错了,耗时一定长;路径选对了,耗时不一定短。定位具体耗时用PROFILING或者EXPLAIN ANALYZE更准。

2. 逐列拆解EXPLAIN输出,搞懂每一个字段的真实含义

2.1 一行记录就是执行计划中的一个步骤

EXPLAIN输出的结果是多行记录的集合,每一行代表执行计划中的一个“步骤”,而不是一张表对应一行。多表JOIN时,你会发现出现多行,它们的顺序并不完全等于SQL里表的书写顺序,而是优化器决定的执行顺序。第一行往往是查询的起点,后续行是逐步嵌套关联进去的。

id列用来标识执行步骤,相同id说明这些表在同一个查询层次里,id越大越先执行。很多人一味追求“全是const”“全是eq_ref”,其实并不必要,不同场景合理即可。重点要关注的是type、key、rows、Extra这四列,它们决定了这条SQL是“快但准”还是“慢且险”。

2.2 type列:连接类型的健康等级

type列直接反映MySQL在访问表时用了什么方式,它从好到差大致是:

type含义典型场景
system表里只有一行极少见,一般系统表或极限条件下出现
const按主键或唯一索引查询,最多返回一行WHERE主键=值
eq_ref每次只匹配一行,常见于JOIN主键关联JOIN的驱动表走唯一键
ref使用非唯一索引前缀匹配WHERE普通索引列=值
range使用索引做范围扫描BETWEEN、IN、>、<等条件
index遍历整个索引树覆盖索引但仍然要全量扫
ALL全表扫描没有可用索引或优化器认为更优

最怕的是看到ALL。全表扫描在数据量小的时候无感,一旦表里几百万行甚至上千万行,性能就会直线下降。但也要清醒:ALL不一定代表SQL写得有问题,有可能你查的就是一张只有几十行数据的配置表,全表扫反而最快。如果大表查询遇到ALL,优化方向就是看能不能通过调整WHERE条件命中索引,或者改写成更合理的关联逻辑。

range是日常业务中见到最多的类型,比如时间范围查询、分页查询、IN列表查询。它虽然不如ref那么精准,但已经利用了索引的范围扫描能力,通常可以接受。如果range扫描后rows非常大,那意味着过滤出的数据量太多,后续排序、分组、回表成本依然很高,该考虑分页细化或用其他条件压缩范围。

2.3 key、rows、filtered:三个数字放在一起看才有意义

possible_keys列出可选索引,key是优化器真正选中的索引。我经常看到有人盯着possible_keys发愁:“明明有这个索引,为什么没走?”其实possible_keys只是“备选项”,key才是最终决策。优化器选了别的路径,通常是基于成本计算的结果。你可以在不改SQL语义的情况下用FORCE INDEX强制走某个索引做对比测试——注意,这只是验证手段,生产环境不要长期依赖FORCE INDEX,因为数据量变化后强制索引可能适得其反。

rows是优化器预估要读取的行数,filtered表示经过WHERE条件过滤后剩余行数的百分比。两者相乘大约等于该步骤最终返回给上层步骤的行数。比如rows=10000、filtered=10,则预计返回1000行。如果filtered值极低,但rows又特别大,说明索引筛选了大量数据,条件字段加上索引可能更好。如果rows已经很小,但查询还是慢,瓶颈通常不在扫描阶段,而是在排序、临时表、回表等环节。

key_len是索引使用的字节数。它很有用——复合索引到底用到了哪几列,看key_len就能判断。比如一个复合索引idx_abc(a,b,c),都是INT类型,key_len是4时说明只用到了第一列,是8时说明用到了a和b两列,是12才是三列都用到。如果复合索引只用到了部分列,业务上又希望更精确的匹配,可以考虑调整索引列顺序或根据实际情况新增更合适的索引。

2.4 Extra列:藏着很多性能细节的提示信息

Extra列的内容就像执行计划的“批注”,里面经常出现Using index、Using where、Using temporary、Using filesort这些关键词,每一个都对应不同的性能信号。

Using index说明是覆盖索引,查询所需的列全部在索引树上,不需要回表,速度极快。这是一种非常理想的状态。Using where表示存储引擎返回行后还要再做条件过滤,通常是当前索引无法完全覆盖WHERE条件导致的。Using temporary意味着MySQL创建了临时表来处理结果,常见于GROUP BY、ORDER BY列不一致或DISTINCT场景。临时表可能建立在内存中,数据量大时会落到磁盘,非常伤性能。Using filesort听起来吓人,其实它不一定是文件排序,只是表示排序无法直接利用索引的顺序,需要额外排序操作。如果是内存排序还好,一旦sort_buffer不够用就会落盘,慢就慢在这里。

还有一种值得关注的是Using index condition,它对应的是索引下推(Index Condition Pushdown)。简单说就是MySQL把部分WHERE条件下推到存储引擎层,在读取索引时就过滤掉一部分行,减少回表次数。看到这个,多数情况下是好事,说明你的条件可以用索引字段做前置过滤。

注意:如果Extra里同时出现Using temporary和Using filesort,多半意味着你要对SQL做分组、排序结构上的调整了,光加索引不一定能解决。

3. 从一条真实慢SQL说起:完整的分析优化流程

3.1 现场现象和初步判断

有次一个线上接口超时严重,接口内部主要就执行一条订单查询。原始SQL大概是这样的:

SELECT order_id, user_id, amount, status, created_at FROM user_order WHERE status = 1 AND created_at >= '2024-01-01' AND created_at < '2024-02-01' ORDER BY created_at DESC LIMIT 20;

请注意,这不是具体某家公司的代码,只是一个类似的模拟场景。当时表里大概有800万行,用户反馈翻页特别慢。EXPLAIN结果如下:

idtypekeyrowsExtra
1ALLNULL8000000Using where; Using filesort

type是ALL,key是NULL,rows是800万,Extra里还有Using filesort。这意味着MySQL把整张表扫了一遍,然后还在内存里做了排序,再取20行。数据量小一点还无所谓,800万行全扫一遍再排序,再快的机器也扛不住。

status和created_at在两张独立索引上吗?其实是有的,单列索引status_idx和created_at_idx都存在。但优化器为什么宁可全表扫描也不走索引?因为它估算下来,如果用status索引取回大量行再回表,成本比全表扫描还高。谁让你status=1的行占了大半张表呢。索引不是万能药,低区分度字段的索引经常被优化器放弃。

3.2 第一步优化:把单列索引调整为复合索引

这个查询的特征是:一个等式条件status=1,一个范围条件created_at。范围条件+排序字段如果都在同一个索引里,合并效果会好很多。于是我把status_idx和created_at_idx删掉,新建了复合索引:

ALTER TABLE user_order ADD INDEX idx_status_created (status, created_at);

这里有一个关键点:复合索引的顺序,等式条件的列放前面,范围条件列放后面。因为索引最左前缀原则,status=1可以直接定位到一个区间,然后在这个区间内按created_at天然有序地扫描。更重要的是,ORDER BY created_at可以直接利用索引的有序性,连排序操作都省了,Using filesort自然消失。

再看EXPLAIN:

idtypekeyrowsExtra
1refidx_status_created8900Using index condition

rows直接从800万降到8900,type从ALL变成ref,Extra里还出现了Using index condition。这个查询的单次耗时就降到了几十毫秒以内,翻页接口恢复正常。

为什么不是把created_at放前面、status放后面?如果你把created_at放在复合索引第一列,那status=1的等值过滤就只能在索引内取到若干区间后做过滤,无法精准定位到status=1的起点。而且ORDER BY created_at是降序,索引默认是升序存储的,MySQL 8.0版本之前不支持降序索引,所以倒序读取索引依然可能导致排序。虽然现在的MySQL版本里可以用DESC关键字创建降序索引,但最稳妥的做法仍然是让等值条件在最左,让范围条件决定扫描区间。

3.3 第二步验证:看看还有没有再优化的空间

索引建好之后,我又看了一下返回行数和覆盖情况。这个查询SELECT的列包括user_id、amount这些,它们不在索引里,所以需要回表。也就是说Extra是Using index condition而不是Using index,说明每命中一行,就要按主键回表取一次完整行。查询只取20条,回表成本不大,没必要为了全覆盖再加更多列把索引撑得很大。

如果有一天要查询的量特别大,比如需要统计整个月的订单,那么可以考虑把查询改成覆盖索引的形式,把常用的列都放进索引。但索引不是越宽越好,索引页越大,读取成本越高,写入代价也越大,需要在查询加速和写入性能之间做取舍。我这里只取20行,保持现有索引就很合理。

3.4 优化后带来的另外一层影响

新的复合索引不单单对这个查询有效。对于类似的管理端列表查询,比如状态栏加上时间段的组合筛选,也会命中这个索引。虽然idx_status_created是按status精确匹配后按时间有序的组合,但对于后台那种按状态过滤、按时间排序的常见需求,它同样管用。

不过也要注意,如果查询条件里只带created_at而不带status,那这个索引就发挥不了作用。因为最左前缀原则决定了索引最左边的status没有被条件约束时,优化器无法利用这个索引做范围扫描。所以如果业务上经常单独按时间去查,还得单独保留一个created_at索引。我在做这个调整时也保留了created_at单列索引,避免另一种查询路径退化。

4. 从EXPLAIN还能看到更细的成本数据:高级用法

4.1 EXPLAIN FORMAT=JSON:把成本值拉出来看

普通EXPLAIN输出是表格形式,很多内部数据被吞掉了。想要看具体成本,可以用:

EXPLAIN FORMAT=JSON SELECT order_id, user_id, amount, status, created_at FROM user_order WHERE status = 1 AND created_at >= '2024-01-01' AND created_at < '2024-02-01' ORDER BY created_at DESC LIMIT 20;

输出会变成一段JSON结构,里面会看到read_cost、eval_cost、prefix_cost、rows_examined_per_scan等字段。我一直把prefix_cost当作核心参考值,因为它累加了整个执行步骤之前的成本。对比两个不同写法的SQL时,prefix_cost越小,优化器认为总成本越低。

JSON格式的另一个好处是能看到每个步骤使用了哪个索引、覆盖了哪些列、是否有用了MRR(Multi-Range Read)优化。比如输出里出现"attached_condition"字段,里面会显示索引下推条件下推到什么程度。对于调试复杂的关联查询,JSON输出的层次结构比表格清晰得多,因为它能体现哪张表是内层、哪张表是外层,关联顺序一目了然。

4.2 EXPLAIN ANALYZE:不只是“预估”,而是真正执行

从MySQL 8.0.18开始,EXPLAIN ANALYZE可以直接执行SQL并返回真实执行时间和各步骤耗时。它的输出不是分析预估,而是实测数据:

EXPLAIN ANALYZE SELECT order_id, user_id, amount, status, created_at FROM user_order WHERE status = 1 AND created_at >= '2024-01-01' AND created_at < '2024-02-01' ORDER BY created_at DESC LIMIT 20;

输出里有actual time,比如actual time=0.187..0.254。前面是取第一行的时间,后面是取完所有行的时间。如果两个数字差距很大,说明数据获取过程是渐进式返回的,不是批量一次性返回;如果第一个数字就很大,说明SQL在准备阶段就费了很大功夫,比如建立了临时表或者排序区。

这个命令的好处是能直接告诉你真实扫描了多少行、返回了多少行、每个步骤实际执行耗时。它不会用估算值糊弄你。但缺点也很明确:它真的会执行SQL,如果是UPDATE、DELETE或者非常重的报表查询,线上环境要小心使用,别直接在业务高峰期跑,否则可能本身就成了新的慢SQL。

4.3 把EXPLAIN和慢查询日志配合起来用

我排查线上慢SQL的固定流程一般是三步:第一步,从慢查询日志里把耗时长的SQL捞出来;第二步,对捞出来的SQL跑EXPLAIN,确认执行计划里的扫描行数和Extra列;第三步,如果涉及真实耗时确认,再根据查询的轻重决定要不要用EXPLAIN ANALYZE。

慢查询日志里通常能看到实际执行时间和扫描行数,但它不会告诉你为什么慢。EXPLAIN恰恰补上这一段因果:扫描行数暴涨、走了ALL、发生了临时表和文件排序,每一条都在告诉你慢在哪里。通过set global slow_query_log=ON打开慢查询日志后,mysql.slow_log表或慢日志文件里会积累一堆候选SQL。重点关注那些Rows_examined和Rows_sent差距特别大的,前者是实际读取行数,后者是最终返回行数。如果读取行数是返回行数的几百倍,说明SQL的过滤效率极低,找到对应SQL后用EXPLAIN去拆解,优化点自然浮出水面。

注意:EXPLAIN ANALYZE是真实执行,EXPLAIN FORMAT=JSON不真实执行。前者适合在测试环境或者确定可以接受的查询上用,后者适合上线前的通用检查。

5. 常见问题与排查技巧实录

5.1 为什么明明有索引,EXPLAIN还是走全表扫描

这是被问得最多的问题。有索引却不走索引,常见原因有这么几类:

  • 数据量不大:表就几百行甚至几十行,优化器计算后认为全表扫描更划算。
  • 区分度太低:比如一个性别字段只有两个值,索引扫出来的结果集过大,回表成本超过全扫描。
  • 统计信息过期:数据分布变化后MySQL“印象”还停留在旧状态,用ANALYZE TABLE刷新。
  • 查询条件写法导致索引失效:对索引列使用函数、隐式类型转换、LIKE前置通配符等。

遇到不走索引,先看EXPLAIN的rows是多少、using where是什么状态,不要急着加索引。几年前我参加过一个模拟项目,某同事的做法就是遇到一次慢查询就加一个索引,最后一张表上挂了十几个索引,写入慢得离谱,查询还经常因为优化器选错而更慢。合理的思路是,根据EXPLAIN判断是扫描行数问题还是过滤条件问题,再决定是不是真的需要新增索引。

5.2 隐式类型转换:索引列和查询值类型不一致

有个典型的坑:字段是VARCHAR类型,但查询时传的是数字。MySQL会自动把字符串转成数字或者反过来转,但这个转换放在索引列上时,会导致索引无法直接用于搜索。比如:

SELECT * FROM user_order WHERE mobile_no = 13800138000;

如果mobile_no是VARCHAR,MySQL会把mobile_no隐式转换为数字再比较,这时候相当于对索引列做了函数运算,优化器只能放弃索引。EXPLAIN输出里key是NULL,Extra出现Using where。把数字改成带引号的字符串:

SELECT * FROM user_order WHERE mobile_no = '13800138000';

索引立刻就能命中。类似的情况还有日期字符串和DATETIME比较时因格式不匹配引发的类型转换。这个问题的根源不在MySQL,而在参数传入方式。如果你的代码框架里有SQL拼接而且没有正确加引号,最容易踩雷。我有一次排查一个接口慢问题,最后发现是某框架把所有参数自动转成了整型,导致日期条件列被隐式转换掉,走了全表扫描。

5.3 LIKE条件、OR条件、函数包裹的字段

LIKE前缀通配符是经典场景。LIKE 'abc%'是可以走索引的,LIKE '%abc'不行,LIKE '%abc%'基本不行。因为索引的排序结构只支持从前缀方向匹配,前缀不确定就无法利用索引树的查找能力。如果你的业务里必须做模糊搜索,建议使用全文索引或者独立的搜索引擎方案,而不是指望数据库的普通索引。

OR条件也会让索引失效。比如WHERE status=1 OR created_at>'2024-01-01',即便status和created_at上都有索引,优化器也可能不走索引,因为两个条件取并集,需要分别扫描再合并,成本不一定低。碰到这类SQL,优先改成UNION或者拆成两个查询。如果你用的是MySQL 5.7以上版本,OR条件两端都能用索引的情况下优化器还是有机会走Index Merge的,但结果很不稳定,不能依赖。

函数包裹字段的问题和隐式类型转换本质一样。比如WHERE DATE(created_at)='2024-01-01',就是对created_at做了函数运算。你在执行计划里看到的往往是key为NULL,rows巨大。改成范围条件created_at >= '2024-01-01 00:00:00' AND created_at < '2024-01-02 00:00:00',优化器就能用索引做range扫描。一个原则实用性很强:等号左边保持原始的列,所有计算都放到右边去。

5.4 被LIMIT和COUNT(*)误导的执行计划

LIMIT 20这种条件下,EXPLAIN显示的rows可能只是估算的前20行扫描量,但真实执行可能扫描了数十万行才停下来。EXPLAIN是估算值,LIMIT场景下它无法精确预估每个匹配位置前的扫描代价。如果你在翻页查询里发现后几页特别慢,不要光看EXPLAIN的rows,尝试EXPLAIN ANALYZE看真实扫描行数,往往真相是每往后翻一页,扫描行数就越大。缓解方案是使用“延迟关联”或“游标式分页”:先查到主键ID列表,再用主键回表取完整行。

COUNT()也会骗人。InnoDB不单独存储总行数,COUNT()只能老老实实统计,即便EXPLAIN显示走了二级索引的index扫描,不需要回表,但扫描的数据量依然是全表级。因为二级索引节点数可能比聚簇索引少,走二级索引统计比主键快,但本质上仍要遍历全量索引。业务上如果频繁需要大数据量的COUNT,应当考虑缓存计数或者独立的计数表。

5.5 每次查询只取必要字段,减少回表

回表是隐形的性能杀手。同样一条SQL,SELECT *和只查询索引包含的列,Extra列会从Using index condition变成Using where,可能多出大量随机I/O。随机回表的成本比顺序扫描大得多。我在项目里见过一条SQL从SELECT *改成只查需要的列,性能提升好几倍的例子。配合覆盖索引使用,如果能把常用查询的字段都吸收进索引,那回表就完全消失了。不过还是那句话,索引宽度要控制,千万别为了一点查询收益把索引列堆得太宽。

6. 用EXPLAIN建立起自己的SQL健康检查习惯

6.1 我平时执行EXPLAIN的几个进阶习惯

习惯一:不光看type和key,固定看rows、filtered、Extra三个字段组合。这三个字段告诉你的是“数据集怎么流动的”,type和key只是“门从哪里开”。只看门从哪里开,不看数据量有多大,很容易被假象迷惑。

习惯二:同一张表的SQL不只测一条。我会把相近查询都跑一遍EXPLAIN,确认它们都能命中同一个核心索引。一个索引如果是为一条SQL建的,但相似SQL用不上,说明索引设计有问题,需要重新设计列顺序。

习惯三:对GROUP BY、ORDER BY、DISTINCT这类SQL,特别关注Using temporary和Using filesort。频繁出现的分组排序类慢SQL,多数不是扫描慢,而是排序和临时表慢。排序字段和分组字段尽量保持一致,尽量利用索引天然有序性来消除filesort。

习惯四:上线前过一遍EXPLAIN已经变成例行检查。特别是新上线报表或者管理后台查询,一旦发现ALL或者Using filesort,就打回去改。这个问题越早发现越省事,上线后再排查要面对的东西就多很多了。

6.2 对EXPLAIN发挥作用的边界,也要有清醒认识

EXPLAIN不是万能的。它看不到行锁等待、网络传输耗时、连接池排队这些问题。有时候一条SQL执行计划非常漂亮,但实际慢得出奇,那问题可能不在执行路径,而在锁竞争、磁盘I/O延迟或者服务器负载。遇到这种情况再去反复调索引就是白费力气,应该看PROCESSLIST、INNODB_TRX、慢日志里的Lock_time指标。

还有一点,EXPLAIN基于优化器当时的统计信息,它给你的是“此刻最优”的预判。数据量增长、索引变化、参数调整都会影响执行计划,同一个SQL在开发环境和生产环境的EXPLAIN输出不一定一样,所以别拿开发环境的EXPLAIN结论直接推断生产环境。我一般在稳定环境上做验证,生产数据有关系时才在生产环境跑无害的查询EXPLAIN,重查询就先在从库或测试库上验证。

EXPLAIN读熟了以后,你会发现它已经变成一种直觉。拿到一条慢SQL,不用看完整输出,扫一眼type和rows大概就能嗅到问题在哪个环节。这种能力不是背出来的,是反复看执行计划、反复和真实执行时间对比,慢慢沉淀出来的手感。我一直觉得,与其背一堆“什么情况索引失效”的口诀,不如把每条失败SQL的EXPLAIN切切实实看一遍,经验会自己长出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询