← Blog

分页锚点不稳定:从 Offset 漂移到 Cursor 设计

从真实故障出发,重新讨论为什么分页设计应该走向 Cursor。

Backend

从查询数据分页不稳定引发的灾难————讨论分页的设计

原文参考:https://zhuanlan.zhihu.com/p/1973760548039577981

1. 一个真实现场

最近读取了转转团队的谈分页稳定性的设计。 让我想起来之前做导出的一次故障。

以前我做数据导出的时候, 只用来创建时间进行排序。 当时其实我已经考虑到了分页稳定性的问题。 但是呢, 我少考虑一点就是当数据时间戳一直的case。 导致给客户导出额外数据,让客户产生质疑和挑战,提了工单。

这里好像让我明白,我好像针对分页设计,从未有过自己的独立思考。 为什么要这样设计? 我从未回答过这个问题。 仅仅是copy别人的代码和逻辑,哦,以前就是这样写的,没出问题,我也这样写。 这是不符合独立思考的。

2. 快问快搭

为什么要进行分页?

从后端角度看,这不是“可选优化”,而是系统基本生存策略。

  1. 保护数据库:拉取上百万数据的全量查询是灾难性的,尤其 QPS 一上来,数据库很容易被拖垮。
  2. 保护后端服务:哪怕 QPS 不高,一次性捞取超大结果集,也有把服务内存打爆的风险。
  3. 控制网络成本:数据越大,网络传输越慢,带宽和延迟成本都会变高。
  4. 对齐前端展示:前端本来就不会一次性展示所有数据,通常只展示一个窗口。

所以分页的第一价值,不是“代码优雅”,而是“让系统可承载”。

3. 先从管理系统分页讲起

最常见、也最“默认”的分页接口一般长这样:

  • 返回:total + itemList
  • 入参:pageIndex + pageSize
  • 可选:orderBy(按某些字段排序)

对应 SQL 也很直接:

SELECT ...
FROM ...
WHERE ...
ORDER BY ...
LIMIT :offset, :pageSize;

这套设计在低频变更的后端管理数据里,通常是岁月静好。

原因也简单:

  1. 数据变更不频繁,翻页期间结果集相对稳定。
  2. 管理端更强调“第 N 页”这种跳页能力。
  3. 用户对偶发重复/漏读的容忍度相对更高。

但要注意,这只是“场景暂时匹配”,不是“方案天然正确”。

一旦进入高并发写入、实时流式浏览、强一致业务链路,这套默认方案就会暴露锚点漂移问题。

4. 四个常见故障:先基础,再揭晓本质

下面这 4 个故障,我建议当成分页设计评审时的固定检查项。
其中故障 1 和故障 2 本质接近,都是在解决低频数据下的分页稳定性基础问题。
故障 3 我先不下最终结论,留到最后一章再讨论:它究竟是分页问题,还是整体设计问题。

故障 1:提供了分页接口,但 SQL 没有 ORDER BY(低级错误)

最危险,也最容易被忽略。
接口看起来是分页,实际上每一页都没有稳定顺序语义。

SELECT * FROM test_order LIMIT 0, 10;
SELECT * FROM test_order LIMIT 10, 10;

这会带来致命不确定性,原因至少有三层:

  1. 存储引擎差异。
  2. 是否命中索引。
  3. 优化器执行计划变化。

例如:

EXPLAIN SELECT * FROM test_order; -- 可能全表扫描
EXPLAIN SELECT id FROM test_order; -- 可能走主键索引

在 InnoDB 下,你“可能看起来”像按聚簇索引顺序返回;
在 MyISAM 下,又“可能看起来”像插入顺序(且删除/更新后还会变化)。
再加上优化器会动态选计划,你不能假定 MySQL 会“默认按 id 排序”。

结论只有一句:不写 ORDER BY 的分页,在语义上就是不稳定的。

故障 2:用了 created_at 排序,但没处理“同一时间戳”问题

这时即使没有并发插入,也会抖动。
因为多条记录的 created_at 相同,数据库在这些同值记录内部没有确定顺序。

SELECT ...
FROM coupons
ORDER BY created_at DESC
LIMIT 0, 10;

下一次查第二页时,同值区间可能重排,导致重复或漏读。
所以 created_at 只能做主排序键,不能单独做锚点,必须追加唯一 tie-breaker(如 id):

ORDER BY created_at DESC, id DESC

故障 3:created_at + id 已稳定排序,但仍用 offset 面对并发写入

这就是线上最常见 case。
假设运营批量发券,每页 10 条:

  1. 先查第 1 页:ORDER BY created_at DESC, id DESC LIMIT 0,10
  2. 查第 2 页前,新插入 5 条更“新”的记录。
  3. 再查第 2 页:ORDER BY created_at DESC, id DESC LIMIT 10,10

结果集整体后移,第一页末尾部分记录会再次掉进第二页窗口。
如果发放动作没有幂等和唯一约束,就会出现重复发放。

分页四故障递进示意

offset 的语义是“当前时刻第 N 段位置”,不是“上一页之后的连续记录”。

