分頁 cursor 用毫秒,翻頁就會重複:timestamptz 精度與複合 keyset

九月做遊戲商系統原型時,有一個給營運商拉交易資料的 datafeed API,用時間當 cursor 往後翻頁,測試卻發現翻頁邊界有重複資料。原因是 cursor 只帶到毫秒,而 PostgreSQL 的 timestamptz 存到微秒,當時改用 UnixMicro 當 cursor,測試就過了。

當時這樣改只是權宜之計,微秒 cursor 解決了精度不一致,遇到兩筆時間完全相同的資料,單用時間當 cursor 仍會漏或重複。在 PostgreSQL 裡,最容易遇到這種情況的就是同一個 transaction 用 DEFAULT now() 寫入多筆資料,長期還是該用 (created_at, id) 複合 keyset,下面在 PostgreSQL 18.6 上把三種寫法各跑一次。

一、毫秒 cursor:下一頁從頭再來一次

建一張簡化的表,放六筆資料,前五筆都落在 .123 這一毫秒,其中 r3 和 r4 連微秒都相同:

CREATE TABLE feed (
  id         bigserial PRIMARY KEY,
  created_at timestamptz NOT NULL DEFAULT now(),
  payload    text NOT NULL
);

INSERT INTO feed (created_at, payload) VALUES
  ('2026-10-09 04:00:00.123401+00', 'r1'),
  ('2026-10-09 04:00:00.123402+00', 'r2'),
  ('2026-10-09 04:00:00.123403+00', 'r3'),
  ('2026-10-09 04:00:00.123403+00', 'r4'),
  ('2026-10-09 04:00:00.123900+00', 'r5'),
  ('2026-10-09 04:00:00.124100+00', 'r6');

每頁 2 筆,第一頁最後一筆是 r2,把它的時間轉成毫秒和微秒:

 id |          created_at           | payload |  unix_milli   |    unix_micro    
----+-------------------------------+---------+---------------+------------------
  1 | 2026-10-09 04:00:00.123401+00 | r1      | 1791518400123 | 1791518400123401
  2 | 2026-10-09 04:00:00.123402+00 | r2      | 1791518400123 | 1791518400123402

用毫秒當 cursor,第二頁查 created_at > 這個毫秒值:

SELECT id, payload FROM feed
WHERE created_at > to_timestamp(1791518400123 / 1000.0)
ORDER BY created_at, id LIMIT 2;
 id | payload 
----+---------
  1 | r1
  2 | r2

截斷成毫秒後,cursor 是 .123000,比 r1、r2 都早,第二頁就又拿到同樣兩筆,如果整頁資料都在同一毫秒內,呼叫端會一直拿到同一頁,永遠翻不過去。Go 的 time.Time 本身保有奈秒,精度是在轉 cursor 時丟掉的,UnixMilli() 和 JavaScript 的 Date 都只到毫秒,很多 JSON 序列化的時間格式也一樣,資料跨過系統邊界時,後三位就沒了。

二、微秒 cursor:時間相同的資料會漏或重複

換成微秒後,大部分情況都能正確分頁,剩下的問題是時間完全相同的兩筆資料剛好分在前後兩頁。每頁 3 筆,第一頁是 r1、r2、r3,cursor 是 r3 的微秒值:

-- page 2 with > (cursor = r3 micro)
 id | payload 
----+---------
  5 | r5
  6 | r6

-- page 2 with >= (cursor = r3 micro)
 id | payload 
----+---------
  3 | r3
  4 | r4
  5 | r5

用 > 會跳過和 r3 同一微秒的 r4,用 >= 又會重複出現 r3,兩種結果都不好。資料遺漏通常更難發現,重複至少還有冪等可以擋。

時間相同的情況沒有想像中少見,PostgreSQL 文件明確說明,now() 等於 transaction_timestamp(),回傳目前 transaction 開始的時間,整個 transaction 內都不會變。在一張新的空表上,用一個 transaction 插三筆:

