總覽與定位
快取策略篇已把 Cache-Aside 的讀寫路徑講完了。本篇只處理下一題:miss 一旦變成「同時、大量、打空」,origin 會不會被打死。
快取正常時,幾乎所有讀都停在副本上。失效模式談的不是「某次 miss 多 80 ms」,而是 miss 的形狀突然改變——請求打到根本不存在的 key、一把熱 key 到期、或幾千把 key 同一秒過期。這三種形狀打到 origin 的方式不同,對應的防護也不同;混成一句「加個鎖」通常答不到點上。
這三類都會被 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 前面還能擋什麼。
穿透:請求打空
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。
- 用戶重試、錯誤書籤、已下架但仍被分享的 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。
m 是位元數,n 是插入的真實 ID 數,k 是 hash 函數個數。誤判率由這三個數決定,不是魔法。
面試常被反問:假陽性有什麼關係?答:假陽性 = 多打一次 origin,安全性仍在;若實作成「Filter 說沒有就當沒有」而集合漏插,才會變成假陰性——用戶看不到真實存在的資料。所以目錄變更時要重建或用 Counting Bloom/Cuckoo;刪除頻繁的集合,普通 Bloom 會越來越髒。
| 手段 | 擋什麼攻擊 | 主要代價 | 不該用的時候 |
|---|---|---|---|
| 空值快取 | 重複的同一批空 key | 額外記憶體;建立後的假陰性窗口 | key 幾乎不重複(純隨機掃描) |
| Bloom Filter | 隨機、一次性的空 key | m 的記憶體;FPR 仍漏打 origin;刪除/變更要重建 | 集合變化極快、或必須支援即時刪除卻不願重建 |
| 參數校驗 / 權限 | 格式非法、越權 ID | 幾乎免費 | ——這應該是第一道,不是快取手段 |
穿透的根因是「origin 在回答不存在」。先讓非法請求在應用層死掉;剩下的才用空值與 Bloom 分擔。
負快取與真實資料的寫入必須成對:建立/恢復一個 ID 時,DEL 對應空值,並更新 Bloom(或標記重建)。只在讀路徑塞 nil、寫路徑忘記,會出現「後台已上架、前台仍 404」——這比多打幾次 DB 更難查。
擊穿:單點被打穿
一把熱 key 到期或被驅逐。流量形狀從「幾乎全命中」瞬間變成「全部打同一條 origin 查詢」。
擊穿跟穿透相反:資料存在而且極熱。Cache-Aside 在 TTL 到期的那一刻,所有併發讀同時 miss,一起跑去重建。origin 上那條查詢若是聚合、多表 join、或要打下游,一台 DB 連線池可以在幾百毫秒內被打乾。
看 t 這一列:N 個請求算出來的是同一份結果,origin 卻付了 N 次帳——重複的不是資料,是查詢。
1 · 互斥重建鎖(Mutex / Single-flight lock)
只有拿到鎖的那一個請求去打 origin 並 SET;其他人等鎖,然後讀剛填好的快取。分散式場景常用 SET key NX PX 當鎖。
- 首頁聚合、庫存投影、權限決策快照
- 能把 origin QPS 從 N 降到 1
- 與 Singleflight 搭配:鎖是「誰去重建」,合併是「誰共用結果」
- 等待者的尾延遲被對齊到重建耗時;P99 會明顯變差
- 持有者崩潰或超時:鎖要有 TTL,否則死鎖;TTL 太短會雙重建,太長會卡住
- 沒搶到鎖的人若立刻 fallback 打 DB,鎖等於沒加
2 · 邏輯過期(Logical Expire / 軟 TTL)
快取值裡帶 expireAt。物理 TTL 設得比邏輯期限長(或不設)。讀到邏輯過期時:先把舊值回給這次請求,再非同步重建。用戶幾乎總是命中;過期只觸發背景更新。
這與 Write-Back 的 dirty 不同:權威來源仍是 DB,快取只是承認「我願意再賣幾百毫秒的舊首頁」。產品必須能說出可容忍 stale。一旦重建失敗,你還在賣舊值——要有失敗預算(重試、熔斷、降級預設值),不能假裝一定會更新成功。
| 手段 | 用戶讀到什麼 | origin 在到期瞬間 | 主要代價 |
|---|---|---|---|
| 互斥重建 | 等鎖;成功後是新值 | 1 次查詢 | 尾延遲 = 鎖持有時間;要處理鎖失效 |
| 邏輯過期 | 立即拿到可能過期的舊值 | 背景 1 次 | 明確的 stale 窗口;重建失敗會一直舊 |
| 什麼都不做 | 同時 miss,大家一起等 origin | N 次查詢 | 連線池與尾延遲一起炸 |
熱 key 的「拆分到多個 shard/多個 replica」屬於 Redis 運維與資料模型,本篇不展開;這裡只處理「到期那一瞬誰去重建」。
每個 API 進程自己 synchronized,十個 pod 仍會打出十次 origin。互斥要放在快取層或用分散式鎖;Singleflight 若只在進程內,效果上限是「實例數 × 1」。面試被追問規模時,先說清楚鎖的範圍。
雪崩:大量同時到期
很多把 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)
真正寫入的過期時間不是常數,而是:
例如 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。
40 把 key 同時到期,即使每把都 Singleflight,origin 仍會收到 40 次重建。合併只把「每把 N 次」收成「每把 1 次」。要把 40 攤開,靠的是 TTL 抖動或錯開預熱。面試把兩個詞互換,會被追問「那 40 次誰來擋」。
線上為了「清髒資料」對整個 cache 下 flush,等於把 TTL 對齊到現在。正確做法是按 key 模式逐步失效、或發版用版本化前綴(v12:sku:)讓舊資料自然過期。防護策略擋不住自己下的指令。
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 都是這個模型。
- 鎖回答「誰被允許打 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 節的模擬器就是把這一欄的數字算出來給你看。
互動 Demo:同一波流量,裸快取 vs 加防護
同一組流量與 TTL 設定,同時餵進左邊沒有任何防護的快取與右邊勾了防護的快取。整條回源時間序列一次算完,兩張面積圖上下並排、共用同一條時間軸與同一個刻度。
兩張面積圖都以自己的基線往上長、共用同一條秒軸與同一個刻度:上面那張越高就是 origin 吃越多。虛線是 DB 容量門檻,斜紋紅色是真的打穿的那幾秒;中間那條帶子是逐秒「省下的回源」,往上是省下、往下代表防護反而多打了。
| 指標 | 裸快取 | 加防護 | 差 |
|---|
面試折衷與邊界
先分類 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 與邊緣快取)。
被問雪崩時,不要展開 eviction policy 或 slot 遷移——那是「這台 Redis 怎麼活」的題。先把 miss 分類講對,主動一句「熱 key 拆分與 maxmemory 是下一層」,分數通常更高。