1 分钟

使用游标分页让 API 列表稳定,远离神秘 Bug

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

使用游标分页让 API 列表稳定,远离神秘 Bug

问题:在你眼前悄然改变的列表

你打开一个信息流,向下滚动,一切都正常,直到出现异常。你看到同一项出现两次。你明明记得看到过的项不见了。你即将点击的一行位置发生了变化,结果跳到了错误的详情页。

这些是用户可见的错误,即使你的 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 中用 LIMITOFFSET 实现也很直接。

但陷阱在于一个隐藏的假设:数据集在用户分页期间保持静止。现实应用中会有新行插入、行被删除、排序键发生变化。这正是“神秘错误”产生的地方。

为什么偏移在插入或删除时会出问题

偏移分页假设列表在请求间不会移动。但现实中列表会移动。当列表移动时,像“跳过 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 中,插入发生在顶部,正是它会移动后续所有偏移的位置。

对 Web 和移动端来说,稳定列表意味着什么

“稳定列表”是用户期望的体验:当他们向前滚动时,项不会跳动、不会重复,也不会莫名其妙消失。这更多是关于让翻页可预测,而不是冻结时间。

两个概念常被混淆:

  • 稳定排序:有明确的排序规则(例如 created_at,并用 id 作 tiebreaker),这样在相同输入下两次请求返回相同顺序。
  • 稳定翻页:一旦用户开始向前滚动,下一页应当从他们看到的最后一项继续,即便有新项被添加或某些项被删除。

刷新与向前滚动是不同的动作。刷新意味着“现在给我最新的内容”,所以顶部可以变化。向前滚动意味着“从我停下的地方继续”,因此不应因边界移动而看到重复或意外的空档。

一个简单规则可防止大多数分页 bug:向前滚动时绝不应该看到重复项。

游标分页:简单思路

游标分页使用书签而不是页码来遍历列表。客户端不是说“给我第 3 页”,而是说“从这里继续”。

契约很直接:

  • API 返回一批项以及一个表示最后一项之后位置的游标。
  • 客户端把该游标发回以获取下一批。

这对插入和删除更有容错能力,因为游标锚定在排序中的某个位置,而不是行计数。

非我不可的前提是确定性的排序规则。你需要稳固的排序规则和一致的 tiebreaker,否则游标无法作为可靠书签。

选择游标与排序规则

Scaffold a messages API
Create a deterministic created_at plus id cursor contract for web and mobile in one place.

先选一种与用户阅读习惯一致的排序。Feed、消息和活动记录通常是最新在前;发票和审计日志这样的历史记录通常从最旧开始更容易理解。

游标必须能唯一标识排序中的一个位置。如果两个项能共享相同的游标值,最终会出现重复或空档。

常见选择与注意点:

  • created_at:简单,但当许多行共享同一时间戳时不安全。
  • id:若 ID 是单调递增且能反映产品排序,这种方法是安全的,但可能不符合你想要的用户体验顺序。
  • created_at + id:通常是最优的组合(时间戳用于产品友好排序,id 用作 tiebreaker)。
  • updated_at 作为主要排序:对无限滚动来说有风险,因为编辑会改变项的位置。

如果你提供多种排序选项,则每种排序模式应被视为不同的列表并有各自的游标规则。一个游标只对一种精确的排序有意义。

逐步:一个干净的游标 API 形态

你可以保持 API 接口简单:两个输入,两个输出。

1) 请求:limit + cursor

发送一个 limit(你想要多少项)和一个可选的 cursor(从哪里继续)。如果没有游标,服务器返回第一页。

示例请求:

GET /api/messages?limit=30&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNlQxMDowMDowMFoiLCJpZCI6Ijk4NzYifQ==

2) 响应:items + next_cursor

返回项和 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 行。

3) 游标编码:不透明更好

你可以把游标编码为 base64 的 JSON blob(简单)或签名/加密的令牌(更复杂)。不透明令牌更安全,因为它让你以后修改内部实现而不破坏客户端。

