主題: PostgreSQL

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

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

動態迷因(展開/收合)
找下一批資料時,先分清楚是在數位置,還是從已知邊界繼續找。 · 來源: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。

動態迷因(展開/收合)
接續閱讀需要明確的起點;API 也要保存實際回傳資料的最後邊界。 · 來源: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,前提是排序、篩選與索引都配合。任意跳到第幾頁不再是它天然提供的能力。

我學到什麼

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

延伸學習