BEGIN;
INSERT INTO feed (payload) VALUES ('tx-1'), ('tx-2'), ('tx-3');
COMMIT;
SELECT count(*) AS rows, count(DISTINCT created_at) AS distinct_ts FROM feed;
 rows | distinct_ts 
------+-------------
    3 |           1

三筆資料共用同一個時間值,注單、交易這類資料又常常整批寫入,微秒 cursor 用在這種表上,遲早會在頁邊界遇到問題。改用 clock_timestamp() 可以讓每筆時間不同,卻得靠寫入端來保證分頁正確,而且不同連線同時寫入時,還是可能落在同一微秒,不能依賴這個做法。

三、複合 keyset:用 (created_at, id) 當 cursor

Markus Winand 在 Use The Index, Luke 把這種做法叫 seek method,排序欄位不唯一時,要再加一個唯一欄位才能確定排序結果,cursor 也得帶上這兩個值。PostgreSQL 支援 row value 比較,可以直接寫成一個條件:

SELECT id, payload FROM feed
WHERE (created_at, id) > ('2026-10-09 04:00:00.123403+00', 3)
ORDER BY created_at, id LIMIT 3;
 id | payload 
----+---------
  4 | r4
  5 | r5
  6 | r6

r4 沒有漏,r3 也沒有重複,這裡的 ORDER BY 要和比較時的欄位順序完全一致,索引也建在同樣的兩個欄位上。放入 20 萬筆資料,看看執行計畫:

CREATE INDEX feed_created_id ON feed (created_at, id);
EXPLAIN (COSTS OFF)
SELECT id, payload FROM feed
WHERE (created_at, id) > ('2026-10-09 05:01:00+00', 60000)
ORDER BY created_at, id LIMIT 100;
                                                 QUERY PLAN                                                 
------------------------------------------------------------------------------------------------------------
 Limit
   ->  Index Scan using feed_created_id on feed
         Index Cond: (ROW(created_at, id) > ROW('2026-10-09 05:01:00+00'::timestamp with time zone, 60000))

整個 row 比較都用作 index condition,直接從 cursor 的位置往後掃,不用像 OFFSET 那樣把前面的資料數過一遍再丟掉。Winand 提到 Oracle、MySQL 和 SQL Server 2017 對 row value 的支援各有不小的限制,在這些資料庫上要改寫成近似的 created_at > ? OR (created_at = ? AND id > ?),PostgreSQL 不需要。

對外的 cursor 可以把兩個值包成一個不透明字串(例如 base64 編碼的 micro:id),呼叫端不需要知道裡面有什麼,日後要換欄位也不會改到 API 介面。

四、晚 commit 的資料仍可能漏掉

複合 keyset 能確定排序結果,卻仍可能漏資料,例如 transaction A 在 10:00:00 開始、10:00:05 才 commit,它寫入的 created_at 是 10:00:00;呼叫端在 10:00:03 拉資料時看不到這筆,cursor 已經走過 10:00:00,之後也不會回頭拿。datafeed 是給對方對帳用的 API,這個問題尤其需要留意。

用了複合 keyset,這個問題還得另外處理,常見做法是讓 cursor 只走到「現在減去一段安全時間」,例如只提供幾秒鐘之前的資料,安全時間要長過最長的寫入 transaction,取多少得看實際 transaction 長度。也可以改用 commit 順序可控的序號,例如由單一寫入者依序產生的編號。

補充筆記

  • timestamptz 精度是微秒;cursor 只帶毫秒時,截斷後的值比該頁最後一筆早,下一頁會再拿到同樣的資料。
  • 微秒 cursor 在時間完全相同的資料落在頁邊界時,用 > 會漏筆,用 >= 會重複。
  • now() 是 transaction 開始的時間,同一個 transaction 插入的多筆資料時間完全相同,批次寫入的表最容易遇到。
  • (created_at, id) 複合 keyset 能確定排序結果,PostgreSQL 的 row value 比較能直接用 (created_at, id) 索引。
  • keyset 不處理晚 commit 的資料,cursor 要停在安全時間之前,或改用 commit 順序可控的序號。

延伸閱讀

想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 PostgreSQL 18.6 實測。