列表分页为什么会重复:理解游标和稳定排序

假设列表按创建时间倒序排列,第一页读取前 10 条,第二页跳过前 10 条。如果两次请求之间又插入了几条新记录,第二次“前 10 条”的含义就变了,读者可能看到重复内容或漏掉记录。

本篇目标: 理解稳定排序与游标边界,知道什么时候适合用游标分页。

翻页时记住位置,不只记住数量
翻页时记住位置,不只记住数量放大图解 ↗

排序必须能确定唯一顺序

只按时间排序时,同一时间戳的多行之间可能没有稳定顺序。可以再加入唯一标识作为第二排序键,例如 created_at DESC, id DESC。使用 LIMIT 时,明确排序十分重要,否则不同请求取到的子集可能不一致。PostgreSQL:LIMIT 与 OFFSET

用上一页的末尾作为边界

下面以虚构的 notes 表为例,使用 PostgreSQL 的行值比较。第一页没有边界条件,后续页带上上一页最后一行的时间和 ID。

SELECT id, title, created_at
FROM notes
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 11;

如果页面需要 10 条,可以多取一条判断是否还有下一页。返回给用户前移除额外一条,下一次游标仍取实际展示的最后一行,而不是额外读取的那一行。

游标里还要绑定查询条件

排序方式、搜索条件或可见范围发生变化后,旧游标通常不应继续使用。可以在游标中保存版本与查询指纹,由服务器校验。需要防止篡改时可签名,但 Base64 编码本身不提供保密或真实性。

无论游标看起来多复杂,都不能代替对象权限检查。每次查询仍然要应用当前身份允许的范围。

认识它解决不了的问题

游标分页适合持续向后浏览,不擅长直接跳到任意页。如果排序字段在翻页期间被修改,仍可能出现新的边界问题;严格的历史一致性需要额外快照或固定查询范围。

测试可以包含同时间戳、多条并发插入、删除边界行、修改排序字段和筛选条件变化。先决定读者真正需要的是“逐步浏览”还是“稳定快照”,再选择分页方法,能避免把一种工具当成所有列表的答案。

见字如晤

图解