# 分頁 cursor 用毫秒，翻頁就會重複：timestamptz 精度與複合 keyset

> cursor 只帶毫秒，下一頁會重撈；改成微秒，同一個 transaction 寫入的資料仍會漏筆或重複。穩定做法是 (created_at, id) 複合 keyset，附 PostgreSQL 18 實測。

原文：https://sheng.page/posts/cursor-pagination-timestamp-precision/ · 發布：2026-10-15 · 作者：Sheng

九月做遊戲商系統原型時，有一個給營運商拉交易資料的 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 連微秒都相同：

```sql
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，把它的時間轉成毫秒和微秒：

```text
 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 >` 這個毫秒值：

```sql
SELECT id, payload FROM feed
WHERE created_at > to_timestamp(1791518400123 / 1000.0)
ORDER BY created_at, id LIMIT 2;
```

```text
 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 的微秒值：

```text
-- 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 插三筆：

```sql
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;
```

```text
 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 比較，可以直接寫成一個條件：

```sql
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;
```

```text
 id | payload 
----+---------
  4 | r4
  5 | r5
  6 | r6
```

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

```sql
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;
```

```text
                                                 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 順序可控的序號。

## 延伸閱讀

- PostgreSQL 文件〈[Date/Time Types](https://www.postgresql.org/docs/current/datatype-datetime.html)〉與〈[Current Date/Time](https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-CURRENT)〉：`timestamptz` 的 1 microsecond 精度，以及 `now()` 回傳 transaction 開始時間、`clock_timestamp()` 會變動的原文。
- Markus Winand〈[OFFSET is bad for skipping previous rows](https://use-the-index-luke.com/sql/partial-results/fetch-next-page)〉：為什麼 seek method 要加唯一欄位，以及各資料庫對 row value 的支援程度。
- 〈[PostgreSQL 排程 SQL 的三個坑](/posts/postgres-scheduled-sql-pitfalls/)〉：另一組和時間有關的問題，`timestamp` 與 `timestamptz` 比較時會套用 session 時區。

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