个人主页: > for_ever_love__ <(欢迎各位大佬莅临😊)
其他栏目: > 大模型开发从0到1 <
其他栏目: > iOS项目总结大全 <
其他栏目: > 我想学python了 <
其他栏目: > iOS UI <
文章目录
- [MySQL ORDER BY 排序优化:从 Using filesort 到索引有序扫描(含双路 / 单路排序原理)](#MySQL ORDER BY 排序优化:从 Using filesort 到索引有序扫描(含双路 / 单路排序原理))
-
- [一、MySQL 只有两种排序方式](#一、MySQL 只有两种排序方式)
- 二、什么时候能用索引排序
-
- [2.1 四条判断规则](#2.1 四条判断规则)
- [2.2 能走索引的典型写法](#2.2 能走索引的典型写法)
- [2.3 走不了索引的典型写法](#2.3 走不了索引的典型写法)
- [2.4 降序索引:8.0 才真正实现](#2.4 降序索引:8.0 才真正实现)
- [三、filesort 内部:双路排序与单路排序](#三、filesort 内部:双路排序与单路排序)
-
- [3.1 双路排序(rowid 排序)](#3.1 双路排序(rowid 排序))
- [3.2 单路排序(全字段排序)](#3.2 单路排序(全字段排序))
- [3.3 两者对比与选择规则](#3.3 两者对比与选择规则)
- [3.4 用 optimizer_trace 看真实发生了什么](#3.4 用 optimizer_trace 看真实发生了什么)
- [四、为什么建了索引还是走 filesort](#四、为什么建了索引还是走 filesort)
-
- [4.1 原因一:回表代价太大](#4.1 原因一:回表代价太大)
- [4.2 原因二:加了 LIMIT 就变了](#4.2 原因二:加了 LIMIT 就变了)
- [4.3 原因三:覆盖索引能翻盘](#4.3 原因三:覆盖索引能翻盘)
- [五、WHERE 和 ORDER BY 抢索引位时,给谁](#五、WHERE 和 ORDER BY 抢索引位时,给谁)
- [六、GROUP BY 的排序:8.0 把隐式排序砍了](#六、GROUP BY 的排序:8.0 把隐式排序砍了)
- [七、filesort 真的不可避免时,怎么调优](#七、filesort 真的不可避免时,怎么调优)
-
- [7.1 缩小参与排序的数据量](#7.1 缩小参与排序的数据量)
- [7.2 别 `SELECT *`](#7.2 别
SELECT *) - [7.3 调参数(谨慎)](#7.3 调参数(谨慎))
- [7.4 用状态变量监控排序压力](#7.4 用状态变量监控排序压力)
- 八、四个常见误区
- 九、小结
MySQL ORDER BY 排序优化:从 Using filesort 到索引有序扫描(含双路 / 单路排序原理)
EXPLAIN里出现Using filesort到底要不要优化?为什么明明建了索引还是不走?为什么有时候加了 LIMIT 就从全表扫变成走索引了?
这篇把 MySQL 8.0 的排序从"两种排序方式"讲到"filesort 内部的两套算法",
再给出一套能直接落地的判断顺序。5.7 与 8.0 的差异会单独标出来。
一、MySQL 只有两种排序方式
sql
-- 方式一:索引排序(Extra 里什么都没有,或者只出现 Using index)
-- 直接顺着索引的顺序读,读出来的天然有序,不需要额外排序
SELECT id, name FROM student ORDER BY age LIMIT 10; -- 若有 idx_age 则直接顺序扫描
-- 方式二:filesort(Extra: Using filesort)
-- 先把满足条件的行读出来,再在内存/磁盘里排序
SELECT * FROM student ORDER BY age, classid;
| 排序方式 | EXPLAIN 表现 | 是否需要额外排序 | 代价来源 |
|---|---|---|---|
| 索引排序 | Extra 无 Using filesort,可能是 Using index |
否 | 索引扫描 + 可能的回表 |
| filesort | Extra 有 Using filesort |
是 | 排序本身(CPU)+ 可能的磁盘临时文件(IO) |
⚠️ filesort 这个名字极具误导性 。它并不表示"用文件排序",
只是表示"没法利用索引的有序性,需要自己排"。
数据量小时它完全在内存的 sort buffer 里完成,一个文件都不会产生。
二、什么时候能用索引排序
B+ 树索引本身就是有序的,所以只要 ORDER BY 的顺序和索引的顺序"对得上",就不用排序。
2.1 四条判断规则
设有联合索引 idx_a_b_c(a, b, c):
| 规则 | 说明 | 例子 |
|---|---|---|
| ① 最左前缀 | ORDER BY 的列必须是索引的最左前缀 | ORDER BY a / a,b / a,b,c ✅ |
| ② 方向一致 | 要么全 ASC,要么全 DESC,不能混 | ORDER BY a DESC, b DESC ✅;a ASC, b DESC ❌ |
| ③ 等值条件可"接上" | WHERE 里对前面列用等值条件,后面的列可以接着排 | WHERE a = 1 ORDER BY b, c ✅ |
| ④ 范围条件会"截断" | WHERE 里对中间列用了范围/IN,后面的列排不了 | WHERE a = 1 AND b > 10 ORDER BY c ❌ |
2.2 能走索引的典型写法
sql
-- 索引:idx_a_b_c(a, b, c)
SELECT * FROM t ORDER BY a;
SELECT * FROM t ORDER BY a, b;
SELECT * FROM t ORDER BY a, b, c;
SELECT * FROM t ORDER BY a DESC, b DESC, c DESC; -- 8.0 真正的降序索引
SELECT * FROM t WHERE a = 5 ORDER BY b, c; -- 等值条件把 a 固定住了
SELECT * FROM t WHERE a = 5 AND b = 9 ORDER BY c; -- a、b 都固定住
SELECT * FROM t WHERE a = 5 AND b > 9 ORDER BY b, c; -- b 是范围但排的就是 b 本身
2.3 走不了索引的典型写法
sql
-- ❌ 跨了索引列
SELECT * FROM t ORDER BY b;
SELECT * FROM t WHERE a = 5 ORDER BY c; -- 跳过了 b
-- ❌ 方向混用
SELECT * FROM t ORDER BY a ASC, b DESC;
-- ❌ 排序字段和 WHERE 用的不是同一个索引
SELECT * FROM t WHERE a = 5 ORDER BY d; -- d 不在索引里
-- ❌ 范围/IN 之后断裂(对于"排序"来说,多个等值条件组成的 IN 也算范围)
SELECT * FROM t WHERE a IN (1,2,3) ORDER BY b, c;
最后一条特别反直觉:a IN (1,2,3) 是等值条件,但对排序而言它等价于范围查询 ------
结果里 a 的取值有多个,b、c 的有序性只在每个 a 值内部成立,整体不成立。
2.4 降序索引:8.0 才真正实现
sql
-- 5.7:语法能过,但 DESC 被静默忽略,索引实际还是全升序
-- 8.0:真正支持降序索引
CREATE INDEX idx_age_desc_name_asc ON student(age DESC, name ASC);
-- 8.0 下这条可以走上面的索引
SELECT * FROM student ORDER BY age DESC, name ASC;
如果你的业务里"一个升序一个降序"是刚需(比如"分数降序、时间升序"),
8.0 的降序索引是唯一干净的解法;5.7 只能靠生成列取负数之类的技巧绕。
三、filesort 内部:双路排序与单路排序
当索引帮不上忙时,MySQL 就要自己排序。它有两套算法。
3.1 双路排序(rowid 排序)
MySQL 4.1 之前只有这一种。核心是扫描两遍:
- 读满足 WHERE 的行,只把**排序字段 + 行指针(rowid)**放进 sort buffer
- 在 sort buffer 里按排序字段排好
- 排序完成后,再按 rowid 回到表里把其他列读出来(第二次随机 IO)
sql
-- optimizer_trace 里看到的 sort_mode
-- "sort_mode": "<sort_key, rowid>" ← 双路
优点 :sort buffer 里放的东西少,同样内存能装更多行,不容易落盘。
缺点 :第二步的回表是随机 IO,这是它最大的代价。
3.2 单路排序(全字段排序)
4.1 之后引入的改进算法,核心是只扫描一遍:
- 读满足 WHERE 的行,把排序字段 + 所有要返回的字段都放进 sort buffer
- 在 sort buffer 里排好,直接返回结果,不再回表
sql
-- optimizer_trace 里看到的 sort_mode
-- "sort_mode": "<sort_key, additional_fields>" ← 单路
-- "sort_mode": "<sort_key, packed_additional_fields>" ← 单路 + 打包(8.0)
优点 :把随机 IO 变成顺序 IO,少一次回表,通常更快。
缺点:sort buffer 占用大得多,行数据放不下时反而要分很多段、写很多临时文件。
3.3 两者对比与选择规则
| 维度 | 双路排序(rowid) | 单路排序(全字段) |
|---|---|---|
| sort_mode | <sort_key, rowid> |
<sort_key, (packed_)additional_fields> |
| 扫描表次数 | 2 次 | 1 次 |
| IO 类型 | 第二次是随机 IO | 全程顺序 IO |
| 内存占用 | 小 | 大 |
| 落盘风险 | 低 | 高 |
| 适用场景 | 行很长、返回列多 | 行较短、能装进 sort buffer |
MySQL 用这个规则选:
如果一行要返回的数据总长度 ≤
max_length_for_sort_data,用单路排序;否则用双路排序。另外,如果排序涉及
TEXT/BLOB类型,一律用双路。
sql
-- 查看当前值
SHOW VARIABLES LIKE 'max_length_for_sort_data';
SHOW VARIABLES LIKE 'sort_buffer_size';
-- 8.0 默认:max_length_for_sort_data = 4096 字节,sort_buffer_size = 256KB
-- 5.7 默认:max_length_for_sort_data = 1024 字节
⚠️ 调大 max_length_for_sort_data 不一定变快 。它会让 MySQL 更倾向于选单路排序,
而单路排序占用的空间大,一旦总数据量超过 sort_buffer_size,就要切分成多段、
写临时文件、再做多路归并------原本想省一次 IO,结果变成大量 IO。
典型症状是磁盘 IO 飙高而 CPU 使用率很低。
3.4 用 optimizer_trace 看真实发生了什么
sql
SET optimizer_trace = 'enabled=on';
SELECT * FROM student WHERE age > 20 ORDER BY name LIMIT 1000;
SELECT trace FROM information_schema.optimizer_trace\G
SET optimizer_trace = 'enabled=off';
重点看这段:
json
"filesort_summary": {
"rows": 1000,
"examined_rows": 385721,
"number_of_tmp_files": 7, ← 用了 7 个磁盘临时文件做归并
"sort_buffer_size": 262144,
"sort_mode": "<sort_key, packed_additional_fields>"
}
| 字段 | 怎么读 |
|---|---|
examined_rows |
参与排序的行数,越大越糟 |
number_of_tmp_files |
0 表示纯内存排序;> 0 表示落盘了,值越大越糟 |
sort_mode |
判断用的是单路还是双路 |
sort_buffer_size |
实际用到的排序缓冲区大小 |
如果同时看到 filesort_priority_queue_optimization: { "usable": true },
说明优化器用了优先队列(堆排序) ------带 LIMIT 且数据量可控时,
它只需要维护一个大小为 N 的堆,根本不需要排全部数据,也就不需要临时文件。
四、为什么建了索引还是走 filesort
这是最让人困惑的一点。明明 ORDER BY 的字段上有索引,EXPLAIN 却还是 Using filesort。
4.1 原因一:回表代价太大
sql
-- 有索引 idx_age(age),但这条大概率不走
EXPLAIN SELECT * FROM student ORDER BY age;
走 idx_age 排序的话,流程是:顺着索引取 age 有序的每一行 → 用主键回表取 * 的所有列 → 返回。
300 万行就要回表 300 万次,而且主键顺序和 age 顺序不一致,是随机 IO。
优化器算了一笔账:全表扫是 300 万次顺序 IO,走索引是 300 万次随机 IO + 排序,
全表扫 + filesort 反而更便宜。于是它选了后者。
4.2 原因二:加了 LIMIT 就变了
sql
-- ❌ 没有 LIMIT,走 filesort
EXPLAIN SELECT * FROM student ORDER BY age, classid;
-- ✅ 加 LIMIT 10000,走索引(key_len 有值,Extra 无 filesort)
EXPLAIN SELECT * FROM student ORDER BY age, classid LIMIT 10000;
-- ⚠️ LIMIT 太大时又会退回 filesort(经验值:约 1 万行往上就危险)
EXPLAIN SELECT * FROM student ORDER BY age, classid LIMIT 1000000;
原因很直白:只要回表次数被 LIMIT 限制住了,走索引的代价就降下来了。
所以"用不用索引排序"本质上是个代价问题,不是规则问题。
4.3 原因三:覆盖索引能翻盘
回表太贵,那就别回表:
sql
-- 建覆盖索引
CREATE INDEX idx_age_classid_name ON student(age, classid, name);
-- 只取索引内的列:走索引 + Using index,零回表
EXPLAIN SELECT age, classid, name FROM student ORDER BY age, classid;
这是最有效的一招:排序字段 + 查询字段一起进联合索引,既有序又不回表。
五、WHERE 和 ORDER BY 抢索引位时,给谁
这是实际工作中最高频的决策。
sql
-- 300 万行 student 表,场景:查 age=30 且 stuno<101000 的学生,按 name 排序
SELECT * FROM student WHERE age = 30 AND stuno < 101000 ORDER BY NAME;
| 方案 | 索引 | EXPLAIN 表现 | 实际耗时 |
|---|---|---|---|
| A:索引给过滤列 | (age, stuno) |
type=range,仍有 Using filesort |
~0.00x 秒 |
| B:索引给排序列 | (age, name) |
type=ref,无 Using filesort |
~3 秒 |
方案 A 反而快得多。为什么?
因为 stuno < 101000 把 300 万行过滤到只剩几百行,剩下几百行在内存里做 filesort
的代价可以忽略;而方案 B 虽然"避免"了排序,却要把 age=30 的全部几十万行
走索引读出来再回表,代价高几个数量级。
决策规则:
| 观察 | 结论 |
|---|---|
| 过滤条件能砍掉绝大部分行(只剩几百几千行) | 索引给过滤列,排序交给 filesort |
| 过滤条件几乎不过滤,但需要排序的行极多 | 索引给排序列 |
| 两者都要 | 建成 (过滤列..., 排序列...) 的联合索引,配合覆盖索引 |
六、GROUP BY 的排序:8.0 把隐式排序砍了
GROUP BY 本质上也要先分组,MySQL 的老实现是"先排序再分组"。
sql
-- 5.7:GROUP BY 会隐式按分组字段排序,所以下面两条等价
SELECT order_id FROM t_order GROUP BY order_id;
SELECT order_id FROM t_order GROUP BY order_id ORDER BY order_id ASC;
-- 8.0:不再保证隐式排序!想要有序必须显式写 ORDER BY
SELECT order_id FROM t_order GROUP BY order_id ORDER BY order_id;
🚨 这是 5.7 升 8.0 最常见的"结果顺序变了"事故源头 。
如果你的老代码依赖 GROUP BY 的返回顺序,升级后一定要补上 ORDER BY。
GROUP BY 的索引规则和 ORDER BY 几乎一致:
sql
-- 索引 (a, b, c)
SELECT a, COUNT(*) FROM t GROUP BY a; -- ✅
SELECT a, b, COUNT(*) FROM t GROUP BY a, b; -- ✅
SELECT b, COUNT(*) FROM t GROUP BY b; -- ❌ 不符合最左前缀
SELECT a, COUNT(*) FROM t WHERE a > 10 GROUP BY b; -- ❌ a 是范围,断裂
还有一条永远成立的原则:能写在 WHERE 里的条件,不要写进 HAVING 。
WHERE 是分组前过滤,参与分组的行越少越便宜;HAVING 是分组后过滤,白干了聚合。
七、filesort 真的不可避免时,怎么调优
按优先级来:
7.1 缩小参与排序的数据量
sql
-- ❌ 把 30 万行拉出来排序
SELECT * FROM orders WHERE create_time >= '2024-01-01' ORDER BY amount DESC;
-- ✅ 加更严格的条件 + LIMIT
SELECT * FROM orders
WHERE create_time >= '2024-01-01' AND create_time < '2024-02-01'
ORDER BY amount DESC
LIMIT 100;
7.2 别 SELECT *
这是性价比最高的一条。SELECT * 会让行的长度暴涨,
直接导致:① 单路排序装不下 → 退化成双路或落盘;② 无法走覆盖索引。
7.3 调参数(谨慎)
sql
-- 排序缓冲区,默认 256KB,是**每个连接独享**的!
SET SESSION sort_buffer_size = 2 * 1024 * 1024;
-- 决定单路/双路的分界线,8.0 默认 4096
SET SESSION max_length_for_sort_data = 8192;
-- 双路排序第二阶段回表时的读缓冲,默认 256KB
SET SESSION read_rnd_buffer_size = 512 * 1024;
⚠️ sort_buffer_size 是每个 session 独享 的,不是全局共享池。
设成 8MB,1000 个连接最坏情况就是 8GB。
建议只在会话级临时调,或者只给跑批的连接调。
7.4 用状态变量监控排序压力
sql
-- 累计的排序次数与行数
SHOW GLOBAL STATUS LIKE 'Sort%';
-- 关注这一个:归并排序的轮数,持续增长说明大量排序在落盘
SHOW GLOBAL STATUS LIKE 'Sort_merge_passes';
| 状态变量 | 含义 |
|---|---|
Sort_scan |
通过全表扫描触发的排序次数 |
Sort_range |
通过范围扫描触发的排序次数 |
Sort_rows |
累计排序的行数 |
Sort_merge_passes |
归并轮数,越大说明落盘越严重 |
八、四个常见误区
误区 1:看到 Using filesort 就要消灭它
------错。它只说明"没用索引的有序性"。如果过滤后只剩几百行,内存排序可能比走索引再回表更快。
判断依据是 EXPLAIN ANALYZE(8.0.18+)给出的真实耗时,不是那个标签。
误区 2:sort_buffer_size 越大越好
------错。它是每连接独享的,调大了内存会失控。它也不能决定用单路还是双路,
决定那个的是 max_length_for_sort_data;sort_buffer_size 只决定要不要落盘分段。
误区 3:联合索引的顺序要照着 WHERE 的书写顺序建
------错。等值条件优化器会重排。真正的顺序规则是:等值列 → 范围列 → 排序列。
误区 4:GROUP BY 的结果天然有序
------5.7 是(隐式排序),8.0 不是。依赖隐式排序的代码升级后会出问题。
九、小结
- MySQL 排序只有两条路:索引有序扫描 和 filesort ,
Using filesort≠ 用了文件 - 能走索引排序的条件:最左前缀、方向一致、等值条件可接上、范围/IN 会截断后续列
- 8.0 才真正支持降序索引 ,5.7 的
DESC会被静默忽略 - filesort 有两套算法 :双路(
<sort_key, rowid>,两次扫描 + 随机 IO 回表)和
单路(<sort_key, additional_fields>,一次扫描,内存占用大) - 算法选择由
max_length_for_sort_data决定 (8.0 默认 4096,5.7 默认 1024),
sort_buffer_size只决定要不要落盘分段 - 用
optimizer_trace的filesort_summary看真相 :
number_of_tmp_files为 0 才是纯内存排序 - 带 LIMIT 时可能启用优先队列,只维护一个 N 大小的堆,不需要排全量
- 为什么建了索引还 filesort:回表是随机 IO,优化器算完账觉得全表扫更便宜
- WHERE 和 ORDER BY 抢索引位时:看谁过滤得多,过滤收益大于排序收益就把索引给过滤列
- 8.0 移除了
GROUP BY的隐式排序 ,依赖它的老代码必须补ORDER BY - 调优顺序:缩数据量 → 去
SELECT *→ 建覆盖索引 → 最后才动参数
下一篇讲深分页:LIMIT 1000000, 10 为什么慢,以及四种能真正落地的解法。