具代表性的面試主題

後端面試:如何設計穩定的游標分頁 API?

後端困難
OfferCC 編輯團隊發佈 更新

題幹

訂單資料表有 2 億筆資料並持續寫入,列表依 created_at DESC、id DESC 排序。請設計每頁 50 筆、支援前後翻頁的 API,避免深度分頁效能退化,並說明併發新增、刪除與狀態變更時能保證什麼。

題目與適用情境

一個多租戶訂單服務使用 PostgreSQL,orders 資料表有 2 億筆資料,持續發生新增訂單與狀態變更。營運後台依租戶與狀態篩選訂單,固定使用 created_at DESC, id DESC 排序,每頁最多 50 筆。created_at 與 id 都不可為空,建立後不再修改;id 唯一。產品需要「下一頁」與「上一頁」,不要求直接跳到第 500 頁。

請設計分頁 API、游標內容、查詢與索引。方案要處理相同建立時間的訂單、深度分頁、翻頁期間的新增與刪除、狀態篩選變化、游標竄改與雙向導覽,並明確說明它提供的是即時走訪或固定快照。

題目中的 2 億筆資料與每頁 50 筆是面試假設。核心考察能力是把 API 語意落實成資料庫存取路徑、併發邊界與驗證方法,因此歸入 backend。跨區域複寫、分庫分表與前端列表狀態不在第一輪範圍。

面試官考察重點

第一個訊號是先定義一致性,再選分頁演算法。「不重不漏」必須有明確對象:是避免 offset 位移造成重複與跳過,還是要求整段瀏覽等同於讀取同一個資料庫快照?一般游標分頁只能穩定跨過一個排序邊界,無法自動凍結刪除、狀態變更或其他篩選欄位。

第二個訊號是排序必須形成全序。只依 created_at 排序時,多筆訂單可能有相同時間,資料庫可以任意排列它們。把唯一 id 當成第二排序鍵,游標也同時攜帶兩個欄位,才能精確表達「上一頁最後一筆之後」。

第三個訊號是查詢、索引與游標來自同一個順序。tenant_id、status 是等值篩選,created_at、id 是範圍邊界與排序鍵,因此候選 B-tree 索引是 (tenant_id, status, created_at DESC, id DESC)。回答若只說「把 offset 換成 cursor」,卻沒有比較條件、索引與反向查詢,設計仍不完整。

最後看契約與測試。強回答會把游標綁定篩選條件與授權範圍、加上簽章、限制頁面大小,並以併發新增、刪除邊界列、相同時間戳與前後翻頁往返驗證結果。也會保留 offset 的適用位置:小型、穩定且需要頁碼跳轉的資料集。

回答前需要釐清的問題

  • 需要即時列表或固定快照? 即時列表允許後續頁面反映部分變化;固定快照要求一次走訪看到同一個集合,通常需要版本化資料、實體化結果或能跨請求維持的快照機制。兩者的成本與過期策略不同。
  • 是否必須跳到任意頁? keyset 分頁適合依序前進與後退,無法只靠「第 500 頁」算出邊界。若頁碼跳轉是硬性需求,可在有限深度保留 offset,或預先計算錨點。
  • 排序與篩選欄位會不會修改? created_at 與 id 必須穩定;若依可變的 updated_at、價格或熱門度排序,紀錄可能跨過已走訪的邊界。狀態變化也會改變結果集成員,必須在契約中公開。
  • 是否需要精確總數與最後一頁? hasNextPage 可藉由多取一筆得到,精確 totalCount 則是獨立的彙總成本。若 UI 只需要「繼續載入」,不應讓每次分頁都執行全量計數。
  • 游標可以保留多久? 版本升級、篩選規則改變、簽章金鑰輪替與快照保留期都會影響有效期。伺服器要定義過期錯誤與重新從第一頁開始的方式。
  • 租戶與權限可能在翻頁期間改變嗎? 游標不能取代目前請求的身分驗證與授權。每一頁都要重新檢查目前使用者,並把游標綁定到正規化篩選與作用域,避免跨租戶重用。

30 秒回答架構

