☰
MySQL EXPLAIN执行计划详解:从看懂到优化SQL性能
2026/10/21 8:05:59 网站建设 项目流程

干了这么多年后端,线上SQL慢、接口超时,十有八九都能靠MySQL 的 EXPLAIN定位到问题。EXPLAIN 就干一件事:把一条 SQL 的执行计划摆在你面前,告诉你 MySQL 到底打算怎么跑这条语句——先查哪张表、走哪条索引、预估扫多少行、有没有额外的排序和临时表。这份执行计划,就是 SQL 性能问题的照妖镜。很多同学说“我加了索引查询还是慢”,其实大多数情况不是索引没加,而是索引没被用上,或者用错了方向,EXPLAIN 一眼就能看穿。

这篇文章我按自己平时排查慢查询的习惯来写,先讲怎么看懂每个字段,再用几个真实场景带你把一条问题 SQL 从“全表扫描”优化到“走索引”。适合刚学会写 SQL 但还没系统看过执行计划的后端开发,也适合那些想搞清楚EXPLAIN输出项到底什么意思的进阶读者。看完你至少能做到:拿到一条慢 SQL,不用猜,直接靠执行计划判断问题在哪。

1. 先搞清楚:EXPLAIN 到底在解释什么

1.1 一条 SQL 从客户端到返回结果经历了什么

MySQL 拿到一条查询语句后,不是直接去数据文件里捞数据,而是先经过一个“翻译”过程。简单说有这么几步:词法语法分析——检查你有没有写错;语义分析——检查表、列存不存在;然后生成执行计划;最后按照执行计划去存储引擎里取数据。

重点就是“生成执行计划”这一步。MySQL 里有一个优化器,它会根据表的统计信息、索引分布、连接顺序等因素,在多个可能的执行方案里挑一个它认为代价最小的。这个“代价最小”的方案输出来,就是你用EXPLAIN看到的内容。

所以你要明白,EXPLAIN 展示的不是 MySQL 实际执行的结果,而是优化器推算出来的预期方案。rows列是预估行数,不是真实扫描行数。理解这个前提很重要,不然你会被某些数值误导。

1.2 为什么说执行计划是索引优化的一手情报

很多开发者的索引优化思路是这样的:SQL 跑得慢,就盲猜是不是没加索引,然后往 where 条件里涉及的字段各建一个索引。结果可能是:索引建了一堆,SQL 还是慢;或者索引失效,MySQL 压根没走,甚至因为多余索引拖累了写入性能。

EXPLAIN 的价值在于,它告诉你 MySQL 在这条 SQL 上选择了哪条路。比如type是ALL,说明全表扫描;key是 NULL,说明你没用上索引;Extra里有Using filesort,说明排序没用上索引,MySQL 要额外排序。这些信息直接决定了你该不该建索引、建什么索引。

我自己的习惯是:任何一条需要上线的 SQL,特别是涉及到多表关联、子查询、排序列的,上线前必须跑一遍EXPLAIN,确认type至少是range级别,rows可控,Extra里没有临时表和文件排序。这应该成为团队的基本开发规范。

2. EXPLAIN 输出字段逐个拆解

MySQL 8.0 的EXPLAIN默认输出 12 个字段:id、select_type、table、partitions、type、possible_keys、key、key_len、ref、rows、filtered、Extra。下面我把它们分成几组来聊,比按顺序念一遍好记。

2.1 id 和 select_type:看清查询的骨架

id是 SELECT 的标识符。对于一条简单查询,id 只有 1;一旦出现子查询、UNION,就会出现多个 id。

这里有个新手最容易理解错的地方:id 的执行顺序不是从小到大,而是从大到小。id 越大,优先级越高,越先执行。如果多个行的 id 相同,那它们是同一组,从上往下顺序执行。

select_type表示这一行属于什么类型的查询:

select_type含义
SIMPLE简单查询,没有子查询和 UNION
PRIMARY最外层的 SELECT,比如包含子查询时外层那个
SUBQUERY子查询中的第一个 SELECT
DERIVED派生表,也就是 FROM 后面的子查询
UNIONUNION 中第二个及以后的 SELECT
UNION RESULT从 UNION 的临时表中读取结果的阶段

