很多搞后端的朋友第一次接触Explain,是在慢SQL压测被领导叫过去的时候。我也不例外:线上有个订单统计页面,运营点一下要等十几秒,一翻日志全是同一条SELECT。当时我做的第一件事不是改代码,而是把这条SQL丢进工具里跑了EXPLAIN——把SQL的执行计划打出来,一看type列是ALL,rows预估500万,心里基本就有数了。这篇分享就围绕SQL的Explain展开,从执行计划里每一个关键字段怎么读,到真实慢SQL怎么一步步调优,再到Explain看不到的盲区,全部用踩过的坑讲一遍,适合刚接手慢SQL优化的后端开发,也适合想系统理解执行计划的DBA新人。
1. 慢SQL找上门时,Explain到底在告诉你什么
做了几年数据库相关的工作,我最大的感受是:慢SQL本身不可怕,可怕的是你不知道它慢在哪。很多时候业务方只丢给你一句"这条SQL很慢",然后你对着几十行SQL发呆,不知道从哪下手。Explain的存在,就是把数据库优化器做决策的过程"透明化"——它不帮你优化,但会清清楚楚告诉你:这条SQL最终打算怎么执行。
1.1 一条SQL从编写到执行,中间发生了什么
先理一下基础。我们写的SQL提交到数据库后,会经历几个阶段:词法解析、语法解析、语义检查、优化器生成执行计划、执行器执行。前几步都是标准动作,真正影响性能的是优化器。优化器会根据表结构、索引信息、统计信息、连接顺序等条件,枚举出它认为"成本最低"的一系列执行方案。
这个过程很像你用导航软件规划路线:起点终点一样,但导航会给你好几条路,有的走高速、有的走国道、有的绕远但少收费。数据库优化器也一样,它要在多个执行路径里挑一个成本最低的。Explain打印出来的,就是优化器最终选中的那条"路线图"。
注意一个关键点:优化器选路线,依赖的是它手里的"地图数据",也就是统计信息和索引信息。如果这张地图本身是旧的、不准的,那它选出来的"最优路线"就可能是个大坑。这也是为什么后面我会强调,Explain的结果需要结合真实执行情况来验证。
1.2 Explain能回答的三个核心问题
我在实际排查中,会把Explain要解决的事情归纳成三个问题,所有字段都是围绕这三个问题展开:
第一,SQL先访问哪张表、以什么顺序访问。多表JOIN时,驱动表和被驱动表的顺序不同,性能差异能到几十倍。Explain的输出行顺序,以及id字段,能帮你还原优化器选择的连接顺序。
第二,每张表是怎么被访问的。是全表扫描,还是走索引?走的是主键索引、普通索引,还是索引范围扫描?这个问题的答案在type字段。type上如果出现ALL,基本就是慢SQL的头号嫌疑。
第三,访问之后还要做多少额外工作。比如要不要排序、要不要建临时表、能不能只用索引就拿到数据。这些看Extra字段。Using filesort、Using temporary一旦出现,就说明数据库在做"扫描之外"的额外功,通常很贵。
这三个问题搞明白,一条慢SQL的病灶基本就浮出水面了。
1.3 什么时候应该习惯性用Explain
很多人只在SQL出问题的时候才想起Explain,我的建议是把它变成一种"下意识动作":
- 新功能上线前,凡是涉及核心表查询的SQL,先Explain一轮,确认没有全表扫描再合代码。
- 索引变更之后,旧SQL的执行计划可能说变就变,跑一次Explain做前后对比,比看执行时间更可靠。
- 慢查询日志里出现的SQL,不要只看日志,把SQL复制出来Explain一下,定位"慢"在哪一步。
- 代码评审时别人问你"这个查询性能怎么样",拿Explain结果说话,比拍胸脯有说服力得多。
慢查询日志只告诉你"这条SQL花了3秒",Explain告诉你"这3秒花在了哪一行操作上"。两者的关系,一个是体检报告上的指标异常,一个是医生给你开的详细检查单。
2. 读懂执行计划的关键字段:先抓住四个“病根”位置
刚接触Explain的人,很容易被输出的一大堆字段吓到。MySQL的EXPLAIN结果里有id、select_type、table、partitions、type、possible_keys、key、key_len、ref、rows、filtered、Extra,一共十几个字段,全看懂当然最好,但如果你想快速定位问题,优先级最高的只有四个:type、key、rows、Extra。其他字段更多是辅助判断。
2.1 核心字段速查表
先给一张我自己常用的速查表,标注每个字段的实际用途:
| 字段 | 含义 | 排查时怎么用 |
|---|---|---|
| id | 查询中各个SELECT的执行顺序编号 | id相同,从上往下执行;id不同,id越大越先执行 |
| select_type | SELECT的类型(SIMPLE、PRIMARY、SUBQUERY、DERIVED等) | 判断是否存在子查询、临时表等复杂结构 |
| table | 当前行操作的目标表 | 多表JOIN时区分哪张表出了问题 |
| type | 访问类型,性能从好到差依次是system > const > eq_ref > ref > range > index > ALL | 出现ALL或index时,先想办法让它变成range或ref |
| possible_keys | 优化器认为可能用到的索引 | 如果一个都没显示,说明这个查询根本没索引可用 |
| key | 优化器实际选中的索引 | key为NULL,说明没走任何索引;key和possible_keys不一致,说明有索引但优化器没用 |
| key_len | 用到的索引字节数 | 联合索引中判断实际用了几个字段,比如key_len=4,通常只用到了第一个字段 |
| rows | 优化器预估要扫描的行数 | 只是个预估值,不是实际值,但能反映量级 |
| filtered | 表条件过滤后剩余行数的百分比 | 结合rows看最终返回的行数级 |
| Extra | 补充的执行信息,如Using index、Using filesort、Using temporary | 这里出现filesort/temporary,往往比全表扫描还值得警惕 |
2.2 type字段:一张表究竟是怎么被访问的
type是执行计划里最核心的一个字段,它直接决定了这张表的访问效率。网上很多文章把它叫"访问类型",我更喜欢叫它"扫描方式"。它的性能排序从好到坏,大致是这样:
| type级别 | 含义 | 典型场景 |
|---|---|---|
| system | 表只有一行记录 | 系统表或极限情况,基本见不到 |
| const | 使用主键或唯一索引等值匹配,最多一行 | WHERE id = 1 |
| eq_ref | JOIN时被驱动表使用主键或唯一索引等值匹配 | 常见于多表JOIN,性能极佳 |
| ref | 使用普通二级索引等值匹配,返回多行 | WHERE user_id = 100 |
| range | 使用索引做范围扫描 | BETWEEN、>、<、IN等条件 |
| index | 扫描整棵索引树,但不需要回表 | 覆盖索引的查询仍会扫全索引 |
| ALL | 全表扫描,整张表逐行读取 | 没走索引的查询,最大性能杀手 |
我在优化SQL时有个不成文的规矩:如果执行计划里出现两个以上type为ALL的表,几乎可以断定这条SQL要重写或者要加索引了。尤其是几千行的小表全表扫描还好说,一旦表到百万级,ALL就是灾难。
2.3 rows是"预估值",别拿它当真实值
很多新手有个误区:看到rows=100,就以为这条SQL真的只扫了100行。实际上rows是优化器基于统计信息推算出来的预估值,不是真实扫描行数。比如一张表前天统计信息记录是500万行,你昨晚删了400万,今天Explain出来的rows可能还是500万,因为统计信息还没更新。
这就解释了为什么有时候Explain看着很漂亮,实际执行却慢得离谱。优化器拿着错误的统计信息,很可能选错索引、算错成本。遇到这种情况,先对表执行ANALYZE TABLE刷新统计信息,再重新Explain,往往会有惊喜。
顺便说一句,MySQL 8.0之前InnoDB对rows的估算误差是出了名的,尤其是范围查询、多表JOIN时误差可能达到一个数量级。所以在关键场景下,我会用EXPLAIN ANALYZE(8.0.18+)去看真实的执行行数和耗时,这个后面专门讲。
2.4 Extra字段:隐藏的额外成本全在这
Extra字段平时最容易被忽略,但恰恰是它揭示了很多"看不见的工作"。几种高频出现的值,我结合场景解释一下:
Using filesort:这是排序操作。注意,这里的filesort不代表一定用了磁盘文件,也可能在内存里做排序。但它意味着MySQL需要额外做一次排序,而不是直接利用索引的有序性。SQL中有ORDER BY但排序字段不满足索引顺序时就会出现。一旦出现,就要检查能不能通过调整索引让排序字段也用上索引。
Using temporary:使用了临时表。通常出现在GROUP BY、DISTINCT、UNION这类操作中,数据量一大,临时表甚至会落到磁盘上,性能断崖式下跌。常见优化手段是让GROUP BY的字段用上索引,或者改写SQL减少中间结果集。
Using index:这是一个好信号,表示查询用到了覆盖索引,直接扫索引树就能拿到全部字段,不需要回表。在追求极致性能的SQL里,这是我最希望看到的Extra值之一。
Using where:表示存储引擎返回记录后,Server层又做了条件过滤。但如果前面几列用了索引,这里通常是正常的。要注意的是,如果type=ALL且Extra里同时出现Using where,这意味着全表扫描加逐行过滤,大概率是个问题SQL。
Using index condition:也就是ICP(索引条件下推),存储引擎层在索引上先过滤一部分记录,减少回表次数。这个在联合索引场景下很常见,一般不需要额外干预。
我见过太多人只盯着key列看有没有用索引,忽略了Extra里的filesort。实际上一条SQL可能走了索引,但由于ORDER BY字段不在索引里,MySQL要把几千行数据重新排序,这个排序成本有时比扫描本身还高。
3. 一条订单统计慢SQL的完整Explain排查链路
讲完字段,来一个完整案例。这是我在一个电商项目里真实处理过的问题,出于脱敏我把表名和字段名做了替换,但执行计划的特点完全保留。
背景是这样的:运营要做一个订单统计报表,需求是查最近30天订单,按照用户昵称模糊匹配关键词,按订单金额倒序展示前30条。最初SQL长这样:
SELECT o.order_id, u.nickname, o.total_amount, o.created_at FROM orders o LEFT JOIN users u ON o.user_id = u.id WHERE u.nickname LIKE '%小明%' AND o.created_at >= '2024-06-01' ORDER BY o.total_amount DESC LIMIT 30;orders表当时大概500万行,users表100万行。这条SQL线上执行耗时12.8秒,直接把运营后台拖到崩溃。
3.1 第一步:先跑一次Explain,拿到原始现场
我拿到这条SQL的第一时间,直接在测试库跑了一次EXPLAIN:
EXPLAIN SELECT o.order_id, u.nickname, o.total_amount, o.created_at FROM orders o LEFT JOIN users u ON o.user_id = u.id WHERE u.nickname LIKE '%小明%' AND o.created_at >= '2024-06-01' ORDER BY o.total_amount DESC LIMIT 30;结果的关键行大致是这样:
| 表 | type | key | rows | Extra |
|---|---|---|---|---|
| orders | ALL | NULL | 5008450 | Using where; Using filesort |
| users | ALL | NULL | 998122 | Using where |
看到这个结果,几个问题非常明显:两张大表都是ALL全表扫描,orders还要做filesort排序。为什么明明orders表有10多个索引,优化器却一个都没用上?只看字段还不够,还得结合数据特性来推断。
3.2 第二步:拆解第一个痛点——主表为何全表扫
orders表是500万行的大表,type=ALL意味着逐行扫500万条记录,这本身就已经是灾难。为什么有索引还不用?问题出在WHERE条件的结构上。
o.created_at >= '2024-06-01'是范围条件,如果orders表有created_at的单列索引,理论上是能走range扫描的。但这个SQL里还有个u.nickname LIKE '%小明%',它作用在被驱动表users上,而且使用了前导通配符"%"。
注意,MySQL的优化器在选择驱动表时,会估算各种连接顺序的成本。由于LIKE '%小明%'无法走索引,users表被认定为"必须全表扫",而orders表又是LEFT JOIN的被驱动侧,优化器最终选择了先全表扫orders,再去匹配users。结果两张大表都成了ALL。
3.3 第三步:先解决主表扫描,让created_at索引生效
定位到问题后,第一步优化很明确:先让orders表尽量缩小扫描范围。检查发现orders表确实没有created_at的单列索引,于是先补上一个:
ALTER TABLE orders ADD INDEX idx_created_at (created_at);再次EXPLAIN,orders表的type从ALL变成了range,rows从500万下降到40多万,Extra里的Using where还在,但Using filesort依旧存在。这个改善很大,但还远远不够——40万行依然不少,而且排序的问题没解决。
这里要提醒一点:给表加索引不是随便加的,要考虑现有索引是否已经覆盖了查询场景。如果orders表已经有一个包含created_at的联合索引,就不需要另建单列索引,避免索引冗余。
3.4 第四步:把LIKE的坑和JOIN方向一起处理
再看users表的全表扫描。u.nickname LIKE '%小明%'是前导通配符模糊匹配,B+Tree索引是按前缀匹配的,前导通配符会导致索引失效。这一点要记住:LIKE以%开头,索引必然用不上。
按照当时的业务需求,运营要求的是"昵称包含关键词",不能简单改成前缀匹配。可选项有几个:一是改成前缀匹配LIKE '小明%',但会损失需求;二是在users表引入冗余的搜索字段,配合全文索引(MySQL 5.7+支持中文ngram全文解析器)来处理模糊搜索;三是如果允许改表结构,干脆把用户昵称相关的搜索条件拆出来独立处理。
我们最终选了全文索引方案,在nickname字段上建了全文索引,把LIKE条件改写为MATCH...AGAINST。这一步之后,users表的type从ALL提升为fulltext,性能有了质的改善。如果你遇到类似问题,但数据量不大、并发不高,也可以考虑用前面加索引加前缀匹配的方式做取舍,不必上全文索引。
3.5 第五步:消除filesort,重新设计联合索引
最后剩下的痛点是Using filesort。ORDER BY o.total_amount DESC要求按金额倒序,而orders表目前没有任何索引能让created_at过滤和total_amount排序同时生效。
这里要用到联合索引的设计思路。联合索引遵循最左前缀原则,我们要让"过滤条件"和"排序条件"命中同一个索引。当前查询对orders的过滤条件是created_at范围,排序条件是total_amount,联合索引可以设计成:
ALTER TABLE orders ADD INDEX idx_created_amount (created_at, total_amount);注意一个细节:范围查询之后,联合索引后面字段的有序性会被破坏。也就是说idx_created_amount中created_at用于range过滤,total_amount在范围过滤后不一定能保证全局有序,MySQL 5.7及以下对这种情况通常还是会文件排序。真正稳妥的做法,是把过滤条件做成等值,再让排序字段接力。
所以我把SQL改写了一下:先把最近30天的时间范围细化为具体的日期分组,让created_at尽量用等值条件匹配,再按total_amount排序。对业务影响不大,但执行计划立刻就变了。加上MySQL 8.0支持降序索引,ORDER BY total_amount DESC可以设定索引字段为降序存储,进一步压榨执行效率。
3.6 优化后的执行计划对比
经过几轮调整,最终SQL的执行计划是这样的:
| 表 | type | key | rows | Extra |
|---|---|---|---|---|
| orders | range | idx_created_amount | 4185 | Using index condition |
| users | eq_ref | PRIMARY | 1 | NULL |
和最初的执行计划放在一起看,差异非常直观:
| 项目 | 优化前 | 优化后 |
|---|---|---|
| orders访问类型 | ALL | range |
| users访问类型 | ALL | eq_ref |
| 预估扫描行数 | 500万+100万 | 约4200行 |
| Extra排序 | Using filesort | 无 |
| 线上真实耗时 | 12.8秒 | 约80毫秒 |
不要只看rows从600万变成4000多这个数字,执行时间的差距更能说明问题。扫描行数差了三个数量级,文件排序又消失,性能自然就回来了。
3.7 进阶验证:EXPLAIN ANALYZE看真实执行
MySQL 8.0.18之后提供了一个更强的工具:EXPLAIN ANALYZE。它不只是输出执行计划,还真正执行SQL,并返回每一步的实际耗时、实际行数、循环次数,用了pretty tree格式展示。
比如对优化后的SQL执行EXPLAIN ANALYZE,输出会包含类似这样的信息:
-> Limit: 30 row(s) (actual time=76.8..78.9 rows=30 loops=1) -> Nested loop inner join (actual time=0.2..77.4 rows=42 loops=1) -> Index range scan on orders using idx_created_amount (actual time=0.1..2.1 rows=4200 loops=1) -> Single-row index lookup on users using PRIMARY (actual time=0.00..0.01 rows=1 loops=4200)这比普通EXPLAIN直接把预估rows替换成了actual rows,一眼就能看出真实扫描了多少行、每一步耗时多少。我在优化慢SQL的后期,基本都会用它做最终验证,避免被预估误差带偏。
4. 当Explain显示“一切正常”,问题可能藏在哪
Explain是慢SQL排查的利器,但它不是万能的。我见过不少场景,Explain输出结果漂漂亮亮,SQL却照样慢。如果只看纸面执行计划就下结论,很容易被表象骗过去。这节就把我踩过的、Explain容易漏掉的盲区单独拿出来说。
4.1 统计信息过期:预估漂亮,实际拉胯
前面提过rows是预估,那它依据什么预估?答案是统计信息。InnoDB的表统计信息不是每次查询实时统计的,而是基于采样计算,维护在一张内部字典表里。如果一张表的数据分布发生重大变化,比如大量删行、批量插入、字段值分布剧变,统计信息却迟迟不更新,优化器就会拿着过时地图做决策。
我自己遇到过一次:一张订单明细表,正常情况下Explain走索引rows只有几百,某天突然变成全表扫描,而且性能雪崩。查了一圈发现表因为数据清理脚本,在半小时内删掉了90%的数据,optimizer统计信息还以为是原来的1000万行。执行ANALYZE TABLE之后,执行计划立刻恢复正常。
所以在Explain结果和实际性能明显不符时,先别怀疑数据库,很有可能是个统计信息过期的问题。定期对频繁变更的大表执行ANALYZE TABLE,或者开启MySQL的自动统计信息更新策略,是运维侧值得做的事。
4.2 锁等待和并发冲突:执行计划再好也救不了阻塞
Explain完全不关心锁。两条SQL的执行计划再完美,如果第三条事务拿着行锁不放,前面两条照样排队等锁。你看到的现象是SQL执行时间超长,但Explain显示type=ref、rows=10,好像一点问题都没有。
这种场景下,直接去看锁等待比看执行计划有用。查询information_schema.innodb_trx、performance_schema.data_lock_waits,或者使用SHOW ENGINE INNODB STATUS查看事务和锁的情况,能快速定位谁阻塞了谁。我处理过最典型的案例是:一个批量更新任务事务长时间不提交,把整个表的一些行锁住了,业务侧所有涉及这些行的UPDATE都卡死。Explain执行计划没变,但后端连接池被占满,系统整体假死。这类问题属于并发设计缺陷,不是索引能解决的。
4.3 深分页和LIMIT的隐藏代价
Explain对LIMIT分页的支持很有限。当你执行LIMIT 1000000, 30这类深分页查询时,Extra和type可能看起来很正常,type=range,rows预估也合适,但真实执行需要先扫描100万行,再丢掉前100万行,最后返回30行。这个"丢掉"的动作在Explain里是看不见的。
深分页的优化手段通常是延迟关联:先查出主键ID,再与原表JOIN返回完整数据。比如:
SELECT o.*, u.nickname FROM orders o INNER JOIN ( SELECT id FROM orders WHERE created_at >= '2024-06-01' ORDER BY total_amount DESC LIMIT 1000000, 30 ) tmp ON o.id = tmp.id LEFT JOIN users u ON o.user_id = u.id;这种方式让子查询在索引上完成排序和分页,代价小得多。如果业务上允许,更推荐使用游标分页(keyset pagination),基于上一页最后一条记录的ID作为下一页起点,效率是质的飞跃。
4.4 其他数据库的"Explain"怎么用
很多人是从MySQL入门执行计划的,实际工作中可能会碰到其他数据库。概念是相通的,但命令和字段不同:
- SQL Server:
SET SHOWPLAN_ALL ON或SET STATISTICS PROFILE ON,输出结果集里能看到StmtText列里的执行计划细节。图形化客户端的"显示估计的执行计划"更直观。 - Oracle:先
EXPLAIN PLAN FOR你的SQL,然后用SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY)查看。生产环境更常用的是从共享池里取真实执行计划,用DBMS_XPLAN.DISPLAY_CURSOR还能看到实际执行统计信息。 - PostgreSQL:
EXPLAIN和EXPLAIN ANALYZE都支持,ANALYZE会真实执行SQL并输出实际行数和耗时,这一点和MySQL 8.0的EXPLAIN ANALYZE是同一个思路。
跨数据库去理解执行计划,核心不是记字段名,而是理解那四个问题:访问顺序是什么、每张表怎么访问、预估扫多少行、额外动作有哪些。只要抓住了这几个维度,换到哪个数据库都能快速上手。
4.5 中和一下:Explain应该放在排查链路的哪一步
总结这么多,我给一个比较务实的建议:Explain适用于80%的慢SQL分析场景,但它应该是排查链路的一个环节,而不是全部。
我的习惯是:拿到一条慢SQL,先看慢查询日志里的实际执行时间和扫描行数,心里有个底;然后Explain看执行计划,判断是访问类型问题、索引问题还是排序临时表问题;有疑问再用EXPLAIN ANALYZE或同类工具确认真实执行情况;最后如果所有执行计划都很正常,才回头去查锁、事务、统计信息、连接池这些执行计划之外的变量。只有把这几步连起来,才能从"SQL慢"定位到"系统慢",而不是看了个Explain就说优化完成。
最后再分享一个实战经验:我会把优化过的每条慢SQL的优化前Explain结果、优化后Explain结果、真实执行时间,整理成一个固定的对比模板,扔到项目文档里。一两个月后回头看,你会发现很多SQL的坑其实是重复的——类型转换导致索引失效、ORDER BY字段没进索引、深分页扫了太多行,翻来覆去就那么几类。把这些典型案例沉淀下来,后续团队审代码或者处理线上问题,效率会高很多。这比每次都从头拆Explain有意义得多。