主題: PostgreSQL
翻到下一頁,怎麼又看到同一筆?OFFSET 與游標分頁
用五筆資料看懂 OFFSET 的位置偏移、Keyset 複合游標與 B-tree 索引,並處理 NULL、下一頁判斷及資料變動的限制。
動態迷因(展開/收合)
第一頁正常,第二頁卻又看到同一篇文章。先別急著在前端加去重,有時候查詢本身就會產生這個結果。
我會先確認列表要提供什麼體驗。需要直接跳到第十頁,與一路往下讀,適合的分頁方式不一定相同。以下用 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。
動態迷因(展開/收合)
多取一筆,但別把它當成下一頁起點
每頁二十筆,可以查二十一筆。若查到二十一筆,就只回傳前二十筆,並標記還有下一頁。下一個游標取第 二十 筆;如果取額外的第二十一筆,下次使用嚴格比較就會跳過尚未回傳的它。
若分類或排序改變,重新開始分頁。時間排序的游標無法直接描述價格排序的位置。「沒有下一頁」也只表示本次查詢沒有更多符合條件的可見資料,不是永久承諾。
複合 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 與時間精度。
- 我會分開確認查詢結果是否正確,以及索引是否真的降低讀取成本。
- 列表可以接續閱讀,不表示匯出或同步已經有一致性保證。
延伸學習
- Use The Index, Luke!:Fetching the next page:從查詢方式理解分頁效能。
- Caleb Curry:API Pagination:英文影片,約 26 分鐘。先看 10:14 的 OFFSET,再接 13:56 的游標與 18:06 的限制;PostgreSQL 的 NULL 與索引細節以本文連結的官方文件為準。