比如EXPLAIN SELECT * FROM orders WHERE id IN (SELECT order_id FROM order_item WHERE ...),你会在结果里看到两行,子查询那行select_type是SUBQUERY,id 更大。

2.2 table:这一步在操作哪张表

table列就是当前这一步访问的表名。有别名时显示别名。除了真实表名,你还会看到类似<derived2>、<union1,2>这样的值,表示这是一个派生表或者 UNION 合并结果。数字对应的就是 id,所以看到<derived2>,意味着这张“表”是 id=2 那一步产生的临时结果。

2.3 type:判断访问级别最关键的一列

type表示 MySQL 用哪种方式读取数据,从好到差大概是这样:

system > const > eq_ref > ref > range > index > ALL

逐个说:

  • system:表只有一行,基本只有系统表才会出现,业务表很少见。
  • const:通过主键或唯一索引等值匹配,结果只有一条,这是最理想的情况。
  • eq_ref:多表 JOIN 时,被驱动表通过主键或唯一索引等值匹配。典型就是 JOIN 的关联字段是主键。
  • ref:通过普通索引等值匹配,可能返回多行。比如WHERE user_id = 123,如果 user_id 是普通索引,就是 ref。
  • range:索引范围扫描,BETWEEN、>、<、IN这类都算。
  • index:全索引扫描,遍历整棵索引树。比全表扫描好一点,因为索引体积通常比数据小,但也好不到哪去。
  • ALL:全表扫描,最差的情况。数据量大时,基本可以断定这条 SQL 需要优化。

你拿 EXPLAIN 看一条 SQL,第一眼就看type。如果看到ALL,脑子里就要报警:为什么会全表扫?是没建索引还是索引失效了?如果看到index,也要想一想:能不能把它升级成range甚至ref。

2.4 possible_keys 和 key:用没用上索引一目了然

possible_keys列出这一步能用的所有索引,key是优化器实际选中的索引。正常情况下两者都有值。如果你发现possible_keys有索引,但key是 NULL,说明索引存在却没用上,原因可能是函数包裹、隐式类型转换、最左前缀不满足等。如果possible_keys本身就是 NULL,说明这条 SQL 涉及的列压根没有可用索引,优先考虑建索引。

但注意,key有值不代表这个索引被用好了。后面说的key_len才能判断复合索引用到哪几个字段。

2.5 key_len:复合索引到底用到了哪几段

key_len是 MySQL 实际使用的索引长度,单位是字节。很多人忽略这个字段,但它特别有价值,尤其是在复合索引场景下。

比如有一个复合索引idx_shop_create (shop_id, create_time),字段类型是bigint和datetime。bigint占 8 字节,datetime在 MySQL 5.6+ 占 5 字节,两条字段都允许为 NULL 就再加 1 字节。

如果EXPLAIN结果显示key_len = 9,说明只用了shop_id这一段索引;如果key_len = 15,说明两段都用上了。这样你就能判断复合索引的使用程度,而不是看到key有值就以为万事大吉。

我见过一种情况:复合索引(a, b, c),SQL 里 where 条件只用了a和c,跳过了b。结果key_len只有a的长度,c根本没有索引可走。这种“中间断档”是复合索引最常见的浪费场景。

2.6 ref 和 rows:关联条件和扫描成本的估算

ref表示与key中索引做等值匹配的列或常量。比如const表示索引匹配的是一个常量;如果是 JOIN,可能会显示另一张表的字段名,比如test.user.id。

rows是优化器预估要扫描的行数。注意,是“预估”,不是实际值。这个数字也不是精确的,但它能反映成本量级。比如同样是查一个用户,rows=1和rows=2000000是完全不同的风险等级。

配合上filtered字段看,filtered表示经过 where 条件过滤后剩余数据的百分比。比如rows=1000、filtered=10%,意味着预估从 1000 行里筛出 100 行。这个字段在 JOIN 场景里能帮你判断驱动表选得对不对。

2.7 Extra:最容易藏性能陷阱的列