「我會先確認這裡需要的是即時順序走訪,不承諾跨請求的資料庫快照。排序固定為不可變的 created_at DESC, id DESC,其中唯一 ID 解決相同時間戳的並列。下一頁游標攜帶上一頁末筆的兩個欄位值、版本與篩選指紋,並做 URL 安全編碼與 HMAC 簽章。查詢使用 (created_at, id) < (cursor_time, cursor_id)、相同倒序與 LIMIT 51,回傳 50 筆並用第 51 筆判斷 hasNextPage;索引使用 (tenant_id, status, created_at DESC, id DESC)。上一頁反轉比較符與查詢順序,取完後再反轉回傳。更新的新增訂單不會把後續頁面往後推,但刪除與狀態變化不會被游標凍結;需要嚴格快照時,我會改用版本化或實體化工作階段。最後用相同時間戳、併發寫入、邊界刪除、游標竄改與深頁執行計畫驗證。」

分步深入解答

先寫清楚 API 契約。第一頁不傳游標;向後瀏覽使用 after,向前瀏覽使用 before,兩者不可同時出現。頁面大小預設 50,伺服器限制在 1 到 100。回應可以採用以下格式:

json
{
  "data": [],
  "pageInfo": {
    "startCursor": "...",
    "endCursor": "...",
    "hasNextPage": true,
    "hasPreviousPage": false
  }
}

startCursor 與 endCursor 來自本頁第一筆與最後一筆。hasNextPage 藉由請求 51 筆、只回傳前 50 筆取得。若要精確回傳另一方向的布林值,可用本頁另一端做一次很小的索引 EXISTS 查詢;只根據「請求曾帶 after」推斷,在前方紀錄已刪除時可能不準確。

以複合邊界取代位置編號

第一頁查詢為:

sql
SELECT id, created_at, total_cents, status
FROM orders
WHERE tenant_id = $1
  AND status = $2
ORDER BY created_at DESC, id DESC
LIMIT 51;

下一頁把上一頁最後一筆的 (created_at, id) 放進嚴格小於條件:

sql
SELECT id, created_at, total_cents, status
FROM orders
WHERE tenant_id = $1
  AND status = $2
  AND (created_at, id) < ($3, $4)
ORDER BY created_at DESC, id DESC
LIMIT 51;

PostgreSQL 的資料列比較會從左到右比較,遇到第一組不相等的值就決定結果。這與兩個倒序排序鍵的字典序邊界一致。兩個欄位都定義為不可為空,可以避開資料列比較遇到 NULL 後得到未知值的語意。id 不必反映時間,只要在相同 created_at 內提供唯一且穩定的決勝順序。

對應索引為:

sql
CREATE INDEX CONCURRENTLY orders_tenant_status_created_id_idx
ON orders (tenant_id, status, created_at DESC, id DESC);

前導等值欄位把掃描限制在單一租戶與狀態,後兩欄提供邊界與順序。是否加入回傳欄位做覆蓋索引,要依資料列寬度、回表次數與寫入放大實測;不能把所有回應欄位都塞進 INCLUDE。狀態頻繁變更時,這個索引也會產生維護成本,因此要同時壓測寫入延遲、WAL 與索引大小。

設計可演進、不可偽造的游標

游標應由伺服器定義為不透明協定,用戶端不得自行組合 created_at。解碼後的邏輯內容可以是:

json
{
  "v": 1,
  "createdAt": "2026-07-16T02:30:00.123Z",
  "id": "ord_01J...",
  "filterHash": "sha256:...",
  "expiresAt": "2026-07-17T02:30:00Z"
}

伺服器對正規化後的內容做 URL 安全編碼,並附上 HMAC。Base64 只有編碼、沒有保密能力;若邊界值本身敏感,就不要直接放入用戶端可見內容,改用伺服器保存的隨機游標 ID。filterHash 至少涵蓋排序版本、狀態篩選與授權作用域識別。每次請求仍以目前登入狀態重新授權,再驗證簽章、版本、過期時間與篩選指紋;任一項不符都回傳穩定的 invalid_cursor,要求用戶端從第一頁重新開始,不可悄悄套用新篩選。

版本欄位允許日後變更排序或編碼。金鑰輪替時,可在游標最長有效期內同時接受目前與上一把驗證金鑰。日誌只記錄錯誤類型與游標版本,不輸出完整游標或篩選明文。

對稱實作上一頁

目前頁面的第一筆是向前瀏覽的邊界。要找較新的 50 筆,比較符改成 >,資料庫查詢順序改成升序,才能先取得最靠近邊界的紀錄:

sql
SELECT id, created_at, total_cents, status
FROM orders
WHERE tenant_id = $1
  AND status = $2
  AND (created_at, id) > ($3, $4)
ORDER BY created_at ASC, id ASC
LIMIT 51;

