01

總覽與定位

快取是一份非權威、可隨時丟棄的副本。本篇只談讀寫路徑怎麼走、一致性縫隙從哪來、以及滿了之後踢誰——不談複寫協定,也不談雪崩怎麼防。

資料庫是權威來源(Source of Truth):它必須能回答「現在正確的值是什麼」,也必須在程序重啟後還在。快取不是。它存在的理由只有三個字能概括:少打來源——少一次跨機房往返、少一次磁碟讀、少一次計費的 origin 請求。一旦這筆帳算不過來,就不該快取。

讀路徑
誰在 miss 時回源、誰把值填進去
Cache-Aside 由應用自己打 DB 再回填;Read-Through 把回源藏進快取層。決定的是「應用要不要認得 DB」。
寫路徑
ACK 發生在哪一步、誰可能丟資料
Write-Through 等 DB 成功才回;Write-Back 先寫快取再異步刷盤;Write-Around 寫入繞過快取。Stale window 幾乎都從這裡長出來。
容量與壽命
TTL · LRU · LFU
記憶體有限,每筆資料都要有被丟掉的理由。TTL 是時間預算;LRU/LFU 是「留下誰」的賭局。命中率是結果,不是目標本身。
座標快取不是複寫,也不是 CDN

資料複寫基礎概念 那篇講的是權威副本之間如何同步、誰能接受寫入。快取是另一種副本:過期、被踢、程序重啟都可以整份丟掉,應用必須能從 DB 重建。本篇借用這個視角,不講 Leader/Follower、也不講 Quorum。
本群後續才展開防護與產品化:穿透/擊穿/雪崩與 Thundering Herd 交給 快取失效模式與雪崩防護;Redis 的資料結構、持久化與 Cluster 交給 Redis 架構與運維;HTTP 快取標頭與邊緣節點交給 CDN 與邊緣快取。如何把 key 攤到多台 cache node,則已在 一致性雜湊 處理。

一句話答法快取 = 可丟棄的熱副本。選模式先畫讀路徑與寫路徑兩張圖,再問「stale 能忍多久」與「cache 掛了會丟什麼」。
02

讀路徑:Cache-Aside 與 Read-Through

Miss 時誰去打 DB、誰負責把結果放進快取——這一步決定應用與快取的耦合,也決定快取掛掉時讀請求還能不能走。

1 · Cache-Aside(Lazy Loading)

應用擁有完整讀路徑。這是面試預設答案,也是 Redis 最常見的用法。

sequenceDiagram
    participant A as 應用
    participant C as 快取
    participant D as 資料庫
    A->>C: GET key
    C-->>A: MISS
    A->>D: SELECT key
    D-->>A: 值 A
    A->>C: SET key = A
    A-->>A: 回傳 A

三條往返全部由應用發起:快取與資料庫都是它的下游,所以回填失敗時它仍握有可以回傳的值。

步驟順序固定:先問快取,沒有才問權威來源,再回填。應用必須同時認得 Cache 與 DB 的介面。好處是快取掛了可以降級直打 DB(可用性高、延遲變差);壞處是回填、失效、編碼格式全散落在業務程式碼。

2 · Read-Through

應用只面對快取。Miss 時由快取層(或它前面的 library/proxy)自己去 load loader、填入、再回給呼叫端。

步驟
應用
快取(含 loader)
資料庫
t1
GET key
查表:MISS
—
t2
仍在等同一次 GET
loader 代為回源
SELECT key → 值 A
t3
收到 A
填入後回傳
—

看 t2 那一列:應用那一欄是空的——回源整段發生在快取內部,所以快取進程掛掉時,讀請求沒有旁路可走。

呼叫端變乾淨,回源策略可以集中(超時、重試、單 key 合併——那些手段留給失效模式那篇)。代價是:快取變成讀路徑上的必經節點。Cache 進程掛掉,讀就停,除非你另外做旁路。Read-Through 幾乎總是跟 Write-Through 或 Write-Back 成對出現,很少單獨搭配「應用自己寫 DB」。

讀路徑上的 stale
冷讀本身不製造過期
  • 只要沒人寫,Cache-Aside 與 Read-Through 讀到的都是剛剛從 DB 填入的值。
  • Stale window 是被另一次寫入打開的:快取裡還是舊值,DB 已經是新值。
  • 讀路徑能做的只有:縮短這個窗口(寫時刪/寫時更新)或給它上限(TTL)。
失敗時讀到什麼
Cache 掛 vs DB 掛
  • Cache 掛:Cache-Aside 變直打 DB;Read-Through 若無旁路就讀失敗。
  • DB 掛、Cache hit:兩邊都能繼續吐舊值——這是快取「暫時頂住」的來源,也是你在賣過期資料。
  • DB 掛、Cache miss:兩邊都讀不到。快取救不了沒預熱過的 key。