Extra是执行计划里信息量很大的一个字段,很多性能杀手都体现在这里。常见值一个个过:

  • Using index:这是好兆头,表示直接通过索引取数,不需要回表,也就是常说的“覆盖索引”。
  • Using where:存储引擎层返回记录后,还要在 server 层再用 where 过滤。如果和Using index一起出现,说明索引里已经筛掉一部分。
  • Using index condition:这是 MySQL 5.6 引入的索引条件下推(ICP),意思是先用索引下推条件过滤,减少回表次数。算是一个“有限度的优化”,但和真正的覆盖索引比还是有差距。
  • Using filesort:大敌。说明排序没有走索引,MySQL 要把结果先放到内存或磁盘里排一遍。数据量小还好,数据量大就是灾难。怎么消除?排序字段加进索引,并且和 where 条件构成正确的复合索引。
  • Using temporary:更大的敌人。说明查询要用临时表,常见于 GROUP BY、去重、子查询、ORDER BY 和 GROUP BY 字段不一致等场景。临时表如果大到落盘,性能直接崩。
  • Using join buffer:连接查询时,被驱动表没有可用索引,MySQL 只能用 join buffer 在内存里硬算。出现这个值说明 JOIN 的关联字段大概率缺索引。
  • impossible where:where 条件永远为假,比如WHERE 1=0,MySQL 直接不查了。
  • No tables used:没有用到任何表,比如SELECT 1。

3. 拿真实 SQL 走一遍完整优化流程

光讲字段定义不够,我拿一套实际环境里很常见的电商订单表结构,带着你从问题 SQL 到执行计划,一步步把优化做完。

3.1 先建一张订单表和用户表

CREATE TABLE `orders` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL COMMENT '订单号', `user_id` bigint(20) NOT NULL COMMENT '用户ID', `shop_id` bigint(20) NOT NULL COMMENT '店铺ID', `amount` decimal(10,2) NOT NULL COMMENT '订单金额', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态', `create_time` datetime NOT NULL COMMENT '下单时间', PRIMARY KEY (`id`), KEY `idx_user` (`user_id`), KEY `idx_shop_create` (`shop_id`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `users` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(32) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意orders里我特意建了idx_shop_create (shop_id, create_time)这个复合索引,后面很多案例都靠它来讲。

3.2 案例一:明明有索引,为什么还是全表扫描

先看一条查询:

EXPLAIN SELECT * FROM orders WHERE order_no = '20240101001';

输出结果里type是ALL,key是 NULL,Extra里可能有Using where。原因很简单:order_no字段上没有索引,SQL 只能一行行扫全表。这种场景就直接给order_no建一个唯一索引:

ALTER TABLE orders ADD UNIQUE KEY uk_order_no (order_no);

再跑一次 EXPLAIN,type会变成const。这是最简单、最典型的优化路径,也是我建议团队所有研发都先学会的第一课:看到ALL,优先确认是不是缺索引。

3.3 案例二:复合索引的最左前缀失效

再看这条查询,它查的是店铺 100 在某一时间段内的订单:

EXPLAIN SELECT * FROM orders WHERE create_time BETWEEN '2024-01-01' AND '2024-01-31';

我的复合索引是(shop_id, create_time),但这条 SQL 的 where 条件只带了create_time,没有带shop_id。结果就是:索引存在,但用不上。possible_keys可能显示idx_shop_create,但key是 NULL,type是ALL。

这就是复合索引的最左前缀原则:索引(a, b)对a和(a, b)生效,但对单独查b不生效。解决办法有两个方向:要么把查询条件补上shop_id:

EXPLAIN SELECT * FROM orders WHERE shop_id = 100 AND create_time BETWEEN '2024-01-01' AND '2024-01-31';

要么单独给create_time建一个索引。哪种选择好?取决于业务里按时间查询和按店铺查询的比例。如果基本按店铺查,复合索引比两个单独索引更省空间;如果纯按时间统计是高频场景,那单独索引更合适。这是我反复强调的一点:索引设计没有标准答案,只有基于实际查询模式的取舍。

3.4 案例三:覆盖索引带来的质变

上面的 SQL 优化后,假如我们只需要店铺和金额字段:

EXPLAIN SELECT shop_id, amount FROM orders WHERE shop_id = 100 AND create_time BETWEEN '2024-01-01' AND '2024-01-31';

