快取的典型問題:擊穿、穿透、雪崩
命中率高不代表快取沒問題:熱門的鍵一過期,上千個請求同時打到資料庫(擊穿);查不存在的資料每次都穿過快取(穿透);大量鍵同時過期讓資料庫瞬間被淹沒(雪崩)。看每一種怎麼發生、怎麼擋。
問題
對策
1,000
資料庫載入
155
最多同時載入
55
要等的請求
155
回舊值
0
這個鍵每 5 秒過期一次;接下來 50 ms 內(第一個重新載入還沒回來),每個請求都沒命中、各自去資料庫載入:資料庫被打了 155 次,最多 55 個同時進行,其實只需要 3 次。
一個鍵每秒被讀 1,000 次(Poisson 到達),快取 5 秒;命中 1 ms,資料庫載入 50 ms。
亮起來的是這一步執行的程式碼
const CACHE_MS = 1; // One hot key. fillAt is when the reload in flight, if any, completes.class HotKeyCache { expiresAt: number; fillAt = Infinity; dbCalls = 0; constructor(public mode: string, public ttl: number, public loadMs: number, now = 0) { this.expiresAt = now + ttl; } settle(now: number): void { if (this.fillAt <= now) { this.expiresAt = this.fillAt + this.ttl; this.fillAt = Infinity; } } load(now: number): void { this.dbCalls++; this.fillAt = Math.min(this.fillAt, now + this.loadMs); } // random is a draw in [0, 1), used only by early refresh. Returns the latency. get(now: number, random: number): number { this.settle(now); const fresh = now < this.expiresAt; if (this.mode === "early" && fresh && this.fillAt === Infinity) { // XFetch: refresh early with a probability that rises near expiry. if (now - this.loadMs * Math.log(1 - random) >= this.expiresAt) this.load(now); return CACHE_MS; } if (fresh) return CACHE_MS; if (this.mode === "plain") { this.load(now); return this.loadMs; } if (this.mode === "stale") { if (this.fillAt === Infinity) this.load(now); return CACHE_MS; } if (this.fillAt === Infinity) { this.load(now); return this.loadMs; } return this.fillAt - now + CACHE_MS; }} // Stands between a lookup for a key the database lacks and the database.class PenetrationGuard { negative = new Map<string, number>(); constructor(public fix: string, public mightExist: (key: string) => boolean, public negativeTtl: number) {} lookupMissing(key: string, now: number): string { if (this.fix === "bloom" && !this.mightExist(key)) return "rejected"; if (this.fix === "negative") { const until = this.negative.get(key); if (until !== undefined && now < until) return "negative-hit"; this.negative.set(key, now + this.negativeTtl); } return "db"; }} // TTL with +/- jitter, so keys cached together do not expire together.function jitteredTtl(base: number, jitter: number, random: number): number { return Math.round(base * (1 + jitter * (2 * random - 1)));} // An app server's own copy of a hot key: at most one shard call per TTL.class LocalCache { expiresAt = -Infinity; shardCalls = 0; constructor(public ttl: number) {} get(now: number): string { if (now < this.expiresAt) return "local"; this.shardCalls++; this.expiresAt = now + this.ttl; return "shard"; }}模型假設與範圍
- 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
- Poisson 到達、命中 1 ms、重載 50 ms;每次比較用相同種子。Single-flight 只在這個共享快取實例內有效,未模擬跨程序合併、DB 飽和與分散式失效;serve stale 會犧牲新鮮度。
小挑戰
熱鍵到期時,把同時進行的資料庫重載限制在 1 個,而且不回傳過期值。
重載策略
同時重載峰值
110
調整選項,再檢查結果。
提示
讓相同鍵的請求等待同一次重載;立即回傳舊值則不符合這題的要求。
查看解答
選擇 single-flight 合併重載。等待者共享同一個結果;代價是重載期間的等待。提前刷新也可能在這個固定樣本達標,但沒有保證完全不等待。
什麼時候用
- 讀取很重的熱門鍵(首頁、熱門商品、設定值):用 single-flight 或先回舊值,避免它一過期就把資料庫打垮。
- 查詢的鍵來自使用者輸入(ID、短網址、優惠碼):快取「不存在」擋住重複查詢;面對攻擊要用 Bloom filter,並做輸入檢查與限流。
- 大量鍵同時寫入快取(部署、快取重啟、批次預熱):TTL 一律加上隨機的加減。
- 單一鍵就讓一個分片過載:短時間的本機快取,或把鍵複製成多份分散到不同分片。
和其他主題的關係
- 由這些組成
- 快取
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 熱門鍵過期,不處理 資料庫載入次數:r 是每秒請求數,L 是載入時間 | O(r·L) | O(r·L) |
| 熱門鍵過期,single-flight 一次載入;但仍有 O(r·L) 個請求在等 | O(1) | O(1) |
| 檢查 Bloom filter k 個雜湊 | O(k) | O(k) |
| 查「不存在」快取 | O(1) | O(1) |
空間:O(n + m),Bloom filter 每個存在的鍵約 10 bits(n 個鍵);「不存在」快取每個查過的假鍵一筆(m 個)
Big O 實測:n 變大時步數怎麼長
數的是:熱門鍵過期一次時,資料庫被載入的次數(n 是每秒請求數)
| Big O | n = 500 | n = 5,000 | n = 50,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 不處理 | O(n) | 25 | 241 | 2,468 | ×99 (×100) |
| Single-flight | O(1) | 1 | 1 | 1 | ×1.0 (×1.0) |
不處理時,過期後 50 ms 內到達的每個請求都去載入,所以流量大十倍、資料庫就被打十倍;single-flight 不管流量多大都只載入一次。
和其他做法比
| 後端負載 | 尖峰 | 代價 | |
|---|---|---|---|
| 擊穿 · 不處理 | 155 | 同時 55 個 | 155 個請求各自等 50 ms |
| 擊穿 · Single-flight | 3 | 同時 1 個 | 155 個請求等同一次載入,最久 50 ms |
| 擊穿 · 先回舊值 | 3 | 同時 1 個 | 155 個回應是舊值 |
| 擊穿 · 提前更新 | 4 | 同時 1 個 | 比 single-flight 多 1 次載入,0 個請求要等 |
| 穿透(攻擊者)· 不處理 | 8,039 | 422/s | — |
| 穿透(攻擊者)· 快取「不存在」 | 8,039 | 422/s | 快取多了 8,039 筆沒用的項目 |
| 穿透(攻擊者)· Bloom filter | 28 | 4/s | 6,250 bytes 的 filter,鍵新增時要更新 |
| 雪崩 · TTL 加減 0% | 4,577 | 465/s | — |
| 雪崩 · TTL 加減 10% | 4,426 | 217/s | 鍵的存活時間不再一致 |
| 雪崩 · TTL 加減 30% | 4,402 | 93/s | 鍵的存活時間不再一致 |
| 熱鍵 · 不處理 | 30,972/s | 3.10× | — |
| 熱鍵 · 本機快取 | 7,056/s | 1.01× | 更新最多晚 1 秒才看得到 |
| 熱鍵 · 複製到每個分片 | 10,056/s | 1.01× | 每次寫入要更新 8 份 |
都是上方示範的預設情境。擊穿、穿透、雪崩的負載是整段模擬中資料庫被打的次數,尖峰是「同時進行的載入」或「最糟的一秒」;熱鍵的負載是最熱分片每秒的請求,尖峰是它與平均的比值。
真實世界裡的它
- Go 的
golang.org/x/sync/singleflight就是 single-flight;Caffeine 的refreshAfterWrite和 HTTP 的stale-while-revalidate都是先回舊值、背景更新。 - 提前更新的公式出自 Vattani 等人 2015 年的〈Optimal Probabilistic Cache Stampede Prevention〉,又稱 XFetch。
- Redis 的
CLIENT TRACKING讓應用伺服器的本機快取在鍵變更時收到失效通知,縮短熱鍵本機快取的延遲。
取捨與陷阱
- single-flight 只在一台機器內有效:100 台應用伺服器還是會各自載入一次;要跨機器合併,得用分散式鎖或先回舊值。
- 「不存在」快取的 TTL 太長,剛新增的資料會一直查不到;新增資料時要一併刪掉它的「不存在」項目。
- Bloom filter 不能刪除:資料刪除後它仍說「可能存在」,要定期重建或改用 counting Bloom filter。
- TTL 加減只能分散「同時寫入」造成的同時過期;快取整台重啟時,所有鍵都同時消失,要靠預熱或限流慢慢放量。