題幹與適用場景
這道題考察後端工程師能否同時處理深分頁效能、並行寫入下的穩定順序與 API 游標契約。假設客戶端需要按時間倒序瀏覽訂單或動態訊息清單,每頁 50 筆,資料持續寫入,使用者主要需要下一頁而不是跳到任意頁。
適用對象包括後端、資料服務和平台職位。不要一開始就宣布「cursor 永遠更快」;先確認排序、跳頁、匯出、一致性、刪除語意、讀取副本和索引條件,因為這些限制會改變方案。
面試官考察點
強回答會解釋為什麼深 OFFSET 會掃描並丟棄前面的資料列,為什麼非唯一排序鍵會在並行寫入時造成漂移,以及為什麼 keyset 需要穩定的總排序和相符索引。回答也應區分無狀態 keyset token 與持有交易的資料庫 cursor,並給出重複、漏項、刪除邊界列和偽造 token 的驗證方法。
回答前需要釐清的問題
- 是否需要跳到第 N 頁或顯示總頁數?若需要,純 keyset 無法直接滿足。
- 清單依什麼欄位排序?時間戳是否唯一、可變,是否需要唯一 ID 作 tie-breaker?
- 客戶端要 live 檢視還是一次遍歷內的 snapshot?兩者對新增和刪除的可見性不同。
- 查詢是否跨讀取副本、分片或租戶篩選?索引和 token 必須繫結這些條件。
- 游標是否可能被客戶端解碼、竄改或長期重放?這決定簽章、到期和版本策略。
30 秒回答框架
「我先確認排序、跳頁和一致性語意。對持續變動的大清單,我會使用帶唯一 tie-breaker 的 keyset 分頁,例如按 created_at DESC, id DESC,並建立相符的複合索引;下一頁攜帶上一頁最後一筆的排序鍵,而不是深 OFFSET。游標應編碼篩選條件、排序版本和邊界並簽章、設定到期時間。若要求整次遍歷的 snapshot,再考慮帶交易或時間戳的 cursor,並說明它占用的資源。最後用並行新增、刪除、重複時間戳和竄改 token 的測試驗證契約。」
分步驟深入解答
第一步:先定義分頁契約
明確 limit 上限、預設排序、nextcursor、hasmore 和篩選條件。穩定排序至少要是總序關係;只按 created_at 在同一時間戳有多筆資料時會產生不確定順序,因此追加不可變唯一 ID。若客戶端需要上一頁,需設計反向比較和邊界處理,不能直接把下一頁邏輯反轉後假設結果正確。
第二步:解釋 OFFSET 的效能與一致性問題
OFFSET k LIMIT n 通常需要先定位並跳過前面的 k 筆資料,深頁的工作量會隨 k 增長。若並行新增或刪除發生在已讀取頁面之前,後續頁面的偏移位置會移動,導致重複或漏項。小型、靜態、需要跳頁的後台清單可以接受 OFFSET,但應限制最大頁數並用執行計畫驗證成本。
第三步:用穩定總序關係實作 keyset
以倒序 (created_at, id) 為例,第一頁不帶邊界,下一頁使用上一頁最後一列的鍵:
-- first page
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 50;
-- next page: boundary comes from the last returned row
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 50;查詢從索引邊界向前掃描固定數量的資料列,不需要跳過全部歷史頁。複合索引的欄位順序、篩選條件和排序方向必須與查詢相符;否則理論上的 keyset 仍可能退化為大範圍掃描。
第四步:設計可驗證的游標 token
不要把資料庫內部偏移量當作 cursor。token 至少應包含邊界鍵、篩選器摘要、排序版本和到期時間,並使用簽章或 AEAD 防止客戶端修改。伺服器收到 token 後驗證版本、租戶和篩選器與當前請求一致;排序規則改變時拒絕舊版本或明確重新開始,避免同一 token 在不同順序下產生無法解釋的結果。
第五步:選擇 live 或 snapshot 語意
live keyset 允許新資料繼續出現在清單頂端,已越過邊界的舊資料列通常不會重複;刪除邊界列不會讓下一頁重新出現它,但總數會改變。若匯出或稽核要求一次遍歷看到固定集合,可以把讀取時間戳、快照版本或資料庫 cursor 放入契約。資料庫 cursor 可能持有交易與資源,不能預設用於長時間的 HTTP 分頁。
第六步:處理讀取副本、分片和邊界列
跨副本讀取時,複製延遲可能讓下一頁暫時看不到主庫已回傳的資料;可以固定讀取路由、攜帶已確認的時間邊界,或接受 eventual consistency 並在文件中說明。分片場景需要每個分片產生局部 keyset,再進行帶游標的 k 路合併。刪除最後一列邊界列、相同時間戳和篩選條件改變都應有回歸測試。
第七步:驗證效能與正確性
效能驗證檢查深頁執行計畫掃描列數、P95 延遲和索引命中;正確性驗證在兩次請求之間插入、刪除和更新資料,斷言不會重複或漏掉違反契約的資料列。記錄每頁的篩選器、排序版本、首尾鍵和 token 版本,生產環境才能把漂移現象映射回具體邊界。
高品質示範回答
「我會先問清楚是否需要跳頁、總數和固定 snapshot,以及排序欄位是否唯一。對一個持續寫入的大清單,如果主要是向後瀏覽,我會選擇 keyset:按不可變的 created_at 加唯一 id 建立總排序和複合索引,下一頁用上一頁最後一筆的兩個鍵做範圍條件。這樣深頁不需要掃描並丟棄前面的資料列,也不會因為前面新增一筆資料而整體錯位。
我會把篩選條件、排序版本、邊界鍵和到期時間編碼進簽章 token,驗證 token 與請求的租戶和篩選條件一致。live 語意下,新資料從頂端出現;需要稽核級固定集合時,我會考慮時間戳或受控交易 cursor,並說明長交易的資源成本。若讀取副本有延遲,我會固定路由或記錄可接受的可見性邊界。
最後用執行計畫和並行測試驗證:深頁掃描量、重複時間戳、新增、刪除、更新邊界列、讀取副本延遲和竄改 token 都要覆蓋。小型且必須跳頁的管理清單可以保留 OFFSET,但會限制深度並監控延遲。」
常見錯誤
- 宣稱 cursor 永遠更快 → 忽略跳頁和長交易成本 → 比較 OFFSET、keyset 與資料庫 cursor 的適用條件。
- 只按時間戳排序 → 同值列順序不穩定 → 追加不可變唯一 ID。
- 把 offset 數字放進 token → 深頁仍昂貴且會漂移 → 攜帶排序邊界和篩選器摘要。
- 不定義 live/snapshot → 客戶端無法解釋新增與刪除 → 在 API 契約中聲明可見性語意。
- 忽略索引方向和篩選條件 → keyset 仍掃描大量資料 → 用執行計畫驗證複合索引。
- 接受任意客戶端 cursor → 可竄改租戶或讀取過期邊界 → 簽章、校驗版本並設定到期。
追問及應對
追問 1:使用者必須跳到第 500 頁,怎麼辦?
對小型或近似靜態管理清單可用有上限的 OFFSET;對大清單可維護預計算頁面邊界或搜尋條件,讓使用者按篩選和排序定位,而不是承諾任意深頁的低延遲。兩者都要公開成本與一致性限制。
追問 2:created_at 會被編輯,游標還穩定嗎?
不穩定。使用不可變的建立時間加唯一 ID,或把可變排序欄位的版本凍結在 snapshot 中。若業務必須按可變欄位排序,需要接受 live 檢視中的移動,或改用固定版本並承擔儲存與讀取成本。
追問 3:讀取副本落後導致下一頁少資料,怎麼處理?
根據一致性要求固定主庫或同一副本,或者攜帶「不得早於某時間/LSN」的邊界並在等待逾時後返回可解釋狀態。不能靜默把缺失當作清單結束;客戶端應區分暫時不可見和 has_more=false。
追問 4:如何證明沒有重複和漏項?
建立可控並行測試:讀取第一頁後在邊界前新增、刪除邊界列、插入相同排序鍵並更新資料;連續取頁後檢查唯一 ID 集合、排序關係和預期可見性。日誌記錄每頁首尾鍵、token 版本和資料快照識別,失敗時可以重現具體邊界。