现在的索引idx_shop_create (shop_id, create_time)不包含amount,所以即使走了索引,查到满足条件的记录后还要回聚簇索引表拿amount,这就是回表。Extra里很可能是Using index condition。

如果把索引改成(shop_id, create_time, amount),查询需要的所有列都包含在索引树里,就变成了覆盖索引:

ALTER TABLE orders DROP INDEX idx_shop_create; ALTER TABLE orders ADD INDEX idx_shop_create_amount (shop_id, create_time, amount);

再跑 EXPLAIN,Extra里会出现Using index,代表完全不需要回表。数据量大的场景下,这一步能省掉大量随机 I/O,查询速度提升非常明显。

但覆盖索引也不是无脑建,索引字段越多,写入时维护成本越高。只有在查询列相对固定、且回表确实是瓶颈的时候才值得加。

3.5 案例四:JOIN 查询里的驱动表陷阱

看这条 SQL:

EXPLAIN SELECT u.name, o.amount FROM orders o JOIN users u ON o.user_id = u.id WHERE o.shop_id = 100;

执行计划里一般orders是驱动表,通过idx_shop_create先筛出店铺 100 的订单,然后对users表通过主键id做eq_ref访问。这个执行计划是比较健康的。

但是,如果我把连接条件反过来写,比如小表驱动大表更容易出问题。这里想提醒你的是:JOIN 的性能大头在连接字段的索引上。如果被驱动表的连接字段没有索引,Extra里会出现Using join buffer,这是很危险的信号,通常意味着这条 JOIN 要笛卡尔乘积式地扫描。

还有一种情况是驱动表选错,导致rows预估很大。比如从几万行的用户表驱动几百万行的订单表,和从订单表先过滤出小结果集再连用户表,成本差距巨大。优化器多数时候能选对,但当统计信息不准确时,可能选错。真遇到这种场景,可以考虑用STRAIGHT_JOIN强制连接顺序,但不建议在没充分确认的情况下滥用。

3.6 案例五:filesort 是怎么拖垮查询的

看这条带排序的查询:

EXPLAIN SELECT * FROM orders WHERE shop_id = 100 ORDER BY create_time DESC;

因为有idx_shop_create (shop_id, create_time),where 条件和排序字段都用上了这个索引,排序也能走索引顺序,Extra里不会出现Using filesort。

但如果把排序字段换成amount:

EXPLAIN SELECT * FROM orders WHERE shop_id = 100 ORDER BY amount DESC;

这时排序没法借助现有索引,Extra里就会出现Using filesort。怎么优化?如果要彻底消除 filesort,需要建(shop_id, amount)索引。但同样,你得先衡量这个排序是不是高频场景,别为一个低频排序增加一条索引的写放大成本。

还有一个容易被忽略的细节:ORDER BY和GROUP BY字段不一致时,极大概率出现Using temporary加Using filesort的组合,这种 SQL 在数据量大时几乎必死,要么改 SQL 逻辑,要么统一索引设计。

4. 常见问题速查表与避坑经验

为了让你平时排查时能快速对照,我把常见的执行计划表现和对应的优化手段整理成一张表:

现象可能原因处理方向
type=ALL没有索引,或索引失效确认字段该不该建索引,检查最左前缀
possible_keys 有值但 key=NULL对索引列做了函数运算、隐式类型转换等改写 SQL,去掉函数,或改成索引友好的写法
type=index走的是全索引扫描,不是精确的范围或等值优化 where 条件,让它能命中索引前缀
Extra=Using filesort排序字段不在索引里调整复合索引,把排序字段纳入索引
Extra=Using temporaryGROUP BY 或去重需要临时表让 group 字段和 order 字段一致,或走索引
Extra=Using join buffer连接字段缺索引给被驱动表的连接字段加索引
Extra=Using index覆盖索引命中这是好的现象,尽量保持
key_len 明显偏短复合索引只用了部分前缀字段调整 where 条件的字段顺序,补全前缀

下面这两个问题出现频率特别高,我单独拎出来说。

4.1 隐式类型转换导致索引失效

假设order_no是 varchar 类型,你写了:

EXPLAIN SELECT * FROM orders WHERE order_no = 123456;

