分頁的爭論常以「offset、cursor、keyset 三選一」的形式出現,而這三個名詞混了三根獨立的軸。offset 與 keyset定位機制:下一頁從哪裡開始、資料庫要付多少成本找到它。一致性模型是第二根:翻頁期間資料在變,而承諾給讀者的是哪一個時點的集合。cursor表示法:這個定位狀態用什麼形式交給消費者、消費者能不能看懂它。一個 opaque cursor 底下可以是 keyset,也可以是包了一層 Base64 的 offset;而 ?after_id=12345 是透明表示法配 keyset 機制 —— 兩軸各自可變,在單一時點上正交。

正交只在單一時點成立。第一版選的表示法會限制第二版還能換哪些機制:透明 cursor 把內部欄位變成介面的一部分,機制就凍在那個形狀上。表示法因此透過選項價值向未來耦合,而這正是爭論的實質 —— 不透明性到底給服務端什麼自由、又對消費者承諾了什麼。分頁放在批次與長時操作旁邊一起看的完整判準,見 集合介面設計

定位機制的成本曲線與能力差

offset 的成本隨翻到多深增長,而非隨表多大增長。LIMIT 20 OFFSET 10000 要求資料庫掃過並丟棄前一萬列,複雜度是 O(offset + limit);keyset 寫成 WHERE id > last_seen_id LIMIT 20,索引直接定位到起點,複雜度恆為 O(limit)。驅動變數是 offset 深度這件事有操作後果:淺頁的量測數字看起來永遠健康,而問題出在沒有人常去的深處,於是它在 p50 與 p95 上都不現形。

複雜度的階數之外還有常數,而常數的差距足以改變結論。走 index-only scan 丟棄一萬列跟回表丟棄一萬列不是同一件事,前者可以是次毫秒級。標準的緩解手法是 deferred join —— 先在覆蓋索引上取出那一頁的主鍵,再用主鍵回表取完整列,丟棄階段完全不碰表。還有一種混合形態直接反證「keyset 做不出跳頁」這個常見說法:用 keyset 定位到第 N 個區塊的起點、區塊內走有界 offset,跳頁做得出來而成本有上界。純 keyset 做不出任意跳頁,加上區塊錨點就做得出有界跳頁。

一致性的差異跟成本無關、且更難事後補救。Slack 的工程紀錄把 offset 的第二個失效模式描述得很清楚:高寫入頻率下,兩次請求之間有新資料插入,page window 漂移,消費者會看到跳項或重複(見 11.C37)。keyset 用上一頁最後一筆的排序鍵值當起點,插入與刪除發生在已翻過的區間時不影響後續頁次。

keyset 換來這兩項的代價落在能力上。它要求一個穩定且唯一的排序鍵 —— 排序欄位是 created_at 這類可能重複的值時,要用 (created_at, id) 複合條件補上 tiebreaker,缺了它翻頁會跳過或重複資料。它也放棄了跳頁:從第一頁直接到第五十頁需要中間頁的鍵值,而那正是還沒查的資料。total count 同樣落在放棄清單裡,因為 keyset 的查詢本身不產生總數。

Slack 遷移時把這兩項損失明列出來:失去 total count 與跳頁能力(C37)。案例判讀特別點出這是明示的產品決策而非技術妥協 —— 選定位機制之前要先確認「第 N 頁」跟「共幾筆」是不是真需求。這一問排在技術比較之前,因為跳頁能力換了機制之後補不回來,而總數只能以近似值替代。

offset 與 keyset 之外還有三條路,各自解掉上面某一項限制。快照分頁(point-in-time)讓整趟翻頁固定在同一個資料版本上:服務端保留一個一致的讀取視圖,消費者帶著它翻完全程 —— 一致性因此跟定位機制解耦,offset 也能拿到不跳項不重複,代價是服務端要為每個進行中的翻頁保存快照資源,且它有存活時限。時間視窗since / until)在 feed 與稽核 log 上是主流:定位鍵就是時間,語意對讀者直觀,限制是它要求資料有可靠的時間序且不回填。不分頁則是大量匯出的正解 —— 需求是「把整批資料搬走」而非「一頁一頁瀏覽」時,走匯出端點或串流,分頁選型整個不適用。把匯出需求硬塞進 cursor 分頁,是這個題目最常見的誤用。

不透明性給服務端的自由

表示法的問題從機制決定之後才開始,而它有兩問。第一問是 cursor 住在哪裡:回應 body 的欄位,還是 RFC 8288 的 Link: <...>; rel="next" header。後者是標準化形式,泛型 client 與 hypermedia client 可以自動跟隨,而且「翻完了」的語意由 Link 缺席自然表達,不必另外約定空字串還是 null。

