題目與適用情境
一個多租戶訂單服務使用 PostgreSQL,orders 資料表有 2 億筆資料,持續發生新增訂單與狀態變更。營運後台依租戶與狀態篩選訂單,固定使用 createdat DESC, id DESC 排序,每頁最多 50 筆。createdat 與 id 都不可為空,建立後不再修改;id 唯一。產品需要「下一頁」與「上一頁」,不要求直接跳到第 500 頁。
請設計分頁 API、游標內容、查詢與索引。方案要處理相同建立時間的訂單、深度分頁、翻頁期間的新增與刪除、狀態篩選變化、游標竄改與雙向導覽,並明確說明它提供的是即時走訪或固定快照。
題目中的 2 億筆資料與每頁 50 筆是面試假設。核心考察能力是把 API 語意落實成資料庫存取路徑、併發邊界與驗證方法,因此歸入 backend。跨區域複寫、分庫分表與前端列表狀態不在第一輪範圍。
面試官考察重點
第一個訊號是先定義一致性,再選分頁演算法。「不重不漏」必須有明確對象:是避免 offset 位移造成重複與跳過,還是要求整段瀏覽等同於讀取同一個資料庫快照?一般游標分頁只能穩定跨過一個排序邊界,無法自動凍結刪除、狀態變更或其他篩選欄位。
第二個訊號是排序必須形成全序。只依 created_at 排序時,多筆訂單可能有相同時間,資料庫可以任意排列它們。把唯一 id 當成第二排序鍵,游標也同時攜帶兩個欄位,才能精確表達「上一頁最後一筆之後」。
第三個訊號是查詢、索引與游標來自同一個順序。tenantid、status 是等值篩選,createdat、id 是範圍邊界與排序鍵,因此候選 B-tree 索引是 (tenantid, status, createdat DESC, id DESC)。回答若只說「把 offset 換成 cursor」,卻沒有比較條件、索引與反向查詢,設計仍不完整。
最後看契約與測試。強回答會把游標綁定篩選條件與授權範圍、加上簽章、限制頁面大小,並以併發新增、刪除邊界列、相同時間戳與前後翻頁往返驗證結果。也會保留 offset 的適用位置:小型、穩定且需要頁碼跳轉的資料集。
回答前需要釐清的問題
- 需要即時列表或固定快照? 即時列表允許後續頁面反映部分變化;固定快照要求一次走訪看到同一個集合,通常需要版本化資料、實體化結果或能跨請求維持的快照機制。兩者的成本與過期策略不同。
- 是否必須跳到任意頁? keyset 分頁適合依序前進與後退,無法只靠「第 500 頁」算出邊界。若頁碼跳轉是硬性需求,可在有限深度保留 offset,或預先計算錨點。
- 排序與篩選欄位會不會修改?
createdat與id必須穩定;若依可變的updatedat、價格或熱門度排序,紀錄可能跨過已走訪的邊界。狀態變化也會改變結果集成員,必須在契約中公開。 - 是否需要精確總數與最後一頁?
hasNextPage可藉由多取一筆得到,精確totalCount則是獨立的彙總成本。若 UI 只需要「繼續載入」,不應讓每次分頁都執行全量計數。 - 游標可以保留多久? 版本升級、篩選規則改變、簽章金鑰輪替與快照保留期都會影響有效期。伺服器要定義過期錯誤與重新從第一頁開始的方式。
- 租戶與權限可能在翻頁期間改變嗎? 游標不能取代目前請求的身分驗證與授權。每一頁都要重新檢查目前使用者,並把游標綁定到正規化篩選與作用域,避免跨租戶重用。
30 秒回答架構
「我會先確認這裡需要的是即時順序走訪,不承諾跨請求的資料庫快照。排序固定為不可變的 createdat DESC, id DESC,其中唯一 ID 解決相同時間戳的並列。下一頁游標攜帶上一頁末筆的兩個欄位值、版本與篩選指紋,並做 URL 安全編碼與 HMAC 簽章。查詢使用 (createdat, id) < (cursortime, cursorid)、相同倒序與 LIMIT 51,回傳 50 筆並用第 51 筆判斷 hasNextPage;索引使用 (tenantid, status, createdat DESC, id DESC)。上一頁反轉比較符與查詢順序,取完後再反轉回傳。更新的新增訂單不會把後續頁面往後推,但刪除與狀態變化不會被游標凍結;需要嚴格快照時,我會改用版本化或實體化工作階段。最後用相同時間戳、併發寫入、邊界刪除、游標竄改與深頁執行計畫驗證。」
分步深入解答
先寫清楚 API 契約。第一頁不傳游標;向後瀏覽使用 after,向前瀏覽使用 before,兩者不可同時出現。頁面大小預設 50,伺服器限制在 1 到 100。回應可以採用以下格式:
{
"data": [],
"pageInfo": {
"startCursor": "...",
"endCursor": "...",
"hasNextPage": true,
"hasPreviousPage": false
}
}startCursor 與 endCursor 來自本頁第一筆與最後一筆。hasNextPage 藉由請求 51 筆、只回傳前 50 筆取得。若要精確回傳另一方向的布林值,可用本頁另一端做一次很小的索引 EXISTS 查詢;只根據「請求曾帶 after」推斷,在前方紀錄已刪除時可能不準確。
以複合邊界取代位置編號
第一頁查詢為:
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) 放進嚴格小於條件:
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 內提供唯一且穩定的決勝順序。
對應索引為:
CREATE INDEX CONCURRENTLY orders_tenant_status_created_id_idx
ON orders (tenant_id, status, created_at DESC, id DESC);前導等值欄位把掃描限制在單一租戶與狀態,後兩欄提供邊界與順序。是否加入回傳欄位做覆蓋索引,要依資料列寬度、回表次數與寫入放大實測;不能把所有回應欄位都塞進 INCLUDE。狀態頻繁變更時,這個索引也會產生維護成本,因此要同時壓測寫入延遲、WAL 與索引大小。
設計可演進、不可偽造的游標
游標應由伺服器定義為不透明協定,用戶端不得自行組合 created_at。解碼後的邏輯內容可以是:
{
"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 筆,比較符改成 >,資料庫查詢順序改成升序,才能先取得最靠近邊界的紀錄:
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;需要全程固定集合時,在任何分頁方式之外再加入快照機制。
以對抗案例證明契約
驗證先檢查集合與順序,再看延遲:
- 插入 120 筆具有相同
created_at的訂單,確認唯一id讓前後頁無重複,合併所有頁後順序嚴格遞減。 - 讀取第一頁後插入 100 筆更新訂單。以 offset 重現實驗中的位移,再確認 keyset 第二頁不會重複第一頁紀錄。
- 刪除第一頁最後一筆後繼續,確認保存的邊界值仍能取得正確下一頁。
- 在兩次請求間改變未讀訂單的
status,確認結果符合公開的即時語意;該結果不能標記為快照。 - 修改游標一個位元組、跨租戶重用、改變狀態篩選、使用過期版本,都應被拒絕。
- 連續前進三頁再後退三頁,檢查每頁 ID 與順序;同時涵蓋 0 筆、1 筆、50 筆與 51 筆。
- 對第一頁、第二頁與很深的邊界執行
EXPLAIN (ANALYZE, BUFFERS),確認使用預期複合索引、掃描筆數接近頁面大小,並記錄讀取 p95、寫入 p95、WAL 與索引大小。
高品質示範回答
「我先限定保證:營運列表需要即時順序瀏覽,不要求直接跳頁,也不承諾跨多個 HTTP 請求維持資料庫快照。因此我會選 keyset,不用 offset。
排序使用建立後不變的 createdat DESC, id DESC。ID 是唯一決勝鍵,否則同一毫秒建立的訂單沒有穩定順序。第一頁取 51 筆、回傳 50 筆。下一頁游標保存末筆的時間與 ID,查詢條件是 (createdat, id) < (?, ?),排序完全相同。索引依等值篩選在前、排序邊界在後,使用 (tenantid, status, createdat 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,讓用戶端從第一頁重新開始,不能為了無感翻頁繼續接受已外洩金鑰。
追問五:為什麼刪除邊界列後仍能繼續,修改排序鍵卻不行?
下一頁查詢只需要游標中的兩個值,不必在資料庫重新找到邊界列,所以刪除它不會改變嚴格小於條件。修改排序鍵會讓同一筆紀錄從邊界一側移到另一側:它可能再次落入未來範圍,或從尚未走訪的範圍跳到已走訪的一側。穩定排序鍵是正確性條件;邊界列可以已經不存在。