題幹與適用情境
一個熱門商品設定鍵承受每秒 5 萬次讀取,來源資料庫的安全上限為每秒 200 次查詢,重建快取的 p95 為 800 毫秒。快取新鮮期為 10 分鐘,業務最多容忍 30 秒舊資料。服務執行在 100 個無狀態應用實例上,共用一個遠端快取與同一來源資料庫。
請設計完整的讀取與刷新流程,並解釋軟過期、硬過期、首次冷啟動、刷新實例崩潰、快取不可用,以及來源資料在刷新期間變更時如何處理。還要說明如何證明方案不會把並行壓力轉移給資料庫。本題的吞吐量、延遲與過期時間都是設計輸入,不代表任何產品的效能承諾。
這是一道後端可靠性題。當前公開的 2026 SRE 面試題資料直接列出「關鍵鍵在每秒 10 萬請求下過期」的快取踩踏情境;本題將它改寫成可計算、可驗證的工程約束。它與單一行程 LRU 的淘汰演算法不同,核心是跨實例並行控制與故障語意。
面試官考察重點
第一,看候選人是否先計算失效瞬間的量級。若 800 毫秒內到達的請求全都繞過快取,理論上有 50,000 × 0.8 = 40,000 個請求試圖存取來源端,遠超每秒 200 次的安全上限。「加上快取」本身沒有解決快取失效時的同步放大。
第二,看能否區分三層保護。本機請求合併只約束一個行程;100 個實例各自選出一名刷新者,仍可能同時產生 100 次重建。分散式租約把正常情況下的跨實例刷新收斂為一個,但租約過期、行程暫停或網路分割仍可能出現重疊刷新。因此,來源端的並行艙壁或限流必須作為獨立的最後防線。
第三,看過期語意是否清楚。新鮮期內直接回傳;軟過期後、30 秒可陳舊視窗內立即回傳舊值,並在背景競爭刷新;超過硬過期後不能無限回傳舊值,沒有刷新資格的請求只能有限等待、降級或失敗,不能全部穿透來源端。
最後,看候選人是否處理「舊刷新者覆蓋新值」。租約只在有效期內提供互斥,不等於恰好執行一次。快取項目要帶來源版本或產生代次,寫回必須比較版本,避免暫停過久的舊刷新者恢復後覆蓋更新結果。
回答前需要釐清的問題
- 舊資料是否真的可用? 本題允許最多 30 秒舊資料;餘額、權限、庫存扣減等高度敏感資料可能不能採用相同策略。
- 來源端安全上限的口徑是什麼? 本題以整個來源資料庫每秒 200 次查詢處理,也應確認單鍵查詢的最大並行與逾時。
- 首次冷啟動能否回傳預設值? 預設沒有可用舊值,只允許一名刷新者存取來源端,其他請求有限等待;若業務有靜態預設值,可作為明確降級。
- 快取項目如何失效? 採用邏輯新鮮期與硬過期,不依賴所有副本在同一秒實體刪鍵。來源資料變更事件可主動刷新或失效。
- 是否需要跨地域? 本題先設計單一地域的 100 個實例;多地域需要分別配置來源端預算,不能預設一把跨地域鎖能解決所有問題。
- 回傳舊值時是否要通知呼叫方? 內部回應至少記錄
stale_age與降級原因;是否顯示給終端使用者由業務決定。 - 快取本身故障時怎麼辦? 必須預先約定降級策略與來源端預算,不能把「快取失敗」直接等同於「所有請求查資料庫」。
30 秒回答框架
「我會在快取值中保存 freshuntil、staleuntil 與來源版本。新鮮命中直接回傳;軟過期後在 30 秒視窗內回傳舊值,並用行程內 singleflight 加上一個帶所有權權杖與 TTL 的分散式租約選出刷新者;硬過期或首次載入時,跟隨者只做有限等待與帶抖動的重新讀取。刷新寫回依來源版本做條件更新,釋放租約時驗證權杖。租約失效不代表來源端可以無限並行,所以資料庫前還要有每鍵與全域艙壁。最後用失效壓測、刷新者崩潰、租約逾時與快取故障演練,驗證來源端 QPS、並行數與舊資料年齡上界。」
分步深入解析
先定義快取項目,而非只儲存業務值:
CacheEntry {
value
source_version
generated_at
fresh_until
stale_until
}freshuntil 是 10 分鐘新鮮期的結束時間,staleuntil 再延後最多 30 秒。遠端快取鍵的實體 TTL 應至少涵蓋 stale_until 與少量清理餘裕,否則快取服務會在軟過期時先刪掉仍可安全回傳的舊值。舊值視窗是明確的業務預算,不應在刷新失敗時悄悄延長。
讀取路徑可寫成以下虛擬碼:
entry = cache.get(key)
now = clock.now()
if entry exists and now < entry.fresh_until:
return entry.value
if entry exists and now < entry.stale_until:
try_refresh_async(key)
return entry.value
return rebuild_or_wait(key, request_deadline)軟過期路徑優先保護請求延遲。第一個觀察到軟過期的請求嘗試背景刷新,其他請求繼續使用舊值。行程內 singleflight 以鍵為粒度,讓同一實例只執行一個刷新函式;Go 的公開實作把此語意定義為「同一鍵同一時刻只有一個呼叫執行,重複呼叫者共享結果」。但它不跨行程,因此不能單獨用於 100 個實例。
跨實例刷新使用有期限的租約。例如刷新者產生隨機且不可重複使用的 token,執行:
SET refresh:{key} {token} NX PX {lease_ms}取得租約後再讀一次快取,避免另一名刷新者剛完成;仍需刷新時才呼叫來源端。釋放時必須「只有目前值仍等於自己的 token 才刪除」。直接 DEL 會有競態:舊刷新者暫停到租約過期,新刷新者已取得租約後,舊刷新者恢復並刪鍵,會誤刪新租約。Redis 的分散式鎖文件也把唯一值與所有權驗證列為安全釋放條件。
lease_ms 應高於實測刷新 p99 加上網路與排程餘裕,而不是直接使用 800 毫秒的 p95。租約太短會增加重疊刷新,太長會在刷新者崩潰後延遲接管。租約到期只允許其他實例重新競爭,不能證明舊刷新者已停止。因此來源讀取要受每鍵 singleflight 服務或資料庫艙壁保護,刷新寫回也必須防止舊結果覆蓋。
寫回時讀取來源資料的單調版本,例如資料庫列版本或事件序號,並做條件更新:只有 new.sourceversion >= cached.sourceversion 才替換快取。若來源資料沒有可靠版本,可在共用協調儲存中以原子遞增值為刷新配置產生代次,並在快取端原子比較。快取仍不是事實來源;延遲完成的刷新不能覆蓋已由變更事件寫入的更新版本。
硬過期與首次載入沒有合格舊值。取得租約的請求在來源端艙壁允許時重建;跟隨者在請求截止時間內,以短間隔加抖動重新讀取快取,而不是使用固定節拍輪詢。等待逾時後回傳明確的降級回應或錯誤。若產品允許靜態預設值,可回傳預設值並標示降級;不能為了「高可用」把 5 萬個請求直接送向來源端。
來源端最後防線至少包含每鍵並行上限、全域刷新並行上限與每秒查詢預算。正常情況下某鍵只有一次重建;租約故障時,艙壁仍保證總查詢不超過安全邊界。艙壁已滿時刷新任務快速失敗或排入有限佇列,軟過期請求繼續使用未超過 30 秒的舊值,硬過期請求依既定降級方式處理。
快取不可用時,應用不能把所有讀取流量切到資料庫。可在每個實例保留短期唯讀近端副本,但全域仍受來源端艙壁約束;沒有近端副本的請求應降級或失敗。恢復後透過限速預熱熱門鍵,避免所有實例同時回填。對大量不同鍵同時過期,應為實體或邏輯新鮮期加入隨機抖動;抖動可緩解快取雪崩,卻無法解決單一熱門鍵的並行重建。
還要區分相鄰問題:快取擊穿或踩踏是既有熱門鍵失效後被並行重建;快取雪崩是大量鍵同時失效或快取整體故障;快取穿透是反覆查詢本來就不存在的資料。TTL 抖動主要處理雪崩,短期負快取或布隆過濾器主要處理穿透,它們不能取代本題的請求合併。
對可預測熱門資料,可在 fresh_until 前主動刷新。Cloudflare 公開的機率式提前刷新方案會讓刷新機率隨到期時間接近而變化,從而在高並行下減少鎖競爭;固定寫成「每次有 1% 機率刷新」會隨請求率改變而失控。可陳舊視窗與背景再驗證的語意也與 HTTP 的 stale-while-revalidate 一致:舊回應只能在明確視窗內回傳,並由背景完成再驗證。
多地域通常依地域保存快取並獨立刷新,再為每個地域分配來源端 QPS 與並行預算。一個全域租約會把跨地域延遲與網路分割帶入讀取路徑。若所有地域共用同一來源端,可由中央控制面配置刷新預算,或讓來源端提供統一重建服務;關鍵是所有地域預算總和仍不超過每秒 200 次。
可觀測性至少包含新鮮命中率、舊值回傳率、硬未命中率、舊值年齡、刷新嘗試與成功率、singleflight 共享請求數、租約取得失敗與過期數、刷新延遲、來源端 QPS/並行/拒絕數,以及快取錯誤與延遲。警示應關注來源端預算、stale_until 即將耗盡與持續刷新失敗,不能只看快取命中率。
驗證應圍繞不變條件:在每秒 5 萬請求下讓鍵同時軟過期,確認正常情況下每鍵只有一次來源端重建;刷新者在寫回前崩潰,確認舊值繼續服務且租約到期後有人接管;讓舊刷新者暫停超過租約,確認它不能覆蓋新版本;讓快取不可用,確認來源端仍受每秒 200 次與並行艙壁保護;讓多個鍵同時過期,確認 TTL 抖動與全域預算生效。
高品質示範回答
「先算最壞情況:每秒 5 萬請求乘以 800 毫秒重建時間,失效視窗會出現約 4 萬個到達請求,資料庫每秒只能安全處理 200 次,所以任何跟隨者都不能直接存取來源端。
我會把值、來源版本、freshuntil 與 staleuntil 一起快取。10 分鐘內直接回傳;接下來 30 秒回傳舊值,同時嘗試刷新。每個行程先用 singleflight 合併本機請求,再用 SET lock token NX PX lease 競爭跨實例刷新租約。取得租約後複查快取,只讓仍有需要的刷新者查詢資料庫。釋放租約時比較 token,寫回時比較來源版本,避免舊刷新者誤刪新租約或覆蓋新值。
首次載入或超過 30 秒時,沒有合格舊值。一個請求負責重建,其他請求只在截止時間內帶抖動重讀快取,逾時就使用明確的預設降級或回傳錯誤。資料庫前再放每鍵與全域艙壁,因為租約過期可能產生重疊刷新,鎖不能取代容量保護。
我會壓測鍵過期瞬間,並注入刷新者崩潰、超長暫停、快取不可用與來源資料並行更新。驗收條件包含來源端 QPS 不超過 200、正常每鍵重建並行為 1、舊資料不超過 30 秒、舊刷新者不能覆蓋新版本。這樣延遲、資料新鮮度與來源端安全都有可測量的上界。」
常見錯誤
- 只為 TTL 加隨機值 → 只能分散多個鍵的過期時間,單一熱門鍵到期時仍會並行存取來源端 → 對同一鍵做請求合併,並保留來源端艙壁。
- 只用行程內 singleflight → 100 個實例仍可能產生 100 個刷新者 → 本機合併後再做跨實例租約。
- 取得鎖之前就查資料庫 → 並行壓力已抵達來源端 → 先競爭刷新資格,獲勝後複查快取,再進入來源端預算。
- 使用
SETNX鎖卻不設 TTL → 刷新者崩潰後鍵可能永遠無法更新 → 使用有期限的租約並設計接管。 - 釋放時直接
DEL→ 舊刷新者可能刪除後來者的新租約 → 用唯一 token 驗證所有權後原子釋放。 - 把租約當成恰好執行一次的保證 → 行程暫停超過 TTL 後可能有兩名刷新者同時執行 → 用來源端艙壁與版本化寫回承受重疊執行。
- 刷新失敗就無限回傳舊值 → 資料陳舊度失去上界 → 只在
stale_until前回傳舊值,之後明確降級或失敗。 - 快取故障時全量存取來源端 → 5 萬次讀取會立刻壓垮每秒 200 次的來源端 → 保留近端副本,並讓所有來源讀取經過總預算。
- 混淆擊穿、雪崩與穿透 → 解法與失敗條件無法對應 → 分別使用請求合併、TTL 抖動與負快取等針對性手段。
- 只監控命中率 → 高命中率也可能隱藏刷新失敗與來源端尖峰 → 同時監控舊值年齡、刷新並行、租約與來源端預算。
追問與應對
追問一:如果業務完全不能回傳舊值呢?
取消軟過期回傳,跟隨者只能有限等待;需要為來源端重建服務提供足夠容量與明確失敗回應,不能犧牲來源端安全換取表面可用性。
追問二:租約 TTL 應設定多久?
從實際刷新 p99、網路逾時與排程暫停出發加上餘裕,並監控租約到期時仍在執行的比例;p95 的 800 毫秒不足以直接當作 TTL。
追問三:刷新期間來源資料又更新怎麼辦?
讀取並攜帶來源版本,在快取端條件寫入;變更事件寫入的新版本不能被延遲刷新覆蓋。
追問四:快取叢集整體不可用怎麼辦?
近端唯讀副本承擔可接受的舊讀,所有必要來源讀取仍經過全域艙壁;沒有副本時依業務降級或失敗,並在恢復時限速預熱。
追問五:如何處理負快取?
對確認不存在的資料快取短 TTL 的「找不到」,防止穿透;它與既有熱門鍵過期的請求合併是兩條獨立路徑。
追問六:機率式提前刷新何時適合?
適合可重算、允許舊讀且請求率較高的資料;機率應隨剩餘新鮮時間與觀測請求率調整,並保留刷新失敗與來源端預算保護。
追問七:多地域是否共用一把鎖?
通常不共用。依地域快取與刷新並配置來源端預算;只有確實需要全域單刷新,且能接受跨地域延遲與分割影響時才考慮中央協調。
追問八:如何證明方案有效?
以每秒 5 萬請求跨越軟過期與硬過期,注入崩潰、暫停、快取故障與版本競爭,斷言正常每鍵單刷新、來源端不超過預算、舊值不超過 30 秒且新版本不會回退。