一个简单SQL的深度解析
从3秒到100ms,我都做了什么
一、噩梦开始:一条"简单"的慢查询
先看这条SQL:
SELECT o.order_id, o.amount, u.name, u.level
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE o.created_at BETWEEN '2026-01-01' AND '2026-03-31'
ORDER BY o.amount DESC
LIMIT 100;
我的第一反应:这么普通的查询,怎么可能慢?
但EXPLAIN的结果让我后背发凉——它扫描了整整200万行数据。
二、你可能也踩过的坑
类似的困惑你遇到过吗?
-
明明加了索引,为什么
type还是ALL? -
加了索引之后,
Using filesort为什么还在? -
用了索引的查询,反而比不用索引还要慢?
我花了2个小时才彻底搞明白。而当我弄懂之后发现:80%的慢查询,都可以用同一个方法快速定位。
这个方法就是——读懂
EXPLAIN的执行计划。
三、第一次诊断:EXPLAIN暴露了真凶
执行EXPLAIN后:
| id | select_type | table | type | key | rows | Extra |
|---|---|---|---|---|---|---|
| 1 | SIMPLE | o | ALL | NULL | 823417 | Using filesort |
| 1 | SIMPLE | u | ALL | PRIMARY | 150000 | Using where |
用大白话翻译三个致命问题:
① type=ALL → 全表扫描
orders表被从头读到尾,82万行,一行都没放过。
② Extra=Using filesort → 额外排序
ORDER BY amount没用到索引,MySQL不得不在内存或磁盘里额外排序。数据量越大,代价越高。
③ 连表走了嵌套循环
对于orders的每一行,都要去users表里查一次。82万次随机IO。
四、第一次尝试:加索引,却失败了
我首先给created_at加了索引:
ALTER TABLE orders ADD INDEX idx_created_at (created_at);
再次EXPLAIN,type还是ALL。
为什么加了索引没生效?
核心原因:返回数据比例超过20%时,MySQL会认为全表扫描更高效。
| 返回比例 | MySQL的决策 |
|---|---|
| < 5% | 使用索引 ✅ |
| 5%-20% | 临界区,看数据分布 |
| > 20% | 全表扫描 ❌ |
我查询的是一个季度的数据,占总数据量约25%,超过了阈值。
验证:强制使用索引
SELECT ... FORCE INDEX (idx_created_at) ...
结果:强制用索引反而更慢了。
| 对比项 | 使用索引 | 全表扫描 |
|---|---|---|
| 扫描行数 | 235,270 | 798,554 |
| I/O类型 | 随机I/O(23.5万次) | 顺序I/O(1次大块读) |
| 额外开销 | 索引页+数据页读取 | 仅数据页读取 |
| 缓存效率 | 低 | 高 |
结论:随机I/O比顺序I/O慢得多。235k次随机访问,不如一次扫完80万行。
五、第二次尝试:覆盖索引,效果显著
既然回表是瓶颈,那就用覆盖索引——把需要的字段都放进索引,避免回表。
ALTER TABLE orders ADD INDEX idx_covering (created_at, amount, user_id);
执行计划变化:
| 关键字 | 含义 | 效果 |
|---|---|---|
Using index | 覆盖索引生效 | 无需回表 ✅ |
Using where | Server层过滤 | WHERE条件部分在Server层处理 |
耗时:3秒 → 100ms
六、追问:为什么Using filesort还在?
即使有了覆盖索引,Extra里仍然有Using filesort。
原因1:覆盖度不够
查询SELECT包含了users表的字段(u.name, u.level),这些不在orders的任何索引里,必须回表。
原因2:范围查询 + 排序的代价评估
WHERE条件是范围查询(BETWEEN),此时amount在created_at范围内不是全局有序的,MySQL仍然需要额外排序。
如何真正消除Using filesort?
需要满足:WHERE条件是等值查询,而不是范围查询。
现实就是:只要ORDER BY的列不是WHERE范围查询的那一列,filesort就躲不掉。
七、方法论:慢查询优化的通用公式
慢查询 → EXPLAIN看执行计划 → 关注三个指标 → 调整索引/SQL → 验证效果
三个核心指标
| 指标 | 关注什么 |
|---|---|
type | 是否为ALL/index?目标是range/ref/eq_ref |
rows | 预估扫描行数,越少越好 |
Extra | 是否出现Using filesort/Using temporary? |
八、专栏预告
接下来我会一步步拆解:
EXPLAIN每一列的真正含义(不是背概念,而是结合实际案例)- 索引设计:联合索引列顺序怎么排,范围查询为什么"断掉"后面的索引
- 排序、分组、分页的优化套路
- 真实慢查询日志分析(pt-query-digest实战)
- 死锁分析:从日志到复现再到解决
九、互动收尾
你在工作中遇到过最离谱的慢查询是什么?最后怎么解决的?
欢迎在评论区分享,我会选出3位送出 《慢查询排查命令速查表》 (PDF)。
如果这篇文章对你有帮助,点个赞或在看,让更多被慢查询困扰的朋友看到。
测试数据脚本
orders表(约80万行):
CREATE TABLE orders (
order_id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT,
amount DECIMAL(10,2),
created_at DATETIME,
status TINYINT
);
DELIMITER $$
CREATE PROCEDURE insert_orders()
BEGIN
DECLARE i INT DEFAULT 1;
WHILE i <= 800000 DO
INSERT INTO orders (user_id, amount, created_at, status)
VALUES (
FLOOR(RAND() * 100000),
ROUND(RAND() * 10000, 2),
DATE_ADD('2026-01-01', INTERVAL FLOOR(RAND() * 365) DAY),
FLOOR(RAND() * 4)
);
SET i = i + 1;
END WHILE;
END$$
DELIMITER ;
CALL insert_orders();
users表(约10万行):
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
email VARCHAR(100) NOT NULL,
level ENUM('bronze','silver','gold','platinum') DEFAULT 'bronze',
register_date DATE,
last_login DATETIME,
status TINYINT DEFAULT 1
);
-- 插入10万条数据(存储过程略,详见正文)
插入 users 测试数据
sql
-- 创建存储过程,插入 10 万条用户数据
DELIMITER $$
CREATE PROCEDURE insert_users()
BEGIN
DECLARE i INT DEFAULT 1;
DECLARE v_name VARCHAR(50);
DECLARE v_email VARCHAR(100);
DECLARE v_level ENUM('bronze', 'silver', 'gold', 'platinum');
DECLARE v_register_date DATE;
DECLARE v_last_login DATETIME;
DECLARE v_status TINYINT;
WHILE i <= 100000 DO
-- 生成随机姓名
SET v_name = CONCAT('user_', i);
-- 生成随机邮箱
SET v_email = CONCAT('user', i, '@test.com');
-- 随机等级(分布:bronze 50%, silver 30%, gold 15%, platinum 5%)
SET v_level = CASE
WHEN RAND() < 0.5 THEN 'bronze'
WHEN RAND() < 0.8 THEN 'silver'
WHEN RAND() < 0.95 THEN 'gold'
ELSE 'platinum'
END;
-- 随机注册日期(过去3年内)
SET v_register_date = DATE_SUB(CURDATE(), INTERVAL FLOOR(RAND() * 1095) DAY);
-- 随机最后登录时间(过去30天内)
SET v_last_login = DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 30) DAY);
-- 随机状态(90%正常,10%禁用)
SET v_status = IF(RAND() < 0.9, 1, 0);
INSERT INTO users (name, email, level, register_date, last_login, status)
VALUES (v_name, v_email, v_level, v_register_date, v_last_login, v_status);
SET i = i + 1;
END WHILE;
END$$
DELIMITER ;
-- 执行插入
CALL insert_users();
-- 查看插入结果
SELECT COUNT(*) FROM users;
-- 添加索引(用于后续优化测试)
ALTER TABLE users ADD INDEX idx_level (level);
ALTER TABLE users ADD INDEX idx_register_date (register_date);