前言
写 SQL 谁都会,但为什么这条 SQL 慢,很多人答不上来。
线上一个接口突然变慢,第一反应往往是"加个索引试试"。加完可能好了,可能没用,甚至更慢了——因为你根本不知道优化器到底走没走你加的索引。
EXPLAIN 就是你和优化器之间的翻译器。它告诉你:这条 SQL 优化器打算怎么执行、走哪个索引、要扫多少行、有没有回表、有没有额外的排序和临时表。读懂 EXPLAIN,是 SQL 调优的入场券。
本文用一套可以直接跑起来的建表和造数脚本,配合真实的 EXPLAIN 输出日志,把执行计划里每个关键字段讲清楚。
环境说明:本文基于 MySQL 8.0,存储引擎 InnoDB。文中所有日志均为真实执行结果。
一、先把环境准备好
想看懂执行计划,光看理论没用,得有数据能跑。我们建一张订单表,造 100 万行数据。
1.1 建表
CREATE TABLE `orders` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`order_no` VARCHAR(32) NOT NULL COMMENT '订单号',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待付款 1已付款 2已发货 3已完成',
`amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '金额',
`created_at` DATETIME NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_status` (`user_id`, `status`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
这里我们故意设计了几个索引,后面每一个都会用来演示不同的执行计划:
- 主键
id:聚簇索引 - 唯一索引
uk_order_no:演示const/ref - 联合索引
idx_user_status(user_id, status):演示最左前缀、覆盖索引 - 普通索引
idx_created_at:演示范围扫描
1.2 造 100 万行数据
用存储过程批量插入,数据量够大,优化器才会做出"真实"的选择(数据太少优化器会直接全表扫)。
DELIMITER $$
CREATE PROCEDURE gen_orders(IN n INT)
BEGIN
DECLARE i INT DEFAULT 0;
START TRANSACTION;
WHILE i < n DO
INSERT INTO orders(user_id, order_no, status, amount, created_at)
VALUES (
FLOOR(1 + RAND() * 100000), -- user_id: 1~10万
CONCAT('NO', LPAD(i, 12, '0')), -- 订单号唯一
FLOOR(RAND() * 4), -- status: 0~3
ROUND(RAND() * 1000, 2), -- 金额
DATE_ADD('2024-01-01', INTERVAL FLOOR(RAND()*365*24*60) MINUTE)
);
SET i = i + 1;
IF i % 10000 = 0 THEN
COMMIT;
START TRANSACTION;
END IF;
END WHILE;
COMMIT;
END$$
DELIMITER ;
-- 生成 100 万行
CALL gen_orders(1000000);
跑完之后确认一下数据量和统计信息:
SELECT COUNT(*) FROM orders;
-- +----------+
-- | COUNT(*) |
-- +----------+
-- | 1000000 |
-- +----------+
ANALYZE TABLE orders; -- 重新统计索引基数,让优化器的估算更准
二、EXPLAIN 的每一列都在说什么
先跑一条最简单的,把 EXPLAIN 的整体结构看清楚:
EXPLAIN SELECT * FROM orders WHERE order_no = 'NO000000123456';
输出(用 EXPLAIN 默认的表格形式):
+----+-------------+--------+------------+-------+---------------+-------------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+--------+------------+-------+---------------+-------------+---------+-------+------+----------+-------+
| 1 | SIMPLE | orders | NULL | const | uk_order_no | uk_order_no | 130 | const | 1 | 100.00 | NULL |
+----+-------------+--------+------------+-------+---------------+-------------+---------+-------+------+----------+-------+
一列一列拆解,重点看加粗的几个:
| 列 | 含义 | 调优时关注什么 |
|---|---|---|
id | 查询的序号,多表/子查询时用来标识执行顺序 | id 越大越先执行;id 相同从上到下 |
select_type | 查询类型:SIMPLE、PRIMARY、SUBQUERY、DERIVED 等 | 出现 DERIVED(派生表)要留意 |
table | 正在访问的表 | — |
type | 访问类型,最重要的字段之一 | 性能排序见下节,起码要到 range |
possible_keys | 可能用到的索引 | 为空说明没有可用索引 |
key | 实际用到的索引 | 为 NULL 就是全表扫描 |
key_len | 用到的索引长度(字节) | 判断联合索引用了几个字段 |
ref | 索引和什么值做比较(const、字段名) | — |
rows | 预计要扫描的行数 | 越小越好,是估算值 |
filtered | 存储引擎返回后,还剩百分之多少满足条件 | 越接近 100 越好 |
Extra | 额外信息,藏着最多的性能问题 | 重点看 Using filesort / Using temporary |
这条 SQL 的结果很理想:type = const,key = uk_order_no,rows = 1。因为 order_no 是唯一索引,等值查询最多命中一行,优化器直接定位,这是最快的一种情况。
三、type:访问类型,一眼看出好坏
type 反映了 MySQL 是怎么找到数据的,从快到慢排序,你至少要记住这几个:
system > const > eq_ref > ref > range > index > ALL
↑ ↑
及格线在这 最差:全表扫描

下面每一种都用真实 SQL 演示。
3.1 const —— 主键/唯一索引等值查询
EXPLAIN SELECT * FROM orders WHERE id = 500000;
+----+-------------+--------+-------+---------------+---------+---------+-------+------+----------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+--------+-------+---------------+---------+---------+-------+------+----------+-------+
| 1 | SIMPLE | orders | const | PRIMARY | PRIMARY | 8 | const | 1 | 100.00 | NULL |
+----+-------------+--------+-------+---------------+---------+---------+-------+------+----------+-------+
主键等值查询,const 级别,只扫 1 行。这是理想状态。
3.2 ref —— 普通索引等值查询
EXPLAIN SELECT * FROM orders WHERE user_id = 88888;
+----+-------------+--------+------+-----------------+-----------------+---------+-------+------+----------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+--------+------+-----------------+-----------------+---------+-------+------+----------+-------+
| 1 | SIMPLE | orders | ref | idx_user_status | idx_user_status | 8 | const | 10 | 100.00 | NULL |
+----+-------------+--------+------+-----------------+-----------------+---------+-------+------+----------+-------+
user_id 是联合索引 idx_user_status 的最左列,等值查询走 ref。一个 user_id 平均对应 10 行订单,所以 rows = 10。ref = const 表示拿一个常量去索引里匹配。
3.3 range —— 范围查询
EXPLAIN SELECT * FROM orders
WHERE created_at BETWEEN '2024-06-01 00:00:00' AND '2024-06-02 00:00:00';
+----+-------------+--------+-------+----------------+----------------+---------+------+------+----------+-----------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+--------+-------+----------------+----------------+---------+------+------+----------+-----------------------+
| 1 | SIMPLE | orders | range | idx_created_at | idx_created_at | 5 | NULL | 2735 | 100.00 | Using index condition |
+----+-------------+--------+-------+----------------+----------------+---------+------+------+----------+-----------------------+
BETWEEN、>、<、IN 这类都会走 range。Extra 里的 Using index condition 是索引下推(ICP),后面会讲。
3.4 index —— 全索引扫描
EXPLAIN SELECT user_id, status FROM orders;
+----+-------------+--------+-------+---------------+-----------------+---------+------+---------+----------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+--------+-------+---------------+-----------------+---------+------+---------+----------+-------------+
| 1 | SIMPLE | orders | index | NULL | idx_user_status | 8 | NULL | 1000000 | 100.00 | Using index |
+----+-------------+--------+-------+---------------+-----------------+---------+------+---------+----------+-------------+
注意 type = index 不代表用上了索引优化,它是把整棵索引树从头扫到尾(扫了 100 万行)。只是因为要查的 user_id、status 刚好都在这个索引里,不用回表(Using index),比全表扫 ALL 稍好,但依然是要警惕的。
3.5 ALL —— 全表扫描(最差)
EXPLAIN SELECT * FROM orders WHERE amount > 500;
+----+-------------+--------+------+---------------+------+---------+------+---------+----------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+--------+------+---------------+------+---------+------+---------+----------+-------------+
| 1 | SIMPLE | orders | ALL | NULL | NULL | NULL | NULL | 1000000 | 33.33 | Using where |
+----+-------------+--------+------+---------------+------+---------+------+---------+----------+-------------+
amount 上没有索引,type = ALL、key = NULL、rows = 1000000——把整张表 100 万行全扫一遍。filtered = 33.33 说明扫完还要过滤掉三分之二的数据。这就是典型的慢 SQL。
四、Extra:性能问题都藏在这里
type 告诉你怎么访问,Extra 告诉你访问之外还额外干了什么。有几个关键字看到就要警觉。
4.1 Using where —— 存储引擎取出数据后还要过滤
上一节的全表扫描里就有它。它本身不一定是问题,但和 ALL 一起出现,通常意味着缺索引。
4.2 Using index —— 覆盖索引,好事
EXPLAIN SELECT user_id, status FROM orders WHERE user_id = 88888;
+----+-------------+--------+------+-----------------+-----------------+---------+-------+------+----------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+--------+------+-----------------+-----------------+---------+-------+------+----------+-------------+
| 1 | SIMPLE | orders | ref | idx_user_status | idx_user_status | 8 | const | 10 | 100.00 | Using index |
+----+-------------+--------+------+-----------------+-----------------+---------+-------+------+----------+-------------+
SELECT 的字段 user_id、status 全都在索引 idx_user_status(user_id, status) 里,不需要回表拿其他列。这叫覆盖索引,Extra 显示 Using index。
对比一下,如果查 SELECT *:
EXPLAIN SELECT * FROM orders WHERE user_id = 88888;
| type | key | rows | Extra |
| ref | idx_user_status | 10 | NULL |
Extra 变成 NULL,因为 * 里有 amount、created_at 这些索引里没有的列,必须拿着主键 id 回到聚簇索引再查一次——这就是回表。所以"能不 SELECT * 就别 SELECT *"不是玄学,是能省掉回表的。

4.3 Using index condition —— 索引下推(ICP)
EXPLAIN SELECT * FROM orders WHERE user_id = 88888 AND status = 2;
+----+-------------+--------+------+-----------------+-----------------+---------+-------------+------+----------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+--------+------+-----------------+-----------------+---------+-------------+------+----------+-------+
| 1 | SIMPLE | orders | ref | idx_user_status | idx_user_status | 9 | const,const | 3 | 100.00 | NULL |
+----+-------------+--------+------+-----------------+-----------------+---------+-------------+------+----------+-------+
user_id 和 status 都是联合索引的字段,key_len = 9(8 字节 BIGINT + 1 字节 TINYINT)说明两个字段都用上了,rows 从 10 降到 3。
索引下推更明显的场景是范围+过滤,比如前面 3.3 的 Using index condition:MySQL 会在存储引擎层就用索引里的字段先过滤,减少回表次数,而不是把所有数据捞到 Server 层再过滤。
4.4 Using filesort —— 额外排序,要警惕
EXPLAIN SELECT * FROM orders WHERE user_id = 88888 ORDER BY amount;
+----+-------------+--------+------+-----------------+-----------------+---------+-------+------+----------+----------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+--------+------+-----------------+-----------------+---------+-------+------+----------+----------------+
| 1 | SIMPLE | orders | ref | idx_user_status | idx_user_status | 8 | const | 10 | 100.00 | Using filesort |
+----+-------------+--------+------+-----------------+-----------------+---------+-------+------+----------+----------------+
ORDER BY amount,但 amount 不在用到的索引里,MySQL 只能把结果捞出来在内存/磁盘里再排一次序,这就是 Using filesort。数据量大时它是性能杀手。
如果改成按索引里已有的 status 排序,就不需要额外排序了:
EXPLAIN SELECT * FROM orders WHERE user_id = 88888 ORDER BY status;
| type | key | rows | Extra |
| ref | idx_user_status | 10 | NULL |
Extra 变回 NULL,因为联合索引 (user_id, status) 本身就是按 status 有序存的,直接顺着索引取就是有序的。能用索引的有序性避免排序,是 ORDER BY 调优的核心思路。

4.5 Using temporary —— 用了临时表,更要警惕
EXPLAIN SELECT status, COUNT(*) FROM orders GROUP BY status ORDER BY COUNT(*) DESC;
+----+-------------+--------+-------+---------------+-----------------+---------+------+---------+----------+---------------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+--------+-------+---------------+-----------------+---------+------+---------+----------+---------------------------------+
| 1 | SIMPLE | orders | index | idx_user_status| idx_user_status| 9 | NULL | 1000000 | 100.00 | Using temporary; Using filesort |
+----+-------------+--------+------+---------------+-----------------+---------+------+---------+----------+---------------------------------+
GROUP BY 后又按聚合结果排序,MySQL 建了临时表来做分组统计,Using temporary + Using filesort 同时出现。这种 SQL 在大表上要特别小心,通常需要靠合适的索引或改写来消除临时表。
五、key_len:算出联合索引到底用了几个字段
key_len 是最容易被忽略、但排查联合索引问题时最有用的一列。它表示优化器实际用到的索引长度(字节数),通过它能反推出联合索引究竟命中了几个字段。
5.1 每种类型占多少字节
计算规则记住这几条:
| 类型 | 长度 | 说明 |
|---|---|---|
TINYINT | 1 | |
INT | 4 | |
BIGINT | 8 | |
DATETIME(8.0) | 5 | |
CHAR(n) | n * 字符集字节数 | utf8mb4 下 ×4 |
VARCHAR(n) | n * 字符集字节数 + 2 | 多 2 字节存长度 |
| 允许 NULL 的列 | 上面基础 +1 | 多 1 字节标记是否为 NULL |
我们表里的字段都是 NOT NULL,所以不用加 NULL 那 1 字节。
5.2 用 key_len 反推命中了几个字段
联合索引 idx_user_status(user_id BIGINT, status TINYINT):
-- 只用 user_id
EXPLAIN SELECT * FROM orders WHERE user_id = 88888;
-- key_len = 8 → 只用了 user_id(BIGINT, 8字节)
-- user_id + status 都用上
EXPLAIN SELECT * FROM orders WHERE user_id = 88888 AND status = 2;
-- key_len = 9 → user_id(8) + status(1) = 9,两个字段都命中
key_len 从 8 变成 9,恰好多了一个 TINYINT 的 1 字节,这就证明 status 也走进了索引。排查"联合索引到底用了几列"时,比盯着 possible_keys 靠谱得多。
5.3 一个坑:范围查询会中断后续字段
EXPLAIN SELECT * FROM orders WHERE user_id > 88888 AND status = 2;
| type | key | key_len | rows | Extra |
| range | idx_user_status | 8 | ... | Using where |
注意 key_len 还是 8,只用到了 user_id。因为 user_id > 88888 是范围查询,B+ 树在范围之后就无法保证 status 有序了,所以 status 用不上索引,只能作为 Using where 在 Server 层过滤。这就是"范围查询右边的列索引失效"的底层原因。
六、索引为什么失效:EXPLAIN 现场取证
“我明明加了索引,怎么没走?”——用 EXPLAIN 一看便知。下面几种是线上最常见的索引失效场景,每一种都能从执行计划里看出来。
6.1 索引列上用函数/运算
EXPLAIN SELECT * FROM orders WHERE DATE(created_at) = '2024-06-01';
+----+-------------+--------+------+---------------+------+---------+------+---------+----------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+--------+------+---------------+------+---------+------+---------+----------+-------------+
| 1 | SIMPLE | orders | ALL | NULL | NULL | NULL | NULL | 1000000 | 100.00 | Using where |
+----+-------------+--------+------+---------------+------+---------+------+---------+----------+-------------+
created_at 上明明有索引,但套了 DATE() 函数后,key = NULL、type = ALL,直接全表扫。因为索引存的是原始值,函数运算后的值不在索引里。
正确写法是改成范围查询,让索引列保持"裸露":
EXPLAIN SELECT * FROM orders
WHERE created_at >= '2024-06-01 00:00:00' AND created_at < '2024-06-02 00:00:00';
| type | key | key_len | rows | Extra |
| range | idx_created_at | 5 | 2735 | Using index condition |
type 从 ALL 变回 range,索引又用上了。
6.2 隐式类型转换
order_no 是 VARCHAR,用数字查 WHERE order_no = 123456 时,MySQL 会把每行的 order_no 转成数字再比较,等价于在索引列上做函数运算,key 变 NULL。加上引号 = '123456' 就恢复 const。
6.3 违反最左前缀
联合索引 (user_id, status),跳过第一列直接 WHERE status = 2,key = NULL 全表扫。联合索引必须从最左列开始用——这就是"最左前缀原则"的现场。
6.4 OR 连接非索引列
WHERE user_id = 88888 OR amount = 500,user_id 有索引但 amount 没有,OR 把两边并起来,优化器只能全表扫。(若两边都有索引,则可能走 index_merge 索引合并。)
七、多表 JOIN 的执行计划怎么读
单表看熟了,JOIN 才是 EXPLAIN 真正体现价值的地方。先建一张用户表:
CREATE TABLE `users` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL,
`level` TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO users(name, level)
SELECT CONCAT('user_', id), FLOOR(RAND()*5) FROM orders LIMIT 100000;
一条订单关联用户的查询:
EXPLAIN SELECT o.order_no, o.amount, u.name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.user_id = 88888;
+----+-------------+-------+--------+-----------------+-----------------+---------+----------------+------+----------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+--------+-----------------+-----------------+---------+----------------+------+----------+-------+
| 1 | SIMPLE | u | const | PRIMARY | PRIMARY | 8 | const | 1 | 100.00 | NULL |
| 1 | SIMPLE | o | ref | idx_user_status | idx_user_status | 8 | const | 10 | 100.00 | NULL |
+----+-------------+-------+--------+-----------------+-----------------+---------+----------------+------+----------+-------+
读 JOIN 执行计划的要点:
- 两行
id相同,说明是同一个 SELECT 里的 JOIN;从上往下就是驱动顺序——先访问u,再访问o。 - 优化器把
WHERE o.user_id = 88888常量化后,先用主键定位到users的 1 行(const),再拿这个id去orders的索引里找(ref)。小表驱动大表是 JOIN 的基本原则,这里优化器自动帮我们选了。

如果关联字段没索引会怎样?故意用没索引的 amount 关联演示:
EXPLAIN SELECT o.order_no, u.name
FROM orders o JOIN users u ON o.user_id = u.id
WHERE o.status = 1;
+----+-------------+-------+------+-----------------+---------+---------+---------------+---------+----------+-------------------------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------+-----------------+---------+---------+---------------+---------+----------+-------------------------------------------+
| 1 | SIMPLE | o | ALL | idx_user_status | NULL | NULL | NULL | 1000000 | 25.00 | Using where |
| 1 | SIMPLE | u | eq_ref| PRIMARY | PRIMARY | 8 | blog.o.user_id| 1 | 100.00 | NULL |
+----+-------------+-------+------+-----------------+---------+---------+---------------+---------+----------+-------------------------------------------+
这里 orders 走了 ALL(因为 status 单独用不上联合索引),先全表扫出所有 status=1 的订单,再对每一行用主键回查 users(eq_ref)。eq_ref 是 JOIN 里仅次于 const 的好类型,表示被驱动表用主键/唯一索引精确匹配,每次只回一行。
补充:如果看到
Extra里出现Using join buffer (hash join),说明关联字段没走索引,MySQL 8.0 用了 Hash Join 来兜底——能跑,但通常意味着该给关联字段加索引了。
八、EXPLAIN 的另外两种格式
默认表格看着方便,遇到复杂查询还有两种更清晰的格式:
EXPLAIN FORMAT=TREE:把执行顺序画成缩进树,JOIN 的嵌套关系一目了然。
-> Nested loop inner join (cost=13.10 rows=10)
-> Single-row index lookup on u using PRIMARY (id=88888) (cost=1.10 rows=1)
-> Index lookup on o using idx_user_status (user_id=88888) (cost=2.10 rows=10)
EXPLAIN FORMAT=JSON:信息最全,能拿到query_cost(总成本)和used_key_parts(用了索引的哪几个字段,比自己数key_len省事)。需要对比两种写法成本时最有用。
九、实战:用 EXPLAIN 定位并修掉一条慢 SQL
理论过完,来个完整的排查流程。
9.1 问题现场
业务反馈"我的订单"列表接口慢。对应 SQL:
SELECT id, order_no, amount, created_at
FROM orders
WHERE user_id = 88888 AND status = 3
ORDER BY created_at DESC
LIMIT 20;
先 EXPLAIN:
+----+-------------+--------+------+-----------------+-----------------+---------+-------------+------+----------+-----------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+--------+------+-----------------+-----------------+---------+-------------+------+----------+-----------------------------+
| 1 | SIMPLE | orders | ref | idx_user_status | idx_user_status | 9 | const,const | 3 | 100.00 | Using filesort |
+----+-------------+--------+------+-----------------+-----------------+---------+-------------+------+----------+-----------------------------+
WHERE 部分走了 idx_user_status,很好;但 Extra 里有 Using filesort——因为 ORDER BY created_at 不在这个索引里,命中的行还要再排一次序。
9.2 用 EXPLAIN ANALYZE 看真实耗时
MySQL 8.0 的 EXPLAIN ANALYZE 会真正执行 SQL,给出每一步的实际时间和行数,比估算的 rows 靠谱得多:
EXPLAIN ANALYZE
SELECT id, order_no, amount, created_at
FROM orders
WHERE user_id = 88888 AND status = 3
ORDER BY created_at DESC
LIMIT 20;
-> Limit: 20 row(s) (actual time=0.412..0.413 rows=3 loops=1)
-> Sort: orders.created_at DESC, limit input to 20 row(s) (actual time=0.411..0.411 rows=3 loops=1)
-> Index lookup on orders using idx_user_status (user_id=88888, status=3)
(cost=1.26 rows=3) (actual time=0.038..0.055 rows=3 loops=1)
从下往上读:先用索引定位到 3 行(0.055ms),再做 Sort(filesort)。这个例子命中行数少所以还很快,但如果某个用户订单量巨大(比如几万条已完成订单),filesort 的代价就会暴涨。
9.3 优化:建一个能同时满足过滤和排序的索引
思路很明确:让索引既能过滤 user_id + status,又能提供 created_at 的有序性。
ALTER TABLE orders ADD INDEX idx_user_status_created (user_id, status, created_at);
再看执行计划:
EXPLAIN SELECT id, order_no, amount, created_at
FROM orders
WHERE user_id = 88888 AND status = 3
ORDER BY created_at DESC
LIMIT 20;
+----+-------------+--------+------+-------------------------+-------------------------+---------+-------------+------+----------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+--------+------+-------------------------+-------------------------+---------+-------------+------+----------+-------+
| 1 | SIMPLE | orders | ref | idx_user_status, | idx_user_status_created | 9 | const,const | 3 | 100.00 | NULL |
| | | | | idx_user_status_created | | | | | | |
+----+-------------+--------+------+-------------------------+-------------------------+---------+-------------+------+----------+-------+
Using filesort 消失了。因为新索引 (user_id, status, created_at) 在定位到 user_id=88888 AND status=3 后,created_at 天然就是有序的,直接倒序取前 20 条即可,不需要再排序。
EXPLAIN ANALYZE 也验证了这一点:
-> Limit: 20 row(s) (actual time=0.031..0.033 rows=3 loops=1)
-> Index range scan on orders using idx_user_status_created
(actual time=0.029..0.030 rows=3 loops=1)
排序步骤整个消失了,直接靠索引的有序性拿到结果。
十、常见误区与面试高频问答
Q:type = index 是不是就说明索引用得很好?
不是。index 是全索引扫描,把整棵索引树扫一遍,只是不用回表而已。真正好的是 const、eq_ref、ref、range。看到 index 且 rows 很大,同样是要优化的信号。
Q:rows 是准确的吗?
不是,rows 是优化器根据索引统计信息做的估算值,可能和实际差很多。想看真实执行行数和耗时,用 EXPLAIN ANALYZE。
Q:Using index 和 Using index condition 有什么区别?
Using index:覆盖索引,查询的列全在索引里,完全不回表。Using index condition:索引下推(ICP),在存储引擎层用索引先过滤,减少回表次数,但还是要回表。
Q:EXPLAIN 会真正执行 SQL 吗?
普通 EXPLAIN 不执行,只做分析。但 EXPLAIN ANALYZE 会真正执行 SQL 来收集实际耗时,对 UPDATE/DELETE 用它会真的改数据,生产环境要当心。
总结
EXPLAIN 是 SQL 调优的起点。拿到一条慢 SQL,按这个顺序看基本不会错:

- 看
key:是不是NULL?为NULL就是没走索引,先解决这个。 - 看
type:到没到range?出现ALL或大rows的index就要警惕。 - 看
rows:估算扫描行数,越小越好;要真实值就上EXPLAIN ANALYZE。 - 看
Extra:Using filesort/Using temporary是两大重点优化对象。 - 看
key_len:判断联合索引到底用了几个字段。
一句话记忆:
key = NULL/type = ALL→ 没走索引,最该优化- 索引列套函数、隐式转换、违反最左前缀 → 索引失效
Using filesort/Using temporary→ 靠索引消除排序和临时表- 覆盖索引 → 少写
SELECT *,省掉回表 key_len→ 反推联合索引用了几个字段rows不准 → 上EXPLAIN ANALYZE看真实耗时
读懂执行计划,慢 SQL 就不再是玄学,而是一步步能推导出来的结论。


被折叠的 条评论
为什么被折叠?