左欄說 stale 只會被「另一次寫入」打開;右欄說故障時讀到什麼取決於是誰掛了——兩欄合起來就是讀路徑的全部風險面。

陷阱回填失敗不要假裝命中

Miss 後 DB 讀成功、但 SET cache 失敗,正確行為是把 DB 的值回給這次請求,快取維持空洞。下一次還是 miss,而不是回傳「我以為寫進去了」的幽靈值。把回填失敗當成硬錯誤而讓整次讀失敗,會把快取可用性問題放大成產品錯誤。

一句話答法Cache-Aside:應用 miss 後自己打 DB 再回填,快取可丟。Read-Through:應用只打快取,loader 藏在裡面——乾淨,但快取變成讀路徑單點。
03

寫路徑:Aside 失效、Write-Through、Write-Back、Write-Around

寫入的 ACK 點決定延遲、耐久與 stale。把「先寫誰、何時刪快取、掛了丟什麼」講清楚,比背模式名字重要。

1 · Cache-Aside 的寫:更新 DB,再讓快取失效

經典寫法不是「寫 DB 再寫 cache」,而是 Write DB → Delete cache key。下次讀走 miss 回填,自然拿到新值。若改成「寫 DB 再 SET cache」,兩次寫入之間任何失敗都會留下 DB 與 cache 長期分歧,而且你還得處理編碼、TTL 與部分更新。

Stale window 就在這兩步之間,通常是毫秒級。真正危險的是刪除失敗:DB 已是 B,cache 裡的 A 會一直活到 TTL。這時 TTL 不是效能參數,是一致性的最後保險。

2 · Write-Through

應用寫入快取;快取同步把同一筆寫進 DB,兩邊都成功才 ACK。

寫延遲 ≈ cache + DB。換來的是:讀幾乎總能 hit 剛寫的值、cache 掛了資料仍在 DB。適合「讀多、寫不能丟、可接受寫變慢」——例如帳戶餘額的讀側加速,但仍以 DB 為準。

3 · Write-Back(Write-Behind)

應用寫入快取後立刻 ACK;DB 由背景排程刷入。Cache 條目帶 dirty bit。

陷阱Write-Back 的權威暫時不在 DB

Flush 之前,DB 是舊的。任何繞過快取的讀(報表、ETL、另一個沒打 cache 的服務)都會讀到 A。Cache 在 flush 前崩潰 = 丟失已 ACK 給客戶端的寫入。 這不是「可能不一致」,是耐久性失敗。Write-Back 只適合能容忍丟失、或快取本身有 WAL/複寫的場景(本地 page cache、某些 LSM 的 memtable)——普通 Redis 單機當 Write-Back 緩衝,面試裡通常是扣分答案。

4 · Write-Around

寫入只打 DB,不碰快取。用意是避免把「寫完很少再讀」的資料佔滿熱點集合(例如一次性匯入、日誌型更新)。

若 key 本來就在 cache 裡,Write-Around 會製造最長、最容易被忽略的 stale window。實務上寫-around 幾乎一定要搭配「順便 DEL」或短 TTL;否則它只適合「這個 key 根本不該在 cache」的寫入。

模式 寫入 ACK 點 Stale window 從哪來 Cache 崩潰會怎樣 典型動機
Cache-Aside + DEL DB 成功(再刪 cache) DB 已新、DEL 尚未完成;DEL 失敗則拖到 TTL 不丟寫入;只是下一讀 miss 預設選擇、應用可控
Write-Through Cache 與 DB 都成功 幾乎沒有(寫路徑同步) 不丟寫入;讀改打 DB 或等回填 讀要新、寫可較慢
Write-Back 只寫入 cache DB 在 flush 前一直舊 可能丟失已 ACK 的寫入 極高寫入、可丟或快取本身耐久
Write-Around 只寫入 DB cache 舊值活到 TTL/驅逐 不丟寫入;可能長期讀到舊值 避免寫入污染 working set

ACK 點愈靠近快取,寫愈快、丟資料風險愈高;愈靠近 DB,愈耐久、stale 愈短、寫延遲愈高。

互動 · 兩種寫路徑跑同一組操作 挑兩種模式、拖 RTT 與崩潰點,兩欄同時重算
快取往返 ACK 給客戶端 該步結束時 cache ≠ DB 快取崩潰

左欄

右欄

同一行比左右兩欄:延遲來自「ACK 之前經過幾次往返 × 每次的 RTT」,stale 長度來自「快取被更新或被刪掉了沒有」,遺失寫入只會出現在 ACK 早於落盤的那一欄。

拖動任一旋鈕,兩欄會同時重算。
判定待算。