第二問是要不要讓消費者看懂。透明形式(?after_id=12345)直觀,而它把 id 這個欄位的存在、型別與遞增性質全部變成介面的一部分。

Slack 選 Base64 編碼的 opaque cursor,介面收斂為 cursorlimit、回傳 next_cursor;不透明編碼允許各 endpoint 底層策略不同,甚至在單一 cursor 內編多個 shard 的位置(C37)。案例判讀把價值講成一句話:把分頁狀態的表示權留在 server 端 —— 消費者不能解析就不能依賴內部格式,服務端因此可以自由更換底層策略而不動介面。

不透明性有強度分級,而 Base64 落在最低的那一級:它擋的是「消費者順手依賴」,擋不住有動機的人。整合方沒有誘因去 decode 一個服務端叫他別碰的字串時,這一級已經夠用,Slack 拿到的表示權是真的;需要真正關門時(cursor 會被轉發給第三方、或編進去的位置資訊本身敏感),要的是隨機 token 加服務端狀態、或加密字串。

這份自由的取得時點則是固定的,而「第一版」指的是這個參數的第一版、不是這個 API 的第一版。先給透明 cursor 再收緊成不透明,是一次破壞相容性的變更;而既有 API 從 ?page= 換過來時是新增一個參數,新參數從它自己的第一版就 opaque,這份自由仍然拿得到——舊的 offset 參數照原樣退場,兩件事互不影響。

不透明性同時是一份多半沒寫下來的承諾

「cursor 的不透明性算承諾還是逃生門」這個問法預設了二選一,而兩者同時成立:對底層策略是逃生門,對 cursor 這個物件本身是承諾。消費者拿到一個看不懂的字串之後,仍然會對它做出各種假設,而每一項假設都是服務端沒說話的地方。沒有宣告的性質會被依賴,之後任何改動都變成 breaking change —— 這跟錯誤訊息文字沒給機器可讀替代品時被消費者拿去 parse 是同一個機制(該形態見 11.C75 的 message 穩定性規則)。

以下條款清單從機制與消費者的實際使用形態推導,未見公開 spec 明文處理;冪等鍵有一份對應的條款清單、且各家有明文承諾可對照(見 API 層冪等設計),cursor 這邊還沒有。這是最小集合而非窮盡清單 —— 反向翻頁的語意、cursor 的長度上限與 URL 限制都是同一層的問題,只是後果較輕。

  • 有效期。cursor 過期嗎、過期多久。消費者若把翻頁流程拆成多個排程批次,中間隔的是小時而非秒。未宣告時的觀察形態是「跑到一半的匯出工作偶爾失敗」。
  • 跨部署的有效性。服務端換版本、換 shard 佈局、改排序鍵之後,舊 cursor 還能用嗎。這一條跟前一條合起來決定消費者能不能把 cursor 持久化到自己的資料庫裡。
  • 目標被刪除時的行為。cursor 指向的那一筆資料在翻頁期間被刪除時,回傳下一頁、回錯誤、還是回空集合。這一條問的是錨點失效:cursor 這個物件還指不指得到東西。
  • 穩定性保證的強度。翻完整個集合是否保證不漏不重。這一條問的是內容完整,跟前一條互補:錨點一路有效,翻出來的集合仍可能漏或重。keyset 對已翻過區間的插入刪除穩定,而對排序鍵被更新的資料並不穩定 —— 一筆資料的 updated_at 被改動時,以它排序的翻頁會看到它兩次或零次。
  • cursor 綁不綁呼叫者。cursor 不綁身分時,A 拿到的 cursor 交給 B 就能從 A 的位置繼續翻 —— 對套用了列級權限的集合,opaque cursor 因此成為一條繞過起點檢查的枚舉面。這是清單裡唯一有安全後果的一條。它在多數情境是選配,而兩個條件會讓它變成必要:集合套用了列級權限(不同呼叫者看得到的子集不同),或 cursor 會流經轉售通路與第三方整合方之手。任一條成立就不該等到出事才補。
  • 參數變更的行為。帶著上一次的 cursor 但改了 limit 或篩選條件時,服務端是沿用、報錯、還是靜默以新參數重新解讀。靜默重新解讀是最難除錯的一種。
  • 空與結束的語意next_cursor 回空字串、回 null、還是欄位缺席代表結束,以及帶著結束後的 cursor 再呼叫一次會發生什麼。

