别再骂数据库慢了,是你的分页在拖后腿

你有没有遇到过这种情况:查询明明不复杂,但越往后翻页面,加载就越慢?
同事说加内存,产品说要换数据库,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 = 记住上次看到哪,直接从那里继续翻。
第二种快得多,也稳得多。因为它从来不回头数。

游标分页:换个思路找数据
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 倍以内。

游标用什么字段?
常见选择:
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 编程实战,欢迎关注我的公众号「梦兽编程交个朋友」,每周更新。