#TypeScript#DevOps

REST API 分页设计:offset vs cursor

分页看着简单,做错就慢、漏数据、翻不动。本文讲清两种分页:offset(简单但有性能与一致性坑)vs cursor(高效稳定但实现复杂)、各自适用、无限滚动的实现、以及分页要返回的元数据(total/hasMore/cursor),让你的 API 分页既快又稳。

本文要点

  • 主题聚焦:TypeScript、DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年6月1日·更新于 2026年7月23日

分页可能是 REST API 里最"看着简单"的功能之一。随便给个 ?page=3&size=20,能跑通,联调通过,上线。直到有一天,用户反馈"我往下翻怎么翻出了刚才看过的内容",DBA 找上门说"这条慢查询把整个库拖垮了",你才发现分页这玩意儿没那么简单。

分页本质上是在回答一个问题:在一个不断变化的大集合里,如何稳定、高效地取出"一段"。这里有两个关键词——稳定(数据一边写入一边读,不能重复、不能跳过)和高效(翻到第一万页也不能慢)。这两种诉求,直接把分页方案分成了两大阵营:offset 和 cursor。下面把这两派的底层逻辑、坑、适用场景和工程实现讲透。

一、offset 分页:简单,但有性能与一致性的双重陷阱

offset 分页是我们最熟悉的形态:

text
GET /api/articles?page=3&size=20

后端把它翻译成 SQL:

sql
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;

意思很直白:按时间倒序,跳过前 40 条,取 20 条。逻辑清晰、支持跳页、前端表格组件天然兼容,这是它最大的优点,也是绝大多数管理后台、报表系统的默认选择。

但 offset 有两个绕不开的坑。

第一个坑:深翻性能急剧下降。

OFFSET 40 在数据库眼里并不是"直接跳到第 40 行"。绝大多数数据库引擎(Mysql、Postgres 都一样)的实际执行方式是:先扫描前 40 行,然后把它们丢掉,再返回接下来的 20 行。也就是说,OFFSET 越大,数据库做的工作越多。

当你翻到 ?page=5000&size=20 时,数据库实际上扫描了 10 万行,只为了给你返回 20 行。这个查询随着页码线性恶化,在百万级数据上很容易拖垮数据库。即使你在 created_at 上建了索引,深翻依然会触发"索引扫描 + 回表"的大量随机 IO,慢查询日志里它就是个常客。

第二个坑:数据漂移(Data Drift)。

假设用户在第 2 页(展示 id 2140),此时有人新发了一篇文章,插到了列表顶部,所有数据的"逻辑位置"整体往后挪了一位。用户点"下一页",offset 从 20 变成 40,取的是当前的 4160——但其中第 41 条,其实是用户上一页已经看过的第 40 条。结果:重复展示一条。

反过来,如果在翻页过程中有数据被删除,位置整体前挪,就会出现跳过一条的情况。

对于信息流、消息列表这种"边写边读"的场景,这种漂移是致命的。用户会困惑:这条我明明没看过,为什么显示已读?或者:这条刚才出现了,怎么又出现一次?

offset 的两个坑,根源都在同一个地方:它用一个"绝对位置"去描述数据的位置,而数据的绝对位置是会变的。

二、cursor 分页:用游标锚定"位置",高效且稳定

cursor 分页换了一个思路:不描述"第几页",而是描述"上次读到哪儿了"。

text
GET /api/articles?limit=20&after=eyJpZCI6MTIzNDV9

after 是一个不透明的游标(通常是 base64 编码的 JSON,内部包含最后一条记录的排序字段值)。后端的 SQL 长这样:

sql
SELECT id, title, created_at
FROM articles
WHERE created_at < :cursor_time
ORDER BY created_at DESC
LIMIT 21;   -- 多取 1 条用于判断 hasMore

注意这里没有 OFFSET。它直接用 WHERE 条件从游标位置往后取,数据库可以通过 created_at 上的索引,以极低的成本定位到起点。翻第 1 页和翻第 1 万页的代价是一样的——都是一次索引范围扫描,性能稳定。

更重要的是,这种方式天然免疫数据漂移。因为游标锚定的是"最后一条记录的值",而不是"第几条"。即便中途有新数据插入到顶部,只要游标的值还在,下一次查询就会从这个值往后取,既不会重复也不会跳过。这对信息流这类高写入场景来说是决定性的优势。

