題目與適用情境
PostgreSQL 是商品資料的事實來源,Redis 保存依 product_id 查詢的商品展示快照。系統每秒約有 2 萬次讀取、500 次寫入,活躍商品約 2000 個。名稱、圖片和展示文案最多允許陳舊 5 秒;庫存扣減、價格結算、權限和餘額等決定業務正確性的欄位,不在這份快取契約內。
請設計 cache-aside 的讀取、更新與失效流程,並涵蓋下列情況:
- 資料庫提交後,程序在刪除快取前崩潰;
- 讀取請求先讀到舊資料庫版本,卻在寫入請求讓快取失效後才完成回填;
- 回填從非同步複本讀到落後資料;
- 失效事件重複、亂序或積壓;
- Redis 短暫無法使用;
- 產品要求把「最多陳舊 5 秒」寫成可以監控與測試的契約。
題目中的吞吐量、活躍 key 數量和 5 秒預算都是面試假設,不是通用設定。現有公開的 2026 後端與資深快取面試資料,都明確把 cache-aside、寫入後失效和快取一致性列為考察內容。本題核心是資料庫提交後的後端讀寫協定與失敗語意,因此歸為 backend。它與「熱門 key 過期時如何避免快取擊穿」不同:快取擊穿控制同時回源,本題要證明寫入後舊值何時、為何,以及最多還能存在多久。
面試官評估重點
第一,候選人能否先定義一致性目標。「資料庫和快取永遠一致」沒有說明哪些讀取結果可被接受。強回答會分開處理展示資料的 5 秒有限陳舊、同一使用者的 read-your-writes,以及庫存與權限的權威讀取;三種契約需要不同方案。
第二,是否能說清楚寫入順序。cache-aside 常見的寫入路徑是先提交資料庫,再刪除快取。先刪快取再更新資料庫會留下明確的競態:讀取請求在兩步之間 miss,讀出舊資料庫值並重新寫入快取。資料庫先提交仍不是原子雙寫;提交與刪除之間還有短暫視窗,刪除失敗也可能讓舊值一直留到 TTL。
第三,能否畫出「舊讀取延遲回填」的時間線。讀取請求可能在資料庫更新前讀到版本 41,寫入請求接著提交版本 42 並刪除快取,最後舊讀取才把版本 41 寫回。只在快取值加版本號不一定擋得住:刪除後快取是空的,普通的「只有不小於目前快取版本才寫入」會接受版本 41。需要在刪除後繼續存在的版本柵欄、會被更新失效的回填 lease,或在回填前重新驗證來源版本。
第四,是否把可靠失效與有限時間分開。交易式 outbox 或資料庫變更資料擷取,可以避免「資料庫已提交但失效訊息未記錄」的雙寫缺口;至少一次消費搭配冪等刪除可以承受重複。不過重試只保證最終會處理,不能自動證明 5 秒內完成。有限陳舊還需要硬 TTL、事件延遲門檻,或在失效鏈路超出預算時略過快取。
第五,是否處理事實來源、資料庫複本和維運驗證。資料庫提交成功才產生新事實;從落後複本回填,可能在正確失效後再次放回舊值。優秀回答會限制回填資料來源,監控事件與快取版本,並用可控的並行時間線和故障注入驗證方案,而不只查看命中率。
回答前需要釐清的問題
- 一致性契約是什麼? 若允許最多 5 秒陳舊,可以採用非同步失效加硬時限;若要求 read-your-writes,寫入者後續請求需要短時間略過快取或攜帶最低版本;若完全不能接受舊讀取,就應直接讀權威儲存,或使用具備對應一致性契約的儲存路徑。
- 哪些欄位會決定不可逆的業務動作? 展示名稱和圖片可以陳舊,庫存檢查、優惠資格、權限、餘額與付款金額不能把快取快照當作授權依據。欄位混在同一物件時,應拆分讀取契約,或在關鍵動作中重新讀取事實來源。
- 5 秒從何時開始計算? 本題從資料庫交易提交時間開始。若從快取寫入時間計算,複本原本已落後 4 秒,再快取 5 秒會讓實際資料年齡接近 9 秒。
- 所有寫入入口都由同一服務控制嗎? 若批次工作、後台工具和其他服務也能寫資料庫,只在 API 寫入路徑呼叫
DEL會漏失效。應把失效紀錄放入同一資料庫交易,或從資料庫日誌擷取所有受支援的變更。 - 讀取 miss 從主資料庫還是複本回填? 需要知道複本延遲上限,以及是否支援工作階段層級的 read-your-writes。無法證明複本在預算內時,失效後第一次回填應讀主資料庫,或要求結果版本達到呼叫端的最低版本。
- 資料庫能承受多少回填? 在 5 秒 TTL 下,若 2000 個活躍 key 均勻過期且都有人讀取,平均約有
2000 ÷ 5 = 400次回填/秒;實際值還受存取分布、抖動與請求合併影響。資料庫預算不足時,不能靠縮短 TTL 假裝滿足一致性。 - Redis 無法使用時優先可用性還是新鮮度? 展示資料可以在明確舊值上限內降級;權威欄位必須回源並受到限流保護。若資料庫容量不足以接住所有 miss,就需要限流、艙壁和明確失敗,不能無限制回源。
30 秒回答架構
「我會先依資料類型定義契約:商品展示資料允許從資料庫提交起最多舊 5 秒,庫存和權限永遠查權威路徑。讀取先查 Redis,miss 時合併同 key 的同時請求,再從符合新鮮度要求的資料來源讀取帶版本紀錄,並進行條件式回填。寫入在 PostgreSQL 交易內更新業務資料列,同時寫一筆帶資料列版本的 outbox;提交成功後先嘗試刪除快取,outbox 或 CDC 再做可重試的失效修復。
我會先提交資料庫再刪快取。為避免舊讀取在刪除後延遲回填,miss 時取得回填世代,失效時推進版本柵欄並刪除值;只有世代未改變且來源版本不低於柵欄時,回填才會成功。5 秒不能靠『最終重試』證明:快取要有不超過預算的硬 TTL,失效延遲接近預算時就略過快取。複本延遲無法符合預算時,失效後回填讀主資料庫。最後用提交後崩潰、舊讀取晚到、事件重複亂序和複本落後的故障注入,斷言舊值年齡、版本單調性和資料庫負載。」
逐步深入解答
第一步:把一致性寫成讀取結果契約
先依業務後果分成三條路徑:
| 路徑 | 可接受的結果 | 建議讀取方式 | | --- | --- | --- | | 商品展示 | 從資料庫提交起最多陳舊 5 秒 | Redis cache-aside、硬 TTL 與失效延遲門檻 | | 寫入者接著讀取 | 至少看到自己剛提交的版本 | 攜帶 min_version,快取不足時讀主資料庫 | | 庫存、權限、餘額、結算 | 業務決定必須依目前權威狀態 | 略過展示快取,在交易或權威服務中驗證 |
這一步決定後續設計。快取適合加速可重建的副本,不能讓庫存扣減依賴可能遺失、延遲或遭淘汰的值。即使快取命中率是 99.9%,也沒有回答剩下 0.1% 是否會導致超賣或越權。
快取項目至少保存來源版本和產生時間。以下是虛擬結構,不是特定語言的可執行型別:
ProductCacheEntry {
value
source_version
source_committed_at
cached_at
}sourcecommittedat 用來計算真正的陳舊年齡;cached_at 只表示何時寫入快取。來源版本可以是每筆資料列單調遞增的版本、提交序號,或可比較新舊的領域版本。牆上時鐘時間戳若可能相同或漂移,不適合單獨負責順序證明。
第二步:建立基本 cache-aside 讀寫路徑
讀取流程先取快取,命中且未違反呼叫端最低版本與陳舊預算就回傳。miss 時合併同一 product_id 的同時回填,避免 2 萬次讀取一起打到資料庫。回填讀取帶版本資料,再嘗試寫入 Redis。以下是流程虛擬碼:
read(product_id, min_version = none):
entry = cache.get(product_id)
if entry satisfies age_budget and min_version:
return entry.value
return singleflight(product_id):
recheck cache
row = read_authoritative_version(product_id)
conditional_fill(product_id, row)
return row.value寫入流程把業務資料列和 outbox 放在同一個 PostgreSQL 交易。COMMIT 成功後,業務變更才會對其他交易可見並持久保存。應用程式接著執行一次快速失效;獨立 relay 或 CDC 消費者處理持久失效事件,負責重試與修復。寫入路徑虛擬碼如下:
transaction:
row = update product and increment source_version
insert outbox(product_id, source_version, committed_at)
commit
best_effort_invalidate(product_id, source_version)
return committed source_version直接失效縮短一般情況下的視窗,outbox 封閉程序在提交後崩潰造成的訊息遺失。兩者處理同一事件時,刪除必須冪等;同一版本重複抵達不會產生新的業務副作用。
第三步:逐一分析三個競態視窗
視窗一:資料庫提交與快取刪除之間。
W: COMMIT version 42
R: read cached version 41
W: DEL cache key讀取請求短暫看到版本 41,在有限陳舊契約內可以接受。若完全不能接受,即使寫入回應前等待快取操作成功,仍無法解決所有網路不確定性;更清楚的契約是讓關鍵讀取走事實來源,或由呼叫端攜帶 min_version=42。
視窗二:資料庫已提交,但失效尚未執行。
W: COMMIT version 42 plus outbox record
W: process crashes before DEL
R: cache still contains version 41
relay: retries invalidation for version 42只建立在 COMMIT 後的記憶體工作會遺失。交易式 outbox 讓業務變更與「必須失效」的意圖一併提交;relay 可以重複投遞,消費者依 key 刪除並記錄已處理版本。若 relay 停滯,硬 TTL 或讀取門檻負責守住 5 秒上限。
視窗三:舊讀取在失效後延遲回填。
R: cache miss; captures generation 7
R: reads database version 41
W: commits version 42
W: advances generation to 8 and deletes cache value
R: tries to fill version 41 with generation 7; rejected這是最容易忽略的競態。若只刪除值,快取為空時 version 41 >= no version 會成立,舊值仍會復活。一種解法是把值與柵欄分開:失效推進一個短期保留的 generation 或最低來源版本;回填透過 Redis script 原子檢查「回填世代仍等於 miss 時的世代,且來源版本不低於最低版本」,再寫入值。在 Redis Cluster 中,值 key 與柵欄 key 必須設計在同一個 hash slot,script 才能原子存取兩者。另一種實作是由快取服務發出 miss lease,資料庫寫入會使 lease 失效。也可以在回填前重新讀取主資料庫版本,但會增加一次資料庫讀取,仍須定義檢查與寫入間的原子邊界。
版本柵欄需要有限保留時間,至少涵蓋最大回填執行時間、重試與網路暫停;太早清除會重新打開延遲回填視窗。它只保護快取寫入順序,不會取代資料庫並行控制,也不會告訴一般讀取者資料庫是否已有尚未傳播的新版本。
第四步:讓失效事件可復原,但不誇大保證
outbox 資料列與業務更新在同一交易寫入,relay 讀取後把失效事件送到持久通道。消費者可為每個 product_id 維護已觀察到的最高版本,處理規則如下:
- 版本低於已觀察最高值的亂序舊事件,只做冪等確認;
- 新版本推進最低版本柵欄並刪除快取值;
- 快取命令失敗時重試,超過限制就進入可見的隔離佇列;
- 定期核對資料庫最新版本、outbox 進度和快取抽樣版本。
至少一次消費適合刪除,因為重複 DEL 不會增加業務含義。不要宣稱「重試等於恰好一次」;消費者可能在 Redis 已刪除後、確認訊息前崩潰,之後再次刪除。設計目標是可安全重複與可查出缺口。
還要監控從資料庫提交到失效成功的延遲,而不只看訊息佇列年齡。必要指標包括 invalidationlagseconds、失效失敗與重試次數、隔離佇列最舊年齡、快取來源版本落後量、超預算快取略過次數、資料庫回填 QPS,以及同 key singleflight 共享數。
第五步:用 TTL 和門檻證明 5 秒上限
可靠事件最終會抵達,但「最終」沒有時間單位。若產品承諾從提交起最多陳舊 5 秒,至少要有一條獨立的有限時間防線:
- 快取實體 TTL 不超過 5 秒扣除時鐘、排程與偵測餘量,並從權威版本的提交時間計算陳舊度;
- 或在失效鏈路延遲接近預算時,整體或依分區停止信任快取,改讀符合新鮮度要求的資料來源;
- 若兩者都做不到,就只能把契約改成最終一致,不能繼續承諾 5 秒。
TTL 是上限後盾,不是主要失效機制。為到期時間加入抖動,避免 2000 個 key 同時過期;同一 key 的 miss 要做請求合併。若所有 2000 個活躍 key 在 5 秒內都至少被讀取一次,均勻情況下平均約 400 次回填/秒。這項估算不是容量保證:熱門度分布、批次過期、Redis 故障和慢查詢都可能造成尖峰,必須用資料庫安全 QPS 與並行艙壁驗證。
若 5 秒 TTL 讓資料庫無法承受,只剩三種選擇:增加可安全承載的回填能力、減少需要該契約的活躍資料,或放寬陳舊預算。偷偷把 TTL 調長會直接違反題意。
第六步:處理複本延遲與 read-your-writes
正確刪除快取後,從落後複本回填仍可能使舊版本復活。對有 5 秒硬契約的 miss,可以依下列順序處理:
- 失效後第一次回填讀主資料庫;
- 只有當複本公開可觀測的 replay position 已達所需提交位置,才允許從複本讀取;
- 寫入回應傳回
sourceversion,呼叫端後續攜帶minversion;快取或複本達不到時改走主資料庫; - 複本延遲或失效延遲超出預算時,打開新鮮度斷路器,停止回傳不合格的快取值。
把 TTL 設為 5 秒,卻從可能已落後 4 秒的複本回填,無法證明資料只舊 5 秒。陳舊年齡必須從事實來源提交開始計算,讀取複本、事件通道和快取三段延遲都要納入。
第七步:比較替代方案
同步更新快取。 資料庫成功後立刻寫入快取,可能改善 read-your-writes,但兩個獨立系統仍有部分失敗:資料庫已提交而快取更新失敗,或兩筆並行寫入以不同順序抵達快取。它需要來源版本條件寫入和失敗修復,不能因為命名為 write-through 就視為原子交易。
先刪快取再寫資料庫。 實作簡單,卻允許讀取者在資料庫提交前回填舊值;「等待後再刪一次」可以降低特定時序的機率,但固定延遲無法涵蓋沒有上限的複本延遲、程序暫停和網路故障,程序也可能在第二次刪除前崩潰。它可以是輔助措施,不能單獨證明有限陳舊。
只依賴短 TTL。 在寫入很少、陳舊預算寬鬆且資料庫能承受回填時,這是足夠簡單的做法。代價是每次寫入後仍可能在整個 TTL 期間讀到舊值,大量 key 同時過期也會放大資料庫負載。
所有讀取都查資料庫。 對低吞吐或強正確性資料,這通常是最清楚的方案。快取不是架構必選項;當一致性協調成本高於省下的資料庫讀取時,移除快取更合理。
第八步:驗證不變量與故障路徑
不要只做「更新後重新整理頁面」的手動測試。用 barrier 控制並行順序,重現舊讀取延遲回填:讀取請求拿到版本 41 後暫停;寫入請求提交版本 42、推進柵欄並刪除;再放行舊讀取,斷言條件式回填失敗。接著涵蓋:
- 在 PostgreSQL 提交成功後立刻終止寫入程序,確認 outbox 最終觸發失效;
- 讓同一失效事件重複且亂序抵達,確認最高版本不回退;
- 讓 Redis 刪除命令失敗,確認重試、隔離和 TTL 後盾生效;
- 刻意暫停複本 replay,確認回填改讀主資料庫或拒絕不合格結果;
- 讓失效消費延遲超過門檻,確認快取讀取被門檻略過;
- 讓 2000 個 key 同時接近過期,確認抖動、singleflight 與資料庫艙壁控制尖峰;
- 讓 Redis 整體無法使用,確認展示流量依預算降級,權威讀取仍依業務規則執行。
核心不變量包括:快取回傳版本不得低於呼叫端 min_version;失效後舊世代不能寫回;權威業務動作不從展示快取授權;快取陳舊年齡不超過 5 秒;資料庫回填不超過已測試的安全並行數與 QPS。
高品質示範回答
「我不會先承諾資料庫與 Redis 每一瞬間相同,而會拆開讀取契約。商品名稱與圖片允許從 PostgreSQL 提交起最多舊 5 秒;庫存扣減、權限、餘額和結算繼續在權威儲存判斷。寫入者需要 read-your-writes 時,寫入 API 回傳來源版本,後續讀取帶最低版本;快取不夠新就讀主資料庫。
基本模式是 cache-aside。讀取命中且符合年齡與最低版本就回傳;miss 依 product_id 做 singleflight,再讀取帶單調版本的資料列並進行條件式回填。寫入在同一 PostgreSQL 交易中更新商品、遞增版本並寫 outbox。提交後應用程式先嘗試刪除 Redis;relay 或 CDC 根據持久事件重試失效,因此程序在提交後崩潰也不會永久遺失失效意圖。
寫入順序是先資料庫、後刪快取。先刪會讓讀取請求在資料庫提交前放回舊值。即使順序正確,仍有延遲回填:讀取者先拿到版本 41,寫入者提交 42 並刪除,舊讀取最後才 set 41。快取值帶版本仍不夠,因為刪除後沒有 42 可比較。我會讓 miss 取得 generation,失效時推進 generation 與最低版本,再刪除值;回填透過原子 script 驗證 generation 未改變且來源版本不低於柵欄,否則拒絕。複本無法證明已追到所需提交位置時,失效後回填讀主資料庫。
outbox 只能證明可復原的最終失效,不能單獨證明 5 秒。我會設定不超過預算並保留餘量的硬 TTL,監控提交到失效成功的延遲;延遲接近預算時略過快取。2000 個活躍 key 在 5 秒內都被存取,均勻回填約每秒 400 次,所以還要做 TTL 抖動、同 key 請求合併和資料庫並行艙壁。
驗證時我會精確控制執行緒順序,注入提交後崩潰、舊讀取晚到、重複亂序事件、Redis 刪除失敗和複本落後。驗收不只看命中率,而要斷言舊世代無法回填、來源版本不回退、陳舊年齡不超過 5 秒、權威動作略過展示快取,而且資料庫負載維持在已測預算內。」
常見錯誤
- 宣稱「資料庫與快取強一致」 → 沒有定義讀取結果、失敗時限和兩個系統的交易邊界 → 依有限陳舊、read-your-writes 與權威讀取分別提出契約。
- 先刪快取再更新資料庫 → 兩步之間的 miss 會讀取舊資料庫值並重新快取 → 先提交資料庫,再讓快取失效,並加入可靠修復。
- 資料庫提交後只送一筆記憶體訊息 → 程序在送出前崩潰會永久漏失效 → 同一交易寫 outbox,或從資料庫日誌擷取變更。
- 認為
DEL後就完全安全 → 先前開始的舊讀取可能在刪除後才完成 set → 用回填 lease,或刪除後仍存在的版本柵欄拒絕延遲舊值。 - 只在快取值加入版本號 → 快取為空時沒有較新版本可比較,舊版本可能被接受 → 把最低版本或 generation 放在獨立柵欄,並原子檢查回填。
- 把重複消費當成錯誤 → 消費者在確認前崩潰,自然會產生重複 → 讓刪除與最高版本推進冪等,依至少一次語意設計。
- 用了 outbox 就承諾 5 秒 → 可重試只保證最終處理,沒有有限時延 → 加入硬 TTL 或超預算略過門檻,並監控提交到失效的端到端延遲。
- 從任意唯讀複本回填 → 複本落後會在正確失效後重新快取舊資料 → 檢查 replay position、讀主資料庫,或要求達到最低來源版本。
- 所有欄位共用一份快取快照 → 展示資料的陳舊容忍會外溢到庫存、權限與結算 → 拆分資料契約,在不可逆動作中重新讀取權威狀態。
- Redis 故障就讓所有流量回源 → 2 萬次讀取可能壓垮資料庫 → 用艙壁、限流、明確降級和受控復原保護事實來源。
- 固定等待後做雙重刪除 → 等待無法涵蓋無上限延遲,第二次刪除也可能遺失 → 把它視為機率最佳化,保留持久失效、柵欄與有限時限後盾。
追問與應對
追問一:如果商品價格也要求 read-your-writes,該怎麼做?
寫入 API 回傳提交後的 sourceversion。同一工作階段接著讀取時攜帶 minversion;快取版本不足就讀主資料庫,成功後只條件式寫入新版本。若只是向寫入者顯示結果,也可以直接回傳剛提交的資料列並短時間略過快取。結算時仍應在權威交易中重新確認價格,不能把 read-your-writes 當成付款授權。
追問二:版本柵欄為何不能只放在快取值中?
失效會刪除快取值,普通比較接著就看不到版本 42。較早讀到版本 41 的請求,此時可能把 41 當成空 key 的新值寫入。獨立柵欄或 miss lease 在值刪除後仍保存「版本 42 已發生」或「原回填資格已失效」,才能拒絕延遲 set。
追問三:交易式 outbox 會不會重複送出?
會。relay 可能在下游接受事件後、把 outbox 標記完成前崩潰。消費者依 product_id 和來源版本冪等處理:舊版本不推進狀態,新版本推進最高版本並刪除值,重複刪除是安全的。需要承諾的是不靜默漏失效且可以對帳,不是恰好執行一次。
追問四:能否把 TTL 設為 30 分鐘,只靠 CDC 失效?
若業務只要求最終一致,並且能接受失效鏈路長時間故障,可以如此取捨。題目承諾最多陳舊 5 秒,30 分鐘 TTL 會在 CDC 停滯時違反上限。除非系統在失效延遲接近 5 秒時自動略過快取,否則必須保留不超過預算的硬時限。
追問五:複本通常只延遲幾十毫秒,為何還要讀主資料庫?
「通常」不是上限。部署、網路分割、長交易或復原都可能放大延遲。仍可使用複本,但要證明它的 replay position 已達所需提交位置,或讓讀取攜帶最低版本;無法證明時,關鍵 miss 改走主資料庫。選擇取決於 5 秒契約和資料庫容量。
追問六:為何不在一個分散式鎖中同時更新 PostgreSQL 和 Redis?
鎖只能限制遵守同一協定的參與者,不能讓兩個系統原子提交,也無法消除持鎖程序崩潰、鎖逾時、網路分割和外部寫入入口。資料庫交易先確定事實,持久失效修復處理第二個系統;鎖可以減少同 key 並行,卻不是一致性證明。
追問七:Redis 完全無法使用時,如何守住 5 秒?
展示讀取略過 Redis,但所有回源都要經過資料庫艙壁與限流;容量不足時回傳明確降級或失敗。若程序內舊副本仍在 5 秒預算內,可以短暫服務並標示降級,超過預算就不能繼續回傳。復原時限速預熱並繼續處理積壓失效,避免冷快取再次壓垮資料庫。
追問八:如何證明線上真的沒有長期舊值?
對抽樣 key 同時讀取資料庫來源版本和快取版本,記錄版本差與從來源提交起算的年齡;核對資料庫變更、outbox 和消費者最高位置;對失效端到端延遲、隔離佇列最舊年齡與超預算略過發出告警。再定期注入提交後崩潰、Redis 命令失敗與複本暫停,驗證門檻和 TTL 在真實故障下仍守住不變量。