1. 写SQL很流畅,一到线上就卡壳?先从explain开始
我接触MySQL也有十来年了,带过的不少同事都有同感:本地写查询、小数据集上跑,SQL怎么写都顺;可一旦推到生产环境,几百万行数据的表摆在那儿,同样的SQL突然慢得像蜗牛。这时候绝大多数人的第一反应是“加索引”,但加了索引真的有用吗?索引建在哪个字段上?为什么明明建了索引查询还是全表扫描?
答案基本都藏在一个命令里:explain。
explain是MySQL提供的查询执行计划分析工具,它不会真的去执行你的SQL,而是让优化器告诉你“我打算怎么查这张表”。是走索引还是扫全表,估计要扫多少行,需不需要临时表,排序怎么排——这些关键信息全部在explain的输出里。看懂explain,你才算真正开始学会和MySQL优化器对话。
这篇内容我会把explain的每一个核心字段掰开揉碎讲清楚,再结合索引最佳实践,从原理到案例走一遍。适合刚接触索引优化的人,也适合写了好几年SQL但没认真看过执行计划的同学。文章里用到的案例都是我在实际业务中遇到过、改过的真实场景的简化版,照着这个思路去分析你自己的慢查询,基本够用了。
提示:本文所有示例基于MySQL 8.0,explain输出结构在5.7版本基本一致,部分差异会单独说明。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. explain输出字段逐个拆解:优化器把话都说完了,就看你能不能听懂
先跑一个最简单的例子。假设有一张用户订单表,结构如下:
sql复制CREATE TABLE `t_order` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT '用户ID',
`order_no` varchar(64) NOT NULL COMMENT '订单号',
`amount` decimal(10,2) NOT NULL COMMENT '订单金额',
`status` tinyint NOT NULL DEFAULT '0' COMMENT '订单状态 0待支付 1已支付 2已取消',
`create_time` datetime NOT NULL COMMENT '下单时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
执行下面这条查询:
sql复制EXPLAIN SELECT id, user_id, order_no, amount
FROM t_order
WHERE user_id = 1001
AND create_time >= '2024-01-01'
ORDER BY create_time DESC
LIMIT 10;
explain的输出大概长这样(我隐去了一些不重要的列):
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | t_order | NULL | range | idx_user_id,idx_create_time | idx_create_time | 5 | NULL | 1245 | 10.00 | Using index condition; Using filesort |
这一行信息量非常大。下面逐个字段拆。
2.1 id:执行顺序的编号
id列表示SELECT语句的执行顺序编号。在一个复杂查询里,可能会有多行记录,id越大越先执行,id相同则从上往下执行。如果某行id是NULL,说明这一行是结果集的“合并”操作,比如UNION的额外行。
实战中最常见的情况是:多表 JOIN 时,优化器会按照驱动表的顺序给每一张表分配一个id。不要纠结于“谁写在前面谁先执行”——写在前面的表不一定是驱动表,优化器会自己算,explain输出的顺序就是它最终决定的执行顺序。
一个小技巧:如果你看到id重复多次,说明MySQL做了子查询的物化或者JOIN缓冲,这时候要重点看type和Extra列,判断是否存在笛卡尔积式的低效扫描。
2.2 select_type:这条SQL里每一段都在干嘛
select_type描述的是“这一行对应的查询片段属于什么类型”,常见值有:
- SIMPLE:普通SELECT,没有子查询也没有UNION,上面订单表示例就是这种。
- PRIMARY:最外层的SELECT。如果SQL里写了子查询,外层那条就是PRIMARY。
- SUBQUERY:在SELECT或WHERE列表里出现的子查询,不是FROM子句里的。
- DERIVED:FROM子句里的派生表,也就是子查询的结果被当成了临时表来用。这是优化器最头痛的类型之一,因为派生表往往意味着要物化,有额外开销。
- UNION:UNION查询中第二个及之后的SELECT。
- UNION RESULT:从UNION的临时表中读取结果的阶段。
- DEPENDENT SUBQUERY / DEPENDENT UNION:子查询的结果依赖外层查询的列,典型的“相关子查询”。这种类型如果出现,要格外警惕,因为每一行外层数据都可能触发一次子查询执行。
看到DEPENDENT开头的类型,基本上可以判断这条SQL存在优化的空间。最典型的情况是把相关子查询改写成JOIN,往往效率提升呈数量级。
2.3 table:正在访问哪张表
这个字段表示这一行执行计划对应的是哪张表。如果SQL里给表起了别名,这里显示的是别名。除了真实的表名,有时候还会看到<derived2>、<union1,2>这样的值,尖括号里的数字对应id列。看到这种值,说明当前在访问一个派生表或者UNION的临时结果。
当遇到<derivedN>时,我会习惯去看对应的id那行到底查了什么,评估一下派生表的数据量。如果派生表数据量大,MySQL 5.7以下版本很可能把它物化成临时表,没法用索引——这时候要么改写SQL把条件推下去,要么手动拆成两条SQL,往往更快。
2.4 type:整个explain里最该盯住的一列
type表示MySQL在表里找到所需行的方式,也就是访问类型。它是有好坏排序的,从高到低大概是:
system > const > eq_ref > ref > range > index > ALL
- system:表只有一行,是const类型的特例。平时几乎遇不到。
- const:表最多只有一行匹配,通常是因为使用了主键或者唯一索引的等值匹配。优化器会把这一行当成常量来对待,速度极快。
- eq_ref:多表JOIN时,被驱动表通过主键或唯一索引等值匹配。走了eq_ref说明JOIN的关联字段上有唯一索引,是JOIN场景下最好的情况之一。
- ref:通过非唯一索引的等值匹配,可能匹配到多行。比如
WHERE user_id = 1001且user_id有普通索引,如果多个订单属于同一个人,就是ref。 - range:索引范围扫描,常见于
>、<、BETWEEN、IN、LIKE 'prefix%'。索引范围扫描的代价取决于范围内有多少行,范围大了照样慢。 - index:全索引扫描,也就是遍历了整棵索引树。这个比全表扫描好一点,因为索引一般比聚簇索引小,而且覆盖了查询列的话就不用回表。但如果查询条件选择性很低,它依然很慢。
- ALL:全表扫描,最差的情况。优化器认为需要读取整张表才能找到结果。
ALL基本是性能问题的代名词,看到它就要问自己:能不能加个索引把它变成range或者ref?我在指导项目组优化慢SQL时,第一步永远是看type列,凡是全表扫描的,先聊聊为什么没走索引。
2.5 possible_keys与key:哪些索引可能被用,实际用了哪个
possible_keys列出优化器认为可能用到的索引,key是它最终选择的索引。注意:possible_keys里的索引不一定最终会被使用,但key为NULL而possible_keys有值时,通常说明优化器评估后认为走索引还不如全表扫描快——比如要返回表中30%以上的数据,优化器大概率选择ALL。
key列的值很关键,它告诉你实际走了哪个索引。分析慢SQL时我会按这个顺序看:key是不是NULL?如果走了索引,用的哪个?key_len算出来是多少?ref是什么?把这几列连起来,基本能还原出优化器完整的决策路径。
2.6 key_len:索引到底吃了多少字节
key_len表示MySQL在索引里用于条件判断的字节长度,不是索引字段定义的长度。它是判断“联合索引用到了哪几列”最直接的依据。
举个例子:假设有一个联合索引(user_id, status),user_id是BIGINT(8字节)且NOT NULL,status是TINYINT(1字节)且NOT NULL。如果执行计划里key_len = 8,说明只用到了联合索引的第一列user_id来做等值过滤;如果key_len = 9,说明两列都用上了。这个原理往下看第4节,我会专门讲怎么精确计算。
2.7 ref:用索引等值匹配时,拿什么去比
ref显示的是当type为const、eq_ref或ref时,查询用哪些列或常量与索引列做等值比较。常见的值是const(用常量比较)、某个库的某个字段(比如testdb.t_order.user_id)或者func。
看到func时要留个心眼:这说明索引列被包在了函数里,比如WHERE DATE(create_time) = '2024-01-01',函数让索引失效的情况在这时候已经埋下伏笔。这个问题后面索引实践部分会细说。
2.8 rows:优化器估摸着要扫多少行
rows是优化器预估的需要读取的行数,注意是估算值,不是精确值。这个字段是调整SQL最重要的参考之一:预估扫描行数越低,查询理论上越快。
但这里有个大坑:优化器的rows基于统计信息估算,如果表的统计信息不准确,或者查询条件存在复杂的多列关联,rows可能和实际行数差很多。我遇到过一条SQL,explain显示预估rows只有几百,实际执行要扫几十万行,原因是查询条件里用了一个选择性极差的字段,而优化器对该字段的区分度统计严重失真。这种情况通常可以通过ANALYZE TABLE刷新统计信息,或者改写SQL来绕过优化器的误判。
2.9 filtered:查出来的行里有多少是真正满足条件的
filtered表示经过索引条件筛选后,剩余符合条件的行数占读取行数的百分比。比如rows=1000、filtered=10,说明预计读完1000行后,只有100行是真正满足所有WHERE条件的。
filtered低本身不一定是坏事,它只说明索引过滤后的结果集里还有大量行需要二次判断,这时候要考虑是否可以通过调整索引,让更多过滤条件下推到索引层,从而减少回表代价。
2.10 Extra:优化器的碎碎念,里面藏着很多关键信号
Extra列包含的信息量很大,常见值有这么几个:
- Using index:查询需要的列全部在索引里,不需要回表。这就是覆盖索引扫描,是理想状态。
- Using index condition:索引条件下推,存储引擎层先按索引里能判断的条件过滤一部分行,减少回表。MySQL 5.6引入的特性,一般混合着range查询出现,算中性偏好的信号。
- Using where:MySQL在存储引擎返回记录后,再用WHERE条件做了一次过滤。看到这个,要反思一下是不是有部分过滤条件没有下推到索引里。
- Using filesort:文件排序,MySQL需要额外做一次排序操作。如果排序字段没有合适的索引,group by、order by、distinct都可能触发filesort。排序的数据量大时会非常慢。
- Using temporary:查询使用了临时表,常见于GROUP BY、DISTINCT、UNION等操作。临时表可能建立在内存或磁盘上,数据量大时性能非常差。
- Using join buffer:JOIN时,被驱动表没法用索引,MySQL被迫用join buffer来做关联。这是一个危险信号,通常意味着关联字段缺少索引。
Extra里的信息是排查SQL性能的“第二现场”。我用explain分析慢查询时,看完type和rows之后,几乎所有注意力都会放在这一列上。
以下是explain输出字段的完整速查表:
| 列名 | 核心含义 | 重点关注 |
|---|---|---|
| id | SELECT执行顺序 | 复杂查询中定位执行路线 |
| select_type | 查询片段的类型 | DEPENDENT开头需警惕 |
| table | 当前访问的表 | <derivedN>说明有派生表 |
| type | 访问类型 | ALL一定有问题,index也要小心 |
| possible_keys | 可能使用的索引 | 有值但key为NULL时原因要说清 |
| key | 实际使用的索引 | 是否为NULL |
| key_len | 索引使用的字节长度 | 判断联合索引用到了几列 |
| ref | 与索引比较的对象 | func出现大概率索引失效 |
| rows | 优化器预估扫描行数 | 和实际行数对比,判断统计信息是否准确 |
| filtered | 条件过滤比例 | 低filtered意味着回表后有大量无效行 |
| Extra | 额外信息 | filesort、temporary、join buffer是性能杀手 |
3. type访问路径:为什么有的SQL秒回,有的SQL等到超时
我把type这一列单独拎出来讲,是因为它在所有字段里最直观地决定了单表访问的性能上限。理解了type,你再看那些慢查询,基本能一眼定位问题所在。
3.1 从const到eq_ref:等值查询的理想形态
const是explain里最让人舒服的type,代表“只查一行”。这种访问类型要求表最多只有一行匹配,因此通常走主键或唯一索引。
sql复制-- 走主键,type为const
EXPLAIN SELECT * FROM t_order WHERE id = 100;
-- 有唯一索引 uk_order_no,走唯一索引,同样为const
EXPLAIN SELECT * FROM t_order WHERE order_no = 'NO20240101001';
eq_ref一般出现在多表JOIN中,被驱动表用主键或唯一索引做等值关联。比如:
sql复制EXPLAIN SELECT *
FROM t_order o
JOIN t_user u ON o.user_id = u.id
WHERE o.id = 100;
这里如果t_user是张标准的主键表,explain第二行大概率就是eq_ref,说明每一条订单记录都能通过主键精确找到对应的用户,效率极高。
const和eq_ref是等值查询的理想状态。如果你的业务查询条件能设计成“主键或唯一键等值匹配”,性能上限是最高的。但现实中大部分查询做不到这一步,于是ref和range成了最常见的优化目标。
3.2 ref是等值查询的及格线,range是范围查询的底线
ref代表通过非唯一索引等值匹配,可能返回多行。
sql复制-- user_id上有普通索引,type为ref
EXPLAIN SELECT * FROM t_order WHERE user_id = 1001;
如果user_id是1对多的外键,一个用户有几十个订单,ref很适合。和ALL比起来,ref的最大优势在于不需要扫描整张表,而是先定位到索引中对应值的B+树位置,然后顺序读取该值下的所有叶子节点。
range则对应索引范围扫描:
sql复制-- create_time上有普通索引,范围查询,type为range
EXPLAIN SELECT * FROM t_order
WHERE create_time >= '2024-01-01' AND create_time < '2024-03-01';
range也是一种“可以接受的方案”,但要看范围有多大。如果走了索引还是慢,请先确认范围是否覆盖了大半张表,MySQL优化器对“范围太大不如全表扫描”的判断通常很准。
IN和BETWEEN在MySQL 8.0里通常也优化为range访问。但注意:IN列表的值太多,比如几千上万个,优化器可能放弃索引,因为它觉得每个值去索引里找一遍的开销还不如直接扫表。
3.3 index和ALL:两种最该警惕的慢访问
index表示“扫了整棵索引树”。有一种常见误解:只要explain里key不为NULL,索引就生效了。实际上type为index时,查询确实在用索引,但它是把索引完整遍历了一遍,不是精准定位。
典型的触发场景是:查询的列恰好全部包含在某个索引中,且WHERE条件没法利用该索引做精确匹配或范围过滤。比如:
sql复制-- 假设有联合索引idx_status_create(status, create_time)
EXPLAIN SELECT status, create_time FROM t_order;
这条查询虽然可以走idx_status_create实现覆盖索引,但要拿到全部订单的状态和创建时间,不得不把整颗索引树从头到尾走一遍。数据量大时,速度依然不能接受。
ALL是全表扫描,优化器认为读取整个聚簇索引比走二级索引更快。触发ALL的原因很多:无可用索引、返回行数占比过高、索引列被函数包裹、隐式类型转换导致索引失效等等。看到ALL之后的排查路径,基本就是顺着索引设计的几个典型坑一个个查过去。
我自己在看执行计划时的一个习惯:除了那些明确知道数据量很小的配置表、字典表之外,不允许ALL出现在核心业务查询中。一旦出现,必须先分析原因,而不是选择性忽略,因为全表扫描的隐患会随着数据量增长而急剧放大。
4. key_len计算:联合索引到底用到了几列,看这一列就够了
很多人在设计联合索引时最困惑的问题是:索引建了多个字段,可实际SQL执行时MySQL到底用到了哪几个?possible_keys里列了联合索引,但key_len才能说明真正用了多少。要精确判断,就得自己会算key_len。
这个看似不起眼的计算能力,是排查“索引建了没走全”“明明符合最左前缀还是只用到一列”这类问题最锋利的手术刀。
4.1 key_len的构成规则
key_len的计算有几个固定组成部分:
- 字段本身的字节长度。
- 如果是varchar等变长类型,额外加2字节记录长度。
- 如果字段允许NULL,额外加1字节。
- 字符集影响:utf8mb4编码下,一个字符占4字节。
拿最常见的几种类型举例:
- BIGINT:8字节
- INT:4字节
- TINYINT:1字节
- DATETIME:5字节(MySQL 5.6之后DATETIME存储为5字节,8.0依然是5字节不含毫秒)
- VARCHAR(n) 在utf8mb4下:4*n字节 + 2字节变长记录
在NULL策略上,InnoDB的每个索引列如果允许存储NULL,会在索引记录中额外标记一个字节。所以设计表时,能加NOT NULL就加NOT NULL,它不只是语义问题,还会实打实影响索引存储和key_len大小。
4.2 实战算一算
表结构沿用第2节的t_order:
sql复制KEY `idx_user_status` (`user_id`, `status`)
两个字段分别是BIGINT NOT NULL和TINYINT NOT NULL。
查询:
sql复制EXPLAIN SELECT * FROM t_order
WHERE user_id = 1001 AND status = 1;
如果执行计划显示key = idx_user_status,key_len = 9(8字节user_id + 1字节status),说明联合索引两列都用上了。如果key_len = 8,说明只用了user_id这一列做条件过滤,status可能是在回表之后才做的判断——这种情况在SQL里如果确实写了status等值条件,通常意味着需要检查一下索引顺序设计是否合理,或者查询条件里status被写成了status + 0 = 1之类的表达式。
再举一个varchar的例子。假设有联合索引idx_order_no_status(order_no, status),其中order_no varchar(64) NOT NULL。utf8mb4下order_no单个字段的key_len = 64*4 + 2 = 258。如果查询使用了order_no和status两个等值条件,key_len = 258 + 1 = 259。
如果order_no字段允许为NULL,则key_len = 258 + 1 = 259,这是单个order_no的长度。使用两列则再加status的1字节,变成260。有一个简便的记忆方法:每多一个“可为NULL的索引列”,最终key_len就会多1字节;每多一个varchar列,就多2字节变长开销。
4.3 字符集对key_len的影响
字符集是计算中很容易被忽略的坑。同一个字段,在latin1、gbk、utf8mb4下的key_len差距很大。因为utf8mb4每个字符最多4字节,一个varchar(64)在latin1下才64+2=66字节,在utf8mb4下直接变为258字节。
如果你接手的老系统里字段用latin1,查询条件里传来的中文还能匹配上,是因为连接层做了字符集转换。但索引过滤时,MySQL既要保证转换后还能走索引,又要处理不同字符集下的字符串比较,优化器有时候会放弃使用索引——这个坑排查起来非常隐蔽。
我在公司优化过一个线上慢查询,表字符集是utf8mb4,但某个关联字段在另一张表里是latin1,两张表JOIN时执行计划直接显示全表扫描。两边改完字符集后,JOIN秒回。排查这种问题的思路就是:先检查参与JOIN、WHERE过滤的字段,两边的字符集和排序规则是否一致。绝大部分情况下,保持全库统一用utf8mb4是省心且安全的选择。
4.4 key_len的参考价值与边界
key_len的约束力和范围感很重要:它可以精确告诉我们联合索引的“使用程度”,但无法告诉我们“查询条件一共有几个”。比如WHERE a = 1 AND b = 2和WHERE a = 1,如果索引是(a, b),两次执行的key_len可能相同,都是a的长度,因为优化器只需要对a做定值比较就能完成查询的主要过滤,b有没有做条件过滤要看Extra是否出现Using where。
另一个边界是:key_len计算的是“MySQL真正拿出来做索引查找的字节长度”,而不是索引叶子节点里存储的全部列。对于覆盖索引的情况,Extra里会出现Using index,但key_len不会把所有覆盖到的列都算进去,它只算参与“定位”的列。
所以看key_len时,脑子里的正确问题是:这条执行计划里,联合索引的前几列被MySQL用于精确查找或范围定位了?而不是:这个索引的叶子节点里存了多少列?
5. Extra列的隐藏信号:filesort、temporary、index condition都是什么意思
Extra列是explain输出里最多人忽略、却也最常出卖SQL性能问题的地方。很多SQL从type上看挑不出毛病——const、ref都齐了——但跑起来还是慢,问题往往就出在Extra里写的那几个英文单词上。
5.1 Using filesort:排序没走索引,临时来一趟
filesort这名字很有误导性,它并不是在磁盘上做排序,而是指“MySQL需要额外执行一次排序操作”,排序可能在内存中完成,也可能使用磁盘临时文件。它通常在ORDER BY、GROUP BY、DISTINCT操作中触发。
为什么排序要尽量走索引?因为InnoDB的B+树索引本身是有序的。如果查询的WHERE条件和ORDER BY字段正好能组成一个联合索引,MySQL就可以沿着索引顺序直接读取,读完即有序,完全不需要额外排序。
举个例子,订单表按创建时间倒序分页是一个非常典型的业务需求:
sql复制EXPLAIN SELECT id, order_no, amount
FROM t_order
WHERE user_id = 1001
ORDER BY create_time DESC
LIMIT 10;
如果只有idx_user_id(user_id)这个索引,执行计划会是:先用user_id找到该用户的全部订单(假设2000行),回表后对这2000行做一次filesort,再取前10条。如果数据量大,这额外的排序开销就会非常明显。
正确做法是建联合索引(user_id, create_time)。这样B+树里同一个user_id下的数据天然按create_time有序,查询时MySQL用user_id定位到第一条,直接顺序向后读10行即可,filesort消失,查询可能会快一个数量级。这是ORDER BY优化的核心思想:WHERE等值条件字段放在联合索引前面,ORDER BY字段紧跟其后。
还有一个分页深坑:LIMIT 100000, 10这种深分页。即使走索引,MySQL也要先读到第100010行才开始返回,前面100000行都要被扫过。优化思路一般是用“上一页的最大ID”做条件,或者用子查询先拿到ID列表再JOIN原表。filesort配合深分页,是慢查询的重灾区,两者叠加时几乎必慢。
5.2 Using temporary:临时表一出现,性能就可能失控
Using temporary表示MySQL为了解决查询创建了临时表,常见于GROUP BY、DISTINCT、ORDER BY的混合场景以及UNION。临时表有时候在内存(MEMORY或TempTable引擎),数据量大时会被自动落盘,一旦落盘,性能急剧下降。
举一个我在实际业务里优化过的例子。需要统计每个用户的状态分布:
sql复制EXPLAIN SELECT user_id, status, COUNT(*)
FROM t_order
GROUP BY user_id, status;
在没有合适联合索引的情况下,这条SQL需要先扫描全表,把结果放到临时表里做分组聚合。Extra里大概率会出现Using temporary; Using filesort。优化方式很简单——如果这张表的写入量没那么大,直接建一个(user_id, status)联合索引,让GROUP BY可以按照索引顺序聚合,MySQL就能避免临时表。
在5.7和8.0版本里,GROUP BY明显携带了排序需求。如果不需要排序,可以在GROUP BY后面加ORDER BY NULL(8.0里可以省略,默认不再自动排序),这也是一个常被忽略的优化小技巧。
5.3 Using index condition:索引条件下推,5.6之后的大福利
Using index condition这个值对应的是MySQL 5.6引入的索引条件下推优化。理解它之前要先想想一条范围查询的流程:
比如有联合索引(status, create_time),执行:
sql复制EXPLAIN SELECT * FROM t_order
WHERE status = 1
AND create_time >= '2024-01-01'
AND create_time < '2024-06-01';
在没有索引条件下推的年代,MySQL会利用索引找到所有status=1的记录,然后逐条回表,把完整行取出来之后再去过滤create_time。这意味着大量create_time不符合条件的行也白白回了一次表。
有了索引条件下推之后,存储引擎层会在读索引的时候就判断create_time的范围条件,只对范围之内的记录回表。Extra里出现Using index condition,说明查询在用这种“推下去一些过滤条件”的方式执行。它对多条件联合索引的查询帮助很大,减少回表次数就是减少随机IO,性能提升肉眼可见。
5.4 Using index:覆盖索引是白嫖级别的优化
Extra里最让人安心的就是Using index,表示当前查询所需的数据列全部包含在索引里,不需要回表。这种状态叫覆盖索引。
举个例子,上面那张表有索引idx_user_status(user_id, status),执行:
sql复制EXPLAIN SELECT user_id, status FROM t_order
WHERE user_id BETWEEN 1001 AND 2000;
索引里正好包含user_id和status两个字段,MySQL直接从索引叶子节点上拿数就行,回表这一步被彻底省掉。对于高频、简单的查询,设计一个正好覆盖查询列的联合索引,效果立竿见影。
但覆盖索引并不是越多越好——索引本身也是存储空间,而且每次写入都需要维护多个索引树。核心思路是:给最热的几条查询路径设计覆盖索引,而不是给所有查询都建索引。
5.5 看一眼就能判断的坏味道
我把这些年看执行计划的“坏味道”总结成一套快速判断标准:
- Extra出现Using filesort:排序没走索引。优先考虑联合索引调整。
- Extra出现Using temporary:聚合、去重或UNION走了临时表。优先检查GROUP BY/DISTINCT字段有没有组合索引。
- Extra出现Using join buffer:JOIN时被驱动表没走索引,关联字段大概率缺索引。
- Extra既无Using index也无Using index condition,同时type是ref或range:查询大概率发生了回表。如果单次查询返回行数少,影响不大;如果返回行数多,就要考虑覆盖索引优化。
- key为NULL且type是ALL:索引彻底没被用上,去检查WHERE条件字段是否违反索引设计规则。
看懂Extra,才算是真正看懂了explain。type告诉你“怎么访问表”,key_len告诉你“索引用到哪”,Extra则告诉你“除了访问表之外MySQL还做了什么额外的事”。这三者结合,SQL的所有性能隐患都藏不住了。
6. 索引最佳实践:怎么设计索引才能少踩坑
explain是诊断工具,索引设计是治疗方案。看懂执行计划之后,你得知道“建什么样的索引才是对的”。下面这些是我在实际项目中验证过、也在面试中反复问过的索引实践要点,每一条背后几乎都有踩坑案例。
6.1 联合索引,字段顺序决定生死
联合索引有一个核心原则叫“最左前缀原则”:MySQL会从联合索引的最左边开始,连续匹配查询条件中的列,直到遇到范围查询(>、<、BETWEEN)或索引列上出现函数、隐式转换等情况为止。
假设有联合索引(a, b, c),能用到索引的查询条件有:
WHERE a = 1WHERE a = 1 AND b = 2WHERE a = 1 AND b = 2 AND c = 3WHERE a = 1 AND c = 3(注意:这里b缺了,只能用到a这一列,c没法利用索引过滤,走的是回表后的二次过滤)
用不到索引的查询:
WHERE b = 2WHERE c = 3WHERE b = 2 AND c = 3
这是初学者最容易踩的坑:明明建了联合索引,却把最左的字段跳过了,MySQL只能全表扫描。优化器不会聪明到“从中间开始用索引”,它只能老老实实按最左前缀匹配。
如果你发现业务上经常单独按b字段查询,正确方案往往是再建一个(b, ...)单列索引,而不是指望(a,b,c)来满足所有场景。
6.2 等值条件放前面,范围条件放后面
这是一个经常被忽略但能立竿见影的规则。当查询里同时存在等值条件和范围条件时,联合索引应该把等值条件字段放前面,范围条件字段放后面。
为什么?因为一旦遇到范围条件,范围列之后的索引列就没法用于精确定位了。比如索引(a, b),执行WHERE a > 1 AND b = 2,只有a列能用到索引定位,b只能在回表后过滤;但如果是(b, a)组合,WHERE a > 1 AND b = 2时b先等值定位,a在索引内做范围扫描,效率会好很多。
举个例子,电商订单查询里最常出现的就是“按用户查某段时间的订单”,索引顺序应该是(user_id, create_time),不是(create_time, user_id)。因为user_id通常是等值条件,create_time是范围条件。
6.3 覆盖索引:查询列尽量锁进索引里
如果查询只需要索引里的几列,就可以避免回表,这就是覆盖索引的威力。设计时需要考虑:高频查询的SELECT字段,能不能全部放进某个联合索引里?
但要注意“度”的问题。索引不是越多越好:每多一个索引,INSERT、UPDATE、DELETE时的维护成本都会增加,存储空间也会上升。一般建议单表索引数量控制在5个以内,字段过多的联合索引(超过4~5个字段)要仔细权衡。
实际操作中,我会优先为那几个最频繁、对延迟敏感的查询路径设计覆盖索引,而不是试图用一个超级联合索引去“覆盖”所有查询。超级索引不但笨重,而且可能因为字段太多导致索引树变大,扫描效率反而下降。
6.4 索引失效的六大杀手
即使建了正确的索引,如果SQL写法出了问题,索引同样会失效。下面是我在工作中反复遇到的六种情况:
-
对索引列使用函数。
WHERE DATE(create_time) = '2024-01-01'和WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02',前者让create_time的索引完全失效,后者能充分利用索引范围扫描。 -
隐式类型转换。
WHERE user_id = '1001',如果user_id是BIGINT,MySQL会尝试把字符串转成数字,这种转换可能导致索引失效。反过来,字符串字段和数字比较也有问题。最稳妥的方式是:应用层传入的参数类型和字段类型保持一致。 -
前导模糊查询。
LIKE '%abc'走不了索引,LIKE 'abc%'可以走range扫描。这个规则的根源还是B+树有序性——只有前缀确定才能利用二分查找定位到起始位置。 -
索引列参与计算。
WHERE amount + 1 > 100这种写法会让MySQL无法使用amount上的索引。把计算放到等号右侧通常可以解决。 -
OR条件中某一边不走索引。
WHERE user_id = 1001 OR status = 1,如果只有user_id上有索引,status上没有,MySQL就没法用索引优化这条OR查询。改成UNION或者给status加索引都可以解决。 -
优化器判断全表扫描更快。即使索引可用,如果返回行数超过表的20%~30%,优化器往往会主动选择全表扫描,因为随机IO比顺序IO贵得多。这个不是“索引失效”,而是优化器的合理决策。遇到这种情况不要硬扛,应该从业务层面减少查询范围。
6.5 排序字段与索引方向
虽然InnoDB支持反向索引扫描(8.0的降序索引是真正在物理上按降序存储,早期版本是反向扫描),但ORDER BY与索引顺序完全匹配时性能天然最好。比如索引是(user_id, create_time),查询是WHERE user_id = 1001 ORDER BY create_time DESC,方向一致可以顺序读,无需filesort。但如果是ORDER BY create_time ASC, id DESC这种混合方向,就可能要filesort。
日常开发中,我会尽量保持业务排序字段和索引顺序一致,能避免filesort就避免filesort。不要小看排序的代价——在几百万行结果集上做一次filesort,耗时可能比查询本身还大。
7. 真实慢查询优化复盘:从explain到索引落地的完整链路
前面把原理讲了不少,最后用一个真实场景把整个分析流程串一遍。这个案例来自我之前优化过的一个订单查询页面,简化后依然非常典型。
7.1 业务背景与慢SQL
业务上有一个“商家订单管理”页面,支持商家查看自己店铺的订单列表,默认按下单时间倒序,分页显示。一开始的SQL长这样:
sql复制SELECT id, order_no, user_id, amount, status, create_time
FROM t_order
WHERE shop_id = 5012
ORDER BY create_time DESC
LIMIT 0, 20;
t_order表当时数据量约600万行,shop_id上有普通索引。页面接口在压测时报出平均3秒的响应时间,DBA侧慢查询日志里这条SQL频繁出现。
explain的结果如下:
| type | possible_keys | key | key_len | rows | Extra |
|---|---|---|---|---|---|
| ref | idx_shop_id | idx_shop_id | 8 | 8690 | Using filesort |
乍一看,type是ref,走了shop_id索引,key也正常。但Extra里有明显的Using filesort,rows估算有8690行。问题是:ORDER BY create_time需要额外排序。即使只取20条,MySQL也得先把该店铺的8690条订单全部查出来排序,再取前20条。
如果这家店订单量持续增长,rows会越变越大,查询只会越来越慢,性能迟早会崩。
7.2 优化过程
一开始我尝试加了单列索引create_time,但explain显示优化器选择了shop_id过滤,然后依然filesort,因为MySQL很难同时利用两个单列索引来避免排序——它选了一个过滤性最好的,另一个就没法兼顾了。真正有效的方案是把排序字段直接组合进联合索引里:
sql复制ALTER TABLE t_order ADD INDEX idx_shop_create (shop_id, create_time);
再加一条SQL:
sql复制SELECT id, order_no, user_id, amount, status, create_time
FROM t_order
WHERE shop_id = 5012
ORDER BY create_time DESC
LIMIT 0, 20;
这个联合索引的结构是:同一个shop_id下,create_time已经在B+树里按顺序排列。MySQL通过shop_id定位到5012对应的索引区间后,create_time天然有序,从后往前(因为DESC)顺序读取20行就完事了。执行计划如下:
| type | possible_keys | key | key_len | rows | Extra |
|---|---|---|---|---|---|
| ref | idx_shop_id,idx_shop_create | idx_shop_create | 8 | 20 | NULL |
Extra里filesort消失了,rows从8690降到了20。压测结果平均响应时间从3秒降到30毫秒左右,提升接近100倍。这就是索引设计里“WHERE等值条件字段放前面,ORDER BY字段放后面”最直接的验证。
7.3 进阶优化:覆盖索引 + 延迟关联
上面的优化已经解决了主要问题,但如果这个查询对性能有更高要求,还能继续往下压。注意SELECT的字段里有order_no、amount、status、create_time这些非索引列,即使联合索引(shop_id, create_time)生效,MySQL依然要回表拿数据。每页20行还好,但分页越深,回表代价越高。
如果查询列表只展示部分核心信息,可以把高频展示字段直接放进索引里,形成覆盖索引:
sql复制ALTER TABLE t_order ADD INDEX idx_shop_create_cover (shop_id, create_time, order_no, amount, status);
这样SELECT语句查询的字段全部在索引里,Extra会显示Using index,回表操作直接消失。但注意:字段越多,索引越大,写入维护成本越高。实际项目中我会评估这个页面是不是真的高频高并发,避免为了一个非核心页面而撑大索引。
大量深分页场景下还有一种通用优化思路叫延迟关联:先用覆盖索引查出当前页的ID,再回表拿完整数据:
sql复制SELECT t.id, t.order_no, t.user_id, t.amount, t.status, t.create_time
FROM t_order t
INNER JOIN (
SELECT id
FROM t_order
WHERE shop_id = 5012
ORDER BY create_time DESC
LIMIT 100000, 20
) tmp ON t.id = tmp.id;
里面的子查询可以直接用覆盖索引完成,只取ID列表,避免对前100000行回表;外层再按主键关联获取完整数据,回表次数被压到只有20次。这个技巧在处理百万级分页需求时几乎是必备的。
7.4 验证索引效果的正确姿势
优化完之后不能只看接口变快就完事,我一般会做三层验证:第一,explain的type、key、rows、Extra是否符合预期,对比优化前后的执行计划变化并解释原因;第二,在测试库跑一遍真实业务数据量级(至少百万行),统计实际耗时;第三,观察线上慢查询日志,确认这条SQL不再上榜。三层都过了,这个优化才算真正落地。
我见过不少人在测试库几百行数据上觉得SQL很快,到线上被打回原形的案例——根因就是没看执行计划,没发现其实走了ALL或filesort。explain最大的价值是在数据量还没大到拖垮服务之前,提前暴露SQL的执行风险。
8. 需要单独提醒的几个暗坑经验
explain和索引实践本身不复杂,但实际业务中会有很多“看起来走了索引,其实走了个寂寞”的情况。最后分享几个我踩过或排查过比较多的暗坑。
第一个坑是范围条件和IN列表导致的“假使用”。联合索引(a, b, c)执行WHERE a = 1 AND b IN (2,3,4) AND c = 5时,key_len往往只到b这一列,c的等值条件不会参与索引定位。IN列表本质上是一个多个等值的集合,优化器把它当成范围来处理,范围之后的索引列就断了。如果IN里的值是有限的几个,可以考虑改写为多个OR等值条件的组合,或者接受这种现状、通过Extra里的Using index condition减少回表损失。
第二个坑是NULL值相关的索引判断。SQL里写WHERE status IS NULL时,如果status上有索引,type可能是ref,但优化器对NULL值的区分度统计往往不准。此外,在允许NULL的列上建索引,会让索引体积变大,每个条目都要额外标记NULL位。设计表时把业务上不可能为空的字段都设为NOT NULL,不只是在节省一个字节,更是为了给索引创造更紧凑的存储结构。
第三个坑是字符集不一致导致的JOIN慢查询。这是跨部门联调时常见的问题:A表是utf8mb4,B表是utf8mb3或是latin1,执行JOIN时MySQL大概率无法使用B表索引,执行计划直接ALL。排查这类问题时,先看两边关联字段的字符集是否一致,再往更深层的排序规则(collation)检查。统一库表字符集是成本最低也最值得做的规范之一。
第四个坑是统计信息不准导致优化器选错执行计划。InnoDB的索引区分度统计基于采样,有时候数据分布变化后不会自动更新。一个典型的症状:某条SQL昨天走索引毫秒级返回,今天突然全表扫描,几秒才出结果。这时候先执行ANALYZE TABLE t_order刷新统计信息,再看执行计划是否恢复。如果仍然选错,可以使用FORCE INDEX临时验证,但最终方案应该是调整SQL或索引设计,而不是长期依赖FORCE INDEX。
第五个坑是ORDER BY和LIMIT组合的假象。如果explain里type是range或ref、Extra里也没用filesort,但响应时间还是不够理想,很可能是深分页带来的回表开销。记住一个经验:LIMIT偏移量超过十万时,无论索引怎么建,都有必要考虑改成“延迟关联”或“基于游标”的方式,否则迟早性能失控。
最后分享一点个人体会
我在实际排查中有一个习惯:拿到一条慢SQL,第一件事不是打开编辑器改SQL,而是先跑一遍explain,把执行计划完整读一遍,把type、key_len、rows、Extra这几个关键列写在一张草稿纸上,再去业务代码里确认WHERE条件的来源字段。很多时候,SQL本身没问题,问题出在传给SQL的参数类型和索引字段对不上,或者ORM框架生成的SQL和预想的不一样。
explain这个命令对于MySQL性能优化来说,是性价比最高的入门工具,但它的上限也很高——需要你真正理解B+树、理解优化器的决策逻辑、理解InnoDB的存储结构。把这个基础打牢之后,再看网上那些所谓的“SQL优化技巧”,你一眼就能判断哪些是真有用,哪些是玄学。
如果在看执行计划时遇到拿不准的字段组合,我的建议是把SQL原样在测试库上反复explain,同时配合SHOW WARNINGS看看优化器改写后的SQL是什么样。大多数疑惑,多跑几次命令就能自己解开。
