分页锚点不稳定:从 Offset 漂移到 Cursor 设计
从真实故障出发,重新讨论为什么分页设计应该走向 Cursor。
从查询数据分页不稳定引发的灾难————讨论分页的设计
原文参考:https://zhuanlan.zhihu.com/p/1973760548039577981
1. 一个真实现场
最近读取了转转团队的谈分页稳定性的设计。 让我想起来之前做导出的一次故障。
以前我做数据导出的时候, 只用来创建时间进行排序。 当时其实我已经考虑到了分页稳定性的问题。 但是呢, 我少考虑一点就是当数据时间戳一直的case。 导致给客户导出额外数据,让客户产生质疑和挑战,提了工单。
这里好像让我明白,我好像针对分页设计,从未有过自己的独立思考。 为什么要这样设计? 我从未回答过这个问题。 仅仅是copy别人的代码和逻辑,哦,以前就是这样写的,没出问题,我也这样写。 这是不符合独立思考的。
2. 快问快搭
为什么要进行分页?
从后端角度看,这不是“可选优化”,而是系统基本生存策略。
- 保护数据库:拉取上百万数据的全量查询是灾难性的,尤其 QPS 一上来,数据库很容易被拖垮。
- 保护后端服务:哪怕 QPS 不高,一次性捞取超大结果集,也有把服务内存打爆的风险。
- 控制网络成本:数据越大,网络传输越慢,带宽和延迟成本都会变高。
- 对齐前端展示:前端本来就不会一次性展示所有数据,通常只展示一个窗口。
所以分页的第一价值,不是“代码优雅”,而是“让系统可承载”。
3. 先从管理系统分页讲起
最常见、也最“默认”的分页接口一般长这样:
- 返回:
total + itemList - 入参:
pageIndex + pageSize - 可选:
orderBy(按某些字段排序)
对应 SQL 也很直接:
SELECT ...FROM ...WHERE ...ORDER BY ...LIMIT :offset, :pageSize;这套设计在低频变更的后端管理数据里,通常是岁月静好。
原因也简单:
- 数据变更不频繁,翻页期间结果集相对稳定。
- 管理端更强调“第 N 页”这种跳页能力。
- 用户对偶发重复/漏读的容忍度相对更高。
但要注意,这只是“场景暂时匹配”,不是“方案天然正确”。
一旦进入高并发写入、实时流式浏览、强一致业务链路,这套默认方案就会暴露锚点漂移问题。
4. 四个常见故障:先基础,再揭晓本质
下面这 4 个故障,我建议当成分页设计评审时的固定检查项。
其中故障 1 和故障 2 本质接近,都是在解决低频数据下的分页稳定性基础问题。
故障 3 我先不下最终结论,留到最后一章再讨论:它究竟是分页问题,还是整体设计问题。
故障 1:提供了分页接口,但 SQL 没有 ORDER BY(低级错误)
最危险,也最容易被忽略。
接口看起来是分页,实际上每一页都没有稳定顺序语义。
SELECT * FROM test_order LIMIT 0, 10;SELECT * FROM test_order LIMIT 10, 10;这会带来致命不确定性,原因至少有三层:
- 存储引擎差异。
- 是否命中索引。
- 优化器执行计划变化。
例如:
EXPLAIN SELECT * FROM test_order; -- 可能全表扫描EXPLAIN SELECT id FROM test_order; -- 可能走主键索引在 InnoDB 下,你“可能看起来”像按聚簇索引顺序返回;
在 MyISAM 下,又“可能看起来”像插入顺序(且删除/更新后还会变化)。
再加上优化器会动态选计划,你不能假定 MySQL 会“默认按 id 排序”。
结论只有一句:不写 ORDER BY 的分页,在语义上就是不稳定的。
故障 2:用了 created_at 排序,但没处理“同一时间戳”问题
这时即使没有并发插入,也会抖动。
因为多条记录的 created_at 相同,数据库在这些同值记录内部没有确定顺序。
SELECT ...FROM couponsORDER BY created_at DESCLIMIT 0, 10;下一次查第二页时,同值区间可能重排,导致重复或漏读。
所以 created_at 只能做主排序键,不能单独做锚点,必须追加唯一 tie-breaker(如 id):
ORDER BY created_at DESC, id DESC故障 3:created_at + id 已稳定排序,但仍用 offset 面对并发写入
这就是线上最常见 case。
假设运营批量发券,每页 10 条:
- 先查第 1 页:
ORDER BY created_at DESC, id DESC LIMIT 0,10。 - 查第 2 页前,新插入 5 条更“新”的记录。
- 再查第 2 页:
ORDER BY created_at DESC, id DESC LIMIT 10,10。
结果集整体后移,第一页末尾部分记录会再次掉进第二页窗口。
如果发放动作没有幂等和唯一约束,就会出现重复发放。

