最近我在看一段实时记录接口。它没有接收 page=2,而是让客户端提交上一批记录中最小的 ID:
GET /api/records?beforeId=108&pageSize=50数据库查询大致是这样:
SELECT id, content, created_at
FROM event_records
WHERE stream_id = :streamId
AND id < :beforeId
ORDER BY id DESC
LIMIT :limit;这不是某个框架的特殊写法。API 层通常把它叫作 Cursor-based Pagination,落到关系数据库查询时,更准确的名字是 Keyset Pagination 或 Seek Pagination。它不记“跳过了多少行”,而是记“上次读到了哪里”。
OFFSET 在实时列表里为什么容易出错
普通分页常写成:
SELECT id, content, created_at
FROM event_records
WHERE stream_id = :streamId
ORDER BY id DESC
LIMIT 3 OFFSET 3;如果记录一直不变,这段 SQL 没什么问题。实时列表偏偏会在用户翻页期间继续插入数据。
假设第一次请求拿到:
110, 109, 108客户端准备请求第二页时,顶部又插入了 112 和 111。此时完整顺序变成:
112, 111, 110, 109, 108, 107, 106再执行 OFFSET 3,第二页从 109 开始。109 和 108 已经在第一页出现过,客户端必须去重;插入和删除更频繁时,还可能漏掉记录。
Slack 在介绍其 API 分页演进时也提到过这个问题:Offset 越大,数据库需要跳过的行越多;数据集持续变化时,分页窗口还会移动。消息、事件、日志这类不断向顶部追加的数据,正是 Cursor Pagination 常见的使用场景。
用最后一条记录确定下一页
改成 ID 游标后,第一次请求仍然读取最新三条:
SELECT id, content, created_at
FROM event_records
WHERE stream_id = :streamId
ORDER BY id DESC
LIMIT 3;客户端记住最后一条记录的 ID,也就是 108。下一页使用:
SELECT id, content, created_at
FROM event_records
WHERE stream_id = :streamId
AND id < 108
ORDER BY id DESC
LIMIT 3;无论顶部又增加多少记录,查询都会从 107 继续。新增数据不再推动旧记录的分页位置。
Microsoft 的 EF Core 分页文档把这种方式称为 Keyset Pagination,并建议在只需要前后翻页时优先考虑它。GitLab 的数据库文档也使用相同思路:从上一页最后一行提取排序键,再用这些键定位下一批数据。
为什么我选择 ID,而不是时间
游标必须和排序规则一致,排序本身还要唯一。
只用 created_at 不一定安全。批量任务或自动事件可能在同一毫秒写入多条记录;数据库即使保存了更高精度,应用层经过 JavaScript Date 后通常也只剩毫秒。两条记录时间相同时,单靠时间无法确定谁在前、谁在后。
自增 ID 在这里省了不少事:
ORDER BY id DESC它唯一、不可变,而且与写入顺序一致,因此一个 beforeId 就够了。
如果产品明确要求按业务时间排序,就需要把时间和 ID 一起作为游标:
WHERE (created_at, id) < (:lastCreatedAt, :lastId)
ORDER BY created_at DESC, id DESCPostgreSQL 支持这种行值比较。对应索引也必须遵循相同顺序:
CREATE INDEX event_records_stream_created_id_idx
ON event_records (stream_id, created_at DESC, id DESC);只按时间排序却用 ID 翻页,或者查询按两列排序、游标却只保存一列,都会留下难查的边界错误。
pageSize + 1 是做什么的
服务端还可以多读取一条记录,用它判断后面是否还有数据:
const rows = await findRecordPage({
streamId,
beforeId,
limit: pageSize + 1,
});
return {
items: rows.slice(0, pageSize),
hasMore: rows.length > pageSize,
};页面大小为 50 时,数据库最多返回 51 条,不是先查询全表再在内存里截取。第 51 条只是一条探路记录。
这种写法避免了额外的 COUNT(*)。它也比 rows.length === pageSize 更准确:如果最后一页刚好有 50 条,只看本页长度会误判为还有下一页,客户端随后会请求一个空页。多读一条的成本很小,却能直接给出可靠的 hasMore。
游标不一定要暴露 ID
当前后端只服务自己的客户端,而且排序长期固定时,直接使用 beforeId=108 足够清楚。
公共 API 往往返回不透明游标:
{
"items": [],
"nextCursor": "eyJsYXN0SWQiOjEwOH0="
}服务端可以在游标里编码 ID、时间或分片位置,客户端只负责原样传回。以后更换内部分页策略时,不必修改公开参数。Slack 的 Cursor API 就采用了这种做法。
Base64 本身不提供安全性,它只是隐藏内部结构。对一个内部、单一排序的小程序接口来说,我更愿意先用数字 ID,等协议确实需要承载多个排序键时再改成不透明游标。
索引要跟着查询走
Keyset Pagination 不会自动让查询变快。下面这个查询:
WHERE stream_id = :streamId
AND id < :beforeId
ORDER BY id DESC
LIMIT 51适合的索引是:
CREATE INDEX event_records_stream_id_id_idx
ON event_records (stream_id, id DESC);如果只有 stream_id 单列索引和 id 主键,PostgreSQL 可能沿主键倒序扫描,再过滤不属于当前数据流的记录。数据少时几乎看不出差别,数据流交错、历史记录变多后,扫描无关行的成本才会暴露。
索引是否生效不能靠猜。我会用接近实际规模的数据检查:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, content, created_at
FROM event_records
WHERE stream_id = 42
AND id < 1000000
ORDER BY id DESC
LIMIT 51;预期执行计划应直接使用 (stream_id, id) 复合索引定位范围,而不是先扫描大量主键记录再执行 Filter。
这种分页也有明确边界
Keyset Pagination 适合“继续加载更早记录”,不适合跳到任意页。产品如果必须提供“直接前往第 37 页”,Offset 仍然更直观。
排序条件也不能在翻页过程中悄悄改变。筛选条件、排序方向或数据流 ID 发生变化时,旧游标应该作废并重新加载第一页。GitLab 的实现要求排序值能够形成确定且唯一的顺序,原因就在这里。
还有一个容易混淆的地方:游标分页解决的是遍历稳定性,不是数据库快照。用户翻页期间被删除的记录不会重新出现,已经更新的正文也会显示新值。如果业务要求浏览整个过程都看到同一份快照,需要另外设计快照版本或事务边界。
回到最初那段接口
我一开始只是想解释一段 id < beforeId,回头才发现,它把实时列表里几件容易纠缠的事拆开了:新记录从 WebSocket 或刷新进入顶部,HTTP 游标只负责向后读取旧记录,pageSize + 1 则回答“后面还有没有”。
对消息、日志和事件流来说,这通常比页码更贴近用户的动作。用户并不关心自己正在第几页,只想继续往前看。