01

總覽與定位

快取策略篇已把 Cache-Aside 的讀寫路徑講完了。本篇只處理下一題:miss 一旦變成「同時、大量、打空」,origin 會不會被打死。

快取正常時,幾乎所有讀都停在副本上。失效模式談的不是「某次 miss 多 80 ms」,而是 miss 的形狀突然改變——請求打到根本不存在的 key、一把熱 key 到期、或幾千把 key 同一秒過期。這三種形狀打到 origin 的方式不同,對應的防護也不同;混成一句「加個鎖」通常答不到點上。

打空 · 穿透
Cache Penetration
請求的 key 在 DB 裡從來沒有。Cache 永遠 miss,每次都打到 origin。掃描不存在的商品 ID、惡意隨機 key,都長這樣。
單點打穿 · 擊穿
Breakdown / Hot Key
一把極熱的 key 到期或被踢。原本被快取擋住的流量,同一瞬間全部回源。問題在「這一把」,不在「很多把」。
大量同時到期 · 雪崩
Cache Avalanche
很多把 key 的 TTL 對齊,或一次 flush。同一秒大量 miss,origin 看到的是「整個 working set 同時變冷」。

這三類都會被 Thundering Herd 放大:同一個 miss 上疊了 N 個併發請求,於是 origin 不是打 1 次,而是打 N 次。Singleflight/請求合併處理的是這個放大機制,不是上面三種根因本身。

座標路徑已經會了,本篇管災害形狀

快取策略 負責讀寫誰先走、ACK 在哪、TTL/LRU/LFU 踢誰——那些定義這裡不重述。本篇假設你已經會 Cache-Aside:miss → origin → SET。
Redis 的 maxmemory policy、大 key/熱 key 偵測與拆分,留給 Redis 架構與運維。邊緣的 stale-if-error、Purge、Origin Shield,留給 CDN 與邊緣快取。這裡只談應用層快取被打穿時,origin 前面還能擋什麼。

一句話答法先問 miss 的形狀:打空(穿透)、單點到期(擊穿)、還是集體到期(雪崩)。再決定 Bloom/空值、互斥重建,還是 TTL 抖動;Singleflight 是放大鏡的開關,不是根因解法。
02

穿透:請求打空

key 在權威來源裡不存在。快取沒東西可填(或填了立刻被下一次隨機 key 錯過),origin 為空氣付帳。

Cache-Aside 對「存在的熱資料」很有效:miss 一次、回填、之後命中。穿透把這個迴路拆掉——回填的對象根本沒有。每次 GET 都 miss,每次都打 DB,回傳 not found。攻擊者用隨機 ID 掃描、爬蟲打過期目錄、客戶端 bug 產生不存在的外鍵,流量形態都一樣。

sequenceDiagram
    participant C as 用戶端
    participant K as 快取
    participant D as DB
    C->>K: GET sku:????
    K-->>C: MISS
    C->>D: SELECT
    D-->>C: 0 rows
    Note over C,K: 沒有值可回填,下一次同樣走完整條
  

看最後那條 Note:Cache-Aside 的迴路靠「把 origin 的答案寫回快取」收斂,而空結果預設不會被寫回,所以這條往返每次都會重跑一遍。

1 · 空值快取(Negative Caching)

miss 且 DB 確認不存在時,把「空」本身寫進快取,帶比正常值更短的 TTL。下一次同一把 key 不會再打 origin。

適用
重複打同一批不存在的 key
  • 用戶重試、錯誤書籤、已下架但仍被分享的 URL
  • 實作便宜:Cache-Aside 多一個 SET nil
  • TTL 短,資料真的被建立後,空值很快過期
代價
記憶體 + 假陰性窗口
  • 空值也佔 slot。隨機 key 攻擊下,空值集合可以跟攻擊基數一樣大,把熱資料擠出去
  • 物品在空值 TTL 內被建立,讀到的仍是「不存在」——這是正確性窗口,要寫入路徑主動 DEL 空值
  • 對「每把 key 只打一次」的掃描幾乎無效

2 · Bloom Filter

先把「確定存在的 ID 集合」放進一個位元陣列。查詢時:

  • Filter 說不在:一定不在(無假陰性,前提是集合更新正確、且沒用會刪除的普通 Bloom)。直接回 not found,不打 origin。
  • Filter 說可能在:再走 Cache → DB。這步會有假陽性(False Positive)——不存在的 key 被當成「也許存在」,仍會打一次 origin。
FPR ≈ (1 − e−k · n / m)k

m 是位元數,n 是插入的真實 ID 數,k 是 hash 函數個數。誤判率由這三個數決定,不是魔法。

關鍵Bloom 擋的是「確定沒有」,不是「確定有」