序列由工作負載樣式循環展開(讀多寫少=R R W R R、寫多讀少=W W R W R、寫後立即讀=W R W R);每次寫入產生下一個值(A→B→C…)。延遲=ACK 前的快取往返 × Cache RTT + DB 往返 × DB RTT;Write-Back 的背景刷盤不計入客戶端延遲。Read-Through 這一欄假設應用自己寫 DB 再讓快取失效,好讓它與 Cache-Aside 只差在讀路徑。

一句話答法Aside 寫 DB 再 DEL cache;Through 同步雙寫才 ACK;Back 先 ACK 再刷盤(崩潰可能丟寫);Around 只寫 DB、cache 靠 TTL 自己過期。
04

TTL 與 Eviction:命中率是容量的函數

快取會滿。滿了之後不是「再買記憶體」一句話能結束——你在選擇誰被犧牲,以及過期資料最多能活多久。

兩件獨立的事常被混成一件。 TTL 回答「這筆資料最多能舊多久」;Eviction 回答「容量不夠時踢誰」。TTL 到期的 key 即使還有空位也會消失;Eviction 則是在 TTL 還沒到、但記憶體先到時發生。

有效壽命 = min(TTL 剩餘, 被驅逐前的停留時間)

1 · TTL 是 stale 的預算,不是命中率旋鈕

把 TTL 設成 24 小時,意思是你願意讓讀到最多一天前的值。它同時也是 Aside 刪除失敗時的安全網。把 TTL 調長可以提高命中率,代價是更舊的資料與更難除錯的「為什麼畫面上還是舊價」。

  • 絕對 TTL:從寫入起算,讀命中不延長。適合「這個值在 T 之後一定錯」。
  • 滑動 TTL:每次 hit 重設。熱 key 可以幾乎不過期——也因此更需要 Eviction 來限制記憶體。

同一批 key 設成同一個整秒過期,會讓回源對齊成尖峰。那是雪崩的原料,本篇只點到為止:TTL 要有散佈(jitter),細節留給失效模式那篇。

2 · LRU / LFU / 純 TTL 在賭不同的局部性

策略 留下誰 賭對的流量 賭錯的流量
LRU 最近被碰到的 時間局部性:剛讀的很快再讀(session、動態首頁) 掃描型讀(一次掃過歷史資料)會把熱點洗掉
LFU 累計最常被碰到的 穩定熱點(熱門商品、設定檔) 過氣熱點佔著位置;新熱點要累積次數才進得去
TTL only 還沒到期的(先到期先走,或隨機) 每筆資料有明確時效、容量大致夠用 容量不足時,不熱的 key 跟熱 key 一起活到點

沒有「命中率最高的演算法」,只有「與存取分布匹配的演算法」。掃描污染用 LRU 會很慘;LFU 則要處理頻率老化,否則去年的熱搜會永遠不走。

關鍵先量 working set,再談演算法

若熱點集合永遠大於快取容量,換 LRU 或 LFU 只是換一種 miss 圖案,命中率不會奇蹟上升。面試裡先說:估計 working set 大小 × 物件平均體積,對上記憶體預算;容量明顯不夠就分層(本地小 LRU + 遠端大快取)或承認這份資料不該全部進 cache。演算法是微調,容量是一階項。

這一節的每一句斷言都可以被量出來:本篇文末的互動 demo 會把同一條存取序列同時餵給 LRU/LFU/FIFO,讓你把容量與偏斜推到兩端,看命中率、熱 key 被踢次數與 miss 的組成如何隨之改變。

一句話答法TTL 限制能有多舊;Eviction 限制能留多大。LRU 賭最近、LFU 賭最常;容量小於 working set 時,換演算法救不了命中率。
05

這份資料該不該快取

不是熱就快取。先問讀寫比、能忍多舊、物件有多大、以及 miss 與 stale 哪個比較貴。

傾向快取
高讀、可舊、小物件、miss 很貴
設定檔、商品詳情、熱門 feed 頁、權限決策的投影、計算昂貴的聚合結果。讀/寫比高,且過期數秒到數分鐘可接受。
傾向不快取
高寫、必須新、巨大、或根本不重複讀
一次性匯入、每請求都不同的個人化列、大 blob、強一致餘額(應由 DB 或專門帳本服務保證)。寫入會不斷失效,命中率趨近零,還多一次一致性負擔。
可以快取,但要短 TTL 或專用模式
偶發寫、讀要「夠新」
庫存、價目、會話狀態。用 Aside + 短 TTL,或寫路徑顯式 DEL。不要用 Write-Back 除非你能接受丟更新。
安全與正確性否決
機密、授權、一次性 token
被快取的副本多一個洩漏面,也多一個「撤銷後還命中」的窗口。這類資料要嘛不快取,要嘛 TTL 極短且失效必須同步成功。