另外设定合理默认值:移动端默认常见为 20-30,网页常见为 50,并设置服务器端的最大值以防某个有 bug 的客户端请求 10000 行。

处理插入、删除和编辑

Replace offset paging safely
Use Planning Mode to map the change, then generate the API and client flows.

稳定的 feed 主要涉及一个承诺:一旦用户开始向前滚动,未看过的项不应因为他人创建、删除或编辑而跳动。

用游标分页时,插入是最容易处理的。新记录应出现在刷新时,而不是已经加载的页面中间。如果按 created_at DESC, id DESC 排序,新项自然在第一页之前,所以你已有的游标会继续指向更旧的项。

删除不应重排列表。如果一项被删除,它在你本应获取它的那次请求中就不会返回。如果你需要保持页大小一致,可以继续拉取直到收集到 limit 个可见项。

编辑是团队常不经意间重新引入 bug 的地方。关键问题是:编辑是否会改变排序位置?

选择快照式行为或实时行为

快照式行为通常更适合滚动列表:按不可变键(如 created_at)分页。编辑可以改变内容,但项不会跳到新位置。

实时 feed 行为按类似 edited_at 排序。那会导致跳动(旧项被编辑后可能移到靠前),如果选择这种方式,应把列表当作不断变化并在 UX 上设计刷新机制。

当游标所指的项已不存在时

不要让游标依赖于“找到这条确切的行”。改为编码位置值,例如上次返回项的 {created_at, id}。下一次查询基于值而不是行存在性:

  • 对于降序:WHERE (created_at, id) < (:created_at, :id)
  • 始终包含 tiebreaker(id)以避免重复
  • 如果最后一项被删除,这些值依然可用
  • 如果最后一项被编辑,只要排序键是不可变的,仍然可行

后向翻页、刷新与跳转

向前翻页是简单的。更棘手的 UX 问题是后向翻页、刷新与随机访问。

对于后向翻页,通常有两种可行方法:

  • 返回双向游标(next_cursor 用于更旧项,prev_cursor 用于更新的项),同时在屏幕上保持一个固定的排序顺序;
  • 仅保留单向游标,但在用户向上滚动时请求反向排序。

用游标实现随机跳转更困难,因为“第 20 页”在数据变化时没有稳定含义。如果确需跳转,请跳到一个锚点,例如“围绕此时间戳”或“从此消息 id 开始”,而不是页索引。

在移动端,缓存很重要。按列表状态(查询 + 过滤 + 排序)为每个视图存储游标,并把每个选项卡/视图当作独立列表。这可以防止“切换标签后内容混乱”的现象。

导致神秘错误的常见失误

大多数游标分页问题并非数据库本身的问题,而是请求之间的小不一致在真实流量下暴露出来。

最大的问题来源:

  • 使用非唯一的游标(例如仅 created_at),导致平局时产生重复或缺失;
  • 返回的 next_cursor 与实际返回的最后一行不一致;
  • 在页面请求之间改变过滤或排序;
  • 在同一端点混用偏移与游标。

如果你在像 Koder.ai 这样的平台上构建应用,这些边缘情况会很快显现,因为 Web 和移动客户端常共用同一端点。明确的游标契约和确定性的排序规则能让两个客户端表现一致。

上线前的快速核对清单

Deploy your feed quickly
Generate the service, then deploy and host it from your Koder.ai workspace.

在把分页称为“完成”之前,要在插入、删除和重试的情况下验证行为。

  • 排序是明确且确定性的,并包含 tiebreaker
  • 每次请求重复相同的过滤和排序字段
  • next_cursor 来源于实际返回的最后一行
  • limit 有安全最大值并有文档默认值
  • 刷新行为已定义(新项如何出现)

对于刷新,选一个明确规则:要么用户下拉刷新以获取顶部新项,要么周期性检查“是否有比我的第一项更新的内容?”并显示“有新内容”按钮。清晰一致的规则会让列表感觉稳定,而不是诡异。

一个现实例子:在多设备间保持稳定的收件箱

