“SQL优化”这个词,每个跟数据库打过交道的人都不陌生。但很多时候,一提优化就是“加索引”,加完没效果也不知道为什么。作为天天跟慢查询较劲的开发者,我想聊聊最基础也最核心的工具——EXPLAIN关键字。搞懂它,你才算真正开始理解SQL执行背后的逻辑。
EXPLAIN是数据库提供的一把“透视镜”,能让你看到优化器是如何执行一条SQL的。它解决的核心问题是:这条SQL到底慢在哪里?是没走索引全表扫描,还是走了索引却依然要回表?是表连接顺序有问题,还是排序操作拖了后腿?对于想系统学习SQL优化的开发者、被线上慢查询折磨的运维,或者是刚入门但不想只停留在“会写SQL”层面的新人,掌握EXPLAIN都是性价比极高的一件事。
很多时候你问为什么一条SQL跑得慢,如果回答“没加索引”,那基本等于没说。真正有机会优化的点,藏在EXPLAIN输出的一长串字段里。这篇内容我会结合自己实际排查慢查询的经验,把执行计划里那些关键的字段拆开揉碎,讲清楚每个数字背后到底意味着什么,以及遇到具体问题时调整的方向。
1. 内容整体设计与核心思路拆解
1.1 为什么执行计划决定了SQL性能的上限
先打个比方。你写SQL时只告诉数据库“我想要什么结果”,但数据库不傻,它不会真的按你字面上写的顺序一步步执行,而是会自己规划出好几条不同的“执行路线”,再从中挑一条它认为代价最小的。这个“路线图”就是执行计划。同一句话,不同的执行路线,性能可能相差百倍甚至千倍。
EXPLAIN就是把这个优化器选中的路线图显示给你看。理解了这张图,你才知道SQL慢的根源在哪个环节。是type明明是ALL,代表全表扫描?还是key显示用的是主键索引,说明你建的那个普通索引根本没被选中?又或者是rows估算值跟实际相差巨大,导致优化器选错了连接顺序?
把这些信息串起来,你会发现自己逐渐从“瞎蒙式优化”变成“有据可循地优化”。这也是为什么我说,性能优化的核心不在于背几个优化技巧,而在于看懂数据库的决策过程。
1.2 SELECT类型其实暴露了常见查询的通病
EXPLAIN的第一列是id,然后是select_type。很多人会直接忽略它们,但我建议先养成浏览一遍的习惯,因为SIMPLE、PRIMARY、SUBQUERY、DERIVED、UNION这些类型直接告诉你SQL的复杂度分布。
我的排查习惯是:如果看到select_type里出现SUBQUERY或DERIVED,就会多留个心眼——因为这类查询往往是性能杀手。子查询容易造成临时表,而DERIVED表示派生表,通常会先执行子查询生成一张中间表再参与外部查询。如果你的需求能被改写成JOIN,性能大概率会明显提升。这种改写思路不是玄学,是每一条慢SQL做实操时都会验证到的事情。
1.3 通过table字段追踪数据来源
看table字段是为了搞清楚这一步到底在访问哪张表,以及有没有别名。这个信息单看意义不大,但和id、select_type放在一起,就能还原出数据库的执行顺序。给你一个真实场景:某次我排查一条查询比较慢的问题,执行计划里出现了三行,分别对应三张表,但它们的id完全一样。这意味着数据库认为这三张表的连接顺序已经是最优的,不需要额外做子查询嵌套。
而另一次排查时,我故意把一个大表作为驱动表,EXPLAIN的结果就变得非常难看,连接顺序直接导致访问行数翻了好几倍。这种问题如果不看table和id的对应关系,光靠猜是永远猜不准的。
2. 核心细节解析与实操要点
2.1 type字段是执行计划的“体检报告”
如果只能选一个字段来快速判断SQL健康程度,我会毫不犹豫选type。type表示数据库对表的访问方式,从好到差大致是:system、const、eq_ref、ref、range、index、ALL。前三个是理想状态,range也还在可接受范围内,但一旦出现index或者ALL,你就得警觉了。
ALL代表全表扫描,会从头到尾把表的每一行读一遍。数据量小的时候看不出问题,但到了千万级数据量,哪怕只是做一次全表扫描,磁盘IO也能活活把数据库拖垮。index虽然听起来比ALL好一点,但其实也不容乐观,它代表遍历了整棵索引树,通常意味着索引没有完全利用上,只是比全表扫描省去了回表的开销。
我一般在EXPLAIN里看到type是ALL,第一反应不是直接加索引,而是先问自己:这条SQL能不能用LIMIT来限制扫描范围?如果可以,那是业务逻辑层面的优化;如果不行,再考虑联合索引的设计。盲目的加索引,往往导致优化器宁可选择已有索引也不碰你新建的那个,这就是为什么很多人建了一堆索引却完全没效果的原因。
2.2 possible_keys与key的组合拳
possible_keys列出的是数据库认为可能用到的索引,key则是最终实际选用的索引。很多人只看key,我却习惯对比着看。你会在实操中发现一个有趣的现象:possible_keys里有索引,但key是NULL,或者possible_keys是NULL,key却有值。
前者通常意味着索引字段上存在函数操作或隐式类型转换,导致索引无法使用,比如WHERE create_time BETWEEN ...时如果create_time是字符串,但查询条件里用了数字,数据库就会放弃索引。后者则说明虽然没合适的单列索引,但查询条件里出现的字段刚好匹配某个覆盖索引的左边部分,于是数据库顺手用了这个索引。
对比这两个字段最大的价值是:帮你理解为什么优化器没有选择某个索引。有时候不是索引没用,而是可选择性太差。比如在大面积重复的状态字段上建索引,优化器算一下发现走索引还得处理一大堆重复值,成本反而比全表扫描高,于是果断放弃。这个逻辑如果不看possible_keys和key的差异,你是永远猜不到的。
2.3 key_len揭示了索引到底使用了多长
key_len可能是被忽略得最严重的一个字段。它表示数据库在索引中使用到的字节数。举个例子,如果一张表有个(a, b)联合索引,索引字段分别是两个varchar,a字段长度为10,b字段长度为20。只看key字段,你只知道用了哪个索引,但不知道到底用了联合索引的几列。
这个信息在排查时非常关键。因为联合索引有一个基本原则:最左前缀。如果你写了一条件查询WHERE b = ...,key_len几乎等于索引第二列的长度,说明第一列根本没用上,联合索引反而是拖累。但如果key_len等于两列加起来的长度,说明这个查询条件完美命中了联合索引的所有列。
我建议你每次执行EXPLAIN后,顺手把key_len记下来。慢慢你会发现,这个数字比rows更可靠,因为它是根据索引定义直接计算出来的,而rows是优化器的估算值,依赖于统计信息,存在偏差。
2.4 rows和filtered一起看的“真假表引用”
rows是优化器估算的需要扫描的行数,filtered表示经过条件过滤后剩余的比例。正常情况下,你应该期望rows尽量小,且filtered尽量接近100。两个字段结合看,可以判断SELECT是否过滤掉了大量无关行。
有一次排查慢请求,EXPLAIN显示rows只有几百,但实际响应时间却要好几秒。后来发现是filtered只有10%左右,意味着数据库扫描了几百行后还要过滤掉90%,问题就出在索引条件下推没做。MySQL在老版本默认不会将过滤条件“下推”到存储引擎层,导致查出来的数据比预期多很多。后来在查询里调整了条件写法,变成只索引能直接完成过滤的形式,响应时间立刻从秒级降到了毫秒级。
这一类问题不亲自踩一遍坑,很难从文档里获得相同的体感。所以我的建议是:不要单独看任何一个字段,把所有字段当成一个整体来分析,执行计划从来不是孤立信息,而是一张交叉验证的逻辑网。
3. 实操过程与核心环节实现
3.1 用EXPLAIN验证一个典型慢查询实战
直奔主题,假设我们有这么一张订单表,结构大致如下:
CREATE TABLE `order_info` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) DEFAULT NULL, `user_id` bigint DEFAULT NULL, `total_amount` decimal(10,2) DEFAULT NULL, `status` tinyint DEFAULT NULL, `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_user_status` (`user_id`,`status`) ) ENGINE=InnoDB;业务反馈按用户查历史订单时,列表页加载特别慢。很快我定位到一条慢SQL:
SELECT id, order_no, total_amount FROM order_info WHERE user_id = 123456 AND status = 3 ORDER BY create_time DESC LIMIT 20;先执行一次EXPLAIN,看看原始的执行计划是什么样的:
EXPLAIN SELECT id, order_no, total_amount FROM order_info WHERE user_id = 123456 AND status = 3 ORDER BY create_time DESC LIMIT 20;输出的切面大致如下:
+----+-------------+------------+------------+------+-----------------+-----------------+---------+-------+------+----------+-----------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+------------+------------+------+-----------------+-----------------+---------+-------+------+----------+-----------------------+ | 1 | SIMPLE | order_info | NULL | ref | idx_user_status | idx_user_status | 9 | const | 1200 | 20.00 | Using index condition | +----+-------------+------------+------------+------+-----------------+-----------------+---------+-------+------+----------+-----------------------+从执行计划里可以看到,type是ref,不算太差,rows估算1200行,filtered只有20%。表面看似乎还好,但结合实际的400毫秒延迟,就明白问题出在哪了:排序和回表。注意Extra里没有出现Using index,而是Using index condition,意味着数据是先从二级索引定位,再回到主键去取整行数据的。加上ORDER BY create_time DESC,如果索引顺序跟排序条件不一致,数据库就需要额外的排序操作。
我给这个查询加上了覆盖索引:
ALTER TABLE order_info ADD INDEX idx_user_status_time (user_id, status, 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_status,idx_user_status_time | idx_user_status_time | 9 | const | 1000 | 20.00 | Using where; Using index | +----+-------------+------------+------------+------+------------------------+------------------------+---------+-------+------+----------+------------------------------------+关键变化是Extra字段变成了Using index,说明查询不再需要回表。ORDER BY create_time也能直接走索引完成,省去了排序开销。实测这条SQL从400多毫秒降到了十几毫秒,查询体感提升非常明显。这个案例典型地说明了:执行计划的每一个字段变化,背后都对应着一次真实的性能飞跃。
3.2 如何通过索引选择让type从ALL进化到ref
还有一个平行的案例,也很有代表性。某天我收到告警,某个统计报表接口超时。核查后发现了一条典型问题SQL:
SELECT COUNT(*) FROM payment_log WHERE merchant_id = 98765 AND pay_status = 1 AND create_time >= '2024-01-01';EXPLAIN输出直接给我看懵了,type是ALL。possible_keys是NULL,这说明查询条件里的三个字段一个索引都没建。数据量2000万行,每次调用都全表扫,不超时才怪。
这种问题在不允许动业务代码的前提下,最稳妥的方案就是加索引。但一次性建三个单列索引是低效的,因为数据库最终只会挑其中一个来用。我选择建立联合索引(merchant_id, pay_status, create_time),把等值查询的字段放左边,范围查询字段放右边。重新看EXPLAIN,type变成了range,rows从2000万级别直线下降到几千,查询耗时也顺势降到了毫秒级。
这里要强调的是,联合索引字段顺序不是拍脑袋定的。它的规则其实很清晰:等值条件优先,范围条件次之。因为联合索引一旦遇到范围条件,后面的字段就无法继续利用索引排序和查找了。你要是把create_time放最前面,merchant_id反而会失效,执行计划就又会退化回ALL。这种理解必须通过反复查看EXPLAIN才能刻进脑子里。
3.3 快速看懂多表JOIN计划与驱动表选择
多表JOIN的执行计划,是EXPLAIN里最容易让人头皮发麻的场景。其实核心就一点:看每一行的id大小和table顺序。id相同的情况下,越先出现在执行计划里的表就是驱动表。优化器会根据两张表的数据量和索引情况决定谁当驱动表更划算。如果你发现驱动表是大表,而小表反而是被驱动表,就要想想是不是条件写错了。
一个亲测有效的排查技巧:在小表上建立连接字段的索引。因为被驱动表的连接条件如果能快速命中索引,就能极大降低嵌套循环的次数。反之如果你用大表去驱动小表且大表没有索引,那就只能全表扫描大表,再对每行去匹配小表,性能直接崩塌。用EXPLAIN核对key字段和type字段,如果被驱动表那行的type是ALL而possible_keys又为空,那基本可以确认连接条件上缺索引,补上之后执行计划往往会瞬间好看很多。
4. 常见问题与排查技巧实录
4.1 为什么索引明明存在却走了全表扫描
碰见这种情况别急着怀疑人生,先从三个最常见的坑开始查。
第一个坑是索引字段被函数包裹,比如WHERE DATE(create_time) = '2024-01-01'这样写,数据库无法用B+树的排序特性去快速定位,只好放弃索引。解决办法是把条件改成范围写法:create_time >= '2024-01-01' AND create_time < '2024-01-02'。这种写法在语义上完全等价,性能却有天壤之别。
第二个坑是隐式类型转换。字段是字符串类型,传进去的却是数字。数据库不会报错,但内部会把字段转成数字再比较,索引也随之失效。怎么看?执行计划里type直接变ALL。解决方式很简单,让参数类型跟字段类型完全一致。
第三个坑是选择性过低。索引能区分出的唯一值占比太低,优化器觉得走索引比全表扫更亏。说白了,索引本身不是万能的,在一个枚举值只有两三个选项的字段上建索引,大多数时候还不如全表扫描来得省事。这种情况你不需要非得让它走上索引,不如在查询行的数量级上想办法,或者调整业务逻辑。
4.2 覆盖索引与回表的代价差
很多人一知半解地把“回表”挂在嘴边,但真到了用EXPLAIN分析时,就不知道怎么看回表了。回表的本质是:二级索引叶子节点只存了索引字段和主键值,如果查询要达到的字段不在索引里,就必须拿着主键再去主键索引里取一次整行数据。一次回表代价不大,但查询结果集大的时候,回表次数会被无限放大。
避免回表最直接的路径,是让查询用到的所有字段都落在同一个索引内,也就是覆盖索引。怎么确认覆盖索引生效了?看Extra里的Using index。只要出现这个标识,就说明这条SQL不需要回表,扫描索引就完成了所有工作。实操中我建索引时,经常会刻意把高频出现在SELECT列表里的字段放到联合索引的末尾,目的就是让更多查询天然满足覆盖索引的条件。
4.3 排序优化与filesort的应对策略
执行计划里Extra出现Using filesort绝对是一个危险信号。这表示数据库不得不额外开辟一块内存或磁盘空间来做排序,数据量一大就会拖垮整体性能。我自己遇到这种情况,第一反应是先把ORDER BY的字段纳入到联合索引中,因为B+树本身是有序结构,按索引顺序读取就能直接满足排序要求,连排序操作都省了。
比如前面的订单查询例子,ORDER BY create_time之所以慢,是因为索引顺序是user_id, status,而排序字段create_time不在索引中。当我把create_time追加进联合索引后,索引B+树的叶子节点本身就按这个顺序保存,数据库读取出来的结果天然有序,Using filesort就消失了。当一条SQL的Extra里同时出现Using index和“没有Using filesort”时,性能基本上就算是优化到位了。
4.4 问题速查表与排查路径建议
把日常高频出现的问题沉淀成一张速查表,是我个人提高排查效率的秘诀。记录下来,以后遇到类似情况直接对照:
| 执行计划特征 | 可能原因 | 优化方向 |
|---|---|---|
type=ALL且有隐式转换或函数包裹 | 索引失效 | 改写条件、统一类型 |
type=ALL但possible_keys=NULL | 缺索引 | 合理设计联合索引 |
key选了非最优索引 | 索引选择性差或统计信息滞后 | 利用ANALYZE TABLE更新统计,或FORCE INDEX临时指定 |
Extra=Using filesort | 排序字段不在索引中 | 将排序字段并入联合索引 |
Extra=Using temporary | 存在去重、分组或子查询 | 改写为JOIN或优化分组结构 |
key_len过短 | 联合索引只命中部分字段 | 按实际查询补全索引列 |
rows与实际不符 | 统计信息过旧 | 更新统计或主动ANALYZE |
这个表是我排查时候的真实总结,每次看执行计划前我都会扫一遍,快速定位到问题窗口。当你遇到新的疑难杂症,也不妨在表里继续补充自己的心得,慢慢沉淀成自己的“案例库”。
4.5 一个容易被忽略但影响很大的细节
EXPLAIN分析时,我见过有人只看执行计划的关键几个字段,却完全忽略了key_len的计算规则。这里有个细节值得拿出来单独提醒:key_len的计算跟字段类型和字符集强相关。比如varchar(32)在utf8mb4字符集下,每个字符最多占4个字节,一个字段的索引长度就是32*4+2,那个2是用来存储变长长度的额外字节。
了解这个规则后,你在分析联合索引到底命中了几列时就不会算错。比如一个联合索引定义为(a varchar(20), b varchar(30)),如果执行计划里key_len等于82(20*4+2),说明只用到了第一列;如果等于20*4+2+30*4+2,说明两列全用上了。这种计算一旦熟练,你对执行计划的理解就会上一个台阶,不再停留在“看到啥就是啥”的程度。
5. 终极排查闭环:从EXPLAIN到优化落地
拿到EXPLAIN结果不是终点,只是一个起点。我个人的习惯是,把执行计划当成一个“诊断报告”,根据报告里的每一项异常指标,进一步去反推业务SQL本身的设计。经常遇到的情况是,SQL逻辑看起来没问题,但换成EXPLAIN视角后,就发现很多藏在代码里的隐患——例如不必要的子查询、大字段的SELECT *、排序字段和索引不匹配等。
所以我会顺手养成一个习惯:每一步优化后,都重新跑一遍EXPLAIN。对比优化前后的type、key、rows、Extra变化,再验证实际响应时间和资源消耗。长期坚持下来,你会在脑海里积累出一张清晰的“执行计划正常形态”图,之后只要瞄一眼EXPLAIN输出,就能大概猜出这条SQL的真实耗时等级。这种直觉是没法从文档里获得的,只能在一次次实操中建立。
你不妨从今天开始,在手头一条相对慢的查询上先跑一次EXPLAIN,按我上面提到的字段顺序逐项过一遍,再按速查表里的建议去微调。你会惊讶地发现,原来很多慢查询的解决方案,根本不需要大动作,只是看懂了执行计划后的一次小改写。
最后说点实在的。我见过很多开发者把SQL优化想得太复杂,总觉得要懂底层存储引擎原理才能动手。其实不然。EXPLAIN存在的意义,恰好是把数据库内部复杂的决策过程浓缩成一张直白的“路线图”。你不需要提前成为内核专家,只要会读这张图,能从type的变化里看到扫描方式的差异,能从Extra的变化里看到排序和回表的代价,就已经超过了一大批只会“加索引、加索引、再加索引”的同行。优化从来不是靠堆功能和盲目尝试,而是从看懂执行计划开始的一件理性决策的事。