主題: PostgreSQL

翻到下一頁,怎麼又看到同一筆?OFFSET 與游標分頁

用五筆資料看懂 OFFSET 的位置偏移、Keyset 複合游標與 B-tree 索引,並處理 NULL、下一頁判斷及資料變動的限制。

OFFSET 以位置數跳過前面的列;資料在分頁間改變時,位置可能移動,造成重複或遺漏。 · 來源:GIPHY

第一頁正常,第二頁卻又看到同一篇文章。先別急著在前端加去重,有時候查詢本身就會產生這個結果。

我會先確認列表要提供什麼體驗。需要直接跳到第十頁,與一路往下讀,適合的分頁方式不一定相同。以下用 PostgreSQL 和五筆公開假資料推演,不把效能差異寫成沒有量測過的倍數。

OFFSET 跳過的是現在的位置

假設同一天的文章如下,時間與 id 都不為 NULL,id 唯一:

id created_at
105 12:00
104 12:00
103 11:00
102 10:00
101 09:00

排序固定為 created_at DESC, id DESC,每頁兩筆:

SELECT id, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 2 OFFSET 2;

這是第二頁,結果是 103、102。頁碼從 1 開始時,計算方式是 (page - 1) * pageSize;第三頁要跳過四筆,不是兩筆。

現在分別考慮兩種變動,兩個例子互不相接:

第一頁讀完後的變動 目前順序 OFFSET 2 的結果
新增 106,時間 13:00 106、105、104、103、102、101 104、103
刪除 105 104、103、102、101 102、101

新增時,104 重複。刪除時,尚未讀過的 103 被跳過了。資料庫照要求跳過當下前兩筆,並沒有保存「你看過誰」的紀錄。前端去重能隱藏重複,卻補不回漏掉的資料。

PostgreSQL 的 LIMIT/OFFSET 文件也提醒,被跳過的資料仍須處理。因此大型列表的深頁除了正確性問題,還可能更慢。

用最後一筆的值接著查

Keyset 分頁把邊界保存為排序值。第一頁最後一筆是 104,因此游標需要時間和 id。以下假設欄位是 timestamptz,示例日期統一為 UTC:

SELECT id, created_at
FROM posts
WHERE (created_at, id) <
      (TIMESTAMPTZ '2026-09-09 12:00:00+00', 104)
ORDER BY created_at DESC, id DESC
LIMIT 2;

結果是 103、102。前方插入 106,不會把這個邊界往前推。實際 API 應綁定查詢參數,不拼接使用者輸入;游標要保存資料庫回傳的完整時間精度,不能只留下畫面顯示的分鐘。

這種複合比較先比時間,時間相同才比 id。若只用時間,與邊界同時間、尚未讀到的資料可能整批被排除。使用 <= 則會包含邊界本身。Row Constructor Comparison定義了這個比較順序。

游標保存的是邊界值,即使原本那筆被刪除,仍能拿這組值繼續比較。不必把它理解成持續開著的資料庫 cursor。

Keyset 游標記錄上一頁最後一列的排序值;下一頁從這個邊界往後接續,不必保留開啟中的資料庫游標。 · 來源:GIPHY

多取一筆,但別把它當成下一頁起點

每頁二十筆,可以查二十一筆。若查到二十一筆,就只回傳前二十筆,並標記還有下一頁。下一個游標取第 二十 筆;如果取額外的第二十一筆,下次使用嚴格比較就會跳過尚未回傳的它。

若分類或排序改變,重新開始分頁。時間排序的游標無法直接描述價格排序的位置。「沒有下一頁」也只表示本次查詢沒有更多符合條件的可見資料,不是永久承諾。

複合 B-tree 索引幫在哪裡?

對上面的查詢,可以評估:

CREATE INDEX posts_created_at_id_idx
ON posts (created_at, id);

這是一份先按時間、再按 id 排列的索引。邊界條件與索引相符時,資料庫有機會定位到邊界附近,再沿所需方向讀取,而不用先數完大量要略過的位置。

B-tree 可以反向掃描,因此兩欄都不為 NULL 時,一般升冪索引也能支援兩欄同為 DESC。混合 ASC 與 DESC 是另一種排序需求,不能直接套用相同結論。參考索引與排序及多欄索引。

