前言
分页查询最常见的写法是 LIMIT + OFFSET:
SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 999980;
在数据量小的时候它很正常;
但是数据量到千万级后,它会变得越来越慢,常见表现是延迟随页码增长明显上升,CPU/IO 抖动,甚至出现临时表落盘。
本文解释两个问题:
1. LIMIT/OFFSET 在深分页时为什么会越来越慢
2. 如何用游标/Seek 分页让性能稳定下来
OFFSET 并非真正的跳过
OFFSET 在平常用起来很方便,但是它有个问题:数据库通常不是真的直接跳到第 N 条,它需要先产生有序结果,再把前面的丢弃掉。
LIMIT 20 OFFSET 1000000 在效果上等价于:
- 先处理(扫描/过滤/排序)1000020 条
- 丢掉前 1000000 条
- 返回后 20 条
所以深分页的浪费会线性增长:
- 可能只是查 20 条
- 但数据库得处理百万级候选行
这也是为什么同一条 SQL 只要 OFFSET 增大,就会越来越慢。
ORDER BY 也会放大成本:filesort、temporary、落盘
一般情况下,分页肯定还会带上 ORDER BY,那深分页 + ORDER BY 就会把问题继续放大,尤其在以下情况下:
- 排序不能完全利用索引顺序(索引不匹配、排序字段不在合适的联合索引里)
- 个别情况下,查询再带上 join/group by
- 返回列较多,无法覆盖索引,需要回表
属实是雪上加霜了。
比如这个最简单的例子:
SELECT id, created_at, amount
FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20 OFFSET 500000;
为了拿到第 500001~500020 条,常见执行路径是:
- 根据 WHERE 先找到候选行
- 生成按 ORDER BY 排序后的结果(可能需要额外排序)
- 产生中间结果集(sort buffer / 临时表)
- 丢弃 OFFSET 的那部分
- 返回 LIMIT 的那部分
如果排序路径比较重,那我们会在 EXPLAIN 里看到:
Using filesortUsing temporary- 临时表落盘(磁盘临时文件)
rows、rows examined伴随 OFFSET 上升
这类成本就是OFFSET 扩大,导致数据库要做的排序工作也跟着扩大,所以很容易出现后半段延迟陡增、速度暴降的现象。
3)复现实验:1000 万行数据,观察 OFFSET 从 1w 到 100w 的耗时
下面是一个可复现的实验设计(MySQL 为例)。
表结构与索引
CREATE TABLE t_big (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
created_at DATETIME NOT NULL,
payload VARCHAR(100) NOT NULL,
KEY idx_user_created (user_id, created_at, id)
) ENGINE=InnoDB;
说明:
user_id用于过滤(user_id, created_at, id)用于匹配WHERE user_id = ?和ORDER BY created_at, idid设置自增,用于排序稳定性(created_at 可能重复)
测试 SQL
SELECT id, created_at, payload
FROM t_big
WHERE user_id = 42
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET ?;
OFFSET 取值
- 1w、5w、10w、20w、50w、80w、100w
- 再加:每 10w 一个点直到 100w
测量方法
每个 OFFSET:
-
热身 3 次
-
正式跑 5 次,对查询耗时取中位数
-
记录:
- 执行耗时
Rows examined- EXPLAIN / EXPLAIN ANALYZE(重点看
rows、Extra是否出现 temporary/filesort)
通常会出现两类现象:
- Rows examined 大体跟 OFFSET 同量级上升
- 当触发更重的排序/临时表路径时,延迟曲线会出现明显拐点
稳定方案:Seek 分页(Cursor/Keyset Pagination)
解决思路非常直接:不要使用 OFFSET。
那怎么分页?
让数据库从一个确定的位置继续往下取。
也就是前端不传 page=50000,而是传上一页最后一条的排序键(cursor)。
假设排序规则:
ORDER BY created_at DESC, id DESC
上一页最后一条是:
last_created_at = '2026-01-01 10:00:00'last_id = 884211
下一页查询:
SELECT id, created_at, payload
FROM t_big
WHERE user_id = 42
AND (
created_at < :last_created_at
OR (created_at = :last_created_at AND id < :last_id)
)
ORDER BY created_at DESC, id DESC
LIMIT 20;
这类分页的特点是:
- 每次只处理下一页需要的几十条
- 延迟基本与页码无关,而与 page size 相关(更稳定)
- 更容易持续走索引顺序
索引匹配建议
Seek 分页要稳定,还得加上匹配的索引,得和 WHERE + ORDER BY 的字段相匹配:
KEY idx_user_created (user_id, created_at, id)
这里有个小技巧:把过滤列放在前面,把排序列放到在后面,一般能得到更好的执行计划。
注意点:Seek 分页的边界
排序必须稳定
不要只用 created_at 这类时间字段排序。数据量一大、同一时间戳很多时,就很容易出现:
- 翻页时重复数据
- 或者漏数据
正确做法是 时间字段 + 唯一键 组成稳定排序,比如:
(created_at, id)(created_at, order_id)
这样即使 created_at 相同,也能用唯一键把顺序固定下来。
Seek 不适合随便跳到第 N 页
Seek 更适合 “下一页 / 滚动加载” 这种连续翻页场景;
如果让它支持直接跳页,成本会很高(通常要先走很多页才能定位到第 N 页),其实说白了就是不擅长跳页。
如果业务确实强依赖跳页,一般会用这些方案来fallback:
- 先缩小结果集:加搜索条件、按时间段/状态筛选
- 用书签/定位点:按时间分段、按主键区间分段来定位
- 大页跳转走离线化:比如导出、异步任务生成结果再查看
如果有更好的 Seek 跳页方案,也欢迎在评论区提出来,我们互相交流和学习。
最后
今天又看到一个程序员加班猝死了,甚至在抢救时、离世后仍然不停有工作消息找过来,真是荒诞的现实。
说真的,项目可以延期,需求可以砍,公司离了任何一个人都能运转,但命只有一次。
生活总是要负重前行,可一旦感觉有些吃力了,一定要早些休息。