面試常被反問:假陽性有什麼關係?答:假陽性 = 多打一次 origin,安全性仍在;若實作成「Filter 說沒有就當沒有」而集合漏插,才會變成假陰性——用戶看不到真實存在的資料。所以目錄變更時要重建或用 Counting Bloom/Cuckoo;刪除頻繁的集合,普通 Bloom 會越來越髒。

手段 擋什麼攻擊 主要代價 不該用的時候
空值快取 重複的同一批空 key 額外記憶體;建立後的假陰性窗口 key 幾乎不重複(純隨機掃描)
Bloom Filter 隨機、一次性的空 key m 的記憶體;FPR 仍漏打 origin;刪除/變更要重建 集合變化極快、或必須支援即時刪除卻不願重建
參數校驗 / 權限 格式非法、越權 ID 幾乎免費 ——這應該是第一道,不是快取手段

穿透的根因是「origin 在回答不存在」。先讓非法請求在應用層死掉;剩下的才用空值與 Bloom 分擔。

陷阱空值 TTL 設太長,等於短暫地「刪除」剛上架的資料

負快取與真實資料的寫入必須成對:建立/恢復一個 ID 時,DEL 對應空值,並更新 Bloom(或標記重建)。只在讀路徑塞 nil、寫路徑忘記,會出現「後台已上架、前台仍 404」——這比多打幾次 DB 更難查。

一句話答法穿透是打不存在的 key。重複空 key 用短 TTL 空值快取;隨機掃描用 Bloom 在 origin 前攔截。FPR 換的是少量多餘回源,漏插集合才會錯資料。
03

擊穿:單點被打穿

一把熱 key 到期或被驅逐。流量形狀從「幾乎全命中」瞬間變成「全部打同一條 origin 查詢」。

擊穿跟穿透相反:資料存在而且極熱。Cache-Aside 在 TTL 到期的那一刻,所有併發讀同時 miss,一起跑去重建。origin 上那條查詢若是聚合、多表 join、或要打下游,一台 DB 連線池可以在幾百毫秒內被打乾。

時刻
請求 A
其餘 N−1 個併發讀
origin
t−1
GET sku:hot → HIT
全部 HIT
閒置
t
TTL 到期,MISS
同一毫秒一起 MISS
收到 N 筆同樣的查詢
t+1
拿到結果,SET 回快取
各自也 SET 了一次
連線池被 N 筆重複查詢佔滿,尾延遲對齊到最慢那一筆

看 t 這一列:N 個請求算出來的是同一份結果,origin 卻付了 N 次帳——重複的不是資料,是查詢。

1 · 互斥重建鎖(Mutex / Single-flight lock)

只有拿到鎖的那一個請求去打 origin 並 SET;其他人等鎖,然後讀剛填好的快取。分散式場景常用 SET key NX PX 當鎖。

適用
重建貴、熱度集中在少數 key
  • 首頁聚合、庫存投影、權限決策快照
  • 能把 origin QPS 從 N 降到 1
  • 與 Singleflight 搭配:鎖是「誰去重建」,合併是「誰共用結果」
代價
鎖持有時間 = 重建延遲
  • 等待者的尾延遲被對齊到重建耗時;P99 會明顯變差
  • 持有者崩潰或超時:鎖要有 TTL,否則死鎖;TTL 太短會雙重建,太長會卡住
  • 沒搶到鎖的人若立刻 fallback 打 DB,鎖等於沒加

2 · 邏輯過期(Logical Expire / 軟 TTL)

快取值裡帶 expireAt。物理 TTL 設得比邏輯期限長(或不設)。讀到邏輯過期時:先把舊值回給這次請求,再非同步重建。用戶幾乎總是命中;過期只觸發背景更新。

關鍵邏輯過期是在買 stale,換 origin 平穩

這與 Write-Back 的 dirty 不同:權威來源仍是 DB,快取只是承認「我願意再賣幾百毫秒的舊首頁」。產品必須能說出可容忍 stale。一旦重建失敗,你還在賣舊值——要有失敗預算(重試、熔斷、降級預設值),不能假裝一定會更新成功。

手段 用戶讀到什麼 origin 在到期瞬間 主要代價
互斥重建 等鎖;成功後是新值 1 次查詢 尾延遲 = 鎖持有時間;要處理鎖失效
邏輯過期 立即拿到可能過期的舊值 背景 1 次 明確的 stale 窗口;重建失敗會一直舊
什麼都不做 同時 miss,大家一起等 origin N 次查詢 連線池與尾延遲一起炸

熱 key 的「拆分到多個 shard/多個 replica」屬於 Redis 運維與資料模型,本篇不展開;這裡只處理「到期那一瞬誰去重建」。

