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字节
