主題: 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,前提是排序、篩選與索引都配合。任意跳到第幾頁不再是它天然提供的能力。
在本機重現:兩種變動分開跑
下面是可直接貼進 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 與時間精度。
- 我會分開確認查詢結果是否正確,以及索引是否真的降低讀取成本。
- 列表可以接續閱讀,不表示匯出或同步已經有一致性保證。
延伸學習
- Use The Index, Luke!:Fetching the next page:從查詢方式理解分頁效能。
- Caleb Curry:API Pagination:英文影片,約 26 分鐘。先看 10:14 的 OFFSET,再接 13:56 的游標與 18:06 的限制;PostgreSQL 的 NULL 與索引細節以本文連結的官方文件為準。