把判準收成四個數字比空談「熱資料」有用:

  1. 讀/寫比:寫一次會作廢多少次潛在 hit?比接近 1 就別快取。
  2. 可容忍 stale:產品能接受的最大過期時間,直接變成 TTL 上限。
  3. 物件大小 × 基數:全量放得下嗎?放不下就只快取熱點,或只快取投影欄位。
  4. miss 代價 vs stale 代價:miss 是 80 ms 的 DB,還是一次模型推論?stale 是錯一個推薦,還是錯一筆扣款?
陷阱「先快取再說」會把 DB 的正確性問題搬到過期策略

快取一旦上線,除錯單元就從「DB 裡是什麼」變成「這個 key 現在是哪一版、何時寫入、誰該刪它」。沒有 key 命名規範、沒有 TTL 預算、沒有寫路徑的 DEL,最後一定靠重啟 cache 當修復手段。那不是運維彈性,是設計失敗。

一句話答法該快取 iff 讀遠多於寫、stale 可預算化、物件放得下、且 miss 比過期更貴。扣款與機密預設不進 cache。
06

互動 · 淘汰參數掃描:把容量與偏斜推到兩端

同一條存取序列,同時餵給 LRU、LFU 與 FIFO。把容量與偏斜推到兩端,看命中率、熱 key 被踢次數與 miss 的組成怎麼變。

互動 · TTL 與 Eviction 參數掃描 拖任何一個旋鈕,三條策略即時重跑
命中(最矮) 未命中·仍有空位 未命中·踢掉別人(最高) 這次存取的是熱 key

三列共用同一條存取序列與同一個 x 軸:刻度愈高代表這次存取愈貴(命中最矮、踢人最高),刻度下方的針標出被踢掉的 key;紅針代表被踢的是熱 key。右下的方塊是序列跑完後還留在槽位裡的東西。

「首訪 miss」對三種策略永遠相同(誰都不可能命中沒載入過的 key);三者的差距只出現在「重取 miss」那一行——那一行有多大,才決定換演算法值不值得。

拖動旋鈕即可重算。

存取序列由參數以固定種子的線性同餘產生器生成(種子固定,故同一組參數永遠得到同一條序列):key 基數的前 25% 視為熱點集合,熱點佔比是每次存取落在熱點集合的機率;「熱點+掃描污染」會在每 12 次存取中插入 5 次一次性的冷 key 掃描。TTL 以存取次數計,屬絕對 TTL(寫入起算,命中不延長)。

一句話答法把容量拖過 working set,三條策略會合而為一;容量不足時,LFU 靠頻次保住熱 key,LRU 與 FIFO 則會被掃描流量洗掉——而 TTL 夠短的話,三者一起失效。
07

面試折衷與邊界

先畫兩條路徑,再講 TTL 與容量;防護手段與 Redis/CDN 產品細節留給後面的專篇。

面試常問 建議答法骨架
為什麼要快取? 來源延遲、負載與成本。快取是可丟棄副本,不是第二個資料庫。
Cache-Aside 怎麼寫才不容易髒? 讀:miss → DB → SET。寫:DB 成功後 DEL(不要雙寫);DEL 失敗靠 TTL 兜底。
Write-Through 和 Write-Back 怎麼選? 不能丟寫入、可接受寫延遲 → Through。寫爆、且快取本身耐久或允許丟 → Back。普通 Redis 單機當 Back 通常不該。
LRU 一定比較好嗎? 先看 working set 是否放得下。掃描流量用 LRU 會污染;穩定熱點 LFU 較穩。容量不夠時換演算法無效。
什麼資料不該快取? 高寫、必須強一致、超大物件、不重複讀、以及撤銷後不能再命中的機密/授權。
差異化本篇管什麼、不管什麼

管:五種讀寫模式的步驟、stale 從哪來、失敗時讀到或丟失什麼、TTL/LRU/LFU 的容量取捨、該不該快取的判準。不管:穿透、擊穿、雪崩、Singleflight(快取失效模式與雪崩防護);Redis 指令複雜度、RDB/AOF、Cluster slot(Redis 架構與運維);Cache-Control、邊緣 stale-if-error(CDN 與邊緣快取)。與 資料複寫基礎概念 的界線:那裡的副本是權威的、有協定;這裡的副本隨時可以丟。

邊界面試別把下一題提前答完

被問 Cache 模式時,不要展開 Bloom Filter、熱 key 互斥鎖或 TTL jitter 的實作——那是「miss 變成災害」的防護題。先把路徑與 ACK 講對,再主動一句「流量打空/同時失效是另一組手段」,分數通常更高。

一句話答法先畫讀/寫兩張圖選模式,再用 TTL 預算 stale、用容量對 working set;Redis、CDN 與雪崩防護不要塞進同一段答案。