因为 123456 是数字,MySQL 会把order_no隐式转成数字再比较,等价于对索引列做了函数操作,索引失效,type从 const 变回 ALL。这种事我在实际排障里见过不止一次,而且往往是老系统里的历史 SQL,查出来才发现是数据类型不一致。

解决办法除了改 SQL 补引号,更根本的是从设计上统一字段类型。能用数字就用 bigint,必须是字符串就全链路都用字符串。

4.2 前缀模糊查询导致索引失效

EXPLAIN SELECT * FROM orders WHERE order_no LIKE '%202401%';

%在前面时,索引树的顺序没法帮助定位,MySQL 只能全表扫。如果业务上确实需要这种模糊搜索,常见的方案有三个:改成左前缀'202401%'走索引;用全文索引;或者引入搜索引擎做全文检索。别指望普通 B+ 树索引能解决所有模糊查询问题。

4.3 使用 EXPLAIN ANALYZE 实测确认

EXPLAIN输出是预估,有时候统计信息不准,预估和实际可能差异很大。MySQL 8.0.18 之后提供了EXPLAIN ANALYZE,它是真正把 SQL 跑一遍,输出每一步的实际耗时、实际行数:

EXPLAIN ANALYZE SELECT * FROM orders WHERE shop_id = 100;

它会返回类似这样的结构化信息,里面包含了实际执行时间、实际扫描行数、回表次数等。排查性能问题时,先用EXPLAIN看方向,再对可疑 SQL 用EXPLAIN ANALYZE验证优化效果,这套组合拳在实践中特别管用。

当然要用EXPLAIN ANALYZE得有分寸,它是真实执行语句的,线上大表、写操作的 SQL 别乱跑。分析写操作时,可以把语句先包一层事务再ROLLBACK,或者直接分析对应的 SELECT 版本。

5. 我在实践中积累的几条建议

最后分享几条我和团队平时约定俗成的检查习惯,算是我踩过不少坑之后沉淀下来的东西。

检查顺序要固定。我看执行计划有固定顺序:先看type,确认访问级别可接受;再看key和key_len,确认索引用得完整;然后看rows,确认预估成本可控;最后盯Extra,确认没有 filesort、temporary、join buffer。按照这个顺序看,不容易漏掉问题。

小表驱动大表不是绝对真理,但适合多数情况。多表 JOIN 的时候,尽量让过滤后行数少的表做驱动表。你可以在执行计划里看id相同的情况下,哪一行排在前面,哪一行就是驱动表。如果发现几万行的表在驱动几百万行的表,并且被驱动表连接字段还没索引,那基本可以判定这条 SQL 有问题。

索引不是越多越好。每个索引都会拖慢写入,INSERT、UPDATE、DELETE都要同步维护索引树。我看到过一张表上挂了 8 个索引,查询确实快了,但写入性能惨不忍睹。设计索引前,先统计业务里高频查询的 where 条件组合,按条件组合去设计复合索引,效果远比“每列一个单值索引”好。

SELECT *能避开就避开。这不仅是为了少传点数据,更重要的是给覆盖索引留出空间。SELECT *情况下,覆盖索引几乎不可能命中,因为索引不太可能放下所有列。业务查询尽量只取需要的字段,和覆盖索引配合,性能收益非常大。

升级 MySQL 8.0 后,旧习惯要刷新。8.0 里查询优化器更强了,很多以前要靠复杂改写 SQL 才能走对索引的场景,现在优化器自己就能搞定。但这也意味着如果你还在用 5.7 甚至更早版本的习惯去写 SQL,可能会错过 8.0 的优化红利。建议把 EXPLAIN 的输出格式、字段都按 8.0 的标准去熟悉,同时关注EXPLAIN FORMAT=JSON这种可以拿到更细成本信息的写法。

我在实际使用中还有一个体会:执行计划不是看一次就完事。表数据量变化、统计信息更新、索引变更,都可能让同一条 SQL 的执行计划发生翻天覆地的变化。项目里那些最核心的查询,最好在每次发版前都重新看一眼执行计划。别嫌麻烦,慢 SQL 是在线上出了事才最难处理的,提前看一眼,就是把风险摁在源头。

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

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

立即咨询