伺服器移除用來探測的第 51 筆後,把結果反轉,再依公開的倒序回傳。若仍用倒序加 LIMIT,取得的會是整個結果集最前面的 50 筆,偏離緊鄰目前頁面的上一段。前後翻頁必須共用相同的篩選指紋與排序版本。

明確併發變化下的保證

在即時語意下,游標代表「繼續讀取嚴格小於這個不可變邊界的紀錄」。使用者讀完第一頁後,即使插入 100 筆更新的訂單,下一頁仍從舊邊界往後讀,不會因前方新增紀錄而讓第一頁末筆再次出現。邊界資料列之後被刪除也不影響比較,因為游標保存的是值,不需要重新找到那一列。

這不等於固定快照。尚未讀取的訂單若從其他狀態變成目前篩選狀態,而且排序位置在邊界之後,它可能進入後續頁面;既有訂單若離開篩選集合,就不會再出現。刪除也會讓本次走訪少看到紀錄。只在游標加入「第一頁開始時間」只能擋住更新的新增紀錄,無法凍結刪除與狀態變更。

若業務要求匯出、對帳或稽核時一筆不少,一種做法是建立短期實體化結果集,只保存符合條件的訂單 ID 與順序;另一種是讀取支援時間旅行的版本化資料;也可以在受控背景工作中維持同一個資料庫快照。三種方式都會增加儲存空間、交易生命週期或清理成本,需要設定工作階段有效期。互動列表通常適合即時 keyset;稽核流程則應為快照成本另行設計。

保留 offset 的適用邊界

Offset 的優點是實作簡單、可以直接跳頁,也容易表達頁碼。它適合資料量小、排序穩定的後台資料表,或已經固定的搜尋結果。缺點有兩類:PostgreSQL 仍要計算並丟棄 OFFSET 之前的資料列,偏移越深,工作量通常越大;併發插入或刪除也會改變位置編號,讓同一筆重複或被跳過。

Keyset 把工作錨定在有索引的值邊界,深頁查詢不再隨 offset 數值掃描並丟棄前面所有資料列,但無法在沒有錨點時跳到任意頁。選擇規則可直接寫成:需要順序瀏覽、資料持續變化且頁數很深時用 keyset;需要有限頁碼跳轉且資料小或已凍結時用 offset;需要全程固定集合時,在任何分頁方式之外再加入快照機制。

以對抗案例證明契約

驗證先檢查集合與順序,再看延遲:

  1. 插入 120 筆具有相同 created_at 的訂單,確認唯一 id 讓前後頁無重複,合併所有頁後順序嚴格遞減。
  2. 讀取第一頁後插入 100 筆更新訂單。以 offset 重現實驗中的位移,再確認 keyset 第二頁不會重複第一頁紀錄。
  3. 刪除第一頁最後一筆後繼續,確認保存的邊界值仍能取得正確下一頁。
  4. 在兩次請求間改變未讀訂單的 status,確認結果符合公開的即時語意;該結果不能標記為快照。
  5. 修改游標一個位元組、跨租戶重用、改變狀態篩選、使用過期版本,都應被拒絕。
  6. 連續前進三頁再後退三頁,檢查每頁 ID 與順序;同時涵蓋 0 筆、1 筆、50 筆與 51 筆。
  7. 對第一頁、第二頁與很深的邊界執行 EXPLAIN (ANALYZE, BUFFERS),確認使用預期複合索引、掃描筆數接近頁面大小,並記錄讀取 p95、寫入 p95、WAL 與索引大小。

高品質示範回答

「我先限定保證:營運列表需要即時順序瀏覽,不要求直接跳頁,也不承諾跨多個 HTTP 請求維持資料庫快照。因此我會選 keyset,不用 offset。

排序使用建立後不變的 created_at DESC, id DESC。ID 是唯一決勝鍵,否則同一毫秒建立的訂單沒有穩定順序。第一頁取 51 筆、回傳 50 筆。下一頁游標保存末筆的時間與 ID,查詢條件是 (created_at, id) < (?, ?),排序完全相同。索引依等值篩選在前、排序邊界在後,使用 (tenant_id, status, created_at DESC, id DESC)。上一頁以本頁首筆為邊界,改用 > 與升序取得最近 51 筆,再反轉成公開倒序。

游標會包含版本、兩欄邊界、篩選指紋與過期時間,做 URL 安全編碼與 HMAC 簽章。伺服器每頁都重新授權,並拒絕遭竄改、過期或篩選不符的游標。它不依賴邊界列繼續存在,因此刪除邊界列後仍能翻頁。

