“There are only two hard things in Computer Science: cache invalidation and naming things.” — Phil Karlton

上一篇留了一個問題:資料改了,怎麼讓快取裡的舊值「失效」(invalidation,就是讓它作廢、下次重查)?這篇講為什麼這件聽起來一句話的事,被跟「取名字」並列為兩大難題。

難在哪:你要在「不知道誰快取了什麼」的情況下清乾淨

失效難的本質一句話:寫入的地方跟快取的地方是分開的。改商品價格的那段 code,並不知道這個商品被快取在哪些 key 裡——商品詳情頁一份、列表頁一份、搜尋結果一份、首頁推薦一份……漏清任何一份,就是上一篇的「系統說謊」。

三種主流失效法,各自解一部分、各自留一個洞:

法一:TTL——「反正時間到就會好」

設個保鮮期,過期自動重查。最簡單、最穩健:不用寫任何失效邏輯、不會漏(時間一定會到)。

洞:過期前的那段時間,錯的就是錯的。TTL 設 5 分鐘=你宣布「本站資料最多說謊 5 分鐘」。想縮短說謊時間就縮 TTL,但 TTL 越短、快取越常過期、DB 越忙——你在「說謊時長」跟「快取效益」之間拉扯,而且這條拉扯沒有贏的一端,只有你的業務能容忍哪邊。

適用判斷:資料「舊一點沒關係」(排行榜、推薦、統計數字)→ TTL 就夠,別過度設計。

法二:事件失效——「資料一改,馬上去清」

改資料的當下,主動刪掉對應的快取 key(或發一個「商品 123 改了」的事件,讓監聽者去清)。即時,說謊時間趨近於零。

兩個洞:

  1. 你得列得出「所有該清的 key」——就是開頭那個本質難題。商品改了要清詳情頁,那列表頁呢?搜尋快取呢?新同事加了一個新快取,記得回來改失效清單嗎?失效清單跟快取點的同步,靠的是人的紀律,而人會忘。
  2. 清的動作本身會失敗。DB 改成功、刪快取那一下網路抖掉——舊值繼續活到天荒地老(除非你有 TTL 兜底)。這跟 dual-write 問題是同一族的病。

順帶回收上一篇的伏筆——為什麼刪比改安全:失效時你有兩個選項,「刪掉 key(下次讀自然回填新值)」或「直接把新值寫進快取」。寫進去看似省一次回填,但寫的值要自己組(跟讀路徑的組法可能不一致)、兩個人同時改時後寫的可能是舊的(race)——刪除則永遠不會「寫錯」,最壞就是多一次 DB 查詢。刪除是冪等且不會出錯的失效,改寫是多一條會出 bug 的寫路徑。

用 Laravel 的話,這個選擇長這樣——更新訂單後寫 Cache::forget("order:{$id}")(刪,下次讀自然回填),而不是 Cache::put("order:{$id}", $order)(改寫,你要自己保證組出來的跟讀路徑一致)。一行的差別,一族 bug 的差別。

法三:tag / 版本——「一群 key 一起作廢」

進階做法兩種:tag(把相關 key 都貼上「product:123」標籤,失效時按標籤批次清——Redis 沒內建,要自己維護 tag→keys 的對照表);版本 key(key 裡帶版本號 product:123:v7,改資料時把版本推進到 v8——舊 key 不用刪,沒人再讀它,等 TTL 自然回收)。

洞:複雜度轉移。tag 對照表自己也是一份要維護的資料(它壞了怎麼辦?);版本號要有個地方存版本(那個地方自己要不要快取?)。你解掉「列 key」的難題,代價是多一層自己蓋的基礎設施。

大魔王:多層快取的級聯失效

真實系統的快取不只一層:瀏覽器 → CDN → 應用層 Redis → (有些還有 process 內記憶體 L1)。資料改了,每一層各有自己的失效機制、自己的 TTL、自己的清法——Redis 清了但 CDN 還快取著、CDN 清了但使用者瀏覽器還留著。

層數越多,「到底哪一層在給舊值」的排查樹就越大。實務原則:層越外面 TTL 越短(瀏覽器秒級、CDN 分鐘級、Redis 可以長),讓外層自然快速換血;真正需要「立刻全域生效」的東西(價格、庫存、權限),要嘛不要進外層快取,要嘛接受你需要一套跨層清除的工具鏈(CDN purge API + Redis 清除 + 版本號一起動)。

收:invalidation 的決策樹

  1. 舊幾分鐘沒差? → TTL,收工,別炫技
  2. 要即時、且該清的 key 列得全? → 事件失效 + TTL 兜底(事件漏了還有保鮮期救)
  3. 一改就要清一大群? → tag 或版本 key,並接受你在自建基礎設施
  4. 跨很多層? → 外層短 TTL + 關鍵資料不進外層

注意第 2 條的「+ TTL 兜底」——實務上幾乎永遠是組合技:事件負責快、TTL 負責漏網。只用一招的失效策略,洞就是它的原生洞。

我自己在這題繳的學費不戲劇化,但很典型:後台訂單的清單頁和細節頁。一開始的做法是快取時間到就重拉——訂單資料其實沒那麼常變,大量的重拉只是把同樣的東西從 DB 搬出來再放回去,被打的時候 DB 就有被拉爆的風險。後來想通的事現在看理所當然:有更新才需要更新——訂單狀態改變的那一刻去動快取,其他時間讓它躺著。上了事件式的機制之後,重複拉的壓力就消失了。沒有什麼驗屍現場,就是從「沒有這個機制」到「有」——但跨過這一步,你才真的體會法二在講什麼:即時失效的價值,是把「猜資料什麼時候會舊」換成「知道資料什麼時候變了」。

接下來往哪走