2025年10月03日·阅读约1分钟

用于快速管理界面 API 的游标分页与偏移分页

了解游标分页与偏移分页,并使用一致的 API 合约来处理排序、过滤和总数,从而保持 Web 与移动端管理界面的响应速度。

用于快速管理界面 API 的游标分页与偏移分页

为什么分页会让管理界面感觉慢

管理界面通常从一个简单的表格开始:加载前 25 行,加一个搜索框,完成。在数据只有几百条时,这感觉是瞬时的。然后数据集增长,相同的界面就开始变得卡顿。

通常的问题不在于 UI,而在于 API 在返回第 12 页并应用排序和过滤之前必须完成的工作。随着表变大,后端花更多时间去查找匹配项、计数并跳过先前的结果。如果每次点击都触发更重的查询,界面看起来就像在“思考”而不是响应。

你通常会在相同的地方注意到这个问题:翻页随着时间变慢,排序变得迟钝,搜索在不同页面间不一致,且无限滚动加载有时是突发的(先快然后突然变慢)。在繁忙系统中,当数据在请求间发生变化时,你甚至可能看到重复或缺失的行。

Web 和移动端 UI 也推动分页朝不同方向发展。Web 管理表格鼓励跳转到特定页面并根据许多列排序。移动屏幕通常使用无限列表加载下一块内容,用户期望每次拉取都同样快速。如果 API 仅基于页码构建,移动端常受影响;如果仅基于 next/after,Web 表格可能会感觉受限。

目标不仅是返回 25 条。目标是快速、可预测的分页,随着数据增长保持稳定,并提供适用于表格和无限列表的统一规则。

UI 依赖的分页基础

分页是将长列表拆分成较小片段,以便屏幕能快速加载和渲染。UI 不再请求所有记录,而是请求下一段结果。

最重要的控制是页面大小(通常称为 limit)。较小的页面通常感觉更快,因为服务器工作更少,应用渲染的行也更少。但页面太小会让用户频繁点击或滚动,体验变差。对于许多管理表格,25 到 100 条是实用范围,移动端通常偏好较低值。

稳定的排序比大多数团队预期的更重要。如果顺序在请求间可以改变,用户在翻页时会看到重复或缺失的行。稳定排序通常意味着按主字段(如 created_at)加上一个补充键(如 id)排序。无论使用偏移还是游标分页,这点都很重要。

从客户端角度看,分页响应应包含项目、下一页提示(页码或游标令牌)以及界面真正需要的计数。有些界面需要精确总数来显示“1-50 共 12,340”,而有些只需要 has_more

偏移分页:如何工作及其痛点

偏移分页是经典的第 N 页方法。客户端请求固定数量的行并告诉 API 先跳过多少行。你会看到它以 limitoffset 的形式,或以 pagepageSize 的形式由服务器转换为偏移量。

一个典型请求看起来像:

  • GET /tickets?limit=50\u0026offset=950
  • “给我 50 条票据,跳过前 950 条。”

它匹配常见的管理需求:跳到第 20 页,扫描较旧记录,或分块导出大列表。内部也易于讨论:“看第 3 页你就能看到它。”

问题在于深页。许多数据库在返回页面前仍然必须越过被跳过的行,尤其是在排序没有紧密索引支撑时。第 1 页可能很快,但第 200 页可能显著变慢,这正是当用户滚动或跳转时让管理界面感觉滞后的原因。

另一个问题是数据变化时的一致性。想象一下支持经理在按最新排序的情况下打开第 5 页票据。查看时新的票据到来或旧票据被删除。插入会将项向前移动(造成跨页重复)。删除会将项向后移动(记录从用户的浏览路径中消失)。

偏移分页在小表、数据集稳定或一次性导出时仍然可以胜任。在大型、活跃的表上,边缘情况会很快出现。

游标分页:如何工作以及为何持续稳定