这里先埋个伏笔:
这类问题到底该归因于“分页模型不对”,还是“业务动作缺少幂等与约束”,最后一章再统一揭晓。

故障 4:社交高频场景下,帖子列表翻页重复

社交 feed 按“最新时间”排序时,数据持续高频写入。
用户先看第 1 页,再点第 2 页,如果此时有新帖子发布,原本窗口会整体后移。

于是会出现:同一条帖子在不同页码重复出现。
这在社交场景通常是体验问题(重复感、割裂感);
但在任务处理场景(例如批处理发放)就会升级为正确性问题。

这个故障和故障 3 机制相同,但业务后果不同,正好用于区分“体验语义”和“任务语义”。

最后强调一句:分页只是触发器,不是资金正确性的最终保障。
更稳妥的兜底仍然是:

  1. 发放接口幂等。
  2. 数据库唯一约束(如 activity_id + user_id)。
  3. 任务可重试且不会重复发放。

5. 正确姿势:固定记录锚点(Cursor)

把第 4 节四个故障放在一起看,可以统一归因为两句话:

  1. 排序不稳定。
  2. 分页锚点不稳定。

而当我们听到“分页不稳定”,工程上最直接的第一反应就应该是:cursor

核心思想很简单:
不再说“我要第 11~20 条”,而是说“我要某条记录之后的 10 条”。

假设排序为 ORDER BY create_time DESC, id DESC,上一页末尾锚点为 (:t, :id)

SELECT *
FROM posts
WHERE (create_time < :t)
OR (create_time = :t AND id < :id)
ORDER BY create_time DESC, id DESC
LIMIT :size;

如果是升序,则方向反过来(>)。

工程上一般把锚点做成 token,常见是:

  • base64(create_time,id,filters,sort,sign,expire_at)

注意:create_time 单独不够,必须带唯一 tie-breaker(如 id)。

Cursor 逐一解决四故障

用这个视角回看 4 个故障,cursor 都能稳妥覆盖:

  1. 故障 1(无 ORDER BY):cursor 强制你先定义稳定排序键,不再依赖“默认顺序”。
  2. 故障 2(仅 created_at):cursor 要求唯一锚点,自然推动补齐 created_at + id
  3. 故障 3(并发插入导致 offset 漂移):cursor 把“第 N 页”改成“从锚点继续”,避免窗口后移重叠。
  4. 故障 4(社交 feed 翻页重复):cursor 与连续浏览语义一致,减少重复和割裂。

所以这节的结论是:
当问题被识别为“分页不稳定/排序不稳定”时,优先切到 cursor 思维。

Appendix:UUIDv7 能不能当锚点(解释性补充)

可以,但要知道边界。

UUIDv7 的优势:

  1. 本地生成,分布式友好。
  2. 时间有序,索引局部性更好,对数据库写入更友好。

它的边界:

  1. 依赖本机时钟,存在偏差/回拨风险。
  2. 多机并发下不保证严格全局时间顺序。

所以:

  • 普通业务分页:UUIDv7 很实用。
  • 严格时序场景(例如金融级流水对账):要用更强时序源(中心发号、数据库序列、统一时间策略)。

Cursor 模式的可执行清单(落地版)

这部分我建议从接口契约开始,而不是先讲 token。

入参设计(Request):

{
"page_size": 20,
"cursor": "eyJjcmVhdGVkX2F0Ijoi...",
"filters": {
"coupon_batch_id": "BATCH_001"
}
}

字段语义:

  1. page_size:每次拉取条数,后端可设置上限(例如最大 100)。
  2. cursor:可选。首次查询为空;翻下一页时传上一次响应里的 next_cursor
  3. filters:业务过滤条件。
  4. 排序规则不由客户端传入,由服务端固定(例如 created_at DESC, id DESC)。

出参设计(Response):

{
"items": [],
"next_cursor": "eyJjcmVhdGVkX2F0Ijoi...",
"has_more": true
}

字段语义:

  1. items:当前窗口数据。
  2. next_cursor:下一页锚点。一般取“当前页最后一条记录”的 (created_at,id)
  3. has_more:是否还有后续数据。

next_cursor 的最小载荷可以是:

{
"created_at": "2026-05-07T15:10:00Z",
"id": 987654321,
"filters_hash": "9f2a...",
"exp": 1770000000
}

这里的 filters_hash 建议专门解释一下。
它的目标不是加密,而是防止 cursor 串用。

例如第一页查询条件是 coupon_batch_id=BATCH_001
第二页请求时如果把过滤条件改成 BATCH_002,但还带着第一页的 next_cursor,数据窗口就会错位。

所以服务端通常这样做:

  1. filters 做规范化(字段排序、去空值、统一格式)。
  2. 计算哈希(如 sha256),得到 filters_hash 写入 cursor。
  3. 翻页请求时,重新计算当前请求的 filters_hash
  4. 与 cursor 中的 filters_hash 比对,不一致就判定 cursor 失效。

