深分页为什么慢:LIMIT/OFFSET 的隐患,以及更稳的 Seek 分页

0 阅读5分钟

前言

分页查询最常见的写法是 LIMIT + OFFSET:

SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 999980;

在数据量小的时候它很正常;

但是数据量到千万级后,它会变得越来越慢,常见表现是延迟随页码增长明显上升,CPU/IO 抖动,甚至出现临时表落盘。

本文解释两个问题:

1. LIMIT/OFFSET 在深分页时为什么会越来越慢

2. 如何用游标/Seek 分页让性能稳定下来

pokemon GIF (1).gif


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 条,常见执行路径是:

  1. 根据 WHERE 先找到候选行
  2. 生成按 ORDER BY 排序后的结果(可能需要额外排序)
  3. 产生中间结果集(sort buffer / 临时表)
  4. 丢弃 OFFSET 的那部分
  5. 返回 LIMIT 的那部分

如果排序路径比较重,那我们会在 EXPLAIN 里看到:

  • Using filesort
  • Using temporary
  • 临时表落盘(磁盘临时文件)
  • rowsrows 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, id
  • id 设置自增,用于排序稳定性(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(重点看 rowsExtra 是否出现 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 跳页方案,也欢迎在评论区提出来,我们互相交流和学习。


最后

今天又看到一个程序员加班猝死了,甚至在抢救时、离世后仍然不停有工作消息找过来,真是荒诞的现实。

说真的,项目可以延期,需求可以砍,公司离了任何一个人都能运转,但命只有一次。

生活总是要负重前行,可一旦感觉有些吃力了,一定要早些休息。

Tired At Home GIF.gif