分页不是 LIMIT/OFFSET:稳定游标与并发写入的一致性契约
一个接口返回第一页正确,并不代表分页正确。客户端读取第二页之前,可能有人新增、删除或修改了记录;此时简单的 OFFSET 20 已经指向另一组位置,结果可能重复、遗漏,或者在翻页过程中不断漂移。
分页首先是一份一致性契约:集合按什么顺序排列,游标之后是什么意思,并发变化是否可见,令牌能否跨筛选条件使用。数据库语句只是实现。
排序必须唯一且稳定
ORDER BY created_at DESC 不够。多条记录可能拥有相同时间,查询计划也不保证并列项的相对顺序。添加不可重复的 tie-breaker,形成全序:
SELECT id, created_at, title
FROM posts
WHERE tenant_id = :tenant_id
AND (created_at, id) < (:cursor_time, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :page_size_plus_one;
这是键集分页:游标保存上一页最后一项的排序值,下一页查找严格位于它之后的记录。数据库不支持行值比较时,可以展开成 created_at < t OR (created_at = t AND id < i)。索引应从固定筛选维度开始,并按排序字段排列,例如 (tenant_id, created_at DESC, id DESC)。
排序字段最好不可变。若一条记录的 created_at 或分数在翻页间被修改,它可能越过边界而再次出现或完全跳过。确实需要按可变热度排序时,要接受“实时列表”语义,或使用固定榜单版本。
OFFSET 适用范围要明确
PostgreSQL 文档指出,没有唯一 ORDER BY 时,不同 LIMIT/OFFSET 可能返回不可预测的子集;大 OFFSET 仍需计算并跳过前面的行,成本也会随页深增长。后台小表、固定报表和“跳到第 37 页”可以选择 OFFSET,但必须承认并发变化下的位置语义。
面向持续增长的数据流,键集分页通常更稳定:在第一页前插入的新记录不会把旧记录推到下一页;已删除记录自然消失;查询也能从索引边界继续。但它不承诺任意跳页,也不能自动提供精确总页数。
游标是服务端协议,不是数据库值
令牌可以携带以下逻辑信息:
{
"v": 2,
"boundary": ["2026-11-06T00:00:00Z", "post_123"],
"sort": "created_desc",
"filter_hash": "…",
"snapshot": "rev_8472",
"expires_at": 1793923200
}
客户端只把令牌原样传回。Google AIP-158 要求 page token 不透明且 URL-safe,并特别说明 Base64 不是充分的隐藏手段。无状态令牌应通过认证加密或签名防篡改;有状态令牌则保存随机 ID 到服务端记录。载荷不要包含敏感筛选值或可枚举内部 ID。
令牌必须绑定排序、筛选、父资源和租户。后续请求除 page size 外改变其他参数,应返回清晰的无效参数错误,而不是在新集合中继续旧边界。令牌版本让服务端以后迁移编码和索引;过期时间则限制长期兼容成本。
游标永远不是授权凭证。每一页都要重新验证调用者及资源范围,不能因为令牌中包含 tenant_id 就信任它。已签名令牌只能证明由服务端签发,不能证明当前用户仍有权限。
选择实时视图还是快照视图
实时游标以每次请求时的数据为准。第一页后新增且排在边界之前的记录不会出现在后续页;删除不会造成补位重复;修改排序键仍可能移动记录。这适合信息流,只要产品把刷新视为获取最新内容的方式。
快照游标承诺整次遍历面对同一个集合版本。令牌可引用物化结果、搜索索引 revision、业务 watermark 或可重建的 as_of 条件。不要为用户翻几十分钟的页面一直持有数据库事务;应使用有期限的版本化视图,并明确快照过期后的错误和重启方式。
两种语义都合理,不能含糊。若接口声称导出全部账单,就需要快照或去重恢复机制;若只是浏览最新动态,实时语义通常更便宜。
正确生成下一页
查询 page_size + 1 条:只返回前 page_size 条,多出来的一条用于判断是否还有下一页。下一令牌取最后一个已返回项的边界,而不是探测项。到达末尾时令牌应为空;Relay Connection 则用 edges.cursor、pageInfo.hasNextPage 与 endCursor 表达同一概念。
一页少于请求数量不一定意味着结束。权限后过滤、跨分片合并或后端工作预算都可能暂时返回较少甚至零项;若集合尚未穷尽,仍要返回下一令牌。总数也要标注语义:精确 COUNT 可能昂贵,并且实时集合在下一秒就变化。可以提供估算值、快照总数,或干脆不提供。
反向分页必须保持同一逻辑顺序。查询可以反向利用索引,但响应不能偷偷把业务顺序颠倒。GraphQL Cursor Connections 明确要求 first/after 与 last/before 的 edge 顺序一致。
REST 接口可在响应体返回 next_page_token,也可按 RFC 8288 用 Link 头表达 rel="next" 和 rel="prev"。无论表现形式如何,终止条件必须只有一个明确来源。
用并发变化验证契约
测试不能只准备 100 条静态数据。应在页间插入边界前后记录、删除首尾项、制造相同排序值、修改排序键、切换租户和权限、篡改或过期令牌,并覆盖最大与负数 page size。属性测试可以遍历快照,断言 ID 不重复且集合完整。
监控令牌解析失败、过期、页深、查询耗时、扫描行数、返回项数与空页仍带令牌的比例。发布新游标版本时先保持旧版本解码能力,观察其使用降为零后再移除。
稳定分页不是把偏移量藏进 Base64。它需要唯一顺序、定义清楚的并发可见性、与查询参数绑定的不透明令牌、匹配的索引以及可验证的结束条件。先写清这份契约,用户才能在不断变化的集合中可靠地继续向后读。