然后做两步处理:

  1. json -> base64url,方便传输。
  2. 增加 HMAC 签名,防止客户端伪造锚点。

后端消费 cursor 时,解码后拼查询条件:

WHERE (created_at < :created_at)
OR (created_at = :created_at AND id < :id)
ORDER BY created_at DESC, id DESC
LIMIT :page_size

这套实现的关键不是“加密”,而是“接口契约稳定”:固定锚点、服务端固定排序、固定过滤上下文。

Cursor Token 设计与接口契约

6. Cursor 不是银弹

反思一个常见误区:是不是一遇到分页问题就要上 cursor?
并不是。

分页方案本质是 trade-off,不是立场站队。
cursor 很强,但它主要解决“动态数据下的翻页一致性”。
传统分页也有明确价值,特别是在需要页码跳转、数据相对静态的场景。

真正要避免的不是“用了 offset”,而是“offset 用错了”。
传统分页必须先满足一个前提:排序稳定全序

最常见做法:

  1. 明确 ORDER BY,不能省略。
  2. 排序字段不能只用非唯一时间戳。
  3. 使用“时间序字段 + 唯一 ID”(如 created_at DESC, id DESC)形成稳定全序。

在这个前提下,传统分页在很多业务里依然是务实且可维护的选择。

当然,cursor 也有成本:

  1. 不支持随机跳页(例如直接第 500 页)。
  2. 排序/过滤条件变化后,旧 cursor 往往失效。
  3. 接口契约、状态管理和索引设计复杂度更高。

所以更稳妥的结论是:
cursor 不是银弹;先把传统分页“做对”,再按场景决定是否升级为 cursor。

7. 一句话收束

这里最后补一句 tradeoff:
cursor 并非万能,传统分页也并非不可取,关键是先定义场景目标,再选分页模型。

如果你要的是动态数据下的稳定连续读取,优先 cursor。
如果你要的是静态数据下的跳页体验,offset 依然是务实选择。

更本质一点看,这两者的语义并不一样:

  1. 传统分页(offset/limit)是无记忆查询。每次只关心“当前结果集的第 N 段位置”,锚点是位置。
  2. Cursor 分页是有记忆查询。请求携带上一次窗口锚点,语义是“从上次看到的位置继续查”。

所以它们不是简单的“新旧替代关系”,而是不同业务语义下的两种模型。

8. 关键场景指导手册

9.1 数据导出

如果数据来自别人提供的分页接口:

  1. 明确要求接口排序稳定(至少全序),最好支持 cursor。
  2. 如果只能用 offset,要求对方提供稳定快照语义或导出任务 ID。
  3. 导出侧自己做去重(按业务主键),避免重复行混入结果。

如果数据由我们自己查库:

  1. 固定排序键(如 created_at + id)。
  2. 优先 cursor 分页扫描。
  3. 最终写出前做一次主键去重校验。

9.2 批处理任务(发券、消息触达、补偿任务)

  1. 分页只负责“找到候选集”,不负责业务正确性。
  2. 下游执行必须幂等,数据库加唯一约束兜底。
  3. 任务可重试,但重试不应带来重复副作用。

9.3 增量同步(ES 建索引、数仓入湖、跨库同步)

  1. 用“时间戳 + 唯一 ID”作为增量游标,而不是 offset。
  2. 设计断点续传(保存最后成功锚点)。
  3. 消费侧做幂等写入(upsert 或唯一键去重)。

9.4 对账与审计查询

  1. 优先使用快照读或按时间窗冻结查询范围。
  2. 分页扫描必须稳定,避免漏单和重单。
  3. 最终结果要有可复算能力(同条件重跑结果一致)。

9.5 API 设计评审时的四个必问

  1. 这个列表是否要求“连续稳定读取”?
  2. 是否存在高并发新增/删除/排序字段变更?
  3. 是否允许随机跳页?
  4. 一旦重复/漏读,业务后果是什么(体验问题还是资金风险)?

这四个问题回答清楚,分页方案基本就不会选偏。

分页方案选型四问决策框架

9. AI 时代的人该补上的一层能力

这篇对我自己的提醒是: AI 可以快速给出分页方案,但“选哪个、为什么、代价是什么”,不能外包给 AI。

如果我自己没有把分页语义想透,就很容易出现两种风险:

  1. offset/limitcursor 当成“语法替换”,忽略它们背后的业务语义差异。
  2. 看到 AI 给出的“看起来正确”的设计就直接采用,却没有审计一致性、幂等、索引、回滚方案。

真正的人类价值,不是比 AI 写得更快,而是做三件事:

  1. 先定义问题:这是“跳页体验问题”,还是“连续扫描正确性问题”?
  2. 再做 trade-off:一致性、复杂度、可观测性、改造成本谁优先?
  3. 最后做评审:AI 提案里哪些是假设,哪些有证据,哪些需要压测和故障演练验证?

所以与其说“让 AI 设计分页”,不如说“人先建立分页的第一性理解,再让 AI 加速实现”。

当我们能清楚地追问这些问题时,AI 才是工程杠杆,而不是新的不确定性来源。