SQL 键集分页:用稳定游标替代不断增长的 OFFSET

10-01 3阅读

列表越往后翻,OFFSET 越大,数据库需要处理的跳过部分可能越来越多。对于“继续加载下一页”的场景,可以改成从上一页最后一条记录之后继续查,这通常称为键集分页。本文使用 PostgreSQL 18 语法,假定 posts 表的 id 唯一,created_at 非空且用于分页期间不变,并且查询始终限定 tenant_id。

SQL 键集分页:用稳定游标替代不断增长的 OFFSET

AI生成概念配图:游标标记已读边界,查询从边界之后继续向前。仅辅助理解,不代表真实界面或实测结果。

先建立没有歧义的顺序

只按 created_at 排序不够,因为多条记录可能拥有相同时间。加入唯一的 id 作为第二排序键,才能让任意两行有确定先后。游标也必须保存两个值,不能只存日期或页码。时间戳的精度、时区和字段类型应保持原样,避免序列化时截断后让边界发生移动。

SELECT id, created_at, title
FROM posts
WHERE tenant_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 21;

这是页大小为二十的示例,多取一条仅用于判断是否可能还有下一页。接口返回前二十条,并以实际返回的最后一条建立游标;不要使用被丢弃的第二十一条,否则会漏掉它。$1 等是驱动绑定参数的占位符,实际使用时应通过数据库驱动传值,不要拼接用户输入。

下一页条件必须与排序一致

SELECT id, created_at, title
FROM posts
WHERE tenant_id = $1
  AND (created_at, id) < ($2::timestamptz, $3::bigint)
ORDER BY created_at DESC, id DESC
LIMIT 21;

降序列表用小于边界的复合键继续向后查询。PostgreSQL 的行构造比较按字段顺序进行;这里明确假设两列均非空。若允许 NULL,或排序方向一升一降,就不能机械照搬这个条件,应先定义 NULL 排序规则并展开相应的比较逻辑。第一页面与后续页面必须使用同一组过滤和排序条件。

可以在测试环境评估与过滤、排序匹配的多列索引,例如以 tenant_id 开头,再包含 created_at 与 id。是否采用该索引仍由数据分布和查询计划决定,不应承诺固定倍数的性能提升。新增索引还会占空间并影响写入,生产建立方式必须走数据库变更流程。

游标是状态,不是权限

接口可把时间、编号、过滤摘要与游标版本编码成不透明令牌,并按需要校验完整性。编码不等于加密;不应把敏感过滤条件随意塞进可读令牌。签名也不代表用户有权访问对应租户,每次请求仍须重新检查身份与数据范围。服务端应限制页大小、验证字段类型,并拒绝与当前过滤条件不一致的游标。

游标版本有助于在排序规则改变时明确失效,而不是继续用旧令牌得到奇怪结果。对非法、过期或不兼容游标,接口应返回约定的错误和重新开始方式。不要悄悄退回第一页,这会让调用方把重复数据误认成新数据。

明确并发变化时的承诺

普通分页请求通常不共享数据库快照。新记录插入、旧记录删除或排序字段变化,都可能影响后续看到的集合。键集分页减少了对位置偏移的依赖,却不自动提供“把某时刻所有记录恰好遍历一次”的保证。若需求是审计导出,应另行设计快照、版本边界或物化结果,不能直接复用交互列表接口。

它也不天然支持随意跳到第几页,更适合时间线和连续浏览。验收时准备同时间戳的多行、空结果、最后不足一页、边界删除和过滤变化等样本,并核对每个返回 id。选型的关键是列表需要怎样的浏览体验和一致性,而不是认为 OFFSET 永远错误。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。