时间戳与唯一编号共同确定动态信息流的下一页边界。

游标分页从明确边界继续读取

键集分页从客户端上一页实际收到的最后一个排序键继续。它不再从当前结果开头数过多少行,而是把候选行与一个具体边界比较。因此,信息流顶部新插入一条记录时,后面的页不会仅因位置整体后移就跟着错开一行。但这种方法不会冻结整个数据集,也不承诺后续每次请求都读取同一份记录。 坏掉的样子 单独使用时间戳,通常无法给出唯一边界。即使时间精度很细,多行也可能拥有相同创建时间。严格比较会跳过尚未返回的并列记录,包含等号的比较又可能重复已经展示的行。正确做法是增加一个唯一的决胜字段,并在排序与继续查询的谓词中始终使用完整键,不能一边按两个字段排序,另一边只保存其中一个。 排序字段可修改时,会产生另一类问题。已经展示过的行可能移到游标后面,于是再次出现;尚未展示的行可能移到游标前面,于是后续翻页一直看不到它。删除记录、改变过滤条件的匹配状态,也会改变下一次请求能够返回的内容。比较边界保持不变,并不能让持续变化的数据具有不可变快照的性质。 偏移分页也有明确用途:它表达当前查询结果中的编号位置,适合需要页码的界面。但在下一次偏移位置之前插入记录,可能造成重复;删除记录则可能造成遗漏。无论哪一种分页,只因为接口返回字段叫作“一页”,都不会自动获得跨请求的一致快照。需要先把产品希望保证的事情说清楚。 快速验证 连接一个可丢弃的 PostgreSQL 数据库,在同一个 psql 会话里执行下列代码。确认实验模式 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},因此游标来自编号四的时间戳与编号。代码先把真正返回的第一页保存到临时表,再从它取得边界,而不是另外查询一次正在变化的原表来猜测客户端看到了什么。在读写交错时,这个区别直接决定后续边界是否对应上一份响应。 ...

八月 19, 2026 · 2 分钟 · XB Field Notes