游标分页在数据变更时保持列表稳定。了解为什么基于偏移的分页在插入和删除时会出问题,以及如何实现可靠的游标方案。

你打开一个信息流,向下滚动,一切都正常,直到出现异常。你看到同一项出现两次。你明明记得看到过的项不见了。你即将点击的一行位置发生了变化,结果跳到了错误的详情页。
这些是用户可见的错误,即使你的 API 响应在单次请求下看起来“正确”。常见症状容易识别:
移动端会更糟。用户暂停、切换应用、失去网络后再继续。在这段时间里,新项被创建,旧项被删除,某些项被编辑。如果你的应用持续用偏移请求“第 3 页”,在用户滚动过程中页面边界可能会改变。结果是一个感觉不稳定、不可信的 feed。
目标很简单:一旦用户开始向前滚动,列表应当像一个快照一样表现。新项可以存在,但它们不应当重排用户已经在翻页的内容。用户应该得到一个平滑、可预测的序列。
没有哪种分页方法是完美的。真实系统有并发写入、编辑和多种排序选项。但游标分页通常比偏移分页更安全,因为它是基于排序中的某个位置分页,而不是基于会移动的行数。
偏移分页是“跳过 N,取 M”的方式。你告诉 API 要跳过多少项(offset)以及返回多少项(limit)。例如 limit=20 就是每页 20 项。
概念上:
GET /items?limit=20&offset=0(第一页)GET /items?limit=20&offset=20(第二页)GET /items?limit=20&offset=40(第三页)响应通常包含项以及请求下一页所需的信息。
{
"items": [
{"id": 101, "title": "..."},
{"id": 100, "title": "..."}
],
"limit": 20,
"offset": 20,
"total": 523
}
它受欢迎是有原因的:与表格、管理列表、搜索结果和简单 feed 很契合。同时在 SQL 中用 LIMIT 和 OFFSET 实现也很直接。
但陷阱在于一个隐藏的假设:数据集在用户分页期间保持静止。现实应用中会有新行插入、行被删除、排序键发生变化。这正是“神秘错误”产生的地方。
偏移分页假设列表在请求间不会移动。但现实中列表会移动。当列表移动时,像“跳过 20”这样的偏移不再指向相同的项。
想象一个按 created_at desc(最新在前)排序、页面大小为 3 的 feed。
你用 offset=0, limit=3 加载第 1 页,得到 [A, B, C]。
现在有一条新项 X 被创建并出现在最前面。列表变成 [X, A, B, C, D, E, F, ...]。你再用 offset=3, limit=3 加载第 2 页。服务器跳过了 [X, A, B] 并返回 [C, D, E]。
你刚刚又看到了 C(重复),后面你还会错过某些项,因为所有内容都往下移动了。
删除会导致相反的问题。假设开始时是 [A, B, C, D, E, F, ...]。你加载第 1 页看到 [A, B, C]。在请求第 2 页之前 B 被删除,列表变为 [A, C, D, E, F, ...]。以 offset=3 请求时会跳过 [A, C, D] 并返回 [E, F, G]。D 成为你永远无法获取的空档。
在最新优先的 feed 中,插入发生在顶部,正是它会移动后续所有偏移的位置。
“稳定列表”是用户期望的体验:当他们向前滚动时,项不会跳动、不会重复,也不会莫名其妙消失。这更多是关于让翻页可预测,而不是冻结时间。
两个概念常被混淆:
created_at,并用 id 作 tiebreaker),这样在相同输入下两次请求返回相同顺序。刷新与向前滚动是不同的动作。刷新意味着“现在给我最新的内容”,所以顶部可以变化。向前滚动意味着“从我停下的地方继续”,因此不应因边界移动而看到重复或意外的空档。
一个简单规则可防止大多数分页 bug:向前滚动时绝不应该看到重复项。
游标分页使用书签而不是页码来遍历列表。客户端不是说“给我第 3 页”,而是说“从这里继续”。
契约很直接:
这对插入和删除更有容错能力,因为游标锚定在排序中的某个位置,而不是行计数。
非我不可的前提是确定性的排序规则。你需要稳固的排序规则和一致的 tiebreaker,否则游标无法作为可靠书签。
先选一种与用户阅读习惯一致的排序。Feed、消息和活动记录通常是最新在前;发票和审计日志这样的历史记录通常从最旧开始更容易理解。
游标必须能唯一标识排序中的一个位置。如果两个项能共享相同的游标值,最终会出现重复或空档。
常见选择与注意点:
created_at:简单,但当许多行共享同一时间戳时不安全。id:若 ID 是单调递增且能反映产品排序,这种方法是安全的,但可能不符合你想要的用户体验顺序。created_at + id:通常是最优的组合(时间戳用于产品友好排序,id 用作 tiebreaker)。updated_at 作为主要排序:对无限滚动来说有风险,因为编辑会改变项的位置。如果你提供多种排序选项,则每种排序模式应被视为不同的列表并有各自的游标规则。一个游标只对一种精确的排序有意义。
你可以保持 API 接口简单:两个输入,两个输出。
发送一个 limit(你想要多少项)和一个可选的 cursor(从哪里继续)。如果没有游标,服务器返回第一页。
示例请求:
GET /api/messages?limit=30&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNlQxMDowMDowMFoiLCJpZCI6Ijk4NzYifQ==
返回项和 next_cursor。如果没有下一页,返回 next_cursor: null。客户端应把游标当作令牌,不要尝试修改它。
{
"items": [ {"id":"9876","created_at":"2026-01-16T10:00:00Z","subject":"..."} ],
"next_cursor": "...",
"has_more": true
}
服务器端逻辑的口语版:按稳定顺序排序,使用游标进行过滤,然后应用 limit。
如果你按 (created_at DESC, id DESC)(最新优先)排序,把游标解码为 (created_at, id),然后获取满足 (created_at, id) 严格小于游标对的行,按相同顺序取 limit 行。
你可以把游标编码为 base64 的 JSON blob(简单)或签名/加密的令牌(更复杂)。不透明令牌更安全,因为它让你以后修改内部实现而不破坏客户端。
另外设定合理默认值:移动端默认常见为 20-30,网页常见为 50,并设置服务器端的最大值以防某个有 bug 的客户端请求 10000 行。
稳定的 feed 主要涉及一个承诺:一旦用户开始向前滚动,未看过的项不应因为他人创建、删除或编辑而跳动。
用游标分页时,插入是最容易处理的。新记录应出现在刷新时,而不是已经加载的页面中间。如果按 created_at DESC, id DESC 排序,新项自然在第一页之前,所以你已有的游标会继续指向更旧的项。
删除不应重排列表。如果一项被删除,它在你本应获取它的那次请求中就不会返回。如果你需要保持页大小一致,可以继续拉取直到收集到 limit 个可见项。
编辑是团队常不经意间重新引入 bug 的地方。关键问题是:编辑是否会改变排序位置?
快照式行为通常更适合滚动列表:按不可变键(如 created_at)分页。编辑可以改变内容,但项不会跳到新位置。
实时 feed 行为按类似 edited_at 排序。那会导致跳动(旧项被编辑后可能移到靠前),如果选择这种方式,应把列表当作不断变化并在 UX 上设计刷新机制。
不要让游标依赖于“找到这条确切的行”。改为编码位置值,例如上次返回项的 {created_at, id}。下一次查询基于值而不是行存在性:
WHERE (created_at, id) < (:created_at, :id)id)以避免重复向前翻页是简单的。更棘手的 UX 问题是后向翻页、刷新与随机访问。
对于后向翻页,通常有两种可行方法:
next_cursor 用于更旧项,prev_cursor 用于更新的项),同时在屏幕上保持一个固定的排序顺序;用游标实现随机跳转更困难,因为“第 20 页”在数据变化时没有稳定含义。如果确需跳转,请跳到一个锚点,例如“围绕此时间戳”或“从此消息 id 开始”,而不是页索引。
在移动端,缓存很重要。按列表状态(查询 + 过滤 + 排序)为每个视图存储游标,并把每个选项卡/视图当作独立列表。这可以防止“切换标签后内容混乱”的现象。
大多数游标分页问题并非数据库本身的问题,而是请求之间的小不一致在真实流量下暴露出来。
最大的问题来源:
created_at),导致平局时产生重复或缺失;next_cursor 与实际返回的最后一行不一致;如果你在像 Koder.ai 这样的平台上构建应用,这些边缘情况会很快显现,因为 Web 和移动客户端常共用同一端点。明确的游标契约和确定性的排序规则能让两个客户端表现一致。
在把分页称为“完成”之前,要在插入、删除和重试的情况下验证行为。
next_cursor 来源于实际返回的最后一行limit 有安全最大值并有文档默认值对于刷新,选一个明确规则:要么用户下拉刷新以获取顶部新项,要么周期性检查“是否有比我的第一项更新的内容?”并显示“有新内容”按钮。清晰一致的规则会让列表感觉稳定,而不是诡异。
想象一个客服收件箱,坐席在网页端使用,经理在移动端查看同一收件箱。列表按最新在前排序。人们期望的是:当他们向前滚动时,项不会跳动、重复或消失。
用偏移分页,坐席加载第 1 页(第 1-20 项),然后滚到第 2 页(offset=20)。在他们阅读期间,两条新消息到达顶部。现在 offset=20 指向的地方和一秒前不同了。用户会看到重复或错过消息。
用游标分页,应用会请求“在此游标之后的下 20 项”,游标基于用户实际看到的最后一项(通常是 (created_at, id))。新消息全天到达,但下一页仍从用户看到的最后一条消息之后开始。
上线前的一个简单测试方法:
如果你快速原型,Koder.ai 可以帮你从聊天提示生成端点和客户端流程脚手架,然后用 Planning Mode、快照与回滚在测试中安全迭代,当分页变更在测试中引发问题时可以快速回退。
偏移分页是“跳过 N 行”的方式,所以当有新行被插入或旧行被删除时,行号会发生变化。同一个偏移量可能在短时间内对应不同的项,这会在用户滚动过程中产生重复和缺失。
游标分页使用一个表示“我看到的最后一项之后的位置”的书签。下一次请求从该位置继续,并遵守确定性的排序规则,因此顶部的插入和中间的删除不会像偏移那样移动你的页面边界。
使用一个确定性的排序并包含一个 tiebreaker,最常见的是使用 (created_at, id)。created_at 提供产品所期望的顺序,id 确保每个位置唯一,从而在时间戳相同的情况下不会重复或跳过项。
按 updated_at 排序会在项被编辑时让它们在页面间跳动,破坏“向前稳定滚动”的预期。如果你需要“最近更新”视图,应在 UI 层设计刷新机制并接受重排序,而不是承诺稳定的无限滚动。
返回一个不透明的令牌作为 next_cursor,客户端原样发送回去即可。一个简单做法是把最后一项的 (created_at, id) 编码成 base64 的 JSON blob,但关键是将其视为不透明值,以便将来能改变实现而不破坏客户端。
不要依赖“找到这行记录”。应从游标值构建下一次查询,而不是查找确切的行。例如即使最后一项被删除,存储的 (created_at, id) 仍然能定义一个位置,从而可以继续使用相同顺序的比较条件安全查询。
使用严格比较并包含唯一的 tiebreaker,并且始终从你实际返回的最后一行生成游标。大多数重复问题来自使用 <= 而非 <、省略 tiebreaker,或从错误的行生成 next_cursor。
选一个清晰规则:刷新用于加载顶部更新的项,而向前滚动从已有游标继续加载更旧的项。不要在同一个游标流里混合“刷新语义”,否则用户会看到重排并认为列表不可靠。
游标只对精确的排序和过滤组合有效。如果客户端更改了排序模式、搜索查询或过滤条件,就必须以无游标的方式重新开始分页,并为每个列表状态单独保存游标。
游标分页适合顺序浏览,但不适合稳定的“第 20 页”跳转,因为数据集会变化。若确有跳转需求,应跳到一个锚点,比如“围绕这个时间戳”或“从这个 id 开始”,然后从该点用游标继续分页。