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

什么是 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 被更新,它就可能在下一页重复出现——这点容易被忽略。










