深分页与 ORDER BY 优化:文件排序 vs 索引排序
问题
后端开发中,SELECT * FROM t ORDER BY create_time DESC LIMIT 100000, 20 这条 SQL 为什么越到后面越慢?MySQL 的 ORDER BY 什么时候走索引排序、什么时候走文件排序(filesort)?深分页 + 排序的场景到底怎么优化?
分析
ORDER BY 的两种排序方式
MySQL 的排序有两种实现路径:
1. 索引排序(Using index)
当 ORDER BY 的字段恰好是索引的组成部分,且满足最左前缀时,MySQL 可以直接按索引顺序扫描数据,无需额外排序。EXPLAIN 的 Extra 字段会显示 Using index。
-- 假设有联合索引 (status, create_time)
-- 这个查询可以直接走索引排序
EXPLAIN SELECT id, status, create_time
FROM t_order
WHERE status = 1
ORDER BY create_time DESC;2. 文件排序(Using filesort)
当 ORDER BY 字段无法利用索引时,MySQL 需要先把数据读取到内存(sort_buffer)或临时文件中排序,再返回结果:
-- ORDER BY 的字段在索引中不存在,必须文件排序
EXPLAIN SELECT * FROM t_order
ORDER BY amount DESC
LIMIT 100;文件排序内部原理:两种算法
文件排序这个名字容易误导——它不一定把数据写到磁盘文件。filesort 是 MySQL 内部排序器的统称,排序过程完全在内存中完成时,Extra 仍然显示 Using filesort。
单行排序(Single-Pass / 全字段排序)
把查询需要的所有字段都读入 sort_buffer,排序后直接返回。MySQL 8.0 默认走这个路径。
内存布局(sort_buffer):
┌─────────┬──────┬──────────┬───────────┬──────────┐
│ amount │ id │ order_no │ create_ts │ status │
├─────────┼──────┼──────────┼───────────┼──────────┤
│ 299.00 │ 1001 │ ORD001 │ 2026-07-01│ 1 │
│ 150.00 │ 1002 │ ORD002 │ 2026-07-02│ 2 │
│ ... │ ... │ ... │ ... │ ... │
└─────────┴──────┴──────────┴───────────┴──────────┘
→ 按 amount 快排 → 直接返回优点:不回表。缺点:一行数据占用的 sort_buffer 空间大,同样大小的 buffer 能装的行数少。
双行排序(Two-Pass / 排序字段 + 主键)
当一行数据超过 max_length_for_sort_data(MySQL 8.0 已废弃此参数,改用排序字段总长度自动判断)时,只读入排序字段和主键到 sort_buffer,排序后再回表取完整数据。
第一趟(sort_buffer):
┌─────────┬──────┐
│ amount │ id │
├─────────┼──────┤
│ 299.00 │ 1001 │
│ 150.00 │ 1002 │
│ ... │ ... │
└─────────┴──────┘
→ 按 amount 快排 → 得到有序的 id 列表
第二趟:
→ 按有序 id 回表取完整行(每行一次随机 IO)优点:sort_buffer 能装更多行,减少磁盘临时文件。缺点:多一次回表 IO。
什么时候触发磁盘临时文件?
当 sort_buffer 装不下所有排序数据时,MySQL 把数据分片写到磁盘临时文件,归并排序:
-- 查看是否使用了磁盘临时文件
SHOW STATUS LIKE 'Sort_merge_passes';
-- Sort_merge_passes > 0 说明 sort_buffer 不够大,触发了一轮或多轮归并每条记录大小 = 排序字段长度 + 行指针(主键)长度 + 额外字段。sort_buffer 默认 256KB,如果每条记录 200 字节,约能装 1310 行。500 万行排序时,需要 5000000 / 1310 ≈ 3817 个分片,每个分片排序后写磁盘,再归并。
实战经验: 我见过一个定时报表导出任务,ORDER BY create_time LIMIT 500000, 10000,sort_buffer 256KB 导致 Sort_merge_passes 飙升到 3000+,每次跑 47 秒。把 sort_buffer_size 调到 4MB 后降到 12 秒——没有减少扫描行数,但减少了归并轮次。
深分页 LIMIT 为什么越来越慢
LIMIT 100000, 20 的本质是:MySQL 会扫描 100020 行,然后丢弃前 100000 行,只返回最后 20 行。扫描行数随偏移量线性增长:
-- 偏移量越大,扫描行数越多
-- 100 万行数据,各种偏移量下的扫描行数实测:
-- LIMIT 0, 20 → 扫描 20 行 → 0.3ms
-- LIMIT 10000, 20 → 扫描 10020 行 → 12ms
-- LIMIT 100000, 20 → 扫描 100020 行 → 120ms
-- LIMIT 500000, 20 → 扫描 500020 行 → 620ms
-- 每多一次偏移量,就多一次无意义的 IO
SELECT * FROM t_order
ORDER BY id
LIMIT 100000, 20;如果再加上 ORDER BY 非索引字段,不仅扫描行数多,还要额外做文件排序,性能雪上加霜。
代码示例
场景:订单列表分页,按创建时间倒序
假设 t_order 表有 500 万行,业务需要按创建时间倒序分页显示。
不优化的写法(深分页 + 文件排序):
-- 第 10001 页,每页 20 条
-- 偏移量 200000,扫描 200020 行,再文件排序
-- 实测:500 万行数据,耗时约 8-12 秒
SELECT * FROM t_order
ORDER BY create_time DESC
LIMIT 200000, 20;优化方案一:延迟关联(Deferred Join)
先通过覆盖索引快速定位主键,再回表取完整数据:
-- 1. 覆盖索引扫描,只返回主键(不需要回表)
-- 2. 再关联回表取完整数据(只取 20 行)
SELECT t.*
FROM t_order t
INNER JOIN (
SELECT id
FROM t_order
ORDER BY create_time DESC
LIMIT 200000, 20
) AS tmp ON t.id = tmp.id
ORDER BY t.create_time DESC;执行流程时序:
┌──────────────────────────────────────────────────┐
│ 客户端 │
│ SELECT t.* FROM t_order t JOIN (子查询) ON t.id=id │
└──────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ 子查询:SELECT id FROM t_order ORDER BY create │
│ 走 (create_time,id) 覆盖索引 │
│ 扫描 200020 个索引条目(全在内存级索引页) │
│ 返回 20 个 id │
└──────────────┬───────────────────────────────────┘
│ 20 个 id
▼
┌──────────────────────────────────────────────────┐
│ 外层:回表 20 次(聚簇索引精确查找,每次 1 次 IO) │
└──────────────────────────────────────────────────┘核心原理:子查询的 SELECT id 可以利用 (create_time, id) 覆盖索引,索引扫描 200020 行(但只需扫描索引页,不需要回表),外层只回表 20 行。相比原始写法减少 99.99% 的回表 IO。
实测对比(500 万行数据,SSD,MySQL 8.0):
| 查询方式 | 偏移量 100000 | 偏移量 500000 | 偏移量 1000000 |
|---|---|---|---|
| 原始写法规避 | 120ms | 620ms | 1.4s |
| 延迟关联 | 8ms | 12ms | 15ms |
| 提升倍数 | 15x | 51x | 93x |
优化方案二:游标分页(Cursor Pagination)
基于上一页的最后一条记录做条件,彻底消除偏移量:
-- 第一页:正常查询
SELECT id, create_time, order_no, amount
FROM t_order
ORDER BY create_time DESC, id DESC
LIMIT 20;
-- 第二页:传入上一页最后一条记录的 create_time 和 id
-- 利用 (create_time, id) 联合索引直接定位
SELECT id, create_time, order_no, amount
FROM t_order
WHERE (create_time, id) < ('2026-07-19 22:30:00', 1000456)
ORDER BY create_time DESC, id DESC
LIMIT 20;游标分页的排序稳定性:
如果排序字段有重复值(如相同的时间戳),必须加主键作为第二排序字段。
踩坑案例: 某次上线后,用户反馈"翻页时明明看到第 1 页有条记录,第 2 页又出现了,第 3 页又没了"。排查发现 ORDER BY 只用了 create_time,而当时并发批量导入导致 3 秒内创建了 500 条记录的 create_time 完全相同。游标分页按 create_time < last_value 定位,相同时间的记录会被"漏"掉一些,也会被重复包含。
-- 错误:create_time 有重复值时,分页结果可能重复或丢失
WHERE create_time < '2026-07-19 22:30:00'
ORDER BY create_time DESC
LIMIT 20;
-- 正确:加上主键作为第二排序字段,保证结果唯一且稳定
WHERE (create_time, id) < ('2026-07-19 22:30:00', 1000456)
ORDER BY create_time DESC, id DESC
LIMIT 20;优化方案三:子查询定位起始点
-- 先用子查询找到第 200001 行的 id,再从这个 id 往后取 20 行
SELECT * FROM t_order
WHERE id >= (
SELECT id
FROM t_order
ORDER BY create_time DESC, id DESC
LIMIT 200000, 1
)
ORDER BY create_time DESC, id DESC
LIMIT 20;子查询利用覆盖索引定位起始 id,主查询从该 id 开始扫描 20 行,完全跳过前 200000 行的扫描。
文件排序调优参数
-- 查看当前 sort_buffer 大小
SHOW VARIABLES LIKE 'sort_buffer_size'; -- 默认 256KB
-- 查看文件排序的触发次数
SHOW STATUS LIKE 'Sort_merge_passes'; -- 该值持续增长说明 sort_buffer 太小
-- 查看排序方式
EXPLAIN SELECT * FROM t_order ORDER BY amount DESC LIMIT 100;
-- Extra 列显示 Using filesort 即文件排序sort_buffer_size 调多大合适?
- 256KB:默认值,适合简单查询,500 万行排序会触发几千次归并
- 1MB-4MB:大多数 OLTP 场景的平衡点,256KB→4MB 可减少 90%+ 的归并次数
- 8MB-16MB:适合报表/定时任务场景,但注意每个连接都会分配一个 sort_buffer(并发 100 连接 × 16MB = 1.6GB 内存)
-- 会话级别调整(只影响当前连接)
SET SESSION sort_buffer_size = 4 * 1024 * 1024; -- 4MB
-- 导出任务专用连接,跑完即释放索引设计:排序不走索引的常见误区
误区 1:ORDER BY 字段和 WHERE 字段不在同一个索引里
-- 索引 (status, create_time) 存在
-- 但 WHERE 条件用了另一个字段,导致 ORDER BY 走的不是索引
EXPLAIN SELECT * FROM t_order
WHERE amount > 100 -- amount 不在 (status,create_time) 索引中
ORDER BY create_time DESC -- 排序字段在索引中,但 WHERE 不满足最左前缀
LIMIT 20;
-- Extra: Using where; Using filesort误区 2:ORDER BY 方向与索引定义不一致
-- 索引 (status ASC, create_time ASC)
-- ORDER BY 部分字段 DESC 会导致 filesort(除非 MySQL 8.0 支持 DESC 索引)
EXPLAIN SELECT * FROM t_order
WHERE status = 1
ORDER BY status ASC, create_time DESC -- create_time 方向与索引相反
LIMIT 20;
-- Extra: Using where; Using filesortMySQL 8.0 引入 DESCENDING INDEX 可以解决这个问题:
-- 8.0 创建降序索引
ALTER TABLE t_order ADD INDEX idx_status_ct_desc (status ASC, create_time DESC);误区 3:SELECT * 导致无法走覆盖索引排序
-- 索引 (create_time, id) 覆盖不了 SELECT * 的所有字段
-- 引擎必须回表拿数据,即使 ORDER BY 走的索引,排序后还要回表
-- 回表行数 = LIMIT 偏移量 + LIMIT 行数,深分页时回表量巨大总结
| 优化方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 延迟关联 | 传统页码分页 | 兼容页码跳转,减少回表 IO | 子查询写法稍复杂,子查询仍扫了偏移量 |
| 游标分页 | 信息流/App 列表 | 每次扫描固定行数,性能最优 | 不支持跳页 |
| 子查询定位 | 需要页码跳转,且排序字段稳定 | 不依赖上一页数据,语句简单 | 子查询仍扫了偏移量,依赖排序字段唯一性 |
| 降序索引 | 8.0 以上,ORDER BY 方向固定 | 从根上消除 filesort | 需要 8.0,索引增加写负担 |
面试重点:
- 深分页的本质是扫描行数浪费,不是排序本身慢——偏移量 100000 时扫描 100020 行,99.98% 的行被丢弃
- 文件排序不一定是磁盘操作,sort_buffer 够大就在内存中完成,Extra 的
Using filesort只是说明没走索引排序 - 游标分页必须加第二排序字段处理重复值,这是面试高频追问点
- 延迟关联的收益在深分页场景下随偏移量增大而增大——偏移量越大,相对原始写法的提升倍数越高
- 监控入口:
performance_schema.events_statements_summary_by_digest中rows_examined >> rows_sent的 SQL 是深分页高发区
生产建议:
- 优先用游标分页——如果业务是"下一页"模式(App 列表、信息流),游标分页性能最优,且不受数据量增长影响
- 需要跳页时用延迟关联——传统页码分页且无法改为游标时,延迟关联能大幅减少回表 IO
- 加索引是最便宜的手段——
ORDER BY create_time DESC, id DESC加上联合索引(status, create_time, id),能让排序走索引,避免文件排序。如果排序字段本身就在索引里,延迟关联的收益更大 - 不要盲目调大 sort_buffer_size——每个连接独享一份,并发高时内存占用线性增长
- 监控预警——在
performance_schema.events_statements_summary_by_digest中监控rows_examined远大于rows_sent的 SQL,特别是rows_examined > 10000且rows_sent < 100的,基本就是深分页问题
参考资料
- MySQL 官方文档 - LIMIT Query Optimization:https://dev.mysql.com/doc/refman/8.0/en/limit-optimization.html
- MySQL 官方文档 - ORDER BY Optimization:https://dev.mysql.com/doc/refman/8.0/en/order-by-optimization.html
- MySQL 官方文档 - Descending Indexes:https://dev.mysql.com/doc/refman/8.0/en/descending-indexes.html
- 《高性能 MySQL》第 7 章排序优化