SQL 如何实现“游标分页”(keyset pagination)避免深分页慢

14次阅读

keyset pagination 是一种基于排序字段值的游标分页方法,比 OFFSET 更快,因其跳过扫描直接定位;要求排序字段具唯一性,SQL 通过比较上一页末记录的 (created_at, id) 值实现高效分页。

SQL 如何实现“游标分页”(keyset pagination)避免深分页慢

什么是 keyset pagination,为什么 比 OFFSET 快

OFFSET 分页在数据量大、页码靠后时会越来越慢,因为数据库必须扫描并跳过前面所有行。keyset pagination(又称游标分页)不依赖行号,而是用上一页最后一条记录的排序字段值作为下一页的起点,跳过扫描过程,直接定位。

它本质是“基于值的分页”,要求排序字段有唯一性或组合唯一性(比如 created_at, id),否则可能漏行或重复。

常见错误现象:OFFSET 100000 LIMIT 20 执行秒级甚至超时;而等价的 keyset 查询毫秒级返回。

如何写一个安全的 keyset 分页 SQL

核心是把“第 N 页”转换成“大于上一页最后一条的排序键”。假设按 created_at DESC, id DESC 排序,且每页取 20 条:

  • 第一页:直接查最大值,SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT 20
  • 后续页:用上一页最后一条的 (created_at, id) 做条件,注意方向要和 ORDER BY 一致
  • 第二页示例(假设上一页最后一条是 ('2024-05-01 10:00:00', 12345)):
    SELECT * FROM posts WHERE (created_at, id)

关键点:

  • 多字段排序必须用行值比较(PostgreSQL/MySQL 8.0+ 支持),不能拆成两个独立条件(易出错)
  • 方向必须严格匹配:DESC 对应
  • 如果排序字段允许 NULL,需额外处理(例如加 IS NOT NULL

MySQL 和 PostgreSQL 的语法差异与陷阱

MySQL 5.7 不支持行值比较,得改写为复合条件:

等价于上面的 MySQL 5.7 写法:
WHERE created_at

PostgreSQL 支持标准行值语法,但要注意:

  • (a, b) 等价于 a,语义一致
  • 如果字段类型是 TIMESTAMP WITH TIME ZONE,时区不一致会导致游标失效

常见坑:

  • 忘记给排序字段建联合索引(必须是 INDEX(created_at, id),顺序不能反)
  • 在 WHERE 中混用其他过滤条件却没包含在索引里,导致索引失效
  • 游标值被 前端 篡改或精度丢失(比如 JS 把时间截断成秒级)

如何生成和校验游标值(cursor)

游标不是随意拼的字符串,它是排序键的 编码 结果。推荐做法:

  • 服务端从查询结果中取最后一条的排序字段,JSON 序列化后 Base64 编码,例如:base64_encode(json_encode(['2024-05-01 10:00:00', 12345]))
  • 前端只传这个字符串,后端 解码后直接用于 WHERE,避免类型解析错误
  • 务必校验解码后的数组长度和字段类型,防止注入或越界(如传入 ["abc", null]
  • 不要用主键 ID 单独做游标——如果业务允许 ID 不连续或有删改,极易漏数据

游标分页真正难的不是写 SQL,而是保证排序键稳定、索引有效、编码防篡改。一旦某条记录的 created_at 被更新,它就可能在下一页重复出现——这点容易被忽略。

text=ZqhQzanResources