這份清單的用法跟冪等契約清單相同:對照自家文件,缺哪條補哪條。缺漏的成本不是立刻發生的 —— 它以「某個消費者的整合在某次無關的部署後壞掉,而雙方都判定不了契約上誰違約」的形式,在幾個月後到期。

offset 存活的理由

offset 在爭論裡常被寫成過渡方案,而它有一個換不掉的能力:隨機存取。產品要「跳到第 47 頁」、要顯示「共 1,284 筆」時,這兩件事在 keyset 上做不出來。管理後台、報表列表、資料稽核介面經常需要它們,而使用者的操作模式是點頁碼、不是無限捲動。

規模是另一個變數,而要量的不是表有多大。決定深 offset 痛不痛的是列寬、排序鍵有沒有索引、查詢走不走得到覆蓋索引,以及最關鍵的一項——真實流量裡實際被請求過的最深 offset 是多少。寬列無索引的表一千列就會痛,覆蓋索引配 deferred join 的十萬列不會。可執行的觸發條件因此是:把「真實流量中最深的那個 offset」跑一次查詢計畫與延遲量測,超出這個端點的延遲預算才動機制。這比任何列數門檻可靠,因為它量的正是驅動變數本身。

中間路線值得單獨提一句:主分頁走 cursor、總數另開一個端點回近似值。近似值的來源可以是統計資訊或快取的計數,成本跟精確 count 差好幾個量級,而多數列表介面顯示的「約 1,300 筆」已經滿足產品需求。這條路線讓「要總數」不再自動等於「要 offset」。

借用結論而不帶前提

採 cursor 而沒查過跳頁需求。遷移完才發現後台的頁碼列跟總數回不來,此時的選項只剩下再開一套 offset 端點並行。檢查問法在遷移之前:現有介面的頁碼與總數,有哪個角色正在用它做事。消費者在組織外而這一問查不出答案時,退路是看 page 參數的深度分布——沒有人翻超過第三頁,跟每天有人翻到第兩百頁,是兩個不同的決定。

採 opaque 而 cursor 肉眼可辨識。Base64 編碼的 JSON 是可 decode 的,消費者 decode 之後看到 {"id": 12345} 就會開始依賴它。不透明性的實質保護來自消費者無法辨識結構,而非編碼動作本身。檢查問法分兩步:把自家 cursor 貼進 Base64 解碼器,出來的是不是有意義的欄位名;是的話再問這個 cursor 會不會流到有動機去 decode 的人手上 —— 只在自家 SDK 內部流轉時這一級夠用,會交到第三方整合方手上時要升到隨機 token 加服務端狀態。

採 keyset 而排序鍵沒有 tiebreaker。以 created_at 排序、同秒有多筆資料時,翻頁會跳過或重複,而症狀是零星的、在低寫入量下幾乎觀察不到。檢查問法:排序鍵在資料表上是不是唯一,若否,複合條件裡有沒有補上唯一欄位。

採 offset 而集合會長大。offset 的兩個失效模式都隨規模浮現,且都在 production 才浮現。檢查問法:這個集合三年後大概幾筆,以及那時候還會不會有人翻到第一百頁。

選型的順序固定、每一步都有出口

第一步確認跳頁與總數是不是真需求。兩種消費者形態要的是同一種證據、只是取得方式不同。公開 API 的消費者在組織外、問不到人,看既有端點的實際呼叫參數分布(page 參數的深度直方圖、有沒有人真的呼叫 count 端點),或在 deprecation 預告期觀察誰來反映。內部後台與第一方 client 問得到人,而口頭答案不該取代量測——同樣先看既有的 page 參數分布或後台頁碼元件的點擊量測,沒有既有系統可量時才用口頭答案,並記下日期與答的人。答案出來之後——跳頁是真需求時 offset 留下,接受深頁成本並用 limit 上限把它框住;只有總數是真需求時走中間路線,主分頁用 cursor、總數另開端點回近似值;需求其實是整批搬走資料時走匯出端點,分頁選型不適用;以上皆非才進第二步。第二步依翻頁深度與寫入頻率選定位機制:翻得深或寫入頻繁選 keyset,兩者都不成立時 offset 的簡單性划算。第三步決定表示法:選了 keyset 就從第一版做成 opaque,強度依 cursor 會流到誰手上決定,並把不透明性條款清單寫進文件。

三步顛倒過來做的代價很具體:先選了 cursor,再回頭跟產品端解釋為什麼後台沒有頁碼,而此時能給的只剩「再開一套 offset 端點並行」——兩套分頁語意並存,比一開始就選 offset 更差。

下一步路由