總覽與定位
快取是一份非權威、可隨時丟棄的副本。本篇只談讀寫路徑怎麼走、一致性縫隙從哪來、以及滿了之後踢誰——不談複寫協定,也不談雪崩怎麼防。
資料庫是權威來源(Source of Truth):它必須能回答「現在正確的值是什麼」,也必須在程序重啟後還在。快取不是。它存在的理由只有三個字能概括:少打來源——少一次跨機房往返、少一次磁碟讀、少一次計費的 origin 請求。一旦這筆帳算不過來,就不該快取。
資料複寫基礎概念 那篇講的是權威副本之間如何同步、誰能接受寫入。快取是另一種副本:過期、被踢、程序重啟都可以整份丟掉,應用必須能從 DB 重建。本篇借用這個視角,不講 Leader/Follower、也不講 Quorum。
本群後續才展開防護與產品化:穿透/擊穿/雪崩與 Thundering Herd 交給 快取失效模式與雪崩防護;Redis 的資料結構、持久化與 Cluster 交給 Redis 架構與運維;HTTP 快取標頭與邊緣節點交給 CDN 與邊緣快取。如何把 key 攤到多台 cache node,則已在 一致性雜湊 處理。
讀路徑: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、填入、再回給呼叫端。
看 t2 那一列:應用那一欄是空的——回源整段發生在快取內部,所以快取進程掛掉時,讀請求沒有旁路可走。
呼叫端變乾淨,回源策略可以集中(超時、重試、單 key 合併——那些手段留給失效模式那篇)。代價是:快取變成讀路徑上的必經節點。Cache 進程掛掉,讀就停,除非你另外做旁路。Read-Through 幾乎總是跟 Write-Through 或 Write-Back 成對出現,很少單獨搭配「應用自己寫 DB」。
- 只要沒人寫,Cache-Aside 與 Read-Through 讀到的都是剛剛從 DB 填入的值。
- Stale window 是被另一次寫入打開的:快取裡還是舊值,DB 已經是新值。
- 讀路徑能做的只有:縮短這個窗口(寫時刪/寫時更新)或給它上限(TTL)。
- Cache 掛:Cache-Aside 變直打 DB;Read-Through 若無旁路就讀失敗。
- DB 掛、Cache hit:兩邊都能繼續吐舊值——這是快取「暫時頂住」的來源,也是你在賣過期資料。
- DB 掛、Cache miss:兩邊都讀不到。快取救不了沒預熱過的 key。
左欄說 stale 只會被「另一次寫入」打開;右欄說故障時讀到什麼取決於是誰掛了——兩欄合起來就是讀路徑的全部風險面。
Miss 後 DB 讀成功、但 SET cache 失敗,正確行為是把 DB 的值回給這次請求,快取維持空洞。下一次還是 miss,而不是回傳「我以為寫進去了」的幽靈值。把回填失敗當成硬錯誤而讓整次讀失敗,會把快取可用性問題放大成產品錯誤。
寫路徑: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。
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 愈短、寫延遲愈高。
左欄
右欄
同一行比左右兩欄:延遲來自「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 只差在讀路徑。
TTL 與 Eviction:命中率是容量的函數
快取會滿。滿了之後不是「再買記憶體」一句話能結束——你在選擇誰被犧牲,以及過期資料最多能活多久。
兩件獨立的事常被混成一件。 TTL 回答「這筆資料最多能舊多久」;Eviction 回答「容量不夠時踢誰」。TTL 到期的 key 即使還有空位也會消失;Eviction 則是在 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 則要處理頻率老化,否則去年的熱搜會永遠不走。
若熱點集合永遠大於快取容量,換 LRU 或 LFU 只是換一種 miss 圖案,命中率不會奇蹟上升。面試裡先說:估計 working set 大小 × 物件平均體積,對上記憶體預算;容量明顯不夠就分層(本地小 LRU + 遠端大快取)或承認這份資料不該全部進 cache。演算法是微調,容量是一階項。
這一節的每一句斷言都可以被量出來:本篇文末的互動 demo 會把同一條存取序列同時餵給 LRU/LFU/FIFO,讓你把容量與偏斜推到兩端,看命中率、熱 key 被踢次數與 miss 的組成如何隨之改變。
這份資料該不該快取
不是熱就快取。先問讀寫比、能忍多舊、物件有多大、以及 miss 與 stale 哪個比較貴。
把判準收成四個數字比空談「熱資料」有用:
- 讀/寫比:寫一次會作廢多少次潛在 hit?比接近 1 就別快取。
- 可容忍 stale:產品能接受的最大過期時間,直接變成 TTL 上限。
- 物件大小 × 基數:全量放得下嗎?放不下就只快取熱點,或只快取投影欄位。
- miss 代價 vs stale 代價:miss 是 80 ms 的 DB,還是一次模型推論?stale 是錯一個推薦,還是錯一筆扣款?
快取一旦上線,除錯單元就從「DB 裡是什麼」變成「這個 key 現在是哪一版、何時寫入、誰該刪它」。沒有 key 命名規範、沒有 TTL 預算、沒有寫路徑的 DEL,最後一定靠重啟 cache 當修復手段。那不是運維彈性,是設計失敗。
互動 · 淘汰參數掃描:把容量與偏斜推到兩端
同一條存取序列,同時餵給 LRU、LFU 與 FIFO。把容量與偏斜推到兩端,看命中率、熱 key 被踢次數與 miss 的組成怎麼變。
三列共用同一條存取序列與同一個 x 軸:刻度愈高代表這次存取愈貴(命中最矮、踢人最高),刻度下方的針標出被踢掉的 key;紅針代表被踢的是熱 key。右下的方塊是序列跑完後還留在槽位裡的東西。
「首訪 miss」對三種策略永遠相同(誰都不可能命中沒載入過的 key);三者的差距只出現在「重取 miss」那一行——那一行有多大,才決定換演算法值不值得。
存取序列由參數以固定種子的線性同餘產生器生成(種子固定,故同一組參數永遠得到同一條序列):key 基數的前 25% 視為熱點集合,熱點佔比是每次存取落在熱點集合的機率;「熱點+掃描污染」會在每 12 次存取中插入 5 次一次性的冷 key 掃描。TTL 以存取次數計,屬絕對 TTL(寫入起算,命中不延長)。
面試折衷與邊界
先畫兩條路徑,再講 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 講對,再主動一句「流量打空/同時失效是另一組手段」,分數通常更高。