Backend Engineering·2026年6月10日·12 分鐘閱讀

快取一致性與三大災難:穿透、擊穿、雪崩,我在高併發下的取捨

L
Louis Wu

後端工程師,主力 Go,做過交易所撮合引擎、金流與高併發系統。

只要系統一上量,快取幾乎是必經之路。資料庫扛不住的讀流量,加一層 Redis 就解了大半。但我做過交易所行情、活動搶購、後台報表這些場景之後,慢慢體會到:加快取不難,難的是快取跟資料庫怎麼維持一致,以及在流量打進來的那一刻,快取會用什麼姿勢崩給你看。這篇講我對快取一致性的理解,還有穿透、擊穿、雪崩這三個經典災難——它們不是面試題,是真的會在半夜把你叫醒的東西。

先講清楚:快取一致性不是「強一致」

很多人一聽到「一致性」就想到資料庫的 ACID,想做到快取跟 DB 永遠分秒不差。我必須先潑冷水:在加了快取的那一刻,你就已經放棄了強一致

快取本質上是資料的一份副本,副本跟本體之間一定有時間差。你能追求的是最終一致,也就是在可接受的一個短暫窗口之後,快取會收斂到跟 DB 一致。真正要決定的是:

  • 這個窗口可以多長?行情報價可能要求毫秒級,後台的某個統計數字延遲十秒沒人會死
  • 在不一致的窗口裡,讀到舊資料的後果有多嚴重?讀到舊的商品描述沒事,讀到舊的庫存可能會超賣

我的原則是:先問業務能容忍多少不一致,再決定方案。把所有快取都當成需要強一致來設計,只會讓你寫出又慢又複雜、而且照樣有漏洞的程式。

Cache Aside 為什麼是主流

業界最常見的快取模式叫 Cache Aside(旁路快取),它的讀寫邏輯很樸素:

  • :先查快取,命中就回;沒命中就查 DB,把結果寫回快取,再回傳
  • :先更新 DB,然後刪掉快取

聽起來平平無奇,但它能成為主流是有道理的。我比較過另外兩種模式之後就懂了。

跟 Read/Write Through 比

Read Through、Write Through 是把快取當成資料的唯一入口,應用程式只跟快取講話,由快取自己去同步 DB。這在概念上很乾淨,但實務上有兩個問題:

  • 你需要一個夠聰明的快取中介層去代理所有讀寫,多數情況下我們用的 Redis 並不是這種角色,要自己包一層
  • 寫入路徑被綁死,很難針對個別場景做特殊處理(例如某些寫入想跳過快取、某些想批次)

Write Behind(寫回)更激進,先寫快取、非同步刷回 DB,效能最好但風險最大——快取一掛,還沒刷回去的資料就沒了。金流、訂單這種地方我絕對不會用。

Cache Aside 的好處就是控制權在應用程式手上。每個讀寫路徑你都看得到、改得動,出事的時候你知道去哪裡找。對一個要長期維護的系統來說,這種「不漂亮但可控」往往才是對的。

更新時為什麼是「刪快取」而不是「更新快取」

這是新手最容易做錯的一步。直覺上,DB 改了,順手把快取也改成新值不是很合理嗎?我以前也這樣想,後來吃過虧。

刪快取比更新快取好,主要有三個理由。

第一,避免並發寫造成的覆蓋錯亂

想像兩個請求幾乎同時更新同一筆資料:

  • 請求 A 把 DB 改成值 1,正要去更新快取
  • 請求 B 把 DB 改成值 2,也正要去更新快取
  • 因為執行緒排程的關係,B 先把快取寫成 2,A 後寫成 1

結果 DB 是 2,快取是 1,髒了。如果是「刪快取」,兩邊都只是刪掉同一個 key,誰先誰後都無所謂,下次讀的時候自然從 DB 載入最新值。刪除是冪等的,更新不是,這在並發下差很多。

第二,省掉無謂的計算

很多快取裡放的不是 DB 原始欄位,而是經過聚合、組裝、序列化的結果。一筆資料可能被改很多次,但中間那些版本根本沒人讀。如果每次寫都重算快取,等於做了一堆白工。刪掉它,等真的有人來讀的時候再算一次,是一種懶載入(lazy loading),把計算延後到真正需要的時刻。

第三,邏輯更單純

寫入路徑只要管「刪 key」一件事,不用知道這個 key 對應的值該長什麼樣、要怎麼組裝。讀寫職責分離,程式碼好懂太多了。