一致性方面,我只承諾避免 offset 位移造成的重複。第一頁之後新增的較新訂單不會混入後續頁面,但狀態變化與刪除仍會改變尚未讀取的集合。若對帳要求固定快照,我會另建實體化匯出工作階段或讀取版本化資料,不能只在普通游標貼一個時間戳就宣稱完整快照。

最後我會用相同時間戳、併發新增、邊界刪除、狀態變化、游標竄改與前後往返測試正確性,再比較第一頁、第二頁與深頁的執行計畫,以及新索引帶來的寫入延遲與 WAL。」

常見錯誤

  • 只把頁碼編成 Base64 → 伺服器仍執行 OFFSET,效能與位置漂移都未改變 → 游標必須表示有索引的排序邊界。
  • 只用 created_at 當游標 → 相同時間戳的訂單無法形成全序,可能重複或跳過 → 加入唯一、穩定的 id,查詢與游標都使用兩欄。
  • 查詢排序與比較條件不一致 → 邊界不再代表公開順序,翻頁會交錯或產生缺口 → 從同一字典序同時推導 ORDER BY、比較符與索引。
  • 把 Base64 當成防竄改 → 用戶端可以修改時間、ID 或篩選範圍 → 使用 HMAC 驗證完整性,並重新執行目前授權。
  • 游標沒有綁定篩選條件 → 把 pending 頁的游標用到 paid 查詢會產生難以解釋的缺口 → 把正規化篩選、排序版本與作用域納入簽章指紋。
  • 上一頁仍直接使用倒序 LIMIT → 取得的是結果集最前端,偏離邊界附近的上一段 → 反轉查詢順序,截取後再反轉回應。
  • 聲稱 keyset 提供完整快照 → 刪除、狀態變化與可變排序鍵仍會改變集合 → 公開即時語意;嚴格一致時增加版本化或實體化快照。
  • 為了 totalCount 每頁都掃描完整結果 → 分頁查詢變快,計數卻成為新的慢路徑 → 把計數當成獨立、可快取或可近似的產品能力。
  • 只測第一頁 → 第二頁才會觸發複合邊界條件,索引不符可能在此暴露 → 同時檢查前兩頁、深頁與正反兩個方向的計畫。

追問與應對

追問一:產品要求依可修改的 total_cents 排序怎麼辦?

複合游標可以改成 (total_cents, id),但只解決並列,無法處理金額修改後紀錄跨過已走訪邊界。若允許即時排序,就公開可能重複或遺漏,並在用戶端依 ID 去重;若必須穩定,進入瀏覽工作階段時凍結排序值、實體化 ID 清單,或讓修改產生新版本。不能繼續承諾與不可變排序鍵相同的保證。

追問二:現在必須支援直接跳到第 500 頁怎麼辦?

Keyset 沒有第 500 頁的邊界值。先確認真正需求是尋找某個日期、訂單編號,或純頁碼;日期與訂單編號可以成為有索引的搜尋錨點。若頁碼是硬性需求,可限制最大跳轉深度並在這部分使用 offset,或定期保存頁面錨點,再從最近錨點進行 keyset。錨點在即時資料中也會過期,因此需要版本或快照識別。

追問三:匯出 2 億筆資料時還能重用這個 API 嗎?

互動 API 的短期游標與即時語意不適合長時間稽核匯出。建立背景匯出工作,在固定快照或版本邊界上依不可變主鍵分塊,寫入物件儲存,並記錄斷點與校驗數量。如此重試、過期、資源限制與完成校驗都有獨立生命週期,也不會讓一個 HTTP 游標長期綁定線上索引與簽章相容性。

追問四:如何處理分頁期間的簽章金鑰輪替?

游標攜帶版本或金鑰識別。驗證端在最長游標有效期內同時保留目前與上一把驗證金鑰,新游標只用目前金鑰簽章;舊金鑰超過期限後刪除。若安全事件要求立即撤銷,就統一回傳 invalid_cursor,讓用戶端從第一頁重新開始,不能為了無感翻頁繼續接受已外洩金鑰。

追問五:為什麼刪除邊界列後仍能繼續,修改排序鍵卻不行?

下一頁查詢只需要游標中的兩個值,不必在資料庫重新找到邊界列,所以刪除它不會改變嚴格小於條件。修改排序鍵會讓同一筆紀錄從邊界一側移到另一側:它可能再次落入未來範圍,或從尚未走訪的範圍跳到已走訪的一側。穩定排序鍵是正確性條件;邊界列可以已經不存在。

公開來源

同類題目