但 cursor 不是免费的,它有几个代价:

不能跳页。 用户无法直接跳到"第 50 页",因为你没有第 50 页的游标。这对需要分页导航的表格类 UI 是硬伤——所以管理后台基本不会用 cursor。

游标设计有讲究。 游标必须包含排序字段的完整值,否则边界会出错。比如按 created_at DESC 排序,但如果多条记录的 created_at 相同(同一秒插入),仅靠时间戳无法稳定排序,会出现漏数据或重复。正确做法是加上一个唯一的 tiebreaker,通常是主键:

sql
WHERE (created_at, id) < (:cursor_time, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 21;

这就是所谓的 keyset pagination,下面会展开。

游标要当成不透明 token。 不要让客户端自己拼游标,也不要相信客户端传上来的游标格式。后端生成、签名(或至少 base64)、客户端原样回传。这样你才能随时调整游标的内部结构,而不破坏兼容性。

三、offset vs cursor:该怎么选

两种方案没有绝对优劣,只有场景匹配。我的决策原则很简单:看用户是否需要跳页、看数据是否高写入、看集合是否会变得很大。

用 offset 的场景:

  • 管理后台、数据报表、审计日志查询,用户习惯"跳到第几页"。
  • 数据相对稳定,写入不频繁(漂移几乎不发生)。
  • 数据量可控(几十万以内),深翻性能问题不明显。
  • 前端用了带分页器的表格组件,offset 接入成本低。

用 cursor 的场景:

  • 信息流、时间线、消息列表、评论列表——高写入、用户连续消费。
  • 无限滚动(infinite scroll)的产品形态,根本不需要跳页。
  • 数据量大(百万级以上),或者预期会增长到这个量级。
  • 对数据一致性敏感,不能容忍重复/跳过。

一个常见的折中:首屏用 offset,翻页用 cursor。许多 API 会同时支持 page 和 after 参数:第一页用 ?page=1 拿到首屏数据和 cursor,后续翻页全部走 after。这样既兼容了老接口,又规避了深翻的性能问题。但这会增加实现复杂度,建议只在过渡期使用,新接口直接定 cursor。

另外一个反直觉的点:不要用 offset 来近似 total。很多团队为了在前端显示"共 1234 条",会在每次 cursor 分页时跑一个 COUNT(*)。在大表上 COUNT(*) 极慢(尤其 InnoDB),等于把 cursor 的性能优势拱手送还。要么用估算值,要么干脆不返回精确 total,只返回 hasMore。

四、无限滚动的实现:cursor + hasMore 的标准范式

无限滚动是 cursor 分页最经典的落地场景。前端的交互是"滚到底部自动加载下一页",后端要做的是稳定地一段一段喂数据。

后端响应应该是这样的:

json
{
  "data": [
    { "id": 101, "title": "..." },
    { "id": 102, "title": "..." }
  ],
  "nextCursor": "eyJpZCI6MTAyLCJ0IjoiMjAyNi0wNy0xN1QwOTozMDowMFoifQ==",
  "hasMore": true
}

前端逻辑非常清晰:

ts
async function loadMore() {
  if (loading || !hasMore) return;
  loading = true;
  const res = await fetch(`/api/articles?limit=20&after=${nextCursor ?? ""}`);
  const json = await res.json();
  appendToList(json.data);
  nextCursor = json.nextCursor;
  hasMore = json.hasMore;
  loading = false;
}

// IntersectionObserver 监听底部哨兵元素
observer.observe(sentinelEl);

有几个工程细节容易被忽视:

多取一条判断 hasMore。 后端查 LIMIT 21,如果真的返回 21 条,说明还有下一页,hasMore = true,但只把前 20 条返回给客户端。比再跑一次 COUNT 高效得多。

游标为 null 表示首屏。 nextCursor 为 null 或不存在时,hasMore 应该返回 true(如果还有数据),前端发起首次请求时不带 after 参数。

乐观更新与并发。 用户加载到一半又发了新内容(比如自己发了条评论),这条新内容是否要插入到列表顶部?通常的做法是:首屏刷新重新拉,滚动过程中不插入顶部数据,避免视觉跳动。这是产品决策,但 API 层要配合——比如提供 ?before= 做反向分页来补齐新数据。

空状态要明确区分。 data: [] 且 hasMore: false 表示"彻底没有数据";data: [] 但 hasMore: true(理论上不该出现)要当成 bug 处理。前端展示"加载失败"还是"没有更多",全靠这两个字段组合来判定。

五、分页元数据:别只返回一个数组

我最不能容忍的反模式是:API 只返回一个 JSON 数组。

json
// 反面教材
[
  { "id": 1, "title": "..." },
  { "id": 2, "title": "..." }
]

这种设计让前端无从判断:这是全部数据,还是还有下一页?是查到 0 条,还是接口出错了?强行靠"数组长度是否等于 size"来判断 hasMore,边界 bug 一堆(恰好满 size 的情况、空表的情况)。

一个成熟的分页响应应该返回结构化 envelope,把数据和元数据分开放:

json
{
  "data": [...],
  "meta": {
    "total": 1234,
    "page": 3,
    "size": 20,
    "hasMore": true,
    "nextCursor": "eyJ..."
  }
}

关于每个字段要不要返回,我的观点是:

  • hasMore:必返回。 这是前端决定是否继续加载的唯一可靠信号,无论 offset 还是 cursor。
  • nextCursor:cursor 分页必返回,offset 分页不需要。
  • total:看场景。 管理后台需要显示"共 X 条"就返回;信息流、无限滚动就别返回(用户不关心总数,而且 COUNT 很贵)。如果一定要返回又怕慢,返回估算值或定期刷新的缓存值,并在文档里标注"非精确"。
  • page / size:offset 分页回显用,cursor 分页不需要。

元数据的设计原则是:让前端不需要"猜"。任何一个分页状态,前端都应该能通过响应字段直接得出结论,而不是靠推断。

六、进阶:keyset 分页与反向分页

当排序字段不唯一时,前面提到的 keyset pagination 就是标准答案。它的核心思想是:游标由排序键的完整元组构成,确保排序的全序性。

按 (created_at DESC, id DESC) 排序时,游标内部结构是:

json
{ "t": "2026-07-17T09:30:00Z", "id": 102 }

下一页查询用元组比较(以 Postgres 为例,天然支持 row constructor):

sql
WHERE (created_at, id) < ('2026-07-17T09:30:00Z', 102)
ORDER BY created_at DESC, id DESC
LIMIT 21;

对于 Mysql,可以用等价写法:

sql
WHERE created_at <= '2026-07-17T09:30:00Z'
  AND NOT (created_at = '2026-07-17T09:30:00Z' AND id >= 102)
ORDER BY created_at DESC, id DESC
LIMIT 21;

注意一个性能关键点:keyset 要走索引,排序字段组合必须在同一个索引里。即 (created_at, id) 上要有联合索引(且顺序与 ORDER BY 一致),否则数据库要么全表扫,要么走文件排序,优势全无。索引设计是 keyset 能不能落地的分水岭。

反向分页(Reverse Pagination) 是另一个常被忽略的需求。聊天界面"往上翻历史消息"、评论的"查看更早的回复",都需要往前取数据。做法是对称地提供 before 参数:

sql
WHERE (created_at, id) > (:cursor_time, :cursor_id)
ORDER BY created_at ASC, id ASC   -- 注意方向反了
LIMIT 21;

返回时再在应用层反转为 DESC,保证前端拿到的顺序一致。反向分页的游标要单独签发,不能和正向混用——因为内部的比较方向是相反的。设计 API 时,after(向后,更新)和 before(向前,更旧)两个参数都要预留,后期加非常痛。

小结

分页不是"加个 limit/offset"的事,它是一次关于性能和一致性的工程权衡。几条经验:

  1. offset 适合数据稳定、需要跳页、数据量小的场景;但在高写入、大数据量下,深翻慢、数据漂移,迟早出事。
  2. cursor 用游标锚定位置,性能恒定、无漂移,是信息流和无限滚动的标配;代价是不能跳页、游标要设计好。
  3. 排序字段不唯一时必须上 keyset:游标包含完整排序键元组,并配联合索引,否则边界会漏数据。
  4. 永远返回结构化元数据(hasMore / nextCursor / 按需的 total),别只丢一个数组给前端。
  5. 预留反向分页(before),聊天、评论这类场景迟早要。

一句话收尾:分页的本质不是切割数据,而是在变化中锚定一个稳定的位置——offset 描述"第几页",cursor 描述"上次到哪了",前者会变,后者不会。 把这一点想清楚,你设计出来的分页 API,既快又稳。

Related

相关推荐