陷阱本地鎖擋不住多實例

每個 API 進程自己 synchronized,十個 pod 仍會打出十次 origin。互斥要放在快取層或用分散式鎖;Singleflight 若只在進程內,效果上限是「實例數 × 1」。面試被追問規模時,先說清楚鎖的範圍。

一句話答法擊穿是熱 key 到期。互斥鎖把重建收成 1 次、用尾延遲買單;邏輯過期繼續賣舊值、用 stale 買單。不要讓 N 個 miss 各自打 origin。
04

雪崩:大量同時到期

很多把 key 在同一時間窗變冷。origin 看到的不是一把熱查詢,而是 working set 整體回源。

雪崩常被講成「快取掛了」。快取進程崩潰確實會讓所有讀回源,但更常見、也更可預防的是 TTL 對齊:上線時用同一個 EXPIRE 3600 寫入整批物件,一小時後它們一起死。部署腳本 flush、同一輪預熱、甚至「整點過期」的產品習慣,都會製造對齊。

時間點 快取裡發生什麼 origin 看到的 QPS
09:00:00 上線預熱 整批 K 把 key 以同一個 EXPIRE 3600 寫入 一次可控的預熱突發
09:00:01 – 09:59:59 幾乎全命中 接近 0(只有零星背景 miss)
10:00:00 K 把 key 在同一秒一起到期 ≈ 讀 QPS 全額,且每把還要乘上自己的併發數

關鍵在第三列:中間那一小時的「零回源」不是穩定狀態,而是把帳單全部推到同一秒——TTL 對齊等於替 origin 預約了一根尖刺。

1 · TTL 抖動(Jitter)

真正寫入的過期時間不是常數,而是:

TTLactual = TTLbase ± random(0, jitter)

例如 base 3600 秒、jitter 600 秒,過期被攤到 50 分鐘到 70 分鐘的窗口。origin 的重建曲線從一根尖刺變成一道緩坡。

適用
大批量、同質、可容忍一點時效差
  • 商品詳情、設定檔、feed 投影
  • 幾乎零執行期成本:SET 時多一個隨機數
  • 與 Singleflight 正交:抖動減少「同時有多少把 key 死」,合併減少「每一把死的時候打幾次」
代價
新鮮度變得不可預測
  • 有的 key 比基準多活 jitter,stale 上限被拉開
  • 有的 key 提早過期,命中率略降、origin 多吃一點平穩流量
  • 不能解決「快取整個掛掉」——那要靠 origin 限流、降級、或多層快取

2 · 預熱(Warmup)

在流量到來前,由一個受控的 builder 把 working set 寫進快取。到期前也可以提前重建(主動 refresh),讓用戶路徑幾乎不 miss。

  • 適用:發版、快取叢集更換、已知的流量高峰(秒殺開始前)。origin 的尖峰被你安排在「可以加班」的時間。
  • 代價:預熱本身就是一次 origin 突發,要限速;資料若在預熱後變更,第一波用戶仍可能讀到舊快照;working set 估錯會預熱一堆沒人讀的 key。
關鍵擊穿是一把,雪崩是一批——Singleflight 救不了後者的全部

40 把 key 同時到期,即使每把都 Singleflight,origin 仍會收到 40 次重建。合併只把「每把 N 次」收成「每把 1 次」。要把 40 攤開,靠的是 TTL 抖動或錯開預熱。面試把兩個詞互換,會被追問「那 40 次誰來擋」。

陷阱flushall 是人為雪崩

線上為了「清髒資料」對整個 cache 下 flush,等於把 TTL 對齊到現在。正確做法是按 key 模式逐步失效、或發版用版本化前綴(v12:sku:)讓舊資料自然過期。防護策略擋不住自己下的指令。

一句話答法雪崩是 TTL 對齊(或整池消失)。寫入加 jitter 把重建攤開;高峰前預熱把 miss 從用戶路徑拿走。Singleflight 只收斂每一把的併發,不收斂「有多少把同時死」。
05

Thundering Herd 與 Singleflight

同一把 key 上的併發 miss,會把一次重建放大成 N 次。請求合併是放大倍數的開關。

Thundering Herd 不是第四種根因,是前三種的乘數。穿透時隨機 key 通常不重複,乘數接近 1,所以 Bloom 比合併有用。擊穿與雪崩時,同一把熱 key 上可能疊著幾百個等待中的讀,乘數就是那幾百。

請求合併(Singleflight / Request Coalescing)

進程(或快取節點)為每個 in-flight 的 key 留一個 waiter 清單:第一個 miss 去 origin,後來者掛在同一趟結果上。Go 的 singleflight、許多 HTTP 快取的 collapsing 都是這個模型。