雙寫不一致是怎麼來的

選了 Cache Aside、選了刪快取,是不是就一致了?沒那麼簡單。只要有兩個儲存(DB 跟快取)要更新,就有「雙寫不一致」的空間。

先更新 DB 還是先刪快取

兩種順序都有問題,但問題的嚴重程度不同。

先刪快取、再更新 DB 的危險情境:

  • 請求 A 刪掉快取,還沒更新 DB
  • 這時請求 B 進來讀,發現快取沒命中,去 DB 讀到舊值,寫回快取
  • 然後 A 才把 DB 更新成新值

結果:DB 是新的,快取是舊的,而且這個髒資料會一直留在快取裡,直到過期。這個窗口可能很長,很糟。

先更新 DB、再刪快取(也就是標準 Cache Aside)相對安全,但理論上還是有極小機率出問題:

  • 請求 B 讀快取剛好沒命中(例如 key 剛過期),去 DB 讀到舊值
  • 這時請求 A 更新 DB、刪快取
  • 然後 B 才把它讀到的舊值寫回快取

結果一樣髒。但這個情境要成立,得要 B 的「讀 DB」發生在 A 更新前、B 的「寫快取」發生在 A 刪除後,而讀 DB 通常比寫 DB 快得多,這個交錯窗口非常窄,實際很少踩到。

我的選擇是先更新 DB、再刪快取,理由就是出錯機率小一個數量級。

刪快取失敗怎麼辦

更新 DB 成功、刪快取失敗(Redis 抖一下、網路斷一下),這時 DB 新、快取舊,又髒了。我的緩解手段是分層的:

  • 重試:刪除失敗就重試幾次,簡單但有效
  • 靠過期兜底:每個 key 都設 TTL,就算刪除徹底失敗,過期後也會自我修復。這是最後一道保險,TTL 一定要設
  • 訂閱 binlog 補刪:把刪快取這件事接到 DB 的變更日誌(像 MySQL 的 binlog,用 Canal 之類的工具訂閱)。只要 DB 真的變了,就一定會觸發一次刪除。這把「應用程式記得刪」變成「資料變更必然刪」,可靠很多。代價是多一套元件要維護

延遲雙刪

針對「先刪再更新 DB」那個窗口,有個經典做法叫延遲雙刪:更新 DB 之後,先刪一次快取,過個幾百毫秒到一兩秒,再刪第二次。

第二次刪除是為了清掉「在第一次刪除到 DB 更新生效之間,被其他請求重新載入的舊值」。延遲的時間要大於一次正常讀請求的耗時。

我對延遲雙刪的態度是保留但謹慎。它確實能蓋掉一些邊角情況,但那個延遲秒數很難算準,第二次刪除通常還得丟到非同步去做,複雜度上來了。實務上我更傾向「先更新 DB 再刪快取 + 合理 TTL + binlog 補刪」這套組合,能覆蓋絕大多數情境。延遲雙刪我只在對一致性特別敏感、又不方便上 binlog 的地方才會考慮。

講到底,一切以 DB 為準。快取永遠是可以被丟掉、可以重建的副本。任何時候你不確定快取對不對,刪掉它讓它從 DB 重載,永遠是安全的退路。設計快取邏輯時心裡記著這條,很多焦慮會少很多。

災難一:快取穿透

接下來講三個真的會打垮系統的災難。第一個是穿透(penetration)。

穿透指的是:查詢一個根本不存在的資料。 快取裡沒有(因為這資料本來就不存在),於是每次都打到 DB,DB 也查不到,回個空。問題在於——快取沒擋住任何東西,所有這類請求全部穿透到 DB。

正常情況下這不嚴重,但如果有人惡意攻擊,拿一堆不存在的 ID(例如商品 ID 用負數、用超大數字)狂打,你的 DB 會被這些註定查不到的查詢淹死。我在做活動網站的時候就見過這種掃描式的探測流量。

防護一:快取空值

最直接的做法:查 DB 發現不存在,也把這個「空」結果寫進快取,給一個比較短的 TTL。下次同樣的查詢就被快取擋下來,不會再打 DB。

要注意兩點:

  • 空值的 TTL 要短(例如幾十秒到幾分鐘),因為這筆資料之後可能真的被建立出來,你不希望那個「不存在」的標記留太久造成新資料讀不到
  • 真的建立這筆資料時,記得要主動刪掉那個空值快取