游标分页使用游标作为书签。客户端不是说“给我第 7 页”,而是说“从这个准确项之后继续”。游标通常编码最后一条记录的排序值(例如 created_atid),以便服务器能从正确位置恢复。

请求通常只有:

  • limit:要返回多少项
  • cursor:来自上一次响应的不透明令牌(通常称为 after

响应返回项目和一个指向该片段结尾的新游标。实用上的差别在于,游标不会要求数据库去计数并跳过行,而是要求从已知位置开始。

这就是游标分页在向前滚动列表中保持快速的原因。借助良好的索引,数据库可以跳到“在 X 之后的项”,然后读取接下来的 limit 行。使用偏移时,服务器通常必须扫描(或至少跳过)越来越多的行,随着偏移量增长性能下降。

就 UI 行为而言,游标分页使“下一页”变得自然:你拿到返回的游标并在下一次请求时发送回去。“上一页”是可选且更棘手的。有些 API 支持 before 游标,而有些通过反向获取并翻转结果来实现上一页功能。

何时选择游标、偏移或混合方案

快速上线管理面板
快速创建像 Tickets、Orders、Users 这样的内部工具,并复用列表行为。
构建管理面板

选择始于人们如何实际使用列表。

当用户主要向前移动且速度最重要时,游标分页最适合:活动日志、聊天、订单、工单、审计记录以及大多数移动端无限滚动。当有人浏览时,新行插入或删除时它也更稳健。

当用户频繁跳转时,偏移分页更有意义:经典的带页码的管理表格、跳转到某页以及快速来回浏览。它易于解释,但在大型数据集上会变慢,且在数据变动时一致性较差。

实际决策方法:

  • 当主要操作是“下一页、下一页”时选择游标。
  • 当“跳到第 N 页”是实际需求时选择偏移。
  • 把总数视为可选。在巨表上精确总数可能代价昂贵。

混合方案很常见。一种做法是使用基于游标的 next/prev 保持速度,同时对小的、过滤后的子集提供可选的页跳模式,在那种情况下偏移仍然快速。另一种是基于缓存快照的页码计算,这样表格看起来熟悉而不会把每次请求变成重查询。

一个在 Web 与移动上通用的一致 API 合约

当每个列表端点行为一致时,管理 UI 会感觉更快。UI 可以变化(带页码的 Web 表、移动端的无限滚动),但 API 合约应保持稳定,这样就不必为每个界面重新学习分页规则。

一个实用合约有三部分:行、分页状态和可选总数。在不同端点间保持相同的字段名(tickets、users、orders),即便底层分页模式不同,也易于复用。

下面是一种同时适用于 Web 和移动的响应结构:

{
  "data": [ { "id": "...", "createdAt": "..." } ],
  "page": {
    "mode": "cursor",
    "limit": 50,
    "nextCursor": "...",
    "prevCursor": null,
    "hasNext": true,
    "hasPrev": false
  },
  "totals": {
    "count": 12345,
    "filteredCount": 120
  }
}

有几点让它易于重用:

  • page.mode 告诉客户端服务器正在使用什么模式,同时字段名不变。
  • limit 始终是请求的页面大小。
  • 即便为 null,也应包含 nextCursorprevCursor
  • totals 为可选;如果开销大,仅在客户端请求时返回。

Web 表可以通过保持自己的页索引并多次调用 API 来显示“第 3 页”。移动列表可以忽略页码,只请求下一块数据。

如果你同时为 Web 与移动管理界面构建后端(例如使用 AppMaster),像这样的稳定合约会很快产生收益。相同的列表行为可以在多个屏幕间复用,而无需为每个端点编写自定义分页逻辑。

让分页保持稳定的排序规则

让你的 API 合约一致
统一响应结构一次定义,复用到每个列表端点。
开始使用

排序是分页通常出错的地方。如果顺序在请求间会改变,用户会看到重复、间隙或“丢失”的行。

把排序作为合约而不是建议。发布允许的排序字段和方向,并拒绝其它不在列表中的选项。这样可以让 API 可预测,并防止客户端请求在开发时看起来无害但在生产中很慢的排序。

稳定排序需要唯一补充键。如果按 created_at 排序而两条记录有相同时间戳,就要加入 id(或其它唯一列)作为最后的排序键。否则数据库可以在相等值间返回任意顺序。

实用规则:

  • 只允许在有索引、明确定义的字段上排序(例如 created_atupdated_atstatuspriority)。
  • 始终把唯一补充键作为最终键(例如 id ASC)。
  • 定义默认排序(例如 created_at DESC, id DESC),并在客户端间保持一致。
  • 记录 null 的排序方式(例如日期和数字使用“nulls last”)。

排序也驱动游标生成。游标应按排序顺序编码最后一条记录的排序值,包括补充键,以便下一页能使用该元组查询“after”。如果排序更改,旧游标将失效。把排序参数视为游标合约的一部分。

不破坏合约的过滤与 totals

过滤应独立于分页。UI 是在说“给我不同的数据集”,然后再说“对该数据集分页”。如果把过滤字段混入分页令牌,或将过滤视为可选且不校验,就会出现难以调试的行为:空页、重复或游标突然指向不同数据集。

一个简单规则:过滤在查询参数(或 POST 的请求体)中明示,游标是不透明的并且仅对确切的过滤加排序组合有效。如果用户更改任何过滤(状态、日期范围、负责人),客户端应丢弃旧游标并从头开始。

严格控制允许的过滤字段有助于保护性能并保持可预测性:

  • 拒绝未知过滤字段(不要默默忽略)。
  • 验证类型与范围(日期、枚举、ID 等)。
  • 限制大范围过滤(例如 IN 列表最多 50 个 ID)。
  • 对数据与 totals 应用相同过滤(避免数字不匹配)。

Totals 是许多 API 变慢的地方。带多个过滤条件时,在大表上精确计数可能非常耗时。一般有三种选择:精确、估计或不返回。精确适用于小数据集或确实需要“显示 1-25 共 12,431”的场景。估计对管理界面通常足够。若只需“加载更多”,不返回总数也没问题。

为避免每次请求变慢,让 totals 可选:只有在客户端请求时计算(例如加标记 includeTotal=true)、对相同过滤短期缓存,或仅在第一页返回 totals。

逐步:设计并实现端点

让管理界面感觉瞬时响应
创建一个随着数据增长仍然响应迅速的管理表后台。
开始项目

从默认开始。列表端点需要稳定排序并为共享值添加补充键。例如:createdAt DESC, id DESC。补充键(id)能防止在新记录加入时出现重复与间隙。

定义一个请求格式并保持简单。典型参数是 limitcursor(或 offset)、sortfilters。如果支持两种模式,让它们互斥:客户端要么发送 cursor,要么发送 offset,但不能同时发送两者。

保持一致的响应合约,以便 Web 与移动 UI 共享同一列表逻辑:

  • items:记录页面
  • nextCursor:获取下一页的游标(或 null
  • hasMore:布尔值,让 UI 决定是否显示“加载更多”
  • total:匹配记录总数(如果计数昂贵则为 null,除非请求)

实现层面两种方法分歧明显。

偏移查询通常是 ORDER BY ... LIMIT ... OFFSET ...,在大表上会变慢。

游标查询使用基于最后一条记录的寻址条件:比如“给我 (createdAt, id) 小于最后 (createdAt, id) 的项”。这能保持性能更稳定,因为数据库可以利用索引。

在上线前添加保护措施:

  • 限制 limit(例如最大 100)并设默认值。
  • 校验 sort 是否在允许列表中。
  • 按类型验证过滤并拒绝未知键。
  • cursor 不透明(对最后排序值进行编码)并拒绝格式错误的游标。
  • 决定 total 如何被请求。

用在数据变化下的测试覆盖场景:在请求间创建和删除记录,更新影响排序的字段,验证不会看到重复或缺失的行。

示例:在 Web 与移动上都保持快速的工单列表

安全地变更需求
当需求变更时重新生成干净的源码,避免技术债累积。
生成代码

支持团队打开管理界面查看最新工单。他们需要即使在新工单到来和代理更新旧工单时也感觉瞬时响应。

在 Web 上,UI 是表格。默认按 updated_at(最新优先)排序,团队经常筛选为 Open 或 Pending。相同端点可通过稳定排序和游标令牌支持两种操作。

GET /tickets?status=open\u0026sort=-updated_at\u0026limit=50\u0026cursor=eyJ1cGRhdGVkX2F0IjoiMjAyNi0wMS0yNVQxMTo0NTo0MloiLCJpZCI6IjE2OTMifQ==

响应对 UI 保持可预测:

{
  "items": [{"id": 1693, "subject": "Login issue", "status": "open", "updated_at": "2026-01-25T11:45:42Z"}],
  "page": {"next_cursor": "...", "has_more": true},
  "meta": {"total": 128}
}

在移动端,相同端点为无限滚动提供支持。应用每次加载 20 条工单,然后发送 next_cursor 获取下一批。没有页码逻辑,当记录变动时也更少惊喜。

关键在于游标编码了“最后看到的位置”(例如 updated_atid 作为补充键)。如果某工单在用户滚动时被更新,它可能在下一次刷新时移动到列表顶部,但不会在已滚动的 feed 中造成重复或间隙。

Totals 很有用,但在大数据集上代价高。一个简单规则是仅当用户应用了过滤(例如 status=open)或明确请求时才返回 meta.total

导致重复、间隙和延迟的常见错误

大多数分页 Bug 并不在数据库,它们来自一些看似无害的 API 决策,这些决策在测试时没问题,但在数据请求间变化时就会崩溃。

导致重复(或缺失)最常见的原因是按非唯一字段排序。如果按 created_at 排序且两项时间戳相同,顺序可能在请求间翻转。修复很简单:始终添加稳定的补充键,通常是主键,并把排序视为对 (created_at desc, id desc) 这样的对。

另一个常见问题是任由客户端请求任意页面大小。一次大的请求会冲击 CPU、内存与响应时间,从而拖慢所有管理界面。选一个合理默认值并设定硬上限,客户端请求超限时返回错误。

Totals 也会造成问题。在每次请求上统计所有匹配行在带过滤的大表上可能成为最慢环节。如果 UI 需要 totals,就仅在请求时获取(或返回估计值),避免在加载列表时阻塞滚动。

最容易引起间隙、重复和延迟的错误:

  • 排序没有唯一补充键(顺序不稳定)
  • 不限制页面大小(服务器过载)
  • 每次都返回 totals(慢查询)
  • 在一个端点混用偏移和游标规则(客户端行为混乱)
  • 在过滤或排序更改后重用相同游标(结果错误)

当过滤或排序更改时重置分页。把新过滤视为一次新的搜索:清除游标/偏移并从第一页开始。

上线前的快速检查清单

把 API 连接到真实 UI
将你的分页规则连接到实际 UI,使用 AppMaster 的 UI 构建工具。
构建 Web 应用

和 API、UI 并排运行一次这个检查。大多数问题出现在列表屏与服务器间的合约上。

  • 默认排序稳定并包含唯一补充键(例如 created_at DESC, id DESC)。
  • 排序字段与方向在白名单内。
  • 强制最大页面大小,并设合理默认值。
  • 游标令牌为不透明,格式错误的游标以可预测方式失败。
  • 任何过滤或排序更改都会重置分页状态。
  • totals 行为明确:精确、估计或省略。
  • 同一合约支持表格和无限滚动,无需特殊情况处理。

下一步:标准化你的列表并保持一致

选一个每天都会用到的管理列表并把它做成金标准。像 Tickets、Orders 或 Users 这样的繁忙表格是很好的起点。一旦那个端点感觉快速且可预测,就把相同合约复制到其余管理界面。

把合约写下来,哪怕很简短。明确 API 接受什么并返回什么,这样 UI 团队不会靠猜测并意外为不同端点发明不同规则。

一个简单标准可适用于每个列表端点:

  • 允许的排序:精确字段名、方向以及明确默认(加上 id 之类的补充键)。
  • 允许的过滤:哪些字段可被过滤、值格式以及无效过滤时的行为。
  • totals 行为:何时返回计数、何时返回“未知”、何时省略。
  • 响应形状:一致键(items、分页信息、应用的排序/过滤、totals)。
  • 错误规则:一致的状态码与可读的校验信息。

如果你用 AppMaster (appmaster.io) 构建这些管理界面,尽早标准化分页合约会很有帮助。你可以在 Web 应用与原生移动应用之间复用相同行为,减少日后追逐分页边缘情况的时间。

常见问题

偏移分页和游标分页的真实区别是什么?

偏移分页使用 limitoffset(或 page/pageSize)来跳过行,因此当数据库需要跳过更多记录时,较深的页面通常会变慢。游标分页使用基于最后一条记录排序值的 after 令牌,因此它可以跳到已知位置并在向前翻页时保持快速。

为什么我的管理表越翻页感觉越慢?

因为第 1 页通常很便宜,但第 200 页会迫使数据库在返回结果前跳过大量行。如果你同时进行排序和过滤,工作量会增加,所以每次点击更像是一次沉重查询而不是快速取回。

如何防止用户分页时出现重复或缺失的行?

始终使用带有唯一补充键的稳定排序,例如 created_at DESC, id DESCupdated_at DESC, id DESC。没有补充键时,具有相同时间戳的记录可能在请求间顺序交换,这是重复或“丢失”行的常见原因。

什么时候我应优先使用游标分页?

当人们主要向前浏览且速度很重要时使用游标分页,例如活动日志、工单、订单和移动端的无限滚动。它在插入或删除新行时也更一致,因为游标把下一页锚定在确切的“最后看到”位置。

偏移分页何时仍然适用?

当“跳转到第 N 页”是真正的 UI 功能且用户经常来回跳转时,偏移分页更合适。它也适用于小表或数据集稳定的情况,那时深页变慢和结果移动不太可能成为问题。

一致的分页 API 响应应包含什么?

在端点间保持统一的响应结构,并包含 items、分页状态和可选总数。一个实用的默认是返回 items、一个 page 对象(带 limitnextCursor/prevCursoroffset)以及像 hasNext 这样的轻量标记,这样 Web 表格和移动列表都能重用相同的客户端逻辑。

为什么 totals(总数)会让分页变慢,有什么更安全的默认?

因为在大型且带过滤条件的数据表上做精确 COUNT(*) 可能是最慢的部分,会让每次翻页都变慢。更安全的默认是将总数设为可选,仅在客户端请求时返回,或者当 UI 只需“加载更多”时返回 has_more

当过滤或排序更改时,游标应该如何处理?

把过滤器当作数据集的一部分,并且把游标视为仅对确切的过滤和排序组合有效。如果用户更改了任何过滤或排序,应重置分页并从第一页开始;在更改后重用旧游标通常会导致空页或混乱的结果。

如何使排序对分页既快速又可预测?

对允许的排序字段和方向做白名单并拒绝其它请求,这样客户端就不会意外请求缓慢或不稳定的排序。优先对有索引的字段排序,并始终在最后追加一个唯一的补充键,比如 id,以确保各请求间顺序确定。

发布分页端点前我应该添加哪些保护措施?

在发布前添加这些保护措施:强制最大 limit、验证过滤和排序参数、让游标令牌不透明并严格校验。如果你在 AppMaster 中构建管理界面,跨所有列表端点保持这些规则一致可以减少每个屏幕的分页修补工作。

容易上手
创造一些 惊人的东西

使用免费计划试用 AppMaster。
准备就绪后,您可以选择合适的订阅。

开始吧
用于快速管理界面 API 的游标分页与偏移分页 | AppMaster