和互斥鎖差在哪
合併 vs 互斥
  • 鎖回答「誰被允許打 origin」
  • 合併回答「沒去打的人如何拿到結果」
  • 只有鎖、沒有等待共用:輸家可能 timeout 或自己打 DB
  • 只有合併、沒有超時:贏家卡死,所有 waiter 一起卡死
代價
尾延遲對齊 + 範圍有限
  • 所有 waiter 的延遲 = 那一次重建延遲(尾部被「對齊」而不是「平均」)
  • 進程內合併的上限 = 實例數;跨實例要靠快取層或分散式協調
  • 超時後若所有人一起重試,Herd 在下一毫秒重現——重試必須抖動
故障形狀 Herd 乘數 合併能降多少 origin 還需要什麼
穿透(隨機空 key) ≈ 1(key 不重複) 幾乎 0 Bloom + 空值
擊穿(一把熱 key) ≈ 併發讀取數 N N → 1 邏輯過期可再把用戶路徑從「等」改成「賣舊」
雪崩(K 把同時死) 每把各有自己的 N KN → K TTL 抖動把 K 攤開;否則 origin 仍吃 K 次重建

看中間那欄:合併能降多少,完全取決於「同一把 key 上疊了幾個併發」——第 6 節的模擬器就是把這一欄的數字算出來給你看。

一句話答法Singleflight 把「同一把 key 的 N 次回源」收成 1 次,用等待者的尾延遲交換 origin 安全。它不擋隨機穿透,也不把雪崩的 K 把 key 變成 1。
06

互動 Demo:同一波流量,裸快取 vs 加防護

同一組流量與 TTL 設定,同時餵進左邊沒有任何防護的快取與右邊勾了防護的快取。整條回源時間序列一次算完,兩張面積圖上下並排、共用同一條時間軸與同一個刻度。

互動 · 回源負載並排 拖任何一個 slider 都立即重算整條序列
右側(防護後)啟用的手段
裸快取回源 防護後回源 超過 DB 容量門檻 限流丟掉的回源
上下兩條同軸的回源負載面積圖:上為裸快取、下為防護後,中間為每秒省下的回源

兩張面積圖都以自己的基線往上長、共用同一條秒軸與同一個刻度:上面那張越高就是 origin 吃越多。虛線是 DB 容量門檻,斜紋紅色是真的打穿的那幾秒;中間那條帶子是逐秒「省下的回源」,往上是省下、往下代表防護反而多打了。

指標裸快取加防護差
一句話答法同一波流量下,防護要對症:打空看 Bloom、單點到期看 Singleflight 或熱 key 背景刷新、集體到期看 TTL 抖動。抖動降的是峰值不是總量,限流降的是 origin 負載不是失敗率。
07

面試折衷與邊界

先分類 miss 形狀,再報手段與代價;Redis 拆熱 key、CDN 賣 stale-if-error,不要塞進同一段。

面試常問 建議答法骨架
穿透、擊穿、雪崩差在哪? 打空(key 不存在)/單點熱 key 到期/大批 TTL 對齊。形狀不同,手段不能互換。
Bloom Filter 的代價? 記憶體 m、假陽性仍漏打 origin、集合變更要重建。假陽性不損害正確性;漏插才會。
熱 key 到期怎麼辦? 互斥重建把 origin 收成 1,P99 變差;邏輯過期繼續賣舊值。拆 key、加 replica 是 Redis 題。
TTL jitter 為什麼有效? 把 K 把同時重建攤成一條緩坡。代價是新鮮度窗口變寬,且救不了整池崩潰。
Singleflight 能不能代替上面全部? 不能。它只把同一 key 的 N 收成 1。隨機穿透 N≈1;雪崩仍剩 K 次重建。
差異化本篇管什麼、不管什麼

管:三種 miss 形狀、空值快取與 Bloom 的誤判/記憶體、互斥鎖持有時間與邏輯過期的 stale、TTL 抖動與預熱、Singleflight 的乘數與範圍。不管:Cache-Aside 步驟與 LRU/LFU(快取策略);maxmemory、大 key/熱 key 拆分、Cluster(Redis 架構與運維);stale-if-error、Purge、邊緣 TTL(CDN 與邊緣快取)。

邊界面試別把 Redis 運維答進這題

被問雪崩時,不要展開 eviction policy 或 slot 遷移——那是「這台 Redis 怎麼活」的題。先把 miss 分類講對,主動一句「熱 key 拆分與 maxmemory 是下一層」,分數通常更高。

一句話答法先畫 miss 的形狀再選手段:打空用 Bloom/空值,單點用鎖或邏輯過期,集體到期用 jitter/預熱;Singleflight 只關乘數。