页码不是位置
这一节只讨论分页协议:客户端用什么表示「我读到哪里」,服务端如何据此返回下一段。它不讨论信息流容器的状态归属(第 1 章已经给出 SocialClient 的写入边界),也不讨论去重与刷新触发(2.2、2.3 分别展开)。换句话说,我们先假定容器是可靠的,只追问一件事:下一页的请求参数应该长什么样。
页码分页在后台管理列表里工作了很多年,于是很容易被直接搬进内容流。搬过去之后,症状通常不是「报错」,而是难以复现的重复卡片、莫名消失的几条内容,以及越翻越慢的接口。根因是页码隐含了三个在内容流里并不成立的前提。
页码隐含的三个前提
| 前提 | 后台列表 | 内容流 |
|---|---|---|
| 集合总数在翻页期间不变 | 基本成立 | 新内容持续写入 |
| 排序键稳定且唯一 | 通常有自增主键 | 时间戳会重复,内容会删除 |
| 第 N 页的成本与 N 无关 | 数据量小,可接受 | 深分页要扫描前 N 行 |
页码的本质是「跳过前 (page - 1) * size 条」。它假设被跳过的那些条目在两次请求之间没有变化。内容流恰恰相反:刷新、发布、删除、审核下架都在实时改变前缀。前缀一变,同一个 offset 指向的就不是同一批内容。
重复与漏项是怎么发生的
假设一页 3 条,初始顺序如下(越靠左越新):
p9 p8 p7 | p6 p5 p4 | p3 p2 p1
第 1 页 第 2 页
用户读完第 1 页,请求第 2 页,参数是 offset=3。这时有两种常见变化。
插入。 三条新内容 p12 p11 p10 发布,列表整体后移三位。offset=3 现在指向 p9,返回的正是用户刚读完的那一页。客户端如果只按位置去重,会看到三张重复卡片;如果服务端做了跨页去重,又会因为「本页只剩太少新内容」而提前结束,后面的 p6 p5 p4 永远读不到。
删除。 p9 被作者删除,列表整体前移一位。offset=3 现在指向 p5,p6 被跳过。用户不会收到任何错误,只会觉得「好像少了一条」。
这两种变化可以同时发生,也可以发生在两次请求之间的任意毫秒。于是「第几页」这个参数在内容流里不再是位置,只是一个会漂移的计数。
深分页的成本
即使忽略正确性,页码分页还有一个随深度增长的成本。以常见的 SQL 为例:
SELECT id, author_id, created_at, body
FROM posts
WHERE state = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;
数据库仍然要按索引顺序产出前 10020 行,再丢掉前 10000 行。页码越深,扫描越多,延迟越不可预测;高并发下还会放大缓存与 I/O 压力。游标分页把条件写进索引查找:
SELECT id, author_id, created_at, body
FROM posts
WHERE state = 'published'
AND (created_at, id) < ('2026-10-14 07:30:00', 'post-9')
ORDER BY created_at DESC, id DESC
LIMIT 21;
复合索引可以直接定位到锚点之后,成本接近 O(log n + limit),与翻到多深无关。这里的 < 是行值比较,语义是「时间戳更早,或时间戳相同但 id 更小」。
游标里到底编码什么
游标至少要包含排序键的完整值。只有时间戳不够:同一毫秒发布的多条内容在索引里需要第二个字段决定先后。social-lab 用 createdAt|id:
createdAt:毫秒时间戳,与服务端索引第一列一致。id:同一毫秒内的稳定次序,通常用全局唯一字符串。- 分隔符:本实验用
|,并在编码时拒绝 id 里出现它。
真实产品通常把游标做成不透明字符串(base64url 编码的 JSON,带版本号)。不透明有两个好处:客户端不会依赖内部格式,服务端可以换排序键而不破坏旧客户端;同时可以加签名,防止客户端伪造一个巨大的 offset 来探测数据。代价是调试时需要额外解码,所以日志里应同时记录解码后的字段。
游标还要做输入校验。空字符串、负数时间戳、缺 id、id 含分隔符,都应该在进入查询前被拒绝,而不是拼进 SQL 后由数据库报错。本节实现里的 decodeCursor 就是这道门。
一个可运行的游标实现
下面的模块是纯函数,属于 L1:没有网络、没有定时器、没有共享状态,可以直接在 Node 里验证。
// file: src/feed/cursor.ts
// tier: L1
// verify: node --test test/feed/cursor.test.ts
export interface FeedCursor {
createdAt: number;
id: string;
}
export interface CursorRequest {
before: string | null;
limit: number;
}
export interface CursorPage<T> {
items: T[];
nextCursor: string | null;
hasMore: boolean;
}
const SEPARATOR = "|";
export function encodeCursor(cursor: FeedCursor): string {
if (!Number.isSafeInteger(cursor.createdAt) || cursor.createdAt <= 0) {
throw new Error("createdAt 必须是正整数毫秒时间戳");
}
if (cursor.id === "" || cursor.id.includes(SEPARATOR)) {
throw new Error(`id 不能为空且不能包含分隔符 ${SEPARATOR}`);
}
return `${cursor.createdAt}${SEPARATOR}${cursor.id}`;
}
export function decodeCursor(raw: string): FeedCursor {
const cut = raw.indexOf(SEPARATOR);
if (cut <= 0 || cut === raw.length - 1) {
throw new Error("游标格式非法");
}
const createdAt = Number(raw.slice(0, cut));
const id = raw.slice(cut + 1);
if (!Number.isSafeInteger(createdAt) || createdAt <= 0 || id === "") {
throw new Error("游标字段非法");
}
return { createdAt, id };
}
// 与服务端复合索引同序:时间戳倒序,同一毫秒按 id 倒序。
export function compareFeedOrder(a: FeedCursor, b: FeedCursor): number {
if (a.createdAt !== b.createdAt) return b.createdAt - a.createdAt;
return b.id.localeCompare(a.id);
}
// item 是否严格排在 anchor 之后(更旧),用于下一页的开区间过滤。
export function comesAfter(anchor: FeedCursor, item: FeedCursor): boolean {
return compareFeedOrder(item, anchor) > 0;
}
export function paginate<T extends FeedCursor>(all: T[], request: CursorRequest): CursorPage<T> {
if (!Number.isInteger(request.limit) || request.limit < 1) {
throw new Error("limit 必须是正整数");
}
const anchor = request.before === null ? null : decodeCursor(request.before);
const sorted = [...all].sort(compareFeedOrder);
const start = anchor === null ? sorted : sorted.filter((item) => comesAfter(anchor, item));
// 多取一条只用于判断 hasMore,不进入返回结果。
const window = start.slice(0, request.limit + 1);
const hasMore = window.length > request.limit;
const items = hasMore ? window.slice(0, request.limit) : window;
const last = items.at(-1);
const nextCursor = hasMore && last ? encodeCursor({ createdAt: last.createdAt, id: last.id }) : null;
return { items, nextCursor, hasMore };
}
实验里的 paginate 为了独立可测,直接在内存数组上过滤,复杂度是线性的。生产实现应该把同样的条件下推到数据库,接口形状保持不变:输入锚点与 limit,输出条目、下一页游标和是否还有更多。
四个关键决策
开区间,而不是闭区间
comesAfter 使用严格大于,锚点本身不会出现在下一页。如果写成 >=,每次翻页都会重复上一页的最后一条。很多「偶发重复」不是去重坏了,而是边界条件写成了闭区间。
时间戳必须配 id
只比较时间戳时,同一毫秒的条目会被整体跨过或整体重复。把 (createdAt, id) 当成一个复合键,并在游标里保存两个字段,才能让边界唯一。id 的排序方向必须与服务端索引一致,否则会出现「看起来有游标、实际却乱序」的问题。
多取一条判断 hasMore
不要为了 hasMore 额外执行一次 COUNT(*)。多取一条记录,如果拿到 limit + 1 条就说明还有下一页;返回前把多余那条丢掉。这样一次查询同时得到数据与终止信号,成本可控。
end-of-feed 是一个状态,不是异常
短页(少于 limit)意味着到底了,nextCursor 返回 null。恰好满页但确实没有更多时,会多一次空请求;这可以用服务端显式的 exhausted 字段优化,也可以接受。关键是客户端要把「空页且 nextCursor 为 null」当成终止状态,而不是继续用同一个游标重试,否则会形成死循环。
测试与真实输出
本机运行结果如下:
$ node --test test/feed/cursor.test.ts
✔ 游标编码后可以无损解码 (0.901334ms)
✔ 编码拒绝非正整数时间戳与空 id (0.257958ms)
✔ 解码拒绝缺字段、缺 id 与非法时间戳 (0.108583ms)
✔ 同一毫秒按 id 倒序形成稳定顺序 (8.432625ms)
✔ 首页取最新 limit 条并给出下一页游标 (0.235ms)
✔ 按游标翻页不重复锚点也不漏项 (0.125625ms)
✔ 两页之间插入新内容不会移动旧页边界 (0.493583ms)
✔ 锚点同毫秒的更旧 id 仍被下一页收进来 (0.143667ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 140.609542
八条用例分别盯住:编码往返、非法输入、同毫秒排序、首页边界、连续翻页、翻页期间插入、同毫秒锚点。它们不验证数据库索引是否真的生效,那属于服务端查询计划的验证范围。
一次热榜刷新导致的重复与漏项
「回声」的热榜曾经用页码分页,接口是 GET /feed/hot?page=2&size=20。一次活动期间,运营每十分钟推一批新内容,客服同时收到两类投诉:有人看到同一张卡片出现两次,有人说自己明明看到提示「还有 3 条」却怎么也刷不出来。
复盘时把两次请求的 offset 与返回 id 打出来,问题一目了然:第一次请求返回第 1 页后,榜单插入了 7 条新内容;第二次请求的 offset=20 落在旧的第 13 条附近,于是第 1 页末尾的 7 条被重复返回。另一次是运营下架了 5 条违规内容,前缀缩短,offset=20 直接跳过了 5 条正常内容。两类投诉来自同一个根因:offset 指向的是计数,不是内容。
修复分三步:榜单接口改为 before 游标;客户端把 nextCursor 与条目一起存进信息流容器;运营侧的下架操作不再要求客户端刷新,而是让下一次翻页自然跳过。上线后重复与漏项投诉归零,深页接口的 P99 也从随页码增长变成基本持平。
这个事故留下一条团队约定:任何会持续写入的列表,接口文档里不允许出现 page 参数。 后台管理表格是例外,因为它的数据集与排序在操作期间足够稳定。
什么时候仍然可以用页码
页码不是错误,只是适用面窄:
- 后台管理列表,数据量与排序在会话期间基本稳定。
- 搜索结果,且服务端为这次查询固定了快照或排序种子。
- 需要「跳到第 37 页」的产品,且能接受边界漂移。
- 数据量很小、全量可缓存的配置表。
内容流、评论流、通知流、直播弹幕历史,都属于持续写入且用户顺序消费的场景,应该用游标。判断标准不是「数据多不多」,而是「翻页期间前缀会不会变」。
小结
页码分页把「位置」寄托在一个会漂移的计数上,插入导致重复、删除导致漏项,深分页还让查询成本随页码增长。游标分页把位置编码进排序键:时间戳加 id 组成唯一锚点,开区间过滤保证不重不漏,多取一条判断是否到底。
游标解决了「下一页从哪里开始」,但没有解决「同一页内容重复到达」和「新内容插到哪里」。下一节进入客户端窗口:已见集合、实时插入与刷新后的位置保持。
阅读导航:上一节:1.3 lab 的三条验证线:L1 纯逻辑、L2 进程内、L3 本地实时通道 · 下一节:2.2 去重、插入与位置保持 。
常见错误与边界
- 把游标过滤写成闭区间,锚点重复出现在下一页。
- 只用时间戳做游标,同一毫秒的内容被整体跳过或重复。
- 客户端自行拼接或解析游标,服务端换排序键时旧版本直接崩溃。
- 用
COUNT(*)计算总页数来判断是否还有更多,成本随数据量增长。 - 翻页期间删除内容后仍复用旧 offset,造成静默漏项。
- 空页且游标为 null 时继续重试,形成无限请求。
- 把游标当作用户阅读位置持久化,进程重启后从错误位置继续。
- 认为「接口返回 200 且没有重复」就等于分页正确,忽略了漏项这种无报错故障。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。