快取空值簡單好上手,但缺點是如果攻擊者每次用不同的不存在 key,你的快取會被一堆空值塞爆,浪費記憶體。

防護二:布隆過濾器

對付「大量不同的不存在 key」這種攻擊,更好的工具是布隆過濾器(Bloom filter)。

它的概念是:把所有「存在的 key」事先放進一個很省空間的機率型資料結構。查詢進來時先問布隆過濾器「這個 key 可能存在嗎」:

  • 如果它說「不存在」,那就一定不存在,直接擋掉,連快取跟 DB 都不用碰
  • 如果它說「可能存在」,再走正常的查快取、查 DB 流程

布隆過濾器的特性是有偽陽性、沒有偽陰性——它可能把不存在的誤判成「可能存在」(放過去一點點漏網之魚,沒關係,後面還有快取擋),但它絕不會把存在的說成不存在。這個方向性剛好就是我們要的。

代價是:

  • 你得在啟動時或資料變更時維護這個過濾器,把存在的 key 灌進去
  • 標準的布隆過濾器不支援刪除,資料被刪掉時不好同步(要用變形版本,例如 Counting Bloom Filter,或定期重建)

我的取捨:一般場景我先上快取空值就夠了,成本低。只有當不存在的 key 空間非常大、又有惡意掃描的風險(例如公開的、ID 可枚舉的查詢介面),我才會搬出布隆過濾器。Redis 本身有支援布隆過濾器的模組,不一定要自己造輪子。

災難二:快取擊穿

擊穿(breakdown)跟穿透只差一個字,但完全是兩回事。

擊穿指的是:某個熱點 key 過期的瞬間,大量並發請求同時湧入。 這個 key 平常一直擋著巨大的讀流量,結果它一過期,那一瞬間所有請求發現快取沒了,全部一起衝去 DB 重建這同一筆資料。DB 瞬間被同一個查詢打了成千上萬次。

這在我做行情、做熱門商品頁的時候最容易遇到。某個爆款商品、某個熱門交易對,它的快取就是全站最熱的 key,偏偏它過期的那一刻最危險。

防護一:互斥鎖重建

核心想法是:同一個 key 過期後,只讓一個請求去重建,其他請求等它。

實作上,發現快取沒命中時,先去搶一把分散式鎖(在 Redis 裡用 SETNX 那類原子操作):

  • 搶到鎖的那個請求,負責查 DB、回寫快取、最後釋放鎖
  • 沒搶到鎖的請求,稍微等一下再重試讀快取,這時通常已經被前面那位重建好了

這樣 DB 對這個 key 在重建期間只會被打一次,其他人都吃重建好的快取。

要小心的細節:

  • 鎖一定要設過期時間,否則拿到鎖的請求掛了,鎖沒釋放,所有人卡死
  • 等待重試要有上限,不能無限等
  • 重建期間其他請求是「等待」還是「先回個舊值或預設值」,要看業務能不能接受短暫的等待

防護二:邏輯過期

互斥鎖會讓部分請求等待,對延遲敏感的場景不夠好。另一招是邏輯過期

快取的 key 永不設真正的 TTL(或設很長),但在存進去的 value 裡,自己埋一個「邏輯過期時間」欄位。讀的時候:

  • 如果邏輯上還沒過期,直接用
  • 如果邏輯上已經過期,先把舊的(過期的)值回傳出去,同時開一個非同步任務(搶到鎖的那個)在背景去重建快取

這招的精髓是犧牲一致性、換取永遠不阻塞。請求永遠拿得到資料(哪怕是稍微舊一點的),不會有人卡在那邊等重建。背景重建好了,後續的人就讀到新值。

我怎麼選這兩個

  • 如果業務能容忍請求短暫等待,但不能容忍讀到舊資料,用互斥鎖
  • 如果業務不能容忍等待,但能容忍極短時間內讀到舊資料,用邏輯過期

行情這種「寧可慢一點也要對」的,我用互斥鎖;活動頁那種「短暫看到舊文案無所謂、就是不能卡」的,邏輯過期更合適。

災難三:快取雪崩

雪崩(avalanche)是規模最大的災難。

雪崩指的是:大量 key 在同一時間集中過期,或快取服務整個掛掉,導致流量在同一瞬間全部壓到 DB。 跟擊穿的差別在於:擊穿是「單一熱點 key」,雪崩是「一大批 key 一起出事」。