想象一个客服收件箱,坐席在网页端使用,经理在移动端查看同一收件箱。列表按最新在前排序。人们期望的是:当他们向前滚动时,项不会跳动、重复或消失。

用偏移分页,坐席加载第 1 页(第 1-20 项),然后滚到第 2 页(offset=20)。在他们阅读期间,两条新消息到达顶部。现在 offset=20 指向的地方和一秒前不同了。用户会看到重复或错过消息。

用游标分页,应用会请求“在此游标之后的下 20 项”,游标基于用户实际看到的最后一项(通常是 (created_at, id))。新消息全天到达,但下一页仍从用户看到的最后一条消息之后开始。

上线前的一个简单测试方法:

  • 在拉取页面的同时运行脚本每秒插入新消息
  • 在滚动过程中删除几条消息
  • 编辑一条消息(但不改变其排序字段)
  • 确认你不会看到重复、空档或乱序的项
  • 验证移动端和网页端显示一致的页面边界

如果你快速原型,Koder.ai 可以帮你从聊天提示生成端点和客户端流程脚手架,然后用 Planning Mode、快照与回滚在测试中安全迭代,当分页变更在测试中引发问题时可以快速回退。

常见问题

为什么我用偏移分页会遇到重复或缺失的项?

偏移分页是“跳过 N 行”的方式,所以当有新行被插入或旧行被删除时,行号会发生变化。同一个偏移量可能在短时间内对应不同的项,这会在用户滚动过程中产生重复和缺失。

游标分页如何防止“列表在我眼前改变”这一问题?

游标分页使用一个表示“我看到的最后一项之后的位置”的书签。下一次请求从该位置继续,并遵守确定性的排序规则,因此顶部的插入和中间的删除不会像偏移那样移动你的页面边界。

应该用什么作为游标:created_at、id,还是两者?

使用一个确定性的排序并包含一个 tiebreaker,最常见的是使用 (created_at, id)created_at 提供产品所期望的顺序,id 确保每个位置唯一,从而在时间戳相同的情况下不会重复或跳过项。

可以用 updated_at 来做实时 feed 的分页吗?

updated_at 排序会在项被编辑时让它们在页面间跳动,破坏“向前稳定滚动”的预期。如果你需要“最近更新”视图,应在 UI 层设计刷新机制并接受重排序,而不是承诺稳定的无限滚动。

游标分页的 API 响应应该包含什么?

返回一个不透明的令牌作为 next_cursor,客户端原样发送回去即可。一个简单做法是把最后一项的 (created_at, id) 编码成 base64 的 JSON blob,但关键是将其视为不透明值,以便将来能改变实现而不破坏客户端。

如果游标指向的那条记录被删除,会发生什么?

不要依赖“找到这行记录”。应从游标值构建下一次查询,而不是查找确切的行。例如即使最后一项被删除,存储的 (created_at, id) 仍然能定义一个位置,从而可以继续使用相同顺序的比较条件安全查询。

如何在获取下一页时避免重复?

使用严格比较并包含唯一的 tiebreaker,并且始终从你实际返回的最后一行生成游标。大多数重复问题来自使用 <= 而非 <、省略 tiebreaker,或从错误的行生成 next_cursor

刷新与游标分页应该如何协作?

选一个清晰规则:刷新用于加载顶部更新的项,而向前滚动从已有游标继续加载更旧的项。不要在同一个游标流里混合“刷新语义”,否则用户会看到重排并认为列表不可靠。

用户更改过滤或排序后可以重用相同的游标吗?

游标只对精确的排序和过滤组合有效。如果客户端更改了排序模式、搜索查询或过滤条件,就必须以无游标的方式重新开始分页,并为每个列表状态单独保存游标。

如何用游标支持跳转到特定页面或随机访问?

游标分页适合顺序浏览,但不适合稳定的“第 20 页”跳转,因为数据集会变化。若确有跳转需求,应跳到一个锚点,比如“围绕这个时间戳”或“从这个 id 开始”,然后从该点用游标继续分页。

Related posts