offset 的语义是“当前时刻第 N 段位置”,不是“上一页之后的连续记录”。
这里先埋个伏笔:
这类问题到底该归因于“分页模型不对”,还是“业务动作缺少幂等与约束”,最后一章再统一揭晓。
故障 4:社交高频场景下,帖子列表翻页重复
社交 feed 按“最新时间”排序时,数据持续高频写入。
用户先看第 1 页,再点第 2 页,如果此时有新帖子发布,原本窗口会整体后移。
于是会出现:同一条帖子在不同页码重复出现。
这在社交场景通常是体验问题(重复感、割裂感);
但在任务处理场景(例如批处理发放)就会升级为正确性问题。
这个故障和故障 3 机制相同,但业务后果不同,正好用于区分“体验语义”和“任务语义”。
最后强调一句:分页只是触发器,不是资金正确性的最终保障。
更稳妥的兜底仍然是:
- 发放接口幂等。
- 数据库唯一约束(如
activity_id + user_id)。 - 任务可重试且不会重复发放。
5. 正确姿势:固定记录锚点(Cursor)
把第 4 节四个故障放在一起看,可以统一归因为两句话:
- 排序不稳定。
- 分页锚点不稳定。
而当我们听到“分页不稳定”,工程上最直接的第一反应就应该是:cursor。
核心思想很简单:
不再说“我要第 11~20 条”,而是说“我要某条记录之后的 10 条”。
假设排序为 ORDER BY create_time DESC, id DESC,上一页末尾锚点为 (:t, :id):
SELECT *FROM postsWHERE (create_time < :t) OR (create_time = :t AND id < :id)ORDER BY create_time DESC, id DESCLIMIT :size;如果是升序,则方向反过来(>)。
工程上一般把锚点做成 token,常见是:
base64(create_time,id,filters,sort,sign,expire_at)
注意:create_time 单独不够,必须带唯一 tie-breaker(如 id)。

用这个视角回看 4 个故障,cursor 都能稳妥覆盖:
- 故障 1(无
ORDER BY):cursor 强制你先定义稳定排序键,不再依赖“默认顺序”。 - 故障 2(仅
created_at):cursor 要求唯一锚点,自然推动补齐created_at + id。 - 故障 3(并发插入导致 offset 漂移):cursor 把“第 N 页”改成“从锚点继续”,避免窗口后移重叠。
- 故障 4(社交 feed 翻页重复):cursor 与连续浏览语义一致,减少重复和割裂。
所以这节的结论是:
当问题被识别为“分页不稳定/排序不稳定”时,优先切到 cursor 思维。
Appendix:UUIDv7 能不能当锚点(解释性补充)
可以,但要知道边界。
UUIDv7 的优势:
- 本地生成,分布式友好。
- 时间有序,索引局部性更好,对数据库写入更友好。
它的边界:
- 依赖本机时钟,存在偏差/回拨风险。
- 多机并发下不保证严格全局时间顺序。
所以:
- 普通业务分页:
UUIDv7很实用。 - 严格时序场景(例如金融级流水对账):要用更强时序源(中心发号、数据库序列、统一时间策略)。
Cursor 模式的可执行清单(落地版)
这部分我建议从接口契约开始,而不是先讲 token。
入参设计(Request):
{ "page_size": 20, "cursor": "eyJjcmVhdGVkX2F0Ijoi...", "filters": { "coupon_batch_id": "BATCH_001" }}字段语义:
page_size:每次拉取条数,后端可设置上限(例如最大 100)。cursor:可选。首次查询为空;翻下一页时传上一次响应里的next_cursor。filters:业务过滤条件。- 排序规则不由客户端传入,由服务端固定(例如
created_at DESC, id DESC)。
出参设计(Response):
{ "items": [], "next_cursor": "eyJjcmVhdGVkX2F0Ijoi...", "has_more": true}字段语义:
items:当前窗口数据。next_cursor:下一页锚点。一般取“当前页最后一条记录”的(created_at,id)。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,数据窗口就会错位。
所以服务端通常这样做:
- 对
filters做规范化(字段排序、去空值、统一格式)。 - 计算哈希(如
sha256),得到filters_hash写入 cursor。 - 翻页请求时,重新计算当前请求的
filters_hash。 - 与 cursor 中的
filters_hash比对,不一致就判定 cursor 失效。
然后做两步处理:
json -> base64url,方便传输。- 增加 HMAC 签名,防止客户端伪造锚点。
后端消费 cursor 时,解码后拼查询条件:
WHERE (created_at < :created_at) OR (created_at = :created_at AND id < :id)ORDER BY created_at DESC, id DESCLIMIT :page_size这套实现的关键不是“加密”,而是“接口契约稳定”:固定锚点、服务端固定排序、固定过滤上下文。

