跳到主要內容

系統設計

把資料結構放大到好幾台機器

主題 · 快取的典型問題:擊穿、穿透、雪崩

快取的典型問題:擊穿、穿透、雪崩

命中率高不代表快取沒問題:熱門的鍵一過期,上千個請求同時打到資料庫(擊穿);查不存在的資料每次都穿過快取(穿透);大量鍵同時過期讓資料庫瞬間被淹沒(雪崩)。看每一種怎麼發生、怎麼擋。

問題
對策
1,000
資料庫載入
155
最多同時載入
55
要等的請求
155
回舊值
0
02856第 0 秒:0 次載入第 1 秒:0 次載入第 2 秒:0 次載入第 3 秒:0 次載入第 4 秒:0 次載入第 5 秒:53 次載入第 6 秒:0 次載入第 7 秒:0 次載入第 8 秒:0 次載入第 9 秒:0 次載入第 10 秒:55 次載入第 11 秒:0 次載入第 12 秒:0 次載入第 13 秒:0 次載入第 14 秒:0 次載入第 15 秒:47 次載入第 16 秒:0 次載入第 17 秒:0 次載入第 18 秒:0 次載入第 19 秒:0 次載入資料庫載入次數,每秒(共 20 秒)

這個鍵每 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 On = 500n = 5,000n = 50,000成長倍數:實測(理論)
不處理O(n)252412,468×99 (×100)
Single-flightO(1)111×1.0 (×1.0)

不處理時,過期後 50 ms 內到達的每個請求都去載入,所以流量大十倍、資料庫就被打十倍;single-flight 不管流量多大都只載入一次。

和其他做法比

後端負載尖峰代價
擊穿 · 不處理155同時 55 個155 個請求各自等 50 ms
擊穿 · Single-flight3同時 1 個155 個請求等同一次載入,最久 50 ms
擊穿 · 先回舊值3同時 1 個155 個回應是舊值
擊穿 · 提前更新4同時 1 個比 single-flight 多 1 次載入,0 個請求要等
穿透(攻擊者)· 不處理8,039422/s—
穿透(攻擊者)· 快取「不存在」8,039422/s快取多了 8,039 筆沒用的項目
穿透(攻擊者)· Bloom filter284/s6,250 bytes 的 filter,鍵新增時要更新
雪崩 · TTL 加減 0%4,577465/s—
雪崩 · TTL 加減 10%4,426217/s鍵的存活時間不再一致
雪崩 · TTL 加減 30%4,40293/s鍵的存活時間不再一致
熱鍵 · 不處理30,972/s3.10×—
熱鍵 · 本機快取7,056/s1.01×更新最多晚 1 秒才看得到
熱鍵 · 複製到每個分片10,056/s1.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 加減只能分散「同時寫入」造成的同時過期;快取整台重啟時,所有鍵都同時消失,要靠預熱或限流慢慢放量。