1. 背一百遍 type 的含义,不如亲眼看一次全表扫描
我先说个真实场景。前阵子团队做代码评审,一个小伙子信誓旦旦说他知道type=ALL意味着全表扫描,type=ref是普通索引查找,type=const是主键或唯一索引等值查询。等我把一条实际慢查询的EXPLAIN输出甩到他面前,问他"这条语句到底慢在哪、该怎么改",他盯着输出看了半天,憋出一句"这个 rows 是啥意思来着"。
这就是典型的"八股背得熟,动手全抓瞎"。慢 SQL 优化这件事,你可以在面试前把EXPLAIN的每个列背得滚瓜烂熟,但只要没亲手跑过几次、没对着真实业务的慢查询分析过,那些知识点永远是悬在空中的。所以我特别想写一篇纯实战向的东西:不堆理论,就拿真实 SQL 一步步拆给你看,告诉你EXPLAIN输出到底怎么读、读完怎么定位问题、定位完怎么改。
这篇文章适合谁?适合刚接触 SQL 优化、想系统搞懂EXPLAIN的后端开发,也适合写过不少 SQL 但一直靠"加索引就行"糊弄过去的同学。文章会用 MySQL 8.0 环境下的实际输出做演示(8.0 和 5.7 的EXPLAIN输出基本一致,个别字段有差异),部分细节我会单独标注版本差异。看的过程中建议你打开自己的数据库,找一条平时执行比较慢的查询,跟着走一遍,效果比干看强十倍。
先明确一个基本前提:EXPLAIN的作用是让 MySQL 优化器把"它打算怎么执行这条 SQL"告诉你。注意是"打算",不是"实际执行结果"。它输出的是执行计划:先读哪张表、用哪个索引、预估扫多少行、是否需要排序、是否需要临时表。你读懂了这份计划,就等于看穿了优化器的思路,然后才能判断它的思路是不是有问题、要怎么引导它走更好的路径。
举个最直观的例子。假设有张用户订单表,结构大致是:
CREATE TABLE `t_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `order_no` varchar(64) NOT NULL, `status` tinyint NOT NULL DEFAULT '0', `amount` decimal(10,2) NOT NULL, `created_at` datetime NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;表里我插了 20 万行测试数据,其中user_id是随机分布的大概 5 万个不同值。执行这条查询:
EXPLAIN SELECT * FROM t_order WHERE user_id = 12345;你会看到类似这样的输出:
id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra 1 | SIMPLE | t_order| NULL | ALL | NULL | NULL | NULL | NULL | 198432| 10.00 | Using wheretype=ALL,key=NULL,rows=198432。翻译成人话就是:MySQL 打算把整张表从头到尾扫一遍,20 万行全看,然后挑出user_id=12345的。想象一下数据量到 2000 万、2 亿的时候,这条 SQL 会慢成什么样。这就是全表扫描的真实长相。你在八股里背一百遍"ALL 是全表扫描,性能最差",不如亲眼在输出里看到一次type=ALL、rows=20万带来的冲击感强。
对比一下,我们在user_id上建个普通索引:
ALTER TABLE t_order ADD INDEX idx_user_id (user_id);再跑一次同样的EXPLAIN:
id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra 1 | SIMPLE | t_order| NULL | ref | idx_user_id | idx_user_id | 8 | const | 42 | 100.00 | NULL同样是这条 SQL,执行计划完全变了:type从ALL变成ref,key从NULL变成idx_user_id,rows从 198432 降到 42。这意味着 MySQL 只需要通过索引精确定位到 user_id=12345 的记录,然后回表读取完整行数据。索引前后,预估的扫描行数相差近 5000 倍。这就是EXPLAIN最核心的价值:它让你在 SQL 真正跑起来之前,就看到优化器的执行路线,从而提前判断这条路走得对不对。
这里有个细节值得注意:建索引之前Extra列是Using where,建索引之后Extra变成了NULL。为什么会这样?因为没索引时,MySQL 得把全表数据读出来,再用where条件逐行过滤,这个过滤动作被标记为Using where;有索引后,MySQL 直接在索引树里定位,根本不需要额外的过滤步骤,所以Extra为空。同样一个where条件,有没有索引支撑,在优化器眼里的"成本模型"是完全不同的。
所以我的建议是:别满足于背结论,手动建一张测试表、插个几十万行数据,反复执行不同 SQL 的EXPLAIN,观察索引对type、rows、Extra的影响。这种体感一旦建立起来,你以后看任何一条慢查询,脑子里会自动浮现出它可能的执行计划长什么样,定位问题的速度会快非常多。
2. EXPLAIN 每一列到底在说什么:从 id 到 filtered 的完整拆解
很多同学看EXPLAIN输出,只看type和key,觉得这两个字段最关键。这个方向没错,但如果你想真正掌握执行计划的解读,每一列都有它存在的意义,而且某些情况下恰恰是那些不起眼的列(比如key_len、Extra)暴露了真实问题。下面我把每个字段逐个过一遍,结合实际案例说明该怎么读、读到什么值该警惕。
2.1 id 和 select_type:认清查询的复杂结构
id是查询中每个SELECT子句的编号。如果是单表简单查询,id永远是 1。如果你看到id有多个不同的值,说明这条 SQL 里包含了子查询、联合查询或者多表关联,执行顺序也有讲究:id越大越先执行,id相同则从上往下执行。
select_type表示这个SELECT的类型,常见的有:
SIMPLE:最简单的查询,没有子查询和 UNION。PRIMARY:最外层的查询。SUBQUERY:子查询(非 FROM 子句中的)。DERIVED:FROM 子句中的子查询,MySQL 会把它当成一个临时表来处理。UNION:UNION 中的第二个或后续的 SELECT。DEPENDENT SUBQUERY:依赖外层查询结果的子查询,这种往往意味着性能隐患。
举个多表关联的例子。假设除了订单表,还有一张用户表t_user,结构是:
CREATE TABLE `t_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(32) NOT NULL, `phone` varchar(20) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;查询"某个用户名下的所有订单金额大于 100 元的订单":
EXPLAIN SELECT o.* FROM t_order o INNER JOIN t_user u ON o.user_id = u.id WHERE u.name = '张三' AND o.amount > 100;输出的前面几列大概长这样:
id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra 1 | SIMPLE | u | ref | idx_name | idx_name| 98 | const | 1 | 100.00 | Using index condition 1 | SIMPLE | o | ref | idx_user_id | idx_user_id| 8 | test.u.id | 42 | 33.33 | Using where注意这里id都是 1,说明两个表是关联查询,执行顺序由优化器决定,通常是谁的过滤效果更好、成本更低,谁就先被访问。本例优化器先通过idx_name找到张三对应的用户记录(1 行),再根据user_id去订单表找这个用户的所有订单,最后用amount > 100过滤。虽然执行计划写的是先访问u再访问o,但你要清楚这只是逻辑上的执行顺序,实际执行时 InnoDB 会按照驱动表和被驱动表的嵌套循环方式来执行。
2.2 table 和 partitions:这次访问的是哪张表
table列告诉你当前这行执行计划访问的是哪张表。多表关联时,这个字段帮助你把每一行输出和 SQL 里的表对应起来。partitions列是分区表相关的,如果你的表没做分区,它永远是NULL,可以忽略。
这里有个容易懵的地方:table字段偶尔会显示成<derived2>或<union1,2>这样的形式。<derived2>表示这是 id=2 的子查询派生出来的临时表,<union1,2>表示这是 UNION 结果合并的临时表。看到这种值,说明查询里有子查询或者 UNION,优化器需要物化一个临时表来处理,往往伴随着额外的内存和磁盘消耗,需要引起重视。
2.3 type:访问类型,执行计划里最该盯紧的一列
type列是判断查询性能最直观的指标,表示 MySQL 用什么方式在表中找到所需行。性能从好到差大致是:
system > const > eq_ref > ref > range > index > ALL我用一个表格把每种类型出现的前提、含义和典型场景列出来,方便对照:
| type | 含义 | 触发条件示例 | 性能评价 |
|---|---|---|---|
| system | 表只有一行(系统表),是 const 的特例 | 系统表、且只有一条记录 | 极好 |
| const | 用主键或唯一索引等值查询,最多返回一行 | WHERE id = 100(id 是主键) | 极好 |
| eq_ref | 被驱动表通过主键或唯一索引等值查找,每次最多匹配一行 | 多表关联中,用主键关联被驱动表 | 很好 |
| ref | 通过普通索引等值查询,可能返回多行 | WHERE user_id = 123(user_id 有普通索引) | 好 |
| range | 索引范围扫描 | WHERE id BETWEEN 100 AND 200、WHERE status IN (1,2,3) | 较好 |
| index | 遍历整棵索引树才能找到需要的数据 | SELECT COUNT(*) FROM t_order(且只有主键索引时) | 一般 |
| ALL | 全表扫描 | WHERE user_id = 123(user_id 无索引) | 差 |
看到这里你应该明白了:目标至少要做到range,最好能达到ref或const。如果一条查询的type是ALL,而且rows预估很大,那基本可以断定这条 SQL 需要优化。
但要注意一个坑:type=index看起来比ALL好一级,实际上也可能很慢。它虽然不用扫全表数据,但要扫整棵索引树,如果索引树很大、或者需要回表的行很多,性能依然堪忧。比如:
EXPLAIN SELECT order_no FROM t_order WHERE status = 1;如果status上没有索引,这条语句能用的只有主键索引,type会显示为index,意味着 MySQL 决定遍历整个主键索引树来检查每一行。虽然它不会去读完整的行数据,但扫描的索引条目依然是全表数量级,照样慢。
2.4 possible_keys 和 key:优化器"可能用"和"实际用"的索引
possible_keys列出查询中可能用到的索引,key列出优化器实际选用的索引。这两个字段对比着看很有价值:
- 如果
possible_keys有值,但key是NULL,说明存在可用索引,但优化器基于成本评估后决定不使用。常见原因包括:索引选择性太差(比如一个 sex 字段,区分度极低)、优化器认为全表扫描成本更低、或者查询条件里对索引列做了函数操作导致索引失效。 - 如果
possible_keys和key都是NULL,说明这条 SQL 压根没有索引可以用,大概率全表扫描。 - 比较理想的情况是:
key非空,并且possible_keys中包含多个索引,优化器从中选了一个最优的。这时你要确认它选的索引是否合理。
我举个possible_keys有值但key=NULL的典型例子:
EXPLAIN SELECT * FROM t_order WHERE DATE(created_at) = '2024-06-01';created_at上如果没有建索引,这没啥好说的;如果建了索引idx_created_at,你会发现possible_keys里依然可能是NULL。原因是:对索引列使用DATE()函数后,索引字段本身被改变了,优化器无法再走常规索引查找路径。这就是大家常说的"索引列上使用函数会导致索引失效"。
那如果我把条件改成范围查询呢?
EXPLAIN SELECT * FROM t_order WHERE created_at >= '2024-06-01' AND created_at < '2024-06-02';这次possible_keys里就能看到idx_created_at,key也会是它,type变成range。同样是查某一天的数据,写法不同,执行计划天差地别。这种细节就是EXPLAIN实操的价值所在:在真实执行计划面前,你背过的"不要在索引列上使用函数"才真正有落地的感觉。
2.5 key_len:这个字段容易被忽略,但含金量很高
key_len表示索引中实际使用的字节数。很多人不关注这个列,但它能帮你判断多列索引中到底有几个列真正被用上了。这里必须提到"最左前缀原则":MySQL 使用联合索引时,会从最左边的列开始匹配,直到遇到范围查询或其他无法继续匹配的条件为止。
举个例子。假设我们在订单表上建一个联合索引:
ALTER TABLE t_order ADD INDEX idx_user_status (user_id, status);执行:
EXPLAIN SELECT * FROM t_order WHERE user_id = 123 AND status = 1;此时key_len的值会是user_id和status两个字段字节数之和。bigint占 8 字节,tinyint占 1 字节,加上变长字段长度等等,最终key_len = 8 + 1 + 1 = 10左右(额外 1 字节是变长记录标志)。看到key_len=10,说明两个索引列都生效了。
但如果只执行:
EXPLAIN SELECT * FROM t_order WHERE user_id = 123;key_len会变成 8 左右(只用了user_id一个列),因为where条件里没有status,联合索引只能从最左列user_id开始匹配。
再比如条件变成:
EXPLAIN SELECT * FROM t_order WHERE user_id = 123 AND status > 1;当第二个索引列是范围查询时,key_len同样只显示user_id的长度,status并没有被用于索引查找。原因是最左前缀原则遇到范围查询就中断了。所以通过key_len这个数字,你能精确判断联合索引到底吃到了几个列,而不是靠猜。
再看一个进阶案例。假设有联合索引(a, b, c),如果查询条件是:
WHERE a = 1 AND c = 2由于中间跳过了b,key_len只会显示a的长度,c用不上索引。这时候最有效的调整是把c提到索引前面,或者把b也加入查询条件。这些都是EXPLAIN能直接告诉你的结论。
2.6 ref:索引列用什么东西来比较
ref列显示的是索引列与什么进行比较。可能是const(常数,如WHERE user_id = 123)、某个表的字段名(如WHERE t1.user_id = t2.id,ref 会显示test.t2.id),也可能是函数或表达式。这一列和key_len配合,能让你更清楚地理解索引的匹配方式。
如果看到ref是func,意味着索引列被某个函数结果匹配,通常也代表索引使用方式不理想,需要检查查询条件里是否对索引列做了函数操作。
2.7 rows 和 filtered:优化器的心算草稿
rows是优化器预估的需要扫描的行数,filtered是经过where条件过滤后,剩余行数占扫描行数的百分比。两者的乘积约等于最终返回的结果集大小。
举个例子:rows=1000,filtered=20.00,说明优化器预计扫描 1000 行后,最终返回约 200 行。这个预估对判断查询成本非常关键:rows越大,扫描成本越高。
但注意,rows是基于统计信息的估算,不一定是真实的行数。如果表的统计信息过期(比如大量增删改后没跑ANALYZE TABLE),rows可能严重失真。这是EXPLAIN的一个局限,后面我会专门讲怎么规避。
另外还有个细节:5.7 之后,优化器引入了"条件过滤"(Condition Filtering)机制,filtered列从这个版本开始才真正有意义。5.6 及之前版本里filtered列也存在,但意义不同,5.7+ 的估算逻辑更贴近真实情况。
2.8 Extra:藏着很多"问题信号"的附加信息列
Extra列是EXPLAIN输出里信息密度最高、也最容易暴露问题的一列。常见值有:
Using where:通过where条件过滤,但并没有使用索引来定位数据。通常表明查询条件里用到的列没有被索引覆盖,需要逐行判断。Using index:覆盖索引扫描,查询所需的列全部在索引中,不需要回表。这是比较理想的状态。Using index condition:索引条件下推,MySQL 把部分where条件下推到索引层判断,减少回表次数。MySQL 5.6 以后引入的优化机制,通常说明相关列建了索引但索引覆盖不完全。Using filesort:需要额外排序操作,但注意这里的"filesort"并不一定会用磁盘文件,也可能在内存中完成排序。无论如何,看到它就说明排序无法利用索引顺序,需要额外的排序成本。Using temporary:需要创建临时表来处理查询,常见于GROUP BY、DISTINCT、UNION等操作。大量数据时的临时表可能落到磁盘,性能会显著下降。Using join buffer:关联查询时,被驱动表无法使用索引,MySQL 用 join buffer 做缓存,通常意味着关联字段没索引。Impossible WHERE:where条件恒为假,优化器直接返回空结果,不会实际执行。
这些信号中,Using filesort和Using temporary最值得警惕。举个例子:
EXPLAIN SELECT user_id, status, COUNT(*) FROM t_order WHERE created_at >= '2024-06-01' GROUP BY user_id, status;如果created_at有索引,type可能是range,但Extra里大概率会出现Using temporary; Using filesort。为什么?因为GROUP BY的字段顺序(user_id, status)和用于范围查询的索引idx_created_at不一致,优化器没法利用索引顺序来完成分组,只能先把结果放到临时表,再做排序分组。看到这个组合,你就应该考虑是不是需要调整索引设计,或者改写 SQL 来避免临时表。
3. 一次真实慢查询的排查全过程:从发现问题到索引落地的完整链路
前面把EXPLAIN的每一列过了一遍,现在我把一次真实的慢查询排查全过程完整走一遍,把前面那些零散知识点串起来。这个案例是我在自己测试库上还原的,业务场景很简单:订单列表页需要展示"某个用户最近 30 天的订单,按金额降序",SQL 大致长这样:
SELECT id, order_no, amount, status, created_at FROM t_order WHERE user_id = 12345 AND created_at >= '2024-05-01' ORDER BY amount DESC LIMIT 20;测试表结构和前面的一样,数据量 20 万行。这条 SQL 的执行计划我直接贴出来:
id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra 1 | SIMPLE | t_order| ALL | idx_user_id | NULL | NULL | NULL | 198432| 5.00 | Using where; Using filesort看到这个输出,我的第一反应是:有idx_user_id可用,但优化器没选。为什么?因为它需要同时满足user_id等值、created_at范围和amount排序,单个索引无法同时搞定所有条件,优化器评估后可能认为直接全表扫描 + 过滤 + 排序的成本,比先走idx_user_id再回表过滤排序更低。于是它选择了ALL,然后Extra里冒出了Using filesort。
针对这种情况,常见的优化方向有两个:
方向一:让WHERE条件的过滤尽量走索引。在user_id和created_at上建联合索引:
ALTER TABLE t_order ADD INDEX idx_user_created (user_id, created_at);再来一遍EXPLAIN:
id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra 1 | SIMPLE | t_order| range| idx_user_id,idx_user_created | idx_user_created | 16 | NULL | 21 | 100.00 | Using index condition; Using filesorttype从ALL变成了range,rows从 198432 降到了 21,Extra里依然有Using filesort。为什么会这样?因为created_at是范围条件,走联合索引后,ORDER BY amount依然无法利用索引顺序。排序操作虽然存在,但需要排序的数据量从 20 万行骤降到 21 行,内存排序完全扛得住,所以实际执行速度会有质的飞跃。
方向二:如果想要彻底消除Using filesort,就得在索引设计上做文章,把ORDER BY的字段也纳入索引。比如建这样一个联合索引:
ALTER TABLE t_order ADD INDEX idx_user_created_amount (user_id, created_at, amount);注意amount是排序字段,索引顺序是(user_id, created_at, amount),在user_id等值、created_at范围的条件组合下,MySQL 能否利用amount的有序性来避免排序,取决于created_at的范围类型。如果created_at是等值条件,可以直接利用三列索引避免 filesort;但如果created_at是范围条件,优化器通常还是会选择 filesort,因为范围内每条记录的amount顺序不保证。
所以实际业务里,我的经验是:优先保证过滤条件的索引,排序交给 filesort 反而更稳妥,因为真正的问题通常是扫描行数太大,而不是排序本身。用rows=21的 filesort 换掉rows=20万的 filesort,性能提升已经是天壤之别。
再回到这个案例本身。修改索引后,这条 SQL 的执行时间从原始的 800ms 左右,降到了 20ms 以内。这个提升不是靠"背八股"得到的,而是靠EXPLAIN一步步观察、验证、调整索引设计得到的。这个过程值得每个开发自己动手跑一遍,因为只有亲手调整过索引顺序、亲眼看到type和rows的变化,你对索引的理解才会真正牢固。
排查链路简单梳理一下:
- 拿到慢查询 SQL,先跑
EXPLAIN,看type、key、rows、Extra四个核心字段。 - 根据
type=ALL、key=NULL判断没有走索引或者索引失效。 - 看
possible_keys里有哪些可用的索引,为什么优化器没选。 - 根据
where条件和order by设计合适的联合索引,重点考虑等值条件放前面、范围条件和排序字段放后面的原则。 - 调整后重新
EXPLAIN,对比type、rows、Extra的变化。 - 最后用真实执行时间验证,确保优化真的有效。
这套链路看起来简单,但每一步都需要对EXPLAIN输出有准确的理解。你第一次跑的时候可能会卡在"优化器为什么不选这个索引"这个问题上,没关系,多试几次,把possible_keys里的索引逐个建一遍、删一遍,观察执行计划的变化,很快就能摸清优化器的脾气。
4. 最常见的三类性能瓶颈,以及它们在 EXPLAIN 里的"长相"
实际操作中,我发现慢 SQL 的问题大多可以归为三类:索引没走对、排序和分组代价过大、关联查询的设计不合理。下面分别说说它们在EXPLAIN输出里长什么样,以及通常怎么处理。
4.1 索引没走对:type=ALL 且 rows 巨大
这是最典型的一类慢查询,特征就是type=ALL,key=NULL,rows很大,Extra里经常带Using where。原因不外乎以下几种:
- 查询条件涉及的列没有索引。
- 有索引但优化器觉得没必要用(索引区分度太低)。
- 索引列被函数、隐式类型转换等影响了。
- 使用了
LIKE '%keyword'这种无法走索引的前缀模糊匹配。
处理思路很直接:给where条件里的列建合适的索引。但索引不是越多越好,索引过多会增加写入成本和存储开销,所以要根据实际高频查询来设计联合索引,尽量一个索引覆盖多条查询路径。
4.2 排序和分组代价过大:Extra 里的 filesort 和 temporary
排序和分组这两类操作,在EXPLAIN里经常表现为Extra=Using filesort或Extra=Using temporary。它们不一定会让 SQL 立刻变慢,但数据量一大,代价就会呈指数级上升。
拿分组举例:
EXPLAIN SELECT status, COUNT(*) FROM t_order GROUP BY status;如果status上没索引,输出里会出现Using temporary,因为 MySQL 需要建立临时表来统计分组结果。若数据量达到百万甚至千万级别,临时表可能从内存挪到磁盘,性能断崖式下跌。
另一个常见场景是DISTINCT和ORDER BY组合:
EXPLAIN SELECT DISTINCT user_id FROM t_order ORDER BY created_at DESC;这条语句容易同时出现Using temporary和Using filesort。原因是DISTINCT需要去重,ORDER BY需要排序,而两个操作涉及的字段不同,无法在一次索引扫描中同时完成。
改进的方向有两个。一是把GROUP BY的字段顺序和联合索引的最左前缀对齐,让索引天然有序,省掉临时表和排序;二是改写 SQL,比如把DISTINCT改成GROUP BY,或者把排序操作前置、缩小排序结果集。
这里有个实操小技巧:如果GROUP BY只是想去重,而字段本身有索引,尽量用SELECT DISTINCT而不是GROUP BY,因为DISTINCT在部分场景下可以直接走索引去重,省掉临时表。如果GROUP BY还需要聚合函数,那就得让GROUP BY字段有索引支撑,否则Using temporary很难避免。
4.3 关联查询设计不合理:驱动表选错或关联字段缺索引
多表关联查询的慢,通常和两个因素有关:驱动表选得不好、被驱动表的关联字段没索引。
EXPLAIN输出里,多表关联会显示多行记录,执行顺序(id相同的从上到下)就是驱动顺序。优化器会选择"小表驱动大表"的策略,先访问小表拿到结果集,再用这个结果集去大表匹配。如果驱动表选成了大表,会导致被驱动表被反复扫描,性能很差。
举例说明。假设我们要查"用户 ID 在某个范围内的订单信息":
EXPLAIN SELECT o.id, u.name FROM t_user u INNER JOIN t_order o ON o.user_id = u.id WHERE u.id BETWEEN 1 AND 100;正确的执行计划应该是:u作为驱动表,通过主键范围扫描拿到 100 个用户,然后去t_order通过idx_user_id查找订单。这类查询的关键在于被驱动表t_order.user_id必须有索引,否则 MySQL 会对每一行用户记录去全表扫描订单表,产生"嵌套循环 + 全表扫描"的灾难性计划。
EXPLAIN输出里,如果被驱动表的type=ALL、Extra=Using join buffer,基本就是关联字段没索引的典型信号。解决方式是给被驱动表的关联字段加索引,或者反过来调整关联顺序(使用STRAIGHT_JOIN可以让优化器按指定顺序执行,但不推荐作为常态手段)。
另外,多表关联还有一个容易被忽略的问题:关联字段的字符集和排序规则(collation)必须一致。如果两表的关联字段一个是utf8mb4,另一个是utf8,或者排序规则不同,MySQL 无法直接比较,索引可能失效。这与EXPLAIN输出的关系不大,但排查慢查询时碰到"明明有索引却不用"的情况,值得检查一下这个点。
4.4 一个多表关联的完整排查案例
我把前面的知识点串起来,给一个真实的多表关联慢查询案例。表结构是t_order和t_user,之前已经建好了。现在执行:
SELECT u.name, o.order_no, o.amount FROM t_user u INNER JOIN t_order o ON o.user_id = u.id WHERE u.phone = '13800001111';这条查询的本意是:根据手机号找到用户,再查该用户的订单。假设t_user.phone和t_order.user_id上都没有索引,EXPLAIN输出会非常难看:
id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra 1 | SIMPLE | u | ALL | NULL | NULL | NULL | NULL | 50000 | 10.00 | Using where 1 | SIMPLE | o | ALL | NULL | NULL | NULL | NULL | 198432| 10.00 | Using where; Using join buffer两张表都是ALL,相当于先全表扫描t_user(5 万行),再对每一行去全表扫描t_order(20 万行),总扫描量是 5 万乘以 20 万,堪称灾难。
修复方案分两步:
第一步,给t_user.phone加普通索引。这样驱动表的扫描量会从 5 万降到个位数。
第二步,给t_order.user_id加普通索引(如果还没有)。这样被驱动表能通过ref方式快速查找。
改完后的EXPLAIN:
id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra 1 | SIMPLE | u | ref | idx_phone | idx_phone | 83 | const | 1 | 100.00 | NULL 1 | SIMPLE | o | ref | idx_user_id | idx_user_id | 8 | test.u.id | 42 | 100.00 | NULL执行计划从"两个 ALL"变成了"两个 ref",扫描行数从天文数字变成了几十行。这个案例我强烈建议你自己复现一遍,因为多表关联的执行计划变化比单表更直观,也更容易理解"驱动表"和"被驱动表"的概念。
5. EXPLAIN 不只是看个大概:JSON 格式、实际执行与预估的差距
基础用法掌握之后,再聊几个容易被忽略但非常重要的进阶细节。EXPLAIN不只是输出一张表格,它还有更详细的信息模式,并且它的预估和实际执行存在差距,这些你都应该知道。
5.1 EXPLAIN FORMAT=JSON:看到优化器的成本账本
MySQL 从 5.6 开始支持EXPLAIN FORMAT=JSON,输出会包含更详细的信息,尤其是成本估算。比如:
EXPLAIN FORMAT=JSON SELECT * FROM t_order WHERE user_id = 12345;输出里有query_cost、read_cost等字段,这是优化器用来做最终决策的成本数据。read_cost是"读取成本",eval_cost是"计算过滤成本",prefix_cost是"前缀成本",最后一个步骤的prefix_cost通常就是整个查询的总成本。
这些数字有什么用?它帮你量化"优化器为什么选了 A 方案而不是 B 方案"。比如你有两个索引可用,优化器选了其中一个,你可以用 JSON 输出里的cost_info对比如果强制用另一个索引的成本,从而判断优化器的决策是否合理。
举个实际例子:
EXPLAIN FORMAT=JSON SELECT * FROM t_order WHERE user_id = 12345 AND amount > 100;如果user_id和amount上分别有索引,优化器需要决定用哪个索引作为主要访问路径。因为WHERE user_id = 12345 AND amount > 100是一个等值加范围的组合,优化器可以选择先用idx_user_id过滤出 42 行,再在内存中过滤amount;也可以选择先用idx_amount范围扫描,再过滤user_id。从 JSON 输出里你能看到两种方案各自的cost_info,从而确认优化器的选择是否合理。
JSON 格式还有个好处:能看到attached_condition,它会显示优化器下推到索引层的具体条件。比如Using index condition时,你可以看到哪些条件下推了、哪些没有,这对理解索引条件下推机制非常有帮助。
5.2 预估和实际执行差距太大怎么办:ANALYZE TABLE 和 EXPLAIN ANALYZE
EXPLAIN输出的rows是估算值,基于表的统计信息。如果统计信息过期,预估可能严重偏离实际。这种情况下,第一步是更新统计信息:
ANALYZE TABLE t_order;这个命令会重新计算索引的基数(cardinality)和表的大致行数,让优化器的成本估算更准确。大多数情况下,几条ANALYZE TABLE就能让执行计划恢复正常。
如果更新统计信息后依然有问题,可以用 MySQL 8.0 提供的EXPLAIN ANALYZE看实际执行情况。它不只是预估,而是真正执行查询,并输出每个步骤实际扫描的行数和耗时:
EXPLAIN ANALYZE SELECT * FROM t_order WHERE user_id = 12345;输出类似:
-> Index lookup on t_order using idx_user_id (user_id=12345) (cost=4.67 rows=42) (actual time=0.025..0.045 rows=42 loops=1)注意actual time和rows=42,这是真实执行数据,不是预估。如果EXPLAIN预估的rows和EXPLAIN ANALYZE的真实rows相差很大,说明统计信息不准,跑一遍ANALYZE TABLE通常能解决。
这里要提醒:EXPLAIN ANALYZE会真实执行 SQL,如果是INSERT、UPDATE、DELETE或者非常慢的查询,要谨慎使用,避免在生产环境造成额外压力。可以用EXPLAIN ANALYZE FORMAT=JSON来减少输出量。
5.3 FORCE INDEX 和 IGNORE INDEX:必要时引导优化器
有些极端情况下,优化器选错了索引,而你确认另一个索引明显更优,可以通过FORCE INDEX或USE INDEX来引导它:
SELECT * FROM t_order FORCE INDEX (idx_user_created) WHERE user_id = 12345 AND created_at >= '2024-05-01';但这不是一个值得长期依赖的方案。索引名写死在 SQL 里,一旦索引名变更或者数据分布变化,强制索引可能适得其反。更好的做法是调整索引设计本身,让优化器自然选择最佳路径。
我见过不少团队遇到"优化器选错索引"就上FORCE INDEX,这是治标不治本。大多数情况下,统计信息过时或者索引选择性变化才是根因,先把统计信息更新好,再检查索引设计是否合理,最后才考虑强制指定。
5.4 慢查询日志是 EXPLAIN 的最佳入口
EXPLAIN不是凭空跑的,它的输入来源通常是慢查询日志。生产环境应该开启慢查询日志,并设置合理的阈值:
SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1; SET GLOBAL log_queries_not_using_indexes = ON;long_query_time设为 1 秒,超过 1 秒的查询都会被记录。log_queries_not_using_indexes可以记录所有没走索引的查询,帮你发现隐藏的慢查询隐患。拿到慢查询 SQL 后,第一时间EXPLAIN,这是排查的黄金流程。
6. 把 EXPLAIN 变成日常习惯:开发自测阶段的几个实操建议
最后这部分,我想聊聊怎么把EXPLAIN从"排查工具"升级为"日常开发习惯"。毕竟在慢查询出现之后再优化,属于被动救火;在开发阶段就通过EXPLAIN验证 SQL 质量,才是治本之道。
6.1 SQL 提交前,先跑一遍 EXPLAIN
我对团队的基本要求是:任何涉及查询的 SQL,在提交代码前至少跑一次EXPLAIN,并且确认以下几个指标达到合格线:
type不是ALL(全表扫描),最好是range或以上。key不是NULL,除非表很小(比如几千行以内)。rows与实际数据规模相符,如果实际 20 万行而预估 20 万行,说明确实在扫全表。Extra里没有Using filesort和Using temporary,或者有但数据量很小可以接受。
如果上面任意一条不达标,就需要慎重考虑是否合并。
这个习惯的价值在于:把问题在开发阶段截住,而不是等线上告警。很多同学觉得"这条 SQL 本地数据量小跑挺快,上线再说",结果线上数据量一上来就出问题。EXPLAIN能在数据量小的环境里就暴露执行计划的隐患,因为执行计划是由表结构和统计数据决定的,和具体数据量关系不大。
6.2 索引设计不是拍脑袋,而是基于 EXPLAIN 迭代
我见过最常见的索引设计方式是"看到哪个字段在 where 里,就给它加个索引"。这种办法有一定效果,但效率不高,而且容易造出一堆冗余索引。
更好的思路是:拿到业务高频 SQL,先不加索引跑EXPLAIN,记录问题,再根据where、group by、order by的字段组合设计联合索引,每加一个索引就再跑EXPLAIN验证效果。整个过程是"SQL 驱动索引",而不是"字段驱动索引"。
6.3 定期检查线上慢查询日志
即使开发阶段把关严格,线上环境的查询模式和数据分布还是会出现意外。建议定期(比如每周)拉一次慢查询日志,把 Top N 慢查询的 SQL 拿出来EXPLAIN分析。这个过程积累了足够多的案例后,你会慢慢对"哪些 SQL 容易慢"形成直觉,以后写 SQL 时自然会更谨慎。
6.4 关注 MySQL 8.0 的优化器新特性
MySQL 8.0 引入了descending index,支持倒序索引,对ORDER BY xxx DESC的查询可以从根本上避免 filesort。如果你的业务有很多倒序排序的查询,可以考虑把索引改成:
ALTER TABLE t_order ADD INDEX idx_user_created_desc (user_id, created_at DESC);不过归根结底,工具和新功能只是手段,核心还是你能不能熟练读懂执行计划、理解优化器的判断逻辑。EXPLAIN就是你和优化器之间的翻译官,把它用熟了,慢 SQL 优化就不再是玄学。
我在实际工作中最大的体会是:EXPLAIN这个东西,看十篇教程不如自己跑一个下午。找个测试库,造点数据,把各种 SQL 都跑一遍EXPLAIN,观察索引对执行计划的影响,遇到不理解的就查文档、查资料。等你能对着输出准确说出"这条 SQL 慢在哪个环节、应该加什么索引、加了之后会变成什么样",那你对 MySQL 查询优化的理解,基本就到下一个层次了。