快取寫入策略
寫入時要同時寫快取和資料庫(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:已經回覆成功、卻只存在快取裡的寫入
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-through | 10.5 ms | 4.7 ms | 57.6% | 100.0% | 830 | 0.0% | 0 |
| 讀多(90% 讀) · Write-around | 10.0 ms | 5.2 ms | 53.2% | 0.0% | 830 | 0.0% | 0 |
| 讀多(90% 讀) · Write-back | 1.0 ms | 5.3 ms | 57.6% | 100.0% | 456 | 2.9% | 21 |
| 寫多(70% 寫) · Write-through | 10.5 ms | 4.7 ms | 58.4% | 100.0% | 5,643 | 0.0% | 0 |
| 寫多(70% 寫) · Write-around | 10.0 ms | 8.1 ms | 24.1% | 0.0% | 5,643 | 0.0% | 0 |
| 寫多(70% 寫) · Write-back | 3.6 ms | 7.8 ms | 58.4% | 100.0% | 2,495 | 8.3% | 73 |
| 寫完馬上讀 · Write-through | 10.5 ms | 2.8 ms | 77.0% | 100.0% | 2,878 | 0.0% | 0 |
| 寫完馬上讀 · Write-around | 10.0 ms | 7.5 ms | 29.6% | 0.0% | 2,878 | 0.0% | 0 |
| 寫完馬上讀 · Write-back | 2.9 ms | 4.1 ms | 77.0% | 100.0% | 1,371 | 6.9% | 70 |
三種策略跑同一串操作。延遲包含同步淘汰髒資料的寫回成本:write-back 能合併熱門鍵的寫入,但不保證每次都最快;快取小、淘汰頻繁時,讀寫都可能要等資料庫。資料庫寫入不含最後 flush;當機遺失計髒資料鍵;寫完馬上讀只計緊接著寫入同一鍵的讀取。
真實世界裡的它
- CPU 的 L1/L2(第一、二層)快取大多是 write-back:寫入先留在快取,被換出時才寫回記憶體。
- 作業系統的 page cache:
write()寫進記憶體就回傳,之後才寫到磁碟;要確保寫到磁碟得呼叫fsync()。 - 應用程式層多半是 cache-aside 搭配 write-around:寫資料庫、刪掉快取,下次讀取再重新載入。
取捨與陷阱
- 先刪快取再寫資料庫會出錯:中間如果有讀取,會把舊值重新載回快取。要先寫資料庫,再刪快取。
- Write-back 的快取一旦當機或被淘汰前來不及寫回,資料就沒了:要搭配持久化、複製,或接受遺失。
- Write-through 寫資料庫成功、寫快取失敗時,兩邊會不一致:通常改成刪除快取而不是更新它。