跳到主要內容

系統設計

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

主題 · 快取寫入策略

快取寫入策略

寫入時要同時寫快取和資料庫(write-through)、只寫資料庫(write-around),還是先寫快取、晚點再寫回(write-back)?差在寫入延遲、資料庫負載,和當機時會丟多少資料。

寫入策略
工作負載
程式碼路徑
寫入延遲(平均)
3.6 ms
讀取延遲(平均)
7.8 ms
讀取命中率
58.4%
寫完馬上讀:命中
100.0%
資料庫寫入次數
2,495
直接讀資料庫:舊值
8.3%
最多有幾筆未寫回
89

寫入通常只寫快取;若淘汰髒資料,還要等資料庫寫回,平均寫入延遲為 3.6 ms。熱門鍵的多次寫入可以合併成一次寫回——資料庫寫入 2,495 次,write-through 是 5,643 次。在寫回前資料庫會落後:直接讀資料庫的,有 8.3% 讀到舊值;而且當機會遺失每個未寫回鍵的最新值。

Write-back:已經回覆成功、卻只存在快取裡的寫入
0448708,000 次操作第 250 次操作後:76 筆還沒寫回第 500 次操作後:82 筆還沒寫回第 750 次操作後:75 筆還沒寫回第 1,000 次操作後:73 筆還沒寫回第 1,250 次操作後:83 筆還沒寫回第 1,500 次操作後:79 筆還沒寫回第 1,750 次操作後:79 筆還沒寫回第 2,000 次操作後:85 筆還沒寫回第 2,250 次操作後:82 筆還沒寫回第 2,500 次操作後:82 筆還沒寫回第 2,750 次操作後:75 筆還沒寫回第 3,000 次操作後:86 筆還沒寫回第 3,250 次操作後:80 筆還沒寫回第 3,500 次操作後:79 筆還沒寫回第 3,750 次操作後:84 筆還沒寫回第 4,000 次操作後:81 筆還沒寫回第 4,250 次操作後:82 筆還沒寫回第 4,500 次操作後:76 筆還沒寫回第 4,750 次操作後:77 筆還沒寫回第 5,000 次操作後:79 筆還沒寫回第 5,250 次操作後:77 筆還沒寫回第 5,500 次操作後:72 筆還沒寫回第 5,750 次操作後:85 筆還沒寫回第 6,000 次操作後:78 筆還沒寫回第 6,250 次操作後:75 筆還沒寫回第 6,500 次操作後:79 筆還沒寫回第 6,750 次操作後:87 筆還沒寫回第 7,000 次操作後:80 筆還沒寫回第 7,250 次操作後:79 筆還沒寫回第 7,500 次操作後:79 筆還沒寫回第 7,750 次操作後:82 筆還沒寫回第 8,000 次操作後:73 筆還沒寫回當機:丟 81 筆
4,000 次

假設:8,000 次操作、1,000 個鍵、熱門程度依 Zipf 分布(s = 1);LRU 快取 100 個鍵;快取一次 0.5 ms、資料庫一次 10 ms;另有報表程式每 10 次操作直接從資料庫讀一個隨機的鍵。

延遲模型:髒資料淘汰時同步寫回,觸發淘汰的讀取或寫入要多付一次資料庫延遲。請求依序執行,沒有背景寫回。快取存取視為一次合併開銷;未計失效通知、排隊、鎖與最後 flush 的耗時。資料庫寫入次數不含最後 flush 和報表讀取;當機遺失數計算髒資料鍵,不是寫入操作次數。