我見過最典型的雪崩成因,是圖省事,把一批快取都設成同一個 TTL。比方說系統啟動或某次批次預熱時,幾萬個 key 同一時間被寫進去、設了同樣的一小時過期。一小時後,這幾萬個 key 在同一秒一起過期,DB 直接被瞬間的流量峰值打垮。DB 一垮,更多請求堆積、重試,整個系統連鎖崩潰。

防護一:過期時間加隨機抖動

這是成本最低、效果最好的一招,我每個專案都做。

設 TTL 的時候不要給固定值,而是在基準值上加一個隨機數。例如本來想設一小時,就設成「一小時 ± 隨機 0 到 10 分鐘」。這樣那一大批 key 的過期時間被打散到一個時間區間裡,不會在同一秒集體陣亡,DB 的重建壓力被攤平。

一行小小的隨機數,就能擋掉一整類雪崩,CP 值高得不像話。

防護二:多級快取

把快取分層,不要把雞蛋放在同一個籃子:

  • 本地快取(如程序內的記憶體快取) 擋第一層,最熱的資料連 Redis 都不用碰
  • 分散式快取(Redis) 擋第二層
  • 最後才到 DB

就算 Redis 整個掛了,本地快取還能擋掉一部分流量,給你緩衝。代價是本地快取的一致性更難維護(每台機器一份,更新要廣播),所以本地快取我只放那些變動極少、能容忍短暫不一致的資料,例如設定檔、字典表這類。

防護三:限流與降級

前面兩招是「不讓雪崩發生」,這招是「萬一還是發生了,別讓它把整個系統拖下水」。

  • 限流:在重建快取、打 DB 的那條路徑上設個閘門,限制同時打到 DB 的請求數。超過的請求寧可快速失敗或排隊,也不要讓 DB 被打死。保住 DB,才有機會慢慢恢復
  • 熔斷與降級:偵測到 DB 已經扛不住,就暫時切斷對它的請求,回傳一個降級的結果(預設值、舊資料、或一個友善的「稍後再試」)。把核心服務保住,犧牲掉非核心的完整性

降級這件事,要在事前就設計好降級的內容是什麼,不能等出事了才想。我習慣對每個重要的讀路徑都問一句:如果這份資料拿不到,我能回什麼讓使用者不至於看到一片空白或一個錯誤頁?想清楚這個,系統的韌性會差很多。

防護四:快取高可用本身

雪崩的極端情況是 Redis 整個倒了。所以快取服務自己也要做高可用——主從、哨兵(Sentinel)或叢集(Cluster),別讓單一節點故障變成全站災難。這是基礎建設層面的事,但常被忽略。快取掛了導致雪崩,比快取過期導致雪崩更致命。

把三個災難放在一起看

寫到這裡,把三個災難對照一下,會更清楚它們的本質:

  • 穿透:查的資料不存在,快取擋不住,每次都打 DB。防護重點在「擋掉不存在的查詢」——快取空值、布隆過濾器
  • 擊穿單一熱點 key 過期瞬間,並發全打 DB。防護重點在「重建時收斂成一次」——互斥鎖、邏輯過期
  • 雪崩大批 key 同時過期或快取掛掉,流量全壓 DB。防護重點在「分散風險 + 保護 DB」——隨機 TTL、多級快取、限流降級、高可用

你會發現它們的防護手段不太能互相取代,因為觸發條件不同。實務上我不會挑一個來做,而是把它們當成一組組合拳:隨機 TTL 跟合理的 TTL 是基本盤,每個專案都上;熱點 key 加互斥鎖或邏輯過期;公開可枚舉的查詢加空值快取或布隆過濾器;後面用限流降級兜底。

小結

快取是用一點點一致性,換來巨大的效能。理解這個交易的本質,比背下任何一個防護方案都重要。

我這些年做下來的體會是:別追求快取的強一致,那是死路;老老實實做 Cache Aside、更新時刪快取、所有 key 都設帶隨機抖動的 TTL,光這幾條就能擋掉八成的問題。剩下兩成,看你的熱點在哪、攻擊面在哪,再針對性地補上互斥鎖、布隆過濾器、限流降級。

說到底,快取一致性的終極心法只有一句:DB 是唯一的事實來源,快取永遠是可以丟掉重建的副本。 把這句話刻在心裡,無論哪個災難來敲門,你都知道那條最安全的退路在哪。

#快取#Redis#高併發#系統設計#後端架構

相關文章