你有没有遇到过这种情况:查询明明不复杂,但越往后翻页面,加载就越慢?

同事说加内存,产品说要换数据库,DBA 看了一眼说先扩容。

结果改了一圈,问题依旧。

我见过最常见的真相是——不是数据库慢,是分页写法挖的坑


一句话戳破 OFFSET 的伪装

OFFSET 写起来干净利落,看起来完全正常:

SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 10000;

第 1 页很快。第 10 页还行。第 500 页,数据库在干什么?

它在数数。

它从头开始,把前 10000 行全扫一遍,然后才把你要的 20 行给你。

返回 20 行,实际扫描了 10020 行。你只看到了冰山露出水面的那 20 行。


为什么越深越慢,不是数据库的错

数据库读取数据的顺序,像图书馆管理员找书。

OFFSET 的逻辑是这样的:管理员从头开始数,数到你指定的页码,然后把这一页的书给你。

第 1 页,数 0 本,OK。 第 500 页,数 10000 本,然后给你 20 本。

用户只感知到"慢"。没人知道背后数了多少。

而且事情会越来越糟。

数据越多 → OFFSET 越深 → 扫描越多 → 延迟越高。

你的产品在长大,查询在变慢,这是个正反馈的诅咒


还有一个隐藏的正确性问题

OFFSET 的问题不只是慢,还有个更讨厌的特性:不稳定

用户正在翻列表,假设他从第 1 页翻到了第 10 页。

这中间,数据库里插入了新数据。

原来第 10 页的开始位置变了,有一行消失了,有一行重新出现了。

用户看到的是:列表里有些内容重复了,有些不见了。

他们不会说"OFFSET 分页在并发写入下有界外效应"。

他们会说:“这列表是不是有 bug?”

他们的感知是对的。


类比时刻:图书馆员的两种找书法

OFFSET = 从头数到第 N 页。

Keyset = 记住上次看到哪,直接从那里继续翻。

第二种快得多,也稳得多。因为它从来不回头数。

图书馆找书法类比:OFFSET vs 游标分页


游标分页:换个思路找数据

Keyset(也叫游标分页)的思路是:

不告诉数据库"给我第 500 页"。 告诉它"给我上一页最后一条之后的 20 条"。

-- 上一页最后一条的 created_at = '2026-03-20 15:00:00'
-- 上一页最后一条的 id = 12345

SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-03-20 15:00:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 20;

(created_at, id) 这个组合就是游标(cursor)。

id 是兜底的,因为 created_at 可能重复。双重排序保证顺序是确定的。


为什么 Keyset 快:索引在帮忙

关键在于索引要配合查询的形状。

先建一个复合索引:

CREATE INDEX posts_created_id_desc_idx
ON posts (created_at DESC, id DESC);

索引的顺序和查询的排序顺序完全一致。

数据库可以从索引的任意位置直接跳到游标位置,然后线性读取后面的 20 行。

不再回头数数,不再从头扫描。

延迟变得可预测,不再随页码增长而恶化。

根据实际测试,同样的 100 万行表,OFFSET 翻到第 500 页延迟通常是第 1 页的 50 倍以上;换成游标分页后,第 1 页和第 500 页的延迟差异在 2 倍以内

OFFSET vs 游标分页性能曲线对比


游标用什么字段?

常见选择:

  • created_at + id:适合动态列表、消息流、活动日志
  • id:适合严格单调递增的主键
  • 业务字段:如 last_viewed_at,适合特定排序逻辑

原则就一条:游标字段不能是易变的。如果排序字段本身会变,分页就会乱。


换分页方式的代价:页码没了

这是唯一的痛点。

OFFSET 可以跳到任意页,“给我第 37 页”。

Keyset 只能往前翻或往后翻,“给我下一页”。

但你想一下:你的用户真的需要跳到第 37 页吗?

动态列表、消息通知、订单记录、审计日志——这些场景,用户的行为模式都是"往下翻",而不是"跳到第 500 页"。

如果产品真的需要页码跳转,有个务实的做法:

  • 低流量内部列表:保留 OFFSET,页码友好,数据量小无伤大雅
  • 高并发用户列表:上 Keyset,稳定和速度更重要

不教条,按场景来。


实操:迁移 checklist

换之前,确保做到这几条:

  • 选一个稳定的排序字段,加上 id 作为 tie breaker
  • 建一个匹配排序顺序的复合索引
  • 游标字段要做 base64 编码后再传给前端,别直接暴露原始 id 和时间戳
  • 接口返回 next_cursor 字段,用户端翻页时带上它
  • 切完要跑一下深层分页的压力测试,只测第 1 页是假的

换个写法,改变的不只是速度

很多团队迁移的动机是快,但留下来的理由是省心

Keyset 分页带来了这些额外的好处:

  • 并发写入下,列表不再乱跳
  • 减少"列表显示重复/缺失"这类玄学报障
  • API 契约更清晰,翻页逻辑更简单
  • 索引设计更规范

换成分页思维后,想问题的角度也不一样了。

以前问:“这是第几页?” 现在问:“下一页的边界在哪?”

这个问题更干净,数据库也更擅长回答。


常见问题

OFFSET 分页在什么数据量级会开始变慢?

通常在 10 万行以上就能感知到差异,超过 100 万行后第 500 页的延迟会明显高于第 1 页。不过具体表现也取决于服务器硬件和并发量,但本质问题始终存在。

游标分页可以跳到任意一页吗?

不能,这是它最大的取舍。如果产品确实需要"跳到第 X 页"的功能(常见于搜索引擎结果),游标分页不适用,这时建议用 OFFSET 配合限制条件,或者混用两种策略。

顺便分享一个我常用的 AI自媒体运营工具 ,覆盖内容排版、素材处理和效率提效,写公众号会更省力。

最近在玩 Claude Code 的新功能,发现官方出了个 Codex 增强版门户 ,把各种智能编程能力整合在一起了,感兴趣可以去看看。

如果你想看到更多这类工具测评和 AI 编程实战,欢迎关注我的公众号「梦兽编程交个朋友」,每周更新。