亮起來的是這一步執行的程式碼
type Policy = "write-through" | "write-around" | "write-back";
class CachedStore {
cache = new Map<number, number>(); // LRU order: oldest first
dirty = new Map<number, number>(); // written to cache, not yet to db
db = new Map<number, number>();
dbWrites = 0;
constructor(private capacity: number, private policy: Policy) {}
read(key: number): number {
const cached = this.cache.get(key);
if (cached !== undefined) {
this.cache.delete(key);
this.cache.set(key, cached);
return cached;
}
const value = this.db.get(key) ?? 0;
this.fill(key, value);
return value;
}
write(key: number, value: number): void {
if (this.policy === "write-through") {
this.db.set(key, value);
this.dbWrites++;
this.fill(key, value);
} else if (this.policy === "write-around") {
this.db.set(key, value);
this.dbWrites++;
this.cache.delete(key);
} else {
this.fill(key, value);
this.dirty.set(key, value);
}
}
private fill(key: number, value: number): void {
this.cache.delete(key);
this.cache.set(key, value);
if (this.cache.size <= this.capacity) return;
const oldest = this.cache.keys().next().value!;
this.cache.delete(oldest);
if (this.dirty.has(oldest)) {
this.db.set(oldest, this.dirty.get(oldest)!);
this.dirty.delete(oldest);
this.dbWrites++;
}
}
flush(): void {
for (const [key, value] of this.dirty) {
this.db.set(key, value);
this.dbWrites++;
}
this.dirty.clear();
}
}

模型假設與範圍

  • 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
  • cache-aside 讀取與 write-through/around/back 的順序模型;未模擬兩個服務之間的原子交易、併發競爭與真實持久性。

什麼時候用

  • Write-through:寫完很快會被讀、而且不能接受資料庫落後(使用者設定、商品資訊)。
  • Write-around:寫了之後很少馬上被讀的資料(日誌、大量匯入、冷資料),避免把快取塞滿沒人讀的東西。
  • Write-back:寫入非常頻繁、可以接受少量遺失或有其他方式補救(計數器、按讚數、即時排行榜),或快取本身有持久化與複製。

和其他主題的關係

由這些組成
快取

時間與空間複雜度(Big O)

操作平均最差
寫入(三種策略)
描述的是範例中的快取操作;write-back 淘汰髒資料時也要等資料庫寫回
O(1)O(1)
讀取
沒命中時多一次資料庫讀取
O(1)O(1)
flush 所有未寫回的資料
d 是未寫回的筆數,最多等於快取容量 c
O(d)O(c)

空間:O(c),c 是快取容量;write-back 另外記住哪些是未寫回的

和其他做法比

寫入延遲讀取延遲命中率寫完馬上讀命中資料庫寫入直接讀到舊值結束時當機會丟
讀多(90% 讀) · Write-through10.5 ms4.7 ms57.6%100.0%8300.0%0
讀多(90% 讀) · Write-around10.0 ms5.2 ms53.2%0.0%8300.0%0
讀多(90% 讀) · Write-back1.0 ms5.3 ms57.6%100.0%4562.9%21
寫多(70% 寫) · Write-through10.5 ms4.7 ms58.4%100.0%5,6430.0%0
寫多(70% 寫) · Write-around10.0 ms8.1 ms24.1%0.0%5,6430.0%0
寫多(70% 寫) · Write-back3.6 ms7.8 ms58.4%100.0%2,4958.3%73
寫完馬上讀 · Write-through10.5 ms2.8 ms77.0%100.0%2,8780.0%0
寫完馬上讀 · Write-around10.0 ms7.5 ms29.6%0.0%2,8780.0%0
寫完馬上讀 · Write-back2.9 ms4.1 ms77.0%100.0%1,3716.9%70

三種策略跑同一串操作。延遲包含同步淘汰髒資料的寫回成本:write-back 能合併熱門鍵的寫入,但不保證每次都最快;快取小、淘汰頻繁時,讀寫都可能要等資料庫。資料庫寫入不含最後 flush;當機遺失計髒資料鍵;寫完馬上讀只計緊接著寫入同一鍵的讀取。

真實世界裡的它

  • CPU 的 L1/L2(第一、二層)快取大多是 write-back:寫入先留在快取,被換出時才寫回記憶體。
  • 作業系統的 page cache:write() 寫進記憶體就回傳,之後才寫到磁碟;要確保寫到磁碟得呼叫 fsync()。
  • 應用程式層多半是 cache-aside 搭配 write-around:寫資料庫、刪掉快取,下次讀取再重新載入。

取捨與陷阱

  • 先刪快取再寫資料庫會出錯:中間如果有讀取,會把舊值重新載回快取。要先寫資料庫,再刪快取。
  • Write-back 的快取一旦當機或被淘汰前來不及寫回,資料就沒了:要搭配持久化、複製,或接受遺失。
  • Write-through 寫資料庫成功、寫快取失敗時,兩邊會不一致:通常改成刪除快取而不是更新它。