MySQL ORDER BY 排序优化:从 Using filesort 到索引有序扫描(含双路 / 单路排序原理)

个人主页: > 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 之前只有这一种。核心是扫描两遍:

  1. 读满足 WHERE 的行,只把**排序字段 + 行指针(rowid)**放进 sort buffer
  2. 在 sort buffer 里按排序字段排好
  3. 排序完成后,再按 rowid 回到表里把其他列读出来(第二次随机 IO)
sql 复制代码
-- optimizer_trace 里看到的 sort_mode
-- "sort_mode": "<sort_key, rowid>"   ← 双路

优点 :sort buffer 里放的东西少,同样内存能装更多行,不容易落盘。

缺点 :第二步的回表是随机 IO,这是它最大的代价。

3.2 单路排序(全字段排序)

4.1 之后引入的改进算法,核心是只扫描一遍:

  1. 读满足 WHERE 的行,把排序字段 + 所有要返回的字段都放进 sort buffer
  2. 在 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 为什么慢,以及四种能真正落地的解法。

相关推荐
我是人✓2 小时前
Java文件上传三方式对比:从传统到SpringBoot
java·开发语言
欣栀♡2 小时前
JVM 频繁 Full GC 先看日志括号里的 cause,jstat 的 FGC 在 JDK 8 上会骗人
java·jvm
IT研究室2 小时前
最新计算机毕业设计选题推荐-基于spring boot的葡萄酒文化宣传网站-网站-文档指导-Java-springboot
java·spring boot·课程设计
慧都小项2 小时前
MyEclipse 中创建和测试 JAX-RS REST 接口:从项目配置到 REST Explorer
java·restful·myeclipse·jax-rs
Wang's Blog2 小时前
Java 项目实战: 交友项目 - 入门案例:编写 Dubbo 服务提供者
java·dubbo·交友
秋饼3 小时前
Spring AI 2.0 结构化输出企业级实战:从 BeanOutputConverter 源码到生产级解析容错
java·ai·技术分享·后端开发
摇滚侠3 小时前
《计算机是如何工作的:人人都能懂的计算机软硬件工作原理》第 9 章 机器码与汇编语言 阅读笔记 9
java·开发语言·笔记
Cc.Y3 小时前
Java零基础入门:深入Java核心:文件IO与序列化全解
java·开发语言·python
拿本唠嗑AI研究3 小时前
本体工程实战指南:从建模到可维护的完整落地路径
java·人工智能·python·jena·ontology