6. Cursor 不是银弹
反思一个常见误区:是不是一遇到分页问题就要上 cursor?
并不是。
分页方案本质是 trade-off,不是立场站队。
cursor 很强,但它主要解决“动态数据下的翻页一致性”。
传统分页也有明确价值,特别是在需要页码跳转、数据相对静态的场景。
真正要避免的不是“用了 offset”,而是“offset 用错了”。
传统分页必须先满足一个前提:排序稳定全序。
最常见做法:
- 明确
ORDER BY,不能省略。 - 排序字段不能只用非唯一时间戳。
- 使用“时间序字段 + 唯一 ID”(如
created_at DESC, id DESC)形成稳定全序。
在这个前提下,传统分页在很多业务里依然是务实且可维护的选择。
当然,cursor 也有成本:
- 不支持随机跳页(例如直接第 500 页)。
- 排序/过滤条件变化后,旧 cursor 往往失效。
- 接口契约、状态管理和索引设计复杂度更高。
所以更稳妥的结论是:
cursor 不是银弹;先把传统分页“做对”,再按场景决定是否升级为 cursor。
7. 一句话收束
这里最后补一句 tradeoff:
cursor 并非万能,传统分页也并非不可取,关键是先定义场景目标,再选分页模型。
如果你要的是动态数据下的稳定连续读取,优先 cursor。
如果你要的是静态数据下的跳页体验,offset 依然是务实选择。
更本质一点看,这两者的语义并不一样:
- 传统分页(
offset/limit)是无记忆查询。每次只关心“当前结果集的第 N 段位置”,锚点是位置。 - Cursor 分页是有记忆查询。请求携带上一次窗口锚点,语义是“从上次看到的位置继续查”。
所以它们不是简单的“新旧替代关系”,而是不同业务语义下的两种模型。
8. 关键场景指导手册
9.1 数据导出
如果数据来自别人提供的分页接口:
- 明确要求接口排序稳定(至少全序),最好支持 cursor。
- 如果只能用 offset,要求对方提供稳定快照语义或导出任务 ID。
- 导出侧自己做去重(按业务主键),避免重复行混入结果。
如果数据由我们自己查库:
- 固定排序键(如
created_at + id)。 - 优先 cursor 分页扫描。
- 最终写出前做一次主键去重校验。
9.2 批处理任务(发券、消息触达、补偿任务)
- 分页只负责“找到候选集”,不负责业务正确性。
- 下游执行必须幂等,数据库加唯一约束兜底。
- 任务可重试,但重试不应带来重复副作用。
9.3 增量同步(ES 建索引、数仓入湖、跨库同步)
- 用“时间戳 + 唯一 ID”作为增量游标,而不是 offset。
- 设计断点续传(保存最后成功锚点)。
- 消费侧做幂等写入(upsert 或唯一键去重)。
9.4 对账与审计查询
- 优先使用快照读或按时间窗冻结查询范围。
- 分页扫描必须稳定,避免漏单和重单。
- 最终结果要有可复算能力(同条件重跑结果一致)。
9.5 API 设计评审时的四个必问
- 这个列表是否要求“连续稳定读取”?
- 是否存在高并发新增/删除/排序字段变更?
- 是否允许随机跳页?
- 一旦重复/漏读,业务后果是什么(体验问题还是资金风险)?
这四个问题回答清楚,分页方案基本就不会选偏。

9. AI 时代的人该补上的一层能力
这篇对我自己的提醒是: AI 可以快速给出分页方案,但“选哪个、为什么、代价是什么”,不能外包给 AI。
如果我自己没有把分页语义想透,就很容易出现两种风险:
- 把
offset/limit和cursor当成“语法替换”,忽略它们背后的业务语义差异。 - 看到 AI 给出的“看起来正确”的设计就直接采用,却没有审计一致性、幂等、索引、回滚方案。
真正的人类价值,不是比 AI 写得更快,而是做三件事:
- 先定义问题:这是“跳页体验问题”,还是“连续扫描正确性问题”?
- 再做 trade-off:一致性、复杂度、可观测性、改造成本谁优先?
- 最后做评审:AI 提案里哪些是假设,哪些有证据,哪些需要压测和故障演练验证?
所以与其说“让 AI 设计分页”,不如说“人先建立分页的第一性理解,再让 AI 加速实现”。
当我们能清楚地追问这些问题时,AI 才是工程杠杆,而不是新的不确定性来源。
