凌晨两点的监控告警群里躺着一串红字:某核心订单接口的数据库慢查询耗时逼近 8 秒。打开慢查询日志,语句倒是简单,问题就出在 EXPLAIN 执行计划里那一行刺眼的 type=ALL 上。这大概就是 MySQL 性能调优最典型的开场——你不需要立刻去改配置、调参数,而是先学会读懂 EXPLAIN 输出,搞清楚优化器到底打算怎么执行这条 SQL,再对症下药去设计索引。这篇是 MySQL 性能调优系列的第二篇,重点聊透 EXPLAIN 执行计划分析,以及基于执行计划做索引优化的那套完整打法,适合正在为慢查询头疼、或者刚接触索引优化没多久的开发者。
1. 慢查询亮红灯之后,我为什么先打开 EXPLAIN
1.1 一条 8 秒的慢查询
那次线上事故的表结构不算复杂,一张交易流水表,数据量三千多万行,查询语句大概是这样的:
SELECT order_no, user_id, amount, status FROM trade_flow WHERE create_time BETWEEN '2024-01-01 00:00:00' AND '2024-01-31 23:59:59' AND status = 1 ORDER BY create_time DESC LIMIT 50;业务方的反馈是"接口越来越慢",慢查询日志证实了这一点。当时我做的第一件事不是加缓存,不是改代码,而是把这条 SQL 原封不动丢进EXPLAIN,看一眼执行计划。结果很直接:type 是 ALL,rows 估算两千多万,Extra 里还挂着 Using filesort。翻译成人话就是:这条语句打算把整张三千多万行的表从头到尾扫一遍,扫完之后还要对结果做一次额外的排序。不慢才有鬼。
这里有个很重要的观念:慢查询优化,第一步永远是先弄清楚 MySQL 为什么会选择这么差的执行路径。你连优化器怎么想的都不知道,直接加索引属于瞎猜,运气好蒙对了,运气不好加了个寂寞,甚至可能让情况更糟。
1.2 EXPLAIN 到底在解释什么
EXPLAIN 是 MySQL 提供的一条诊断命令,它做的事情很简单:让优化器把"我打算怎么执行这条 SQL"的方案摊开给你看。注意措辞——它展示的是优化器基于当前统计信息估算出来的执行计划,是"预演",不是"实况"。
比如EXPLAIN SELECT ...,输出的每一行都代表执行计划里的一个步骤:先访问哪张表、走哪个索引、预估要读多少行、是否要排序、是否用临时表,全部一目了然。多表 join 的时候,EXPLAIN 会输出多行,每行对应一次表的访问操作,行与行之间还有明确的先后依赖关系。
MySQL 5.6 之后还有EXPLAIN FORMAT=JSON,能看到更细的代价估算;8.0.18 之后引入了EXPLAIN ANALYZE,这个就厉害了,它不只是预演,而是真的把 SQL 跑一遍,再把每一步实际耗时和预估的行数一起反馈给你。后面我会专门讲这个工具什么时候用。
1.3 别把 EXPLAIN 当实测报告
新手容易犯一个错误:看到 EXPLAIN 里 key 字段没有命中自己刚建的索引,就断定索引失效,急着删了重建。实际上 EXPLAIN 显示的是估算值,rows 是优化器按照统计信息推算的,不是真实读取的行数。统计信息过期、表数据分布突变,都会导致估算严重失真。
所以我的建议是:EXPLAIN 用来定位问题和确认方向,比如是不是全表扫描、是不是排序失控;但优化完的最终效果,一定要结合EXPLAIN ANALYZE或者直接看真实执行耗时来验证。诊断用 EXPLAIN,验收用实测,两者配合,才能避免被"纸面执行计划"误导。
2. EXPLAIN 每一列都别放过:从 id 到 Extra 的完整读法
EXPLAIN 输出的每一列都有意义,但重要性完全不同。没必要记住所有细节,可如果连核心字段都读不懂,后面优化就是盲人摸象。我按"阅读顺序"把字段分成三组讲。
2.1 先看 id 和 select_type,理清执行顺序
id是每一步执行的标识符。规则很简单:id 越大越先执行;id 相同时,从上往下顺序执行。遇到子查询、派生表(FROM 子句里的子查询)的时候,id 这个字段能帮你迅速理清 MySQL 真实的执行顺序,而不是你字面上理解的那个顺序。
select_type描述这一步是什么类型的查询:
| select_type | 含义 | 常见场景 |
|---|---|---|
| SIMPLE | 最简单的普通查询 | 单表查询,不包含子查询和 UNION |
| PRIMARY | 最外层查询 | 包含子查询时,外层那条就是 PRIMARY |
| SUBQUERY | 子查询 | WHERE/ON 后面的子查询 |
| DERIVED | 派生表 | FROM 子句里的子查询结果 |
| UNION | UNION 中的第二个及之后的 SELECT | 合并多个结果集时 |
| UNION RESULT | 合并 UNION 结果的临时表 | 出现在 UNION 最后一步 |
看到 DERIVED 和 UNION RESULT 要稍微留心,这两类都涉及临时表,有临时表就可能带来额外的内存或磁盘开销。特别是 DERIVED,MySQL 5.6 之后的派生表合并优化能处理一部分场景,但不是所有子查询都能被合并,一旦走到物化路径,代价不容小觑。
2.2 type、key、key_len、rows 才是性能判官
真正决定一条 SQL 快不快、该不该优化的,是中间那几列。
type是访问类型,这个是最重要的字段,后面专门用一整章讲,这里先记住一个排序:system > const > eq_ref > ref > range > index > ALL。看 EXPLAIN 的时候,先看 type,如果落到 ALL 或 index,大概率就有优化空间;能到 ref 或 range,说明索引起作用了;const 和 eq_ref 则是接近最优的状态。
possible_keys列出优化器"可以考虑"的索引,key是它最终选定的索引,key_len是选中的索引实际用到的字节数。这三列要放一起看。特别注意 key_len,它是判断联合索引到底用到了哪几列的关键,后面我会教你怎么手算。
rows是优化器预估的扫描行数。这个数字越大,执行计划大概率越差。它不是真实值,但作为横向对比指标非常好用:同一个查询,优化前后 rows 从 2000 万掉到几百,那效果立竿见影。
filtered表示存储引擎层返回的数据,经过 Server 层 WHERE 条件过滤后剩余的比例。通常 filtered 越接近 100 越好。对单表查询来说它参考意义一般,但在多表 join 时,rows × filtered可以估算出驱动表真正参与连接的数据量,很有价值。
2.3 Extra 里出现这几种字眼要警惕
Extra 列的信息密度很高,几句话就能告诉你执行计划里藏着的猫腻。我总结了一份自查表:
| Extra 关键字 | 含义 | 危险程度 |
|---|---|---|
| Using index | 使用覆盖索引,无需回表 | 极佳 |
| Using where | 在 Server 层对存储引擎返回的数据做过滤 | 不一定差,需结合 rows 看 |
| Using index condition | 索引下推(ICP),部分过滤条件下推到存储引擎层 | 良好 |
| Using filesort | 需要额外的文件排序 | 高危,务必优化 |
| Using temporary | 使用了临时表 | 高危,务必优化 |
| Using join buffer | 连接时没走索引,用了 join 缓冲 | 高危,多见于 join 条件列无索引 |
这里最值得点名的是Using filesort和Using temporary。filesort 不是真的在文件里排序,但它意味着排序没走上索引,MySQL 得额外分配排序缓冲,数据量一大就可能产生磁盘临时文件,性能断崖式下跌。临时表同理,UNION、DERIVED、某些 ORDER BY 场景都可能触发。出现这两类信息,基本等于告诉你有明显的索引设计缺口。
3. 访问类型全解:ALL 差、const 好,但好不等于适合
3.1 从 ALL 到 const:六种访问类型的代价排序
访问类型决定了 MySQL 读取数据的方式,理解它,是索引优化的基本功。我按效率从高到低逐个说:
const:主键或唯一索引的等值匹配,最多返回一行。比如WHERE id = 10086,优化器直接按主键定位,性能最好。
eq_ref:出现在多表 join 中,被驱动表的连接列是唯一索引或主键。比如用户表通过id被订单表 join,每次从用户表取一行,效率极高。这是 join 查询最理想的形态。
ref:普通二级索引的等值匹配,可能返回多行。比如WHERE user_id = 10086且 user_id 上有普通索引,命中一个区间,按索引顺序扫描。
range:索引范围扫描。BETWEEN、>、<、IN、LIKE 'prefix%'都可能走到 range。它的效率也不错,扫描的是索引上的一个区间。
index:全索引扫描。虽然走的是索引树,但要把索引的所有叶子节点都扫一遍,本质上是"换了一种方式的全表扫描"。这种类型经常出现在覆盖索引的场景里——查询的列都在索引里,不查表,但业务上真的要遍历大量索引项。
ALL:全表扫描。InnoDB 会从聚簇索引的第一个叶子节点开始,把整张表的数据页全部读一遍。数据量过了百万级别,这种访问类型的性能就是灾难。
一句话总结:目标起码要压到 range,最好到 ref 或以上。看到 ALL 和 index,第一反应应该是"这条 SQL 的索引设计有问题"。
3.2 key_len 的估算方法:看懂索引到底用到了哪几列
key_len 是很多人忽略但极有用的字段。联合索引idx(a, b, c),优化器可能只用到了 a 和 b,没用到 c,怎么发现的?就靠 key_len。
MySQL 计算索引字节长度有固定规则:定长类型按实际字节算;varchar 按字符集字节数乘以长度,额外的变长字段标志再加 2 字节;可空列再加 1 字节。我常用的几个基准数:
| 列定义 | key_len 字节数 | 计算逻辑 |
|---|---|---|
| INT NOT NULL | 4 | 定长 4 字节 |
| INT NULL | 5 | 4 + 1(可空标志) |
| VARCHAR(50) NOT NULL(utf8mb4) | 202 | 50×4 + 2(变长标志) |
| VARCHAR(50) NULL(utf8mb4) | 203 | 50×4 + 2 + 1 |
| DATETIME | 5 | MySQL 5.6+ 定长 5 字节 |
| BIGINT NULL | 9 | 8 + 1 |
比如表里有联合索引idx(user_id, status),user_id 是 INT 可空,status 是 TINYINT 可空。执行计划里如果 key_len 显示为 5,说明只用到了 user_id;显示 6,才说明 status 也被用上了。多 1 个字节通常就是多命中一列。通过 key_len 变化来判断联合索引的使用情况,比肉眼看 SQL 可靠得多。
3.3 更复杂的场景:多表 join 的执行计划
多表 join 时,EXPLAIN 会按照驱动顺序输出多行。以两表 join 为例:
EXPLAIN SELECT o.order_no, u.nickname FROM trade_flow o JOIN users u ON u.id = o.user_id WHERE o.create_time >= '2024-01-01';正常情况下,trade_flow 作为驱动表先执行一遍范围扫描,然后对每一行去 users 表通过主键 id 回查,EXPLAIN 第二行应该是 type=eq_ref、ref 列显示db.o.user_id。这说明被驱动表的 join 条件命中了主键,是最理想的连接方式。
反过来,如果 join 条件两边都没有索引,就会出现Using join buffer,MySQL 被迫把驱动表数据塞进连接缓冲区,逐批跟被驱动表做全表匹配,这种查询的数据量稍大,基本就是死路一条。多表 join 的黄金法则是:被驱动表的 join 条件列必须有索引,且驱动表尽量选择结果集小的一方。
4. 索引优化的核心决策:最左前缀、覆盖索引与索引选择性
4.1 最左前缀:联合索引的灵魂
联合索引的匹配规则是最左前缀原则:索引按照定义时的列顺序构建 B+ 树,查询条件只有从最左边的列开始连续命中,索引才会被充分利用。
例如联合索引idx(a, b, c),它实际上是先按 a 排序,a 相同再按 b 排序,b 相同再按 c 排序。因此:
WHERE a = 1 AND b = 2 AND c = 3:三列全命中WHERE a = 1 AND b > 2:命中 a、b,但 c 用不上(范围条件右边的列无法用于索引查找)WHERE b = 2 AND c = 3:完全不命中,因为跳过了 a
这里有个细节要注意:范围条件会截断索引后续列的匹配。b > 2是范围条件,它后面的 c 就只能做过滤,不能在索引树里做精确定位。设计联合索引时,应该把等值条件放在前面,范围条件放在后面,这条经验几乎能解决一半的索引配置问题。
4.2 覆盖索引:用空间换回一次回表
二级索引的叶子节点存的是索引列加主键值。查询走到二级索引后,如果还需要主键以外的列,就必须拿着主键回聚簇索引再查一次,这就是回表。回表次数一多,IO 开销直线上升。
覆盖索引的原理很朴素:让查询要的所有列都包含在索引里,MySQL 就不需要回表了。EXPLAIN 里 Extra 显示Using index,就是覆盖索引生效的标志。
最经典的用法是延迟关联之外的方式。比如查SELECT user_id, status FROM trade_flow WHERE status = 1,status 区分度低不适合单列索引,但建联合索引idx(status, user_id)后,这个查询直接从索引里拿结果,完全不用回表。代价是索引占用空间变大、写入时维护成本变高,属于典型的空间换时间。
4.3 索引选择性:区分度决定索引能不能打
很多人建索引只看"这个列常出现在 WHERE 里",这是不够的。一个索引有没有价值,核心看选择性,也就是列的区分度:
SELECT COUNT(DISTINCT col) / COUNT(*) FROM table_name;这个比值越接近 1,说明这一列的取值越分散,用索引过滤掉的数据越多,效率越高。如果比值很低,比如性别列只有两个取值,比值 0.0001 都不到,那即便建了索引,优化器也可能放弃使用——因为一个取值可能对应几百万行,走索引再回表的代价比全表扫描还高,优化器不傻。
实操里的常见标准是:比值低于 0.1 的列,要慎重建单列索引。这种低选择性列更适合放进联合索引当辅助列,配合高区分度的前置列使用,或者干脆做成覆盖索引的一部分。
4.4 什么时候不该建索引
聊了这么多怎么建索引,也得说说什么时候不该建索引。这是经验里容易被忽略的一半:
- 写入频繁的表,索引不宜多。每个索引都要维护一棵 B+ 树,每次 INSERT/UPDATE/DELETE 都要同步更新索引,索引越多写入越慢。
- 区分度极低的列,单独建索引纯属浪费。比如状态、类型、软删除标记这类取值很少的列。
- 非常小的表,比如几百行的配置表,全表扫描也就几个数据页,建索引反而多一份维护开销。
- 冗余索引要定期清理。
idx(a)和idx(a, b)同时存在时,前者就是冗余的,因为后者天然覆盖了前者的功能。
我在实际项目中见过一张四五十个索引的表,不少索引建了之后从来没在慢查询日志里出现过,纯纯的累赘。索引是调优的手段,不是越多越心安。
5. 三次慢查询优化复盘:从 EXPLAIN 到索引落地的完整链路
这一章我用三个真实遇到过的场景,把"发现问题 → 分析 EXPLAIN → 设计索引 → 验证效果"的完整链路走一遍。
5.1 场景一:中间模糊匹配导致全表扫描
某后台的订单搜索功能,SQL 长这样:
SELECT order_no, amount, status FROM trade_flow WHERE order_no LIKE '%202403%' LIMIT 20;EXPLAIN 的结果:type=ALL,rows=28000000,Extra 里是 Using where。原因很明确——%开头的LIKE没法利用索引树的有序性做前缀匹配,优化器直接放弃索引走全表。
这类问题最怕拍脑袋。有人直接加索引,结果完全没用;有人把LIKE '%key%'改成全文索引,但 MySQL 默认的全文索引对中文分词支持一般,还要改查询语法,引入新复杂度。
我当时给的方案分两步。第一,尽量引导业务用前缀匹配,比如支持order_no前几位查询,LIKE '202403%'配合索引,type 能到 range。第二,中间模糊匹配是刚需的话,别硬刚数据库索引,引入独立的搜索组件或者冗余一个倒排字段,都比在 MySQL 里死磕有意义。优化不是所有问题都必须在索引层解决,有时候换一个技术方案才是性价比最高的路。
5.2 场景二:ORDER BY 触发了 Using filesort
某个用户订单列表页,经常被业务方吐槽"翻页越来越慢":
SELECT id, order_no, amount FROM trade_flow WHERE user_id = 10086 ORDER BY create_time DESC LIMIT 20;表上有单列索引idx_user_id(user_id),EXPLAIN 显示 type=ref,命中索引,看起来不错。但 Extra 里有Using filesort:MySQL 找到所有满足 user_id 的行之后,还得按 create_time 再排一次序。用户订单量大时,这个排序代价不可小觑。
问题出在索引只覆盖了 WHERE 条件,没覆盖排序。解决方案是建联合索引idx_user_id_create_time(user_id, create_time),这样同一 user_id 下的数据在索引树里天然按 create_time 有序,排序步骤直接被消除。加完索引再 EXPLAIN,Extra 里的 Using filesort 消失了。
要注意排序方向的问题。MySQL 8.0 之前,联合索引定义成(user_id, create_time),ORDER BY create_time DESC实际上没法完全利用索引的升序排列,还会触发反向扫描,部分场景下仍会出现 filesort。8.0 引入了降序索引,可以在定义索引时显示声明create_time DESC,彻底解决这个问题。升级不到 8.0 的话,也可以考虑ORDER BY create_time ASC配合查询端反转顺序,用一点小技巧绕开。
5.3 场景三:函数包裹索引列,索引失效
这是一个很经典的翻车现场。运营要查某天的所有订单,SQL 写成:
SELECT order_no, amount FROM trade_flow WHERE DATE(create_time) = '2024-03-01';EXPLAIN 直接给了一个 type=ALL。原因在于 create_time 被DATE()函数包裹之后,索引树里存储的是完整的日期时间值,跟你传给函数的"某一天"没办法直接做比较。MySQL 也只能对全表每一行都算一遍 DATE() 再过滤,索引自然失效。
改写的方式很简单,换成范围条件:
SELECT order_no, amount FROM trade_flow WHERE create_time >= '2024-03-01 00:00:00' AND create_time < '2024-03-02 00:00:00';加上 create_time 索引之后,type 从 ALL 变成了 range,rows 从两千万降到几千。这类问题的通用经验是:别对索引列做任何计算、函数、隐式类型转换,保持索引列在比较表达式里的"纯净"状态。
5.4 每次改动后都要重新 EXPLAIN 验证
三个案例有个共同点:每次改完 SQL 或者加完索引,我都会重新执行一遍 EXPLAIN,对照优化前后的 type、key_len、rows 和 Extra 变化。这看起来是小事,但真的能拦下一堆问题。
我见过有人加完索引不验,第二天发现慢查询还在——一查是索引命名错了,优化器根本没用到;也有人自作主张把查询改成子查询,结果反而引入了临时表。EXPLAIN 的成本几乎为零,养成随手验证的习惯,比记住任何调优口诀都重要。验证标准很简单:type 降级了没有、key_len 是否覆盖了预期列、Extra 里的危险关键字是否消失、rows 估算是否至少降低一个数量级。
6. 容易被忽略的边界:统计信息、隐式转换与版本差异
6.1 统计信息过期:为什么明明有索引它不用
有时候你会遇到很诡异的情况:表上明明有索引,SQL 也符合索引使用条件,EXPLAIN 却显示 type=ALL。先别急着骂优化器,大概率是统计信息过期了。
InnoDB 的优化器依赖表的统计信息来估算 rows 和索引选择性。大量数据增删之后,统计信息可能严重偏离真实分布,优化器基于错误的估算做出错误决策。这时候跑一遍ANALYZE TABLE trade_flow;,强制刷新统计信息,往往问题就消失了。
这里加一句:关心索引之前,先确保统计信息的准。我遇到过不少"索引失效"的案例,最后查下来都是统计信息没刷新,压根不是索引的问题。
6.2 隐式转换:一个引号引发的全表扫描
隐式类型转换是索引失效的高频元凶,最具代表性的就是手机号。业务表里手机号存成 VARCHAR,查询时如果写成:
SELECT * FROM users WHERE mobile = 13800138000;mobile 是字符串列,右边却是一个整数,MySQL 会尝试把字符串转成数字再比较,导致索引失效。EXPLAIN 出来 type=ALL,rows 全表。修正方法就是加引号:
SELECT * FROM users WHERE mobile = '13800138000';判断有没有隐式转换有一个小技巧:EXPLAIN 里 ref 列显示的值如果跟查询条件字面量不一致,比如显示成整数而 SQL 里写的是字符串,多半就是发生了类型转换。保持字段类型和查询条件类型一致,是索引生效的基本前提。
6.3 8.0 版本带来的两个新工具
如果你的项目已经上了 MySQL 8.0,有两个工具能显著提升调优效率。
第一个是EXPLAIN ANALYZE。它和普通 EXPLAIN 的区别在于,它是真正执行查询并返回每一步的实际耗时、实际行数。普通 EXPLAIN 是"纸上谈兵"的估算,EXPLAIN ANALYZE则是"实战演练"的实况。两者的差异能帮你识别出优化器估算偏离真实情况的程度,从而判断到底是索引问题还是统计信息问题。
第二个是不可见索引。8.0 允许把某个索引临时设为 INVISIBLE,优化器会直接无视它,但索引本身还在被维护。这个特性对测试特别有用:想验证某条 SQL 能不能走新索引、走了之后效果如何,可以直接改成 INVISIBLE 观察,不用反复 DROP 再 CREATE,避免误删生产索引的风险。实测下来,我在生产环境做索引下线的灰度验证时,这个功能帮了大忙。
6.4 调优的边界感
最后想聊点技术之外的东西。做 MySQL 调优这几年,我的体会是:索引优化是有边界的,SQL 本身的设计和业务形态往往比索引更能决定性能上限。
有些查询天然不适合在 MySQL 里用索引硬解。比如 deep offset 的深度分页,LIMIT 100000, 20这种,MySQL 需要扫描十万行再丢弃前面十万行的结果,你给它什么索引都救不了,得换成"基于上一页最后一条记录的主键定位"这种方式。又比如超复杂的多表关联和聚合混合场景,与其在 SQL 里堆索引,不如考虑拆分查询、汇总表、缓存等更宏观的方案。
用一句我经常对同事说的话收尾:EXPLAIN 是读懂 MySQL 想法的入口,但真正让查询变快的,是你对数据分布、索引结构、业务形态三者的综合理解。索引设计得越贴合业务的实际访问模式,执行计划才越可能走向最优解。
说到底,执行计划分析练的就是一种直觉——看到一条慢 SQL,扫一眼 EXPLAIN,立刻能判断出瓶颈在访问类型、排序、还是临时表,然后直接给出对应的索引方案。这种直觉没有捷径,就是多看、多改、多验证。希望你读完这一篇,下次再遇到慢查询告警,能多一分从容,少一分慌张。