你有一個 DB 當真相來源,又想用快取加速。兩個一起用,核心問題從頭到尾只有一個:怎麼讓快取不要太髒——跟 DB 差太多的快取,比沒有快取更危險,因為它會理直氣壯地回你錯的資料。

四種 pattern 就是四種「管理髒的策略」。先把最重要的講完,再快速掃其他三個——因為實務上你九成的時間只會用第一個。


Cache-Aside:預設答案

讀的路徑:先讀快取 → 命中就回 → miss 就查 DB → 回填快取(設 TTL)→ 回傳。 快取自己不會去載資料,是 app 負責「旁路」地維護它,所以叫 aside。用 Laravel 的話一行就見過它:Cache::remember($key, $ttl, fn () => DB查詢)——這個 helper 包的正是 cache-aside 的讀路徑。

寫的路徑有一條黃金順序:先更新 DB,再「刪掉」快取。 兩個字都有講究——

為什麼是「刪」不是「更新」? 兩個寫入併發時,「更新快取」可能交錯執行、把舊值蓋回去;刪掉最乾淨,下一個讀的人自然會用 DB 的最新值回填。

為什麼「DB 先、快取後」? 反過來的話(先刪快取再寫 DB),中間衝進來的讀者會把還沒更新的舊 DB 值回填快取——你才剛刪完它就又髒了。先讓 DB 成為新真相,再動快取。

然後是教科書比較少講的:這樣做仍然有一個 race。 讀者先從 DB 讀到舊值(還沒來得及回填),此刻寫者完成了「更新 DB + 刪快取」,然後讀者把手上那個舊值回填進去——快取又髒了。機率低,但長期跑一定遇到。務實的解法不是消滅它,是讓 TTL 當保險:就算髒了,TTL 到期它會自己修正。這就是「回填一定要設 TTL」不是建議、是紀律的原因。


另外三個:知道在解什麼,用到再說

Read-Through——跟 cache-aside 讀起來很像,差在「miss 時去 DB 載資料」這件事由快取層自己做,app 只面對快取。好處是載入邏輯集中;代價是你的快取層(或中介函式庫)要懂怎麼查 DB。本質上是「把 cache-aside 的讀路徑包進基礎設施」。

Write-Through——寫入時同步寫 DB 也寫快取,兩邊一起更新完才回。一致性比較好、快取永遠是熱的;代價是每次寫入都變慢,而且「其中一邊寫失敗」的補償還是要你自己面對。

Write-Behind(回寫)——先寫快取、馬上回,非同步再刷回 DB。寫入快到飛起來;但快取在刷回之前掛掉,那筆資料就沒了。適用場景很窄(吞吐極高、丟一點可容忍的計量類資料),一般業務資料別碰。

模式一句話主要代價
Cache-Asideapp 自己管快取,讀旁路、寫時刪有上面的 race(靠 TTL 收尾)
Read-Throughmiss 時快取層自己去載快取層要懂資料來源
Write-Through寫入同步寫兩邊寫變慢、雙寫失敗要補償
Write-Behind先寫快取、非同步回 DB快取掛了會丟資料

一致性:沒有完美,只有夠好

快取跟 DB 之間做不到強一致(除非把效能全賠進去),目標是「最終一致、髒的窗口夠短」。按性價比排:

  1. 短 TTL——底線、必做。所有 race 的最後保險。
  2. 刪除而非更新——上面講過,降低併發弄髒的機率。
  3. 延遲雙刪——更新 DB 後刪一次,過幾百毫秒再刪一次,蓋掉 race 窗口裡被舊值回填的那筆。對一致性敏感再上。
  4. 版本號——回填時帶版本,舊版本不准覆蓋新版本。跟樂觀鎖同一個思路。

務實結論:cache-aside + 短 TTL + 寫時刪快取,這一組合已經覆蓋絕大多數系統。真的更敏感,才逐級往上加。最常見的過度設計是想做「DB 跟快取全自動同步」——那是 invalidation 地獄的入口,還沒進去的不要進去。

講兩句自己的:被髒資料咬到出事的經驗我沒有。但雙寫失敗我碰過——DB 改了、快取沒跟上,兩邊就這樣岔開。而它最麻煩的地方不是修,是我到現在都不知道怎麼重現它:你知道它發生過(兩邊對不上就是證據),但要在測試環境把那個時序重演一次,排不出來。這就是為什麼上面堅持寫時刪一定要配 TTL 兜底——你防不住一個重現不了的 bug,只能讓它的傷害有期限


一句收束:四個名詞背後是同一個問題——髒的窗口你願意付多少成本去縮短。 90% 的答案是 cache-aside 加短 TTL;剩下 10%,等你真的量到一致性問題再說,不要預付。

(這層邏輯怎麼收進一個 CacheService、寫路徑的 code 長什麼樣,見同系列 #24;TTL 本身怎麼訂,見 #14。)

接下來往哪走