只要你写SQL,就一定有过被一条慢查询折磨到怀疑人生的时刻。页面点了没反应、接口超时、数据库CPU直接飙红,最后排查来排查去,定位到一条跑了十几秒的SELECT。这种时候,绝大多数有经验的DBA和资深后端都会先让你做一件事:EXPLAIN一下。
EXPLAIN是MySQL里用来查看SQL执行计划的关键字,也是SQL优化这条路上绕不开的第一站。通过它,你能让优化器把“准备怎么执行这条SQL”的计划摊开给你看:先读哪张表、用哪个索引、估算扫多少行、要不要排序、要不要临时表,全都在一张表里说得明明白白。这篇文章不聊虚的,我会把EXPLAIN的输出列、访问类型、实际调优案例、常见坑,以及8.0版本之后的新玩法,全部掰开揉碎讲一遍。不管你是刚接触数据库的后端新人,还是已经被慢SQL折磨多时的老手,看完都应该能直接上手,把EXPLAIN当成自己的日常排查工具。
1. 为什么要搞懂EXPLAIN:SQL优化的第一现场
1.1 你是DBA还是后端,都得学会读这张表
很多后端同学写SQL时有个习惯,功能能跑通就行,索引有没有命中完全凭感觉。直到某天表数据量从十万涨到千万,一条原本毫秒级的查询变成了好几秒,这才开始慌。其实SQL执行得慢,原因是多方面的:可能是网络带宽问题,可能是锁等待,可能是服务器负载太高,但在绝大多数OLTP场景里,最核心的原因就一个——查询走了全表扫描或者没有走最优索引。
想要确认这一点,唯一直接的手段就是看执行计划。不管你是用MyBatis、JPA还是其他ORM框架,最终落到MySQL里的都是SQL。ORM能帮你拼语句,但不能帮你保证性能。所以不管是DBA、运维还是后端研发,只要你的工作内容里有“排查慢SQL”这一项,读懂EXPLAIN就是基础中的基础。
我见过不少同学拿到慢SQL后,第一反应是把所有可能的字段都加上索引,结果索引建了一堆,查询反而更慢;也有人不知道怎么看是否命中了索引,只能靠猜。这些问题的共同点,就是没有回到执行计划这个“第一现场”去分析证据。
1.2 EXPLAIN到底在做什么
一条SQL在MySQL里执行,并不是你发过去就直接把结果查出来。它要经过:解析SQL、生成语法树、做逻辑优化、交给优化器做成本估算、生成执行计划、然后由执行器去存储引擎里取数据。EXPLAIN这条命令,就是让你把优化器最终选定的执行计划“打印”出来看。
大多数人以为EXPLAIN只是“查看是否用了索引”,其实它能告诉你的信息远不止这些。它包含了表的读取顺序、表之间的关联方式、可能用到的索引和实际用到的索引、估算需要扫描的行数、是否有临时表和文件排序,等等。这些信息组合起来,就构成了你判断一条SQL是否健康的核心依据。
有一点必须强调:普通的EXPLAIN不会真正执行查询,它只是基于优化器的成本模型,把预估的执行路径返回给你。因此它给出的rows列是估算值,不是精确值。真要精确,得上EXPLAIN ANALYZE(MySQL 8.0.18+),让查询真跑一遍拿到实测数据。这个概念后面我会细讲,但你要先记住,EXPLAIN和EXPLAIN ANALYZE是两回事。
2. EXPLAIN输出列逐个拆解:一张执行计划读懂MySQL的“小算盘”
我随便跑一条EXPLAIN SELECT ...,输出大概长这样(列顺序以MySQL 8.0为准):
+----+-------------+-------------+------------+------+---------------+---------+---------+-------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------------+------------+------+---------------+---------+---------+-------+------+----------+-------+ | 1 | SIMPLE | order_info | NULL | ref | idx_user | idx_user| 8 | const | 234 | 100.00 | NULL | +----+-------------+-------------+------------+------+---------------+---------+---------+-------+------+----------+-------+这一行看起来简单,但里面每一个字段都有讲究。
2.1 id与select_type:语句的“身份信息”
id是查询中SELECT的序列号。如果你只查了一张表,那id通常就是1。如果是多表连接或者子查询,情况会复杂一点。规则是:id相同,可以认为是一组,执行时从上往下;id不同,数字越大越先执行。换句话说,id大的那个查询块往往是最先被处理的“内层”部分。这个规则在实际分析复杂SQL时非常有用,能帮你快速理解执行顺序。
select_type描述的是这个SELECT处在什么位置。最常见的是SIMPLE,代表没有子查询也没有UNION的简单查询。其他你可能会遇到的值包括:
PRIMARY:最外层的SELECTSUBQUERY:子查询中的第一个SELECTDERIVED:派生表,也就是FROM后面的子查询UNION:UNION中的第二个或后续SELECTUNION RESULT:合并UNION结果的那一步MATERIALIZED:优化器选择把子查询物化成临时表来加速访问DEPENDENT SUBQUERY:子查询里引用了外层查询的列,每行都可能要执行一次
我举个简单例子。一条带有UNION的语句,执行计划里有时候会出现三行,一行是第一个SELECT(PRIMARY),一行是第二个SELECT(UNION),最后一行是合并结果的UNION RESULT。看到这种结构,至少能判断出你写的UNION被MySQL按常规方式处理了。
2.2 table与partitions:这次查询动了谁
table列显示的是这一行执行计划访问的表名或别名。正常情况下就是表名,如果你在SQL里写了别名,这里会显示别名。单表查询里它没什么存在感,但多表JOIN时它和id配合,能帮你理清每一步在操作哪张表。
partitions列表示查询涉及的分区。如果表没有分区,就是NULL。用8.0默认会显示这一列;如果是老版本,可能要手动用EXPLAIN PARTITIONS才看得到。平时用不到分区的同学,这一列可以直接跳过,不用太紧张。
2.3 type列:最该先看的一列
type是访问类型,也是EXPLAIN输出里信息量最大的一列。它直接告诉你MySQL是怎么在表里找数据的:是直接主键定位一行,是顺着索引范围扫,还是干脆把整张表从头到尾撸一遍。从性能好到差,常见顺序大概是:
system > const > eq_ref > ref > fulltext > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL你不需要把这些全部背下来,但至少要记住几个分水岭:
const:主键或唯一索引命中,最多返回一行,这是最理想的情况eq_ref:JOIN时用主键或唯一索引关联,被驱动表最多只会匹配一行,非常理想ref:非唯一索引等值匹配,可能返回多行,属于健康的访问方式range:索引范围扫描,比如BETWEEN、>、<、IN等,可以接受index:扫描了整棵索引树,比全表扫描好一点,但本质上是把索引当全表扫了ALL:全表扫描,这通常是慢SQL的头号元凶
我在实际调优时,如果看到type是ALL,第一反应就是“这条SQL是不是没建索引,或者索引没被用上”。如果看到type是index,也要注意,虽然走了索引,但可能是遍历了整个索引,数据量大了照样慢。
2.4 possible_keys与key:索引的“候选”与“最终人选”
possible_keys列出优化器认为可能用到的索引,key是它最终实际选用的索引。这两个字段放到一起看,能发现很多问题。
如果possible_keys有数据,而key是NULL,说明优化器考虑过这些索引,但最后没采用,原因可能是它们对当前查询没什么帮助,也可能是优化器的成本模型认为走这些索引还不如全表扫描。如果possible_keys是NULL,说明这条SQL对应的表上根本没有可用的索引,或者查询条件里压根没有可索引的列。
这里要注意一个容易误导人的地方:key不为空,不代表性能就一定好。举个例子,type为index的查询,key可能也显示某个索引,但实际扫的是整个索引树,可能比全表扫描还要快一点,但也快不了太多。所以看执行计划一定要“多列联动”,不能单看某一个字段。
2.5 key_len、ref、rows、filtered:量化的代价指标
key_len表示优化器实际使用的索引字节长度。这个字段最大的价值,是帮你判断联合索引到底用到了几列。
比如建了一个(user_id, order_status, create_time)的联合索引,如果key_len只等于user_id这一列的长度,说明后面的列没被用上。常见原因就是你没遵循最左前缀原则,或者中间某个条件是范围查询,导致后续列“断掉”。key_len越小,通常代表使用的索引部分越少,但并不是说小一定好,因为有时候覆盖索引需要更大的key_len来避免回表,整体上反而更快。
ref列显示的是通过哪个列或常量去索引里查找。如果是const,说明用的是常数等值匹配;如果是某个表字段,比如t1.id = t2.user_id,那ref列会显示database.t2.user_id。
rows是优化器估算的需要读取的行数。记住是“估算”,不是精确值。这个数字越少越好,但不同表的数据分布差异很大,不能只看绝对值就下结论,要结合过滤条件和实际返回行数一起看。
filtered表示经过存储引擎层查询后,在Server层继续过滤后剩余的比例,单位是百分比。比如rows估算扫100行,filtered是10%,说明最终可能只剩10行。这个字段在MySQL 5.7之后默认输出,看它的意义在于:如果filtered太低,说明虽然有索引,但大部分读取的行在后续过滤中被丢弃了,说明索引的选择性一般,或者需要再加索引字段把过滤前移到索引层。
2.6 Extra:隐藏着的优化信号
Extra列是执行计划里的“备注栏”,很多关键信号都藏在里面。这一列的值太多了,我只说日常调优最高频的几种。
Using index:覆盖索引,查询的所有列都能从索引里取到,不需要回表,这是最理想的情况Using where:存储引擎返回数据后,Server层又做了条件过滤。看到这个说明有一部分条件没用上索引Using index condition:触发了索引条件下推(ICP),部分过滤被下推到存储引擎层处理Using filesort:需要额外做一次文件排序。这个不是磁盘排序的意思,但确实表示排序没走索引,数据量大时很伤Using temporary:执行过程中使用了临时表,常见于GROUP BY、DISTINCT或者某些子查询Using join buffer:JOIN时使用了join buffer,通常说明关联字段没走索引,需要优化Impossible WHERE:WHERE条件恒为假,优化器直接判断查不出数据
看到这些关键字,要形成条件反射。比如Using filesort出现,第一反应就是排序字段没配合索引;Using temporary出现,就要反思GROUP BY字段和索引是不是不匹配。
3. 访问类型type从好到坏逐一实战对比
3.1 system与const:离谱但很爽的路径
system是特殊场景下的极致情况,比如表里只有一行数据(通常是系统表),而且是精确访问。现实业务里很难遇到,但你知道它存在就行。
const就常见多了。当你用主键或者唯一索引做等值匹配时,MySQL能确定最多只有一行结果,于是会把那行数据当成“常量”来对待。比如:
SELECT * FROM order_info WHERE id = 1024;只要id是主键,EXPLAIN的结果会显示type为const,key为PRIMARY。这种查询速度极快,因为InnoDB通过主键索引直接定位到那一行就行。唯一索引也一样,比如你有一个唯一的order_no字段,用WHERE order_no = 'A001'去查,同样能到const级别。
3.2 eq_ref:连接查询的“完美搭档”
eq_ref在多表JOIN里很常见。它的含义是:被驱动表是通过主键或唯一索引进行等值关联,且最多只会匹配到一行。
举个例子:
SELECT * FROM user_info u JOIN order_info o ON u.id = o.user_id WHERE u.id = 123;如果优化器选择先查user_info,再用u.id这个主键去关联order_info,那么order_info这行执行计划的type就有可能是eq_ref。这种访问方式也很高效,因为每一次命中都像点查,不会引发大量回表扫描。
如果执行计划里JOIN出现了ALL,并且Using join buffer,那基本可以断定关联字段上没有合适索引,这会成为慢JOIN的主要来源。优化办法就是在被驱动表的关联字段上加索引,把type从ALL提到ref甚至eq_ref。
3.3 ref与ref_or_null:普通索引的匹配
ref是使用普通二级索引做等值匹配,可能匹配到多行。它并不低效,只是结果可能不止一行。典型场景是:
SELECT * FROM order_info WHERE user_id = 123456;如果user_id上有普通索引,type就是ref。执行时会在二级索引的B+树里找到所有user_id=123456的叶子节点,然后回表去主键索引里取完整行。
ref_or_null和ref差不多,但额外包含了WHERE col IS NULL的查询。MySQL在普通索引上处理IS NULL时,需要额外扫描NULL值所在的区间,因此单独给了一个访问类型。你只要知道ref的变体里有它就行,遇到再查不迟。
3.4 range:索引范围扫描的坎
range表示通过索引做范围扫描。常见的触发条件包括>、<、BETWEEN、IN以及LIKE 'prefix%'这种前缀匹配。它的效率取决于范围有多大,但至少说明索引被用上了。
比如:
SELECT * FROM order_info WHERE create_time BETWEEN '2024-01-01' AND '2024-01-31';如果create_time上有索引,并且优化器认为走索引成本更低,type就会是range。需要注意的是,把IN拆成多个等值匹配,在某些优化器版本下可能走range,也可能走ref,具体看成本估算。
看到range不要慌,但要关注rows列。如果这个范围很大,估算扫几十万行,即使走了索引,也可能很慢。这时候要么缩小范围,要么让索引能覆盖更多条件,减少回表。
3.5 index与ALL:全表扫描的两张面孔
index和ALL都让人头疼,但区别在于:ALL是全表扫描聚簇索引,index是扫描整棵二级索引树。二级索引通常比聚簇索引小,所以index比ALL快一点,但如果查询需要回表,那index也可能产生大量随机I/O。
什么样的查询会变成index?比如:
SELECT user_id, order_status FROM order_info;如果user_id和order_status都包含在某个二级索引里,MySQL可能会直接扫描这个二级索引来覆盖查询,Extra里会显示Using index。这种查询虽然type是index,但数据量一大,照样慢。它适合小表或者只需统计少量字段的场景。
ALL就是全表扫描了。当你SELECT *且WHERE条件没有索引列时,MySQL只能把整张表的聚簇索引从头到尾读一遍。数据量小没事,几百万行就可能要了几秒钟。看到ALL的第一反应,应该是找索引、改SQL,而不是继续往下看。
4. 用EXPLAIN定位慢SQL:一个完整的优化实例
4.1 慢查询的表结构与原始SQL
光讲概念不落地没意义。我们用一个模拟的订单表场景来完整走一遍调优流程。
假设有张订单表order_info,结构大概是这样的:
CREATE TABLE `order_info` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL, `user_id` bigint NOT NULL, `order_status` tinyint NOT NULL DEFAULT '0', `amount` decimal(12,2) NOT NULL DEFAULT '0.00', `pay_type` tinyint NOT NULL DEFAULT '0', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `pay_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;表里大约有200万行数据。业务反馈某个接口最近越来越慢,这个接口对应的核心SQL是:
SELECT * FROM order_info WHERE user_id = 123456 ORDER BY create_time DESC LIMIT 10;这条SQL的逻辑很简单:查某个用户最近的10条订单。
4.2 第一轮EXPLAIN:发现问题
直接执行:
EXPLAIN SELECT * FROM order_info WHERE user_id = 123456 ORDER BY create_time DESC LIMIT 10;得到的执行计划大致如下:
+----+-------------+-------------+------------+------+---------------+------+---------+------+---------+----------+-----------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------------+------------+------+---------------+------+---------+------+---------+----------+-----------------------------+ | 1 | SIMPLE | order_info | NULL | ALL | NULL | NULL | NULL | NULL | 2000000 | 10.00 | Using where; Using filesort | +----+-------------+-------------+------------+------+---------------+------+---------+------+---------+----------+-----------------------------+看到这个结果,问题就很清楚了。
第一,type是ALL,说明全表扫描。第二,possible_keys和key都是NULL,说明user_id上根本没有可用的索引。第三,rows估算200万行,意味着MySQL准备把整张表读一遍。第四,Extra里有Using filesort,说明ORDER BY create_time没法直接走索引顺序,需要在扫描得到结果后再额外排序。
这条SQL慢的核心原因就是:把所有数据翻一遍,再排个序,最后才取10条。用户的数据量一大,自然就慢。
4.3 索引调整后的第二轮EXPLAIN:对比验证
知道了问题,优化方向就很明确:给user_id加索引,并且尽量让create_time也参与进去,因为排序字段如果能走索引顺序,就不需要filesort。
我加了这样一个联合索引:
ALTER TABLE order_info ADD INDEX idx_user_create (user_id, create_time);然后重新EXPLAIN:
+----+-------------+-------------+------------+------+-----------------------+------------------+---------+-------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------------+------------+------+-----------------------+------------------+---------+-------+------+----------+-------+ | 1 | SIMPLE | order_info | NULL | ref | idx_user_create | idx_user_create | 8 | const | 234 | 100.00 | NULL | +----+-------------+-------------+------------+------+-----------------------+------------------+---------+-------+------+----------+-------+对比很明显:type从ALL变ref,rows从200万降到234,Extra里的Using filesort也没了。因为联合索引(user_id, create_time)本身就是按user_id分桶、桶内按create_time排序的B+树,所以当user_id是等值条件时,create_time的顺序可以直接用来做ORDER BY,MySQL沿着索引倒序扫描即可。
这个实例告诉我们:加索引之前,先用EXPLAIN看现状;加完索引,再用EXPLAIN验证效果。前后对比是很直观的优化证据。
4.4 更进一步的索引覆盖优化
上面的查询是SELECT *,即使走idx_user_create,也还要回表拿完整行数据。回表本身有开销,但如果结果集不大(这里估算234行),问题不大。
如果业务上只需要某些字段,比如只要订单号、状态、金额和创建时间,可以把SQL改成:
SELECT order_no, order_status, amount, create_time FROM order_info WHERE user_id = 123456 ORDER BY create_time DESC LIMIT 10;然后对索引结构做调整,让查询字段尽可能被覆盖:
ALTER TABLE order_info DROP INDEX idx_user_create, ADD INDEX idx_user_create_cover (user_id, create_time, order_no, order_status, amount);这时再EXPLAIN,即使type还是ref,Extra里可能会多出Using index,说明查询的所有列都直接从索引里取到了,不需要回表。这能把每次查询的I/O降到最低,尤其是数据量大、高频访问的场景,收益非常可观。
不过覆盖索引也不是万能的。索引字段越多,写入时的维护成本越高,索引占用的空间也越大。所以设计索引时,要在查询性能和写入成本之间权衡。我的习惯是先解决掉全表扫描和filesort,再看高频查询能不能用覆盖索引做极致优化。
5. 常见问题与排查技巧速查
5.1 type=ALL、Extra里没有Using index怎么办
看到type=ALL,不要慌,按这个顺序排查:
- 检查WHERE条件里到底有哪些列
- 确认这些列上有没有合适的索引
- 如果联合索引命中不完整,查key_len判断用到了哪几列
- 如果用不到索引,问自己:这个表是不是小到全表扫描本来就更划算
很多ALL其实不是没索引,而是条件里写的列根本没进索引。比如用户表只有几千行,优化器认为全表扫描比走索引再回表更快,type是ALL反而正常。这时候你强行加索引,收益也不大。
如果确实需要走索引,但优化器就是不走,可以先试一下ANALYZE TABLE刷新统计信息。统计信息过期是优化器选错执行计划的常见原因之一。
5.2 Extra里出现Using filesort / Using temporary
这两个信号都值得警惕。
Using filesort出现,优先看ORDER BY字段是否和索引的列顺序匹配。通常的解法有两种:一是为排序字段单独建索引;二是把排序字段放进联合索引并用在合适的位置,像第4节那样。注意,如果排序字段出现在联合索引中,但它前面的列没有被等值约束,排序也可能用不上。
Using temporary出现,大概率是GROUP BY或者DISTINCT没有合适的索引。比如对无索引列做GROUP BY,MySQL需要在临时表里做聚合排序。看到这个,先检查GROUP BY字段是否在索引里。如果实在避免不了,那就尝试减少临时表的数据量,或者把聚合逻辑拆到应用层。
5.3 索引明明建了却没用上:几个典型场景
这是最让人抓狂的情况:明明表上有索引,EXPLAIN里possible_keys却是NULL,或者key是NULL。常见原因我列一下:
- 对索引列做了函数运算,比如
WHERE DATE(create_time) = '2024-01-01',这会让create_time上的索引失效。正确写法是create_time >= '2024-01-01' AND create_time < '2024-01-02' - 隐式类型转换,比如索引列是varchar类型,却用int去比较,MySQL会把列转成数字再比较,导致索引失效
- 违反最左前缀,比如联合索引是
(a, b, c),你直接查b=1 AND c=2,用不到该索引 - LIKE的左侧有通配符,比如
LIKE '%keyword',索引没法用 - OR条件里包含非索引列,可能整个WHERE都无法高效走索引
遇到索引失效,多结合key_len来判断是彻底失效还是部分失效,能省很多排查时间。
5.4 EXPLAIN的“欺骗性”:准备一条数据分布不均匀的SQL试试
EXPLAIN里的rows是估算值,不是真实扫描行数。这会导致一种“欺骗”现象:明明执行计划很漂亮,type是ref,rows只有几百,实际却慢得要命。
我遇到过一种典型问题,索引列的数据分布极度不均匀。比如一个status字段只有0和1两个值,90%的数据是0,你用WHERE status = 1去查,优化器通过统计信息算出走索引会扫很多行,可能宁可全表扫描。此时EXPLAIN里type显示ALL,但如果你换个值WHERE status = 0,它可能还是ALL或者range。这种场景下,建立索引不一定有用,因为数据本身区分度太差。
还有一种更隐蔽的“欺骗”在深分页场景里。比如:
SELECT * FROM order_info ORDER BY create_time DESC LIMIT 100000, 10;EXPLAIN可能显示的是range或者index,rows也不夸张,但实际执行时MySQL要把前10万行都遍历并丢掉,代价非常大。这在执行计划里往往不会直观反映出来。这类SQL的解法通常是改成基于主键或游标的分页方式:WHERE id < 上一页最大id ORDER BY id DESC LIMIT 10。
5.5 快速定位大表分页慢的问题
大表分页慢是业务开发里非常高频的痛点,EXPLAIN能帮你判断是扫描行数问题还是排序问题。方法很简单:先EXPLAIN看Extra有没有Using filesort,以及rows大概多大。
如果rows很大,但实际只返回少量数据,说明MySQL把大量时间花在“扫了再扔”上。这时优先考虑游标分页。如果Extra里有Using filesort,ORDER BY字段不走索引,可以考虑让排序直接走主键或者二级索引顺序。
再说一个实用技巧:当你看到rows和实际返回行数差距非常大时,除了统计信息过期,还要怀疑是不是因为回表代价被低估了。这时候可以看看能不能用覆盖索引来减少回表,这是很多慢SQL优化的最后一步。
6. EXPLAIN的进阶玩法:JSON格式、TREE格式与EXPLAIN ANALYZE
6.1 EXPLAIN FORMAT=JSON:把执行计划变成可解析的数据
普通EXPLAIN输出是二维表,适合人眼快速扫,但如果要做自动化分析,或者想看更丰富的成本信息,可以用JSON格式:
EXPLAIN FORMAT=JSON SELECT * FROM order_info WHERE user_id = 123456 ORDER BY create_time DESC LIMIT 10;JSON输出里会多出cost_info、query_cost这类字段,比如read_cost、eval_cost、prefix_cost、data_read_per_join。这些数据对判断优化器的成本估算过程很有帮助。尤其是你调整了索引后,想量化对比“优化前”和“优化后”的成本差异,JSON格式是最直观的。
把JSON格式存下来,还能方便做前后版本对比,或者让脚本自动解析执行计划中的access_type、rows等关键指标,搞一个简单的慢查询巡检工具。我平时排查疑难语句时,会优先开JSON格式,信息量比默认输出大不少。
6.2 EXPLAIN ANALYZE:真正去跑一遍的实测代价
EXPLAIN ANALYZE是MySQL 8.0.18引入的“实测版”,会真正执行SQL,并输出实际耗时、循环次数、实际返回行数、实际扫描行数等信息。它输出的格式是TREE,里面有actual time、rows、loops这些字段。
举个例子,如果你在EXPLAIN里看到rows估算50万,但实际执行完发现只返回50行,说明优化器估算偏差很大,执行计划说不定选错了。而EXPLAIN ANALYZE能直接告诉你真实数据,相当于把执行计划从“预测”变成了“事故现场回放”。
用法很简单:
EXPLAIN ANALYZE SELECT * FROM order_info WHERE user_id = 123456 ORDER BY create_time DESC LIMIT 10;这个命令最大的价值,是帮你找到“EXPLAIN看起来很对,但实际很慢”的隐藏问题。比如嵌套循环执行次数非常多的时候,TREE输出里loops列会非常明显。不过要注意,它会真正执行SQL,所以在生产环境跑之前,最好确认一下会不会给数据库带来额外压力。
6.3 8.0里被“合并”掉的EXPLAIN变体
老版本里有很多EXPLAIN变体,比如EXPLAIN EXTENDED和EXPLAIN PARTITIONS,在MySQL 8.0里已经被整合到默认输出中了。所以你不需要再记那些老用法,8.0下直接跑EXPLAIN就能看到filtered和partitions。
另外,执行完EXPLAIN后,接着执行SHOW WARNINGS,能看到优化器给出的额外警告和优化后的SQL重写信息。这在排查“为什么没走索引”时很有用。比如优化器提示Cannot use index 'idx_lower' due to type or collation conversion on column,你一下就明白是隐式转换的问题了。
这些进阶工具用熟练之后,你会发现EXPLAIN不再是“看个索引用没用”的一行命令,而是一个能深入理解MySQL优化器行为的完整工具箱。
7. 再分享几个我实际排查中踩过的坑
首先,不要迷信索引数量。索引不是越多越好,每多一个索引,插入、更新、删除时的维护成本就多一分。我见过有人为了优化一条查询,在表上加了六七个单列索引,结果整张表的写入性能崩了。正确的思路是:优先用联合索引覆盖高频查询,之后再评估收益和代价。
其次,EXPLAIN结果里key_len这个字段,很多人不重视,但它对判断“联合索引到哪一列为止”非常有用。以后遇到“我明明建了联合索引,怎么感觉没生效”的困惑,别只盯着possible_keys和key,先看一眼key_len,马上能定位问题。
还有就是:执行计划好看不等于真实性能好。EXPLAIN是基于估算的,数据分布、统计信息、并发压力都会影响实际执行。所以我做完索引优化后,一定会用实际业务SQL在测试环境压一遍,对比优化前后的延迟,而不是光看EXPLAIN截图变了就收工。
最后再分享一个小技巧:排查慢SQL时,把普通EXPLAIN、JSON格式的EXPLAIN、以及SHOW WARNINGS的结果一起留下来。它们三个组合起来,能覆盖“执行路径、成本计算、优化器备注”三个维度,基本可以把一条慢查询从“症状”看到“病因”。养成这个习惯之后,你处理SQL问题的效率会有质的提升。