有索引仍不保證只讀二十筆。若還有其他篩選條件,可能要排除不少資料才湊足一頁。索引也有儲存與寫入成本,先檢查既有索引,不要只因文章示範就建立一份。

在受控環境使用代表性資料,以 EXPLAIN (ANALYZE, BUFFERS) 比較淺頁與深頁,查看索引條件、過濾量、排序及實際讀取量。ANALYZE 會真的執行查詢,不要直接拿高成本查詢在正式環境試。詳見使用 EXPLAIN。

NULL 排最後,不代表比較時最大

NULLS LAST 決定排序位置,不會把 NULL 變成一般可比較的最大值:

SELECT NULL::integer < 10; -- NULL, not true

WHERE 只保留 true。複合比較若已由前面的欄位分出大小,就不必比較後面的 NULL;若必須比較到 NULL 才能決定,則可能得到未知。

所以不能只加 NULLS LAST,就宣稱游標也能跨過 NULL 區段。若業務允許,選用不為 NULL 的排序欄位較容易推理。若必須保留 NULL,應設計跨區段的游標與條件,並測試邊界,而不是任意用 0 取代缺值。

Keyset 也不是資料快照

改動排序欄位,可能把已讀資料移到游標後面,造成再次出現。各頁若是獨立查詢,也不會自動共享同一份快照。

Sequin 的工程文章從資料回填談 Keyset,也指出資料更新與提交時機的限制。需要完整、不重複的匯出時,必須另外定義一致性策略;不能把一般列表游標當成保證完整的同步協定。

小型、少變動、需要跳頁的管理列表,我會保留 OFFSET 作為選項。大量連續閱讀的列表則值得評估 Keyset,前提是排序、篩選與索引都配合。任意跳到第幾頁不再是它天然提供的能力。

在本機重現:兩種變動分開跑

下面是可直接貼進 psql 的示範;只使用暫存表,最後 rollback。請使用自己的拋棄式測試資料庫,不要連正式環境。SAVEPOINT 讓刪除案例從原始五筆資料開始,避免把兩種變動混在一起。

BEGIN;
CREATE TEMP TABLE posts (id integer PRIMARY KEY, created_at timestamptz NOT NULL);
INSERT INTO posts VALUES
  (105, '2026-09-09 12:00:00+00'), (104, '2026-09-09 12:00:00+00'),
  (103, '2026-09-09 11:00:00+00'), (102, '2026-09-09 10:00:00+00'),
  (101, '2026-09-09 09:00:00+00');
SAVEPOINT baseline;
INSERT INTO posts VALUES (106, '2026-09-09 13:00:00+00');
SELECT id FROM posts ORDER BY created_at DESC,id DESC LIMIT 2 OFFSET 2;
ROLLBACK TO baseline;
DELETE FROM posts WHERE id = 105;
SELECT id FROM posts ORDER BY created_at DESC,id DESC LIMIT 2 OFFSET 2;
ROLLBACK;

第一個 SELECT 回傳 104,103,第二個回傳 102,101。這是 2026-09-10 在 PostgreSQL 18.6 的本機執行結果,沒有模擬並發連線;它重現的是兩次列表查詢之間資料改變後的位置差異。

下載完整分頁檢查,在 psql 已設定為本機測試資料庫的環境執行:

psql -X -v ON_ERROR_STOP=1 -f pagination.sql

完整檔案會對 10 個結果做斷言:第一頁、原始第二頁、插入與刪除後的 OFFSET/Keyset、同時間資料、額外一筆、下一個游標與結尾空頁。預期 Keyset 在兩種前方變動後仍回傳 103,102;同時間的一筆分頁也不漏掉 104。任何不符都會讓 psql 失敗,而不只是印出看似合理的表格。

五筆資料只能驗證結果,不能證明效能;本例也沒有解決跨頁快照或排序欄位被更新的問題。

我學到什麼

  • 我會先用新增與刪除推演兩次查詢,確認分頁是否符合介面期待。
  • 我把游標視為排序值的邊界,會一起檢查唯一性、NULL 與時間精度。
  • 我會分開確認查詢結果是否正確,以及索引是否真的降低讀取成本。
  • 列表可以接續閱讀,不表示匯出或同步已經有一致性保證。

延伸學習