键集分页从客户端上一页实际收到的最后一个排序键继续。它不再从当前结果开头数过多少行,而是把候选行与一个具体边界比较。因此,信息流顶部新插入一条记录时,后面的页不会仅因位置整体后移就跟着错开一行。但这种方法不会冻结整个数据集,也不承诺后续每次请求都读取同一份记录。
坏掉的样子
单独使用时间戳,通常无法给出唯一边界。即使时间精度很细,多行也可能拥有相同创建时间。严格比较会跳过尚未返回的并列记录,包含等号的比较又可能重复已经展示的行。正确做法是增加一个唯一的决胜字段,并在排序与继续查询的谓词中始终使用完整键,不能一边按两个字段排序,另一边只保存其中一个。
排序字段可修改时,会产生另一类问题。已经展示过的行可能移到游标后面,于是再次出现;尚未展示的行可能移到游标前面,于是后续翻页一直看不到它。删除记录、改变过滤条件的匹配状态,也会改变下一次请求能够返回的内容。比较边界保持不变,并不能让持续变化的数据具有不可变快照的性质。
偏移分页也有明确用途:它表达当前查询结果中的编号位置,适合需要页码的界面。但在下一次偏移位置之前插入记录,可能造成重复;删除记录则可能造成遗漏。无论哪一种分页,只因为接口返回字段叫作“一页”,都不会自动获得跨请求的一致快照。需要先把产品希望保证的事情说清楚。
快速验证
把 SQL 保存为 cursor.sql,连接可丢弃的 PostgreSQL 数据库,运行 psql -X -v ON_ERROR_STOP=1 -f cursor.sql。确认实验模式 article_cursor 没被使用,并保持自动提交。遇错即停,避免建模式失败后继续操作已有对象,甚至执行最后的清理。记录中的运行版本为 PostgreSQL 18.6。实验特意让每个时间戳对应两行,先保存实际返回的第一页,再向顶部插入新行,最后修改两个已有排序键:
CREATE SCHEMA article_cursor;
SET search_path=article_cursor,public;
SET TIME ZONE 'UTC';
CREATE TABLE feed (id bigint PRIMARY KEY,published_at timestamptz NOT NULL);
INSERT INTO feed VALUES
(1,'2026-01-01 10:00:00+00'),(2,'2026-01-01 10:00:00+00'),
(3,'2026-01-01 11:00:00+00'),(4,'2026-01-01 11:00:00+00'),
(5,'2026-01-01 12:00:00+00'),(6,'2026-01-01 12:00:00+00');
CREATE INDEX feed_order ON feed (published_at DESC,id DESC);
CREATE TEMP TABLE first_page AS
SELECT id,published_at FROM feed ORDER BY published_at DESC,id DESC LIMIT 3;
SELECT array_agg(id ORDER BY published_at DESC,id DESC) FROM first_page;
SELECT published_at AS cursor_time,id AS cursor_id FROM first_page
ORDER BY published_at,id LIMIT 1 \gset
SELECT array_agg(id ORDER BY published_at DESC,id DESC)
FROM feed WHERE published_at<:'cursor_time'::timestamptz;
PREPARE after_cursor(timestamptz,bigint) AS
SELECT array_agg(id ORDER BY published_at DESC,id DESC) FROM (
SELECT id,published_at FROM feed
WHERE (published_at,id)<($1,$2)
ORDER BY published_at DESC,id DESC LIMIT 3
) AS page;
INSERT INTO feed VALUES (7,'2026-01-01 13:00:00+00');
SELECT array_agg(id ORDER BY published_at DESC,id DESC) FROM (
SELECT id,published_at FROM feed
ORDER BY published_at DESC,id DESC LIMIT 3 OFFSET 3
) AS page;
EXECUTE after_cursor(:'cursor_time'::timestamptz,:cursor_id);
UPDATE feed SET published_at='2026-01-01 09:00:00+00' WHERE id=6;
UPDATE feed SET published_at='2026-01-01 14:00:00+00' WHERE id=3;
EXECUTE after_cursor(:'cursor_time'::timestamptz,:cursor_id);
DEALLOCATE after_cursor;
DROP TABLE first_page;
DROP SCHEMA article_cursor CASCADE;
第一个数组是 {6,5,4},因此游标来自编号四的时间戳与编号。代码先把真正返回的第一页保存到临时表,再从它取得边界,而不是另外查询一次正在变化的原表来猜测客户端看到了什么。在读写交错时,这个区别直接决定后续边界是否对应上一份响应。
只按时间戳严格比较,得到 {2,1},错误漏掉了与边界时间相同的编号三。向顶部加入编号七之后,偏移查询返回 {4,3,2},其中编号四已经在第一页出现过。改用完整元组,返回 {3,2,1},这才是在原排序键未变条件下正确接续第一页的结果。
最后两次更新把已经展示的编号六移到边界之后,把尚未展示的编号三移到边界之前。使用完全相同的游标,结果变成 {2,1,6}。编号六重复出现,编号三则从剩余遍历中消失。这些精确数组同时展示了键集分页的价值和限制,不能只引用中间一次成功结果,就宣称游标永远不会重复或遗漏。
游标要携带完整查询契约
时间戳降序、编号降序时,继续查询采用严格小于的元组比较。整体升序遍历需要相反方向。如果不同字段采用不同排序方向,谓词也必须按照真实排序关系设计,不能不加分析地照抄一个元组比较。示例把时间戳设为非空,是因为空值参与比较另有语义,实际产品需要明确它们位于哪里、如何继续翻页。
游标应完整保留实际取值,包括时间戳精度;同时要与相应的过滤和排序约定关联,避免客户端把某个搜索的边界套到另一个搜索。将字段编码成不透明字符串,可以方便接口演进,但编码本身既不防篡改,也不提供对象权限。输入仍要校验,每一页仍须遵守当前用户的访问规则。能够解码一个游标,不等于可以读取它指向的所有记录。
匹配排序的索引有机会支持直接定位边界并读取附近条目,从而避免丢弃很深的偏移区间。不过,这个很小的样本不能证明优化器一定选择该索引,也没有测量性能提升。需要结合真实数据量、过滤选择性与翻页深度观察执行计划,再判断索引是否有效,不能把建立了索引当作已经获得提速的证据。
如果需求是固定的一次导出,应另行定义更强的保证,例如有明确寿命的数据库快照或物化结果集合,并承担对应保留时间和资源成本。如果需求是实时信息流,也可以明确告知记录会在两次请求之间变化。关键是让接口的保证与用户期望相符,区分正在浏览当前数据,还是正在遍历一份被保存下来的结果。
延伸阅读
来源
- PostgreSQL 18 LIMIT 与 OFFSET,确定性排序和跳过记录的工作量,已核验 2026-09-12。
- PostgreSQL 18 行比较,元组顺序与空值语义,已核验 2026-09-12。
- Relay Cursor Connections,游标边界与一致的边顺序,已核验 2026-09-12。