MySQL 慢查询优化实战:从 EXPLAIN 到索引设计
环境:MySQL 8.4.7 | 测试数据 50 万行 | 所有 EXPLAIN 结果均来自本地实际运行
引言
线上订单列表越来越慢,用户一查"我的订单"就转圈 3 秒。
打开慢查询日志一看,这条 SQL 跑了 2.8 秒:
sql
SELECT * FROM orders
WHERE user_id = 8888
ORDER BY create_time DESC
LIMIT 20;
user_id 上明明有索引,怎么会这么慢?
这个问题比你想象的更常见------索引建了 ≠ 索引生效了 。前两篇我们聊了索引失效的 10 个场景和 NULL 值的陷阱,这一篇我们换个视角:当索引不够好时,怎么主动设计一个高效的索引。
本文用一个 50 万行的订单表,从 EXPLAIN 解读开始,一步步把慢查询从 2.8 秒优化到 50ms 以内。每一步都有真实的 EXPLAIN 结果支撑。
前置知识:EXPLAIN 怎么看
EXPLAIN 是 MySQL 慢查询排查的第一工具。一条 SQL 前面加个 EXPLAIN,MySQL 就会告诉你它打算怎么执行------走的哪个索引、扫描多少行、要不要额外排序。
关键字段只有 5 个:
| 字段 | 含义 | 重点关注 |
|---|---|---|
| type | 访问方式 | 从好到差:const > eq_ref > ref > range > index > ALL |
| key | 实际使用的索引 | NULL 表示没走索引(全表扫描) |
| key_len | 索引中使用的字节数 | 越大说明联合索引中用到的列越多 |
| rows | 预估扫描行数 | 越小越好,是优化器根据统计信息估算的 |
| Extra | 额外信息 | Using filesort(需额外排序)、Using index(覆盖索引)、Using where(回表过滤) |
💡 type=ALL 就是全表扫描,50 万行的表等于把 50 万行全读一遍。优化目标就是让 type 尽量往左靠。
下面我们以订单表为例,跟着 EXPLAIN 的输出,一步步优化。
测试环境
sql
-- MySQL 8.4.7,Docker 启动
-- docker run -d --name mysql_slow_query \
-- -e MYSQL_ROOT_PASSWORD=123456 \
-- -p 3306:3306 mysql:8.4.7
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL COMMENT '订单号',
user_id BIGINT NOT NULL COMMENT '用户ID',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消',
amount DECIMAL(10,2) NOT NULL COMMENT '订单金额',
phone VARCHAR(20) DEFAULT NULL COMMENT '收货电话',
province VARCHAR(20) DEFAULT NULL COMMENT '省份',
city VARCHAR(20) DEFAULT NULL COMMENT '城市',
create_time DATETIME NOT NULL COMMENT '创建时间',
update_time DATETIME DEFAULT NULL COMMENT '更新时间',
remark VARCHAR(200) DEFAULT NULL COMMENT '备注',
is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除',
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 50 万行数据
-- 5 种 status 均匀分布(各 ~20%)
-- user_id 分布在 1~50000
-- create_time 分布在 2023-01-01 ~ 2026-06-30
-- province 10 个省份均匀分布
💡 在公众号后台回复 「慢查询」 即可获取完整的建表和数据生成 SQL 脚本。
第一步:没有索引的地狱模式
先看最原始的状态------表上只有主键索引,没有任何二级索引。
场景 A:按用户查订单
sql
EXPLAIN SELECT * FROM orders
WHERE user_id = 8888
ORDER BY create_time DESC
LIMIT 20;
| type | key | rows | Extra |
|---|---|---|---|
| ALL | NULL | 496483 | Using where; Using filesort |
- type=ALL:全表扫描,50 万行全部读一遍
- key=NULL:没有任何索引可用
- Using filesort:因为没索引,MySQL 需要把匹配的行全部读到内存再排序
这条 SQL 的实际执行时间:~2.8 秒。
场景 B:按用户 + 时间范围统计订单金额
sql
EXPLAIN SELECT user_id, SUM(amount) AS total_amount, COUNT(*) AS order_count
FROM orders
WHERE user_id = 8888
AND create_time >= '2025-01-01'
AND create_time < '2025-07-01'
GROUP BY user_id;
| type | key | rows | Extra |
|---|---|---|---|
| ALL | NULL | 496483 | Using where |
同样是全表扫描。这个查询在真实业务中对应"我的订单-按时间筛选",实际执行约 1.5 秒。
场景 C:按省份 + 时间范围查订单
sql
EXPLAIN SELECT * FROM orders
WHERE province = '广东' AND create_time >= '2025-01-01';
| type | key | rows | Extra |
|---|---|---|---|
| ALL | NULL | 496483 | Using where |
全表扫描,没有悬念。
💡 三个查询,全部全表扫描。 这就是为什么你的订单列表越来越慢------不是 MySQL 不行,是这张表上根本没索引。下面我们一步步加索引。
第二步:加单列索引------初见成效但不够好
用户查订单是最高频的场景,先给 user_id 加个索引:
sql
ALTER TABLE orders ADD INDEX idx_user_id (user_id);
ALTER TABLE orders ADD INDEX idx_status (status);
ALTER TABLE orders ADD INDEX idx_create_time (create_time);
看看效果:
场景 A 再测
sql
EXPLAIN SELECT * FROM orders
WHERE user_id = 8888
ORDER BY create_time DESC
LIMIT 20;
| type | key | rows | Extra |
|---|---|---|---|
| ref | idx_user_id | 8 | Using filesort |
进步显著:
- type 从 ALL 变成 ref:不再全表扫描,通过索引精确定位
- rows 从 49 万降到 8:每个用户平均只有 ~10 条订单
- 但 Extra 里还有 Using filesort :
ORDER BY create_time用不上idx_user_id,MySQL 还得把这 8 行读出来再排序
场景 B 再测
sql
EXPLAIN SELECT user_id, SUM(amount) AS total_amount, COUNT(*) AS order_count
FROM orders
WHERE user_id = 8888
AND create_time >= '2025-01-01'
AND create_time < '2025-07-01'
GROUP BY user_id;
| type | key | rows | Extra |
|---|---|---|---|
| ref | idx_user_id | 8 | Using where |
- rows 降到了 8,但
create_time的范围过滤发生在回表之后(Using where) - 这意味着:先通过
idx_user_id找到 8 行 → 回表取create_time→ 再过滤时间范围
实际执行时间:~50ms。比之前快了很多,但还能继续优化。
第三步:联合索引------消灭 filesort
为什么还有 Using filesort?因为 ORDER BY create_time 需要排序,而 idx_user_id 索引只按 user_id 排序,不知道 create_time 的顺序。
解决思路:把 user_id 和 create_time 建在一起。 这样索引本身按 (user_id, create_time) 有序排列------同一个 user_id 下,create_time 天生就是排好序的。
sql
ALTER TABLE orders ADD INDEX idx_user_time (user_id, create_time);
场景 A 三测
sql
EXPLAIN SELECT * FROM orders
WHERE user_id = 8888
ORDER BY create_time DESC
LIMIT 20;
| type | key | rows | Extra |
|---|---|---|---|
| ref | idx_user_time | 8 | Backward index scan |
关键变化:Using filesort 消失了! 取而代之的是 Backward index scan。
这是 MySQL 8.0 的新特性:索引默认按 ASC 排序,但 ORDER BY ... DESC 时,MySQL 可以从索引的末尾往前扫,仍然利用索引的有序性,不需要额外排序。
💡 Backward index scan 是 MySQL 8.0 的降序索引优化 。如果你的
ORDER BY是多列混合方向(一升一降),这个优化就失效了------还是 filesort。所以联合索引的列顺序和排序方向要尽量统一。
为什么 MySQL 自己就选了联合索引?
你可能注意到我们没有 FORCE INDEX,MySQL 自动选了 idx_user_time 而不是 idx_user_id。因为优化器发现联合索引既能满足 WHERE 又能满足 ORDER BY------代价更低。
场景 B 三测
sql
EXPLAIN SELECT user_id, SUM(amount) AS total_amount, COUNT(*) AS order_count
FROM orders
WHERE user_id = 8888
AND create_time >= '2025-01-01'
AND create_time < '2025-07-01'
GROUP BY user_id;
| type | key | key_len | rows | Extra |
|---|---|---|---|---|
| range | idx_user_time | 13 | 1 | Using index condition |
- type 从 ref 变成 range:因为 create_time 用了范围条件
- key_len=13:8(user_id, BIGINT)+ 5(create_time, DATETIME),联合索引的两列都参与了查找
- rows 进一步降到 1:索引帮忙过滤了时间范围,减少回表
实际执行时间:~10ms。
第四步:覆盖索引------彻底消灭回表
还能不能再优化?
回看场景 B:查询只需要 user_id、amount、create_time 三列,但 idx_user_time 只包含 (user_id, create_time),没有 amount。
所以执行流程是:索引里找到满足条件的行 → 回表取 amount → 计算 SUM。每一步回表都是一次随机 IO。
如果把 amount 也放进索引,查询需要的所有列都在索引里了,就不需要回表。
sql
ALTER TABLE orders ADD INDEX idx_user_time_amount_status (user_id, create_time, amount, status);
测一下:
覆盖索引版:场景 B
sql
EXPLAIN SELECT user_id, SUM(amount) AS total_amount, COUNT(*) AS order_count
FROM orders FORCE INDEX(idx_user_time_amount_status)
WHERE user_id = 8888
AND create_time >= '2025-01-01'
AND create_time < '2025-07-01'
GROUP BY user_id;
| type | key | key_len | rows | Extra |
|---|---|---|---|---|
| range | idx_user_time_amount_status | 13 | 1 | Using where; Using index |
Extra 里出现了 Using index------这就是覆盖索引(Covering Index)的标志。
对比:回表 vs 不回表
同样的索引,SELECT * 的 EXPLAIN:
sql
EXPLAIN SELECT * FROM orders FORCE INDEX(idx_user_time_amount_status)
WHERE user_id = 8888
AND create_time >= '2025-01-01'
AND create_time < '2025-07-01';
| type | key | key_len | rows | Extra |
|---|---|---|---|---|
| range | idx_user_time_amount_status | 13 | 1 | Using index condition |
Extra 变成了 Using index condition(ICP,Index Condition Pushdown)------说明需要回表。SELECT * 里有些列不在索引中(phone、remark 等),必须回聚簇索引取。
💡 Using index = 不用回表,Using index condition = 需要回表。 两者性能差异在数据量大时非常明显。覆盖索引是用"空间换时间"的典型策略:索引文件变大了,但查询不用再随机 IO。
实际执行时间:~2ms。
第五步:省份查询的优化
回到场景 C------按省份查订单。province 列还没有索引。
优化前:全表扫描
sql
EXPLAIN SELECT * FROM orders
WHERE province = '广东' AND create_time >= '2025-01-01';
| type | key | rows | Extra |
|---|---|---|---|
| ALL | NULL | 499803 | Using where |
全表扫描 50 万行。
建联合索引
sql
ALTER TABLE orders ADD INDEX idx_province_time (province, create_time);
优化后
sql
EXPLAIN SELECT * FROM orders
WHERE province = '广东' AND create_time >= '2025-01-01';
| type | key | rows | Extra |
|---|---|---|---|
| range | idx_province_time | 43070 | Using index condition |
- type 从 ALL 变成 range
- rows 从 49 万降到 4 万(广东省占约 10% 数据 + 时间范围过滤)
这个索引还能怎么用?
加了 idx_province_time (province, create_time) 之后,下面这些查询都能用到这个索引:
sql
-- ✅ 只用前导列 province
SELECT * FROM orders WHERE province = '广东';
-- ✅ province 等值 + create_time 范围
SELECT * FROM orders WHERE province = '广东'
AND create_time >= '2025-06-01';
-- ✅ province 等值 + ORDER BY create_time(不用 filesort)
SELECT * FROM orders WHERE province = '广东'
ORDER BY create_time DESC LIMIT 20;
-- ❌ 跳过前导列,用不上
SELECT * FROM orders WHERE create_time >= '2025-06-01';
-- (只能用 idx_create_time 单列索引)
进阶:深分页问题
真实业务中还有一个常见痛点------订单列表的分页,翻到 100 页以后越来越慢。
sql
-- 第 5001 页,每页 20 条
SELECT * FROM orders
WHERE user_id = 8888
ORDER BY create_time DESC
LIMIT 100000, 20;
| type | key | rows | Extra |
|---|---|---|---|
| ref | idx_user_time | 8 | Backward index scan |
rows 虽然是 8,但实际 MySQL 需要扫描 100020 行再丢掉前 100000 行------OFFSET 越大,代价越大。
优化:游标分页(Keyset Pagination)
sql
-- 用上一页最后一条的 create_time 作为游标
SELECT * FROM orders
WHERE user_id = 8888
AND create_time < '2024-06-01 00:00:00' -- 上一页最后一条的时间
ORDER BY create_time DESC
LIMIT 20;
| type | key | key_len | rows | Extra |
|---|---|---|---|---|
| range | idx_user_time | 13 | 2 | Using index condition; Backward index scan |
key_len=13:user_id(8)+ create_time(5),联合索引两列都参与了查找- MySQL 直接在索引中定位到
(user_id=8888, create_time<'2024-06-01')的位置,向后取 20 条,不需要跳过任何行
💡 游标分页的核心思想:不用 OFFSET 跳过数据,而是用索引直接定位到"上一页的终点"。代价是前端不能跳页(只能"上一页/下一页"),但性能稳定,不管翻到第几页都是毫秒级。
总结:索引设计的四步方法论
回顾我们刚才的完整优化路径:
没有索引 → 全表扫描 50 万行,2.8 秒
加单列 idx_user_id → 定位到 10 行,但有 filesort,50ms
加联合 idx_user_time → filesort 消失,10ms
加覆盖 idx_user_time_amount_status → 回表也消失,2ms
这个路径背后是一套可以复用的方法论:
1. EXPLAIN 先行
拿到慢查询 SQL 的第一步永远是 EXPLAIN。重点关注 type(是不是 ALL)、Extra(有没有 filesort / Using where)、rows(扫描行数是否合理)。
2. 等值列在前,范围列在后
联合索引 (A, B, C) 中,等值查询的列放前面,范围查询(> < BETWEEN)的列放最后。因为范围查询会导致后续索引列失效(上篇聊过的"范围断档")。
sql
-- WHERE user_id = ? AND create_time > ? AND status = ?
-- 最优索引:(user_id, status, create_time)
-- user_id 和 status 都是等值,放前面;create_time 是范围,放最后
3. ORDER BY 的列紧跟 WHERE 的列
把 ORDER BY 中用到的列放进索引,放在 WHERE 列的后面。这样索引读取时数据已经排好序,省掉 filesort。
4. SELECT 的列考虑覆盖
如果查询是高频的,把 SELECT 里的列也加到索引里(覆盖索引),彻底避免回表。但不要贪多------索引太大也会影响写入性能。
速查表
| 问题 | EXPLAIN 信号 | 解法 |
|---|---|---|
| 没走索引 | type=ALL, key=NULL | 建合适的索引 |
| 走了索引但扫描行数多 | rows 大 | 检查索引列顺序(更精确的列放前面) |
| 排序慢 | Extra: Using filesort | 把 ORDER BY 列加入联合索引 |
| 回表太多 | Extra: Using index condition | 建覆盖索引(Using index) |
| 分页越来越慢 | OFFSET 大 | 改用游标分页 |
如何复现
本文所有 SQL 和 EXPLAIN 结果均来自 MySQL 8.4.7 + 50 万行测试数据的实际运行。如果你想亲手验证:
-
启动 MySQL:
docker run -d --name mysql_slow_query -e MYSQL_ROOT_PASSWORD=123456 -p 3306:3306 mysql:8.4.7 -
执行建表 + 数据生成脚本(含存储过程,约 60 行)
sql-- ============================================================ -- MySQL 慢查询优化实战 - 配套 SQL 脚本 -- 环境:MySQL 8.4.x | 字符集:utf8mb4 -- 用途:建表 + 生成 50 万行测试数据 + 逐步验证 EXPLAIN -- ============================================================ -- 1. 建库建表 DROP DATABASE IF EXISTS slow_query_test; CREATE DATABASE slow_query_test CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; USE slow_query_test; CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '订单号', user_id BIGINT NOT NULL COMMENT '用户ID', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消', amount DECIMAL(10,2) NOT NULL COMMENT '订单金额', phone VARCHAR(20) DEFAULT NULL COMMENT '收货电话', province VARCHAR(20) DEFAULT NULL COMMENT '省份', city VARCHAR(20) DEFAULT NULL COMMENT '城市', create_time DATETIME NOT NULL COMMENT '创建时间', update_time DATETIME DEFAULT NULL COMMENT '更新时间', remark VARCHAR(200) DEFAULT NULL COMMENT '备注', is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除', PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- 2. 生成 50 万行测试数据 DELIMITER $$ DROP PROCEDURE IF EXISTS generate_orders$$ CREATE PROCEDURE generate_orders(IN total_rows INT) BEGIN DECLARE i INT DEFAULT 0; DECLARE batch_size INT DEFAULT 1000; DECLARE rand_user_id BIGINT; DECLARE rand_status TINYINT; DECLARE rand_amount DECIMAL(10,2); DECLARE rand_province VARCHAR(20); DECLARE rand_city VARCHAR(20); DECLARE rand_create_time DATETIME; SET autocommit = 0; WHILE i < total_rows DO SET rand_user_id = FLOOR(1 + RAND() * 50000); SET rand_status = FLOOR(RAND() * 5); SET rand_amount = ROUND(1 + RAND() * 9999, 2); SET rand_province = ELT(FLOOR(1 + RAND() * 10), '广东','浙江','江苏','北京','上海','四川','湖北','福建','山东','河南'); SET rand_city = ELT(FLOOR(1 + RAND() * 10), '广州','杭州','南京','北京','上海','成都','武汉','福州','济南','郑州'); SET rand_create_time = FROM_UNIXTIME( UNIX_TIMESTAMP('2023-01-01 00:00:00') + FLOOR(RAND() * (UNIX_TIMESTAMP('2026-06-30 23:59:59') - UNIX_TIMESTAMP('2023-01-01 00:00:00'))) ); INSERT INTO orders (order_no, user_id, status, amount, phone, province, city, create_time, update_time, remark, is_deleted) VALUES ( CONCAT('ORD', DATE_FORMAT(rand_create_time, '%Y%m%d%H%i%s'), LPAD(i, 6, '0')), rand_user_id, rand_status, rand_amount, CONCAT('138', LPAD(FLOOR(RAND() * 100000000), 8, '0')), rand_province, rand_city, rand_create_time, IF(RAND() > 0.5, DATE_ADD(rand_create_time, INTERVAL FLOOR(RAND() * 72) HOUR), NULL), IF(RAND() > 0.6, CONCAT('备注信息', FLOOR(RAND() * 1000)), NULL), 0 ); SET i = i + 1; IF i % batch_size = 0 THEN COMMIT; END IF; END WHILE; COMMIT; SET autocommit = 1; END$$ DELIMITER ; -- 执行生成(约 2-5 分钟) CALL generate_orders(500000); -- 3. 查看数据分布 SELECT COUNT(*) AS total_rows FROM orders; SELECT status, COUNT(*) AS cnt FROM orders GROUP BY status ORDER BY status; -- ============================================================ -- 之后按文章步骤逐步验证 -- ============================================================ -
逐步骤执行本文的 EXPLAIN 查询,观察优化前后的差异
这是「MySQL 实战三部曲」的第三篇。前两篇分别聊了索引失效的 10 个场景和 NULL 值的唯一索引陷阱,三篇结合可以覆盖日常开发中 80% 的 MySQL 性能问题。
如果这篇文章帮你解决了一个慢查询,欢迎点赞和在看。你遇到过最头疼的慢查询是什么?评论区聊聊。