快取三大災難:穿透、擊穿、雪崩,與正確解法

· tech

#redis#cache

📑 目錄

把 Redis 當快取,最經典的模式是 cache-aside(旁路快取):讀取先查 cache,命中就回傳、沒命中(miss)才查資料庫,再把結果回填進 cache。平常運作得很好——直到某些情況下,大量請求繞過 cache、直接打爆後面的資料庫。這類破口有三個著名的名字:穿透、擊穿、雪崩。它們念起來很像、常被搞混,但其實是三種不同的失敗,各有各的解法。

三兄弟長不一樣:穿透 / 擊穿 / 雪崩

先把三者的差別講清楚——搞懂它們「破在哪」,解法就不會亂套:

三種破口,長得不一樣 穿透 Penetration 查「不存在」的 key cache 沒有、DB 也沒有 → cache 永遠擋不住 每次都直達 DB 擊穿 Breakdown 單一熱 key ⏰ 剛過期 瞬間大量並行同時 miss → 全湧向 DB 重建 集中打一個點 雪崩 Avalanche 大量 key ⏰ 同時過期 (或 Redis 整個掛掉) → 大範圍 miss DB 崩 → 連鎖 共通:請求繞過 cache 打爆 DB;差別在破口—— 不存在的 key(穿透)· 一個熱點(擊穿)· 一大片(雪崩)
一句話記住差別:穿透是查「不存在」的資料,cache 和 DB 都沒有,所以每一次都會打到 DB;擊穿是「一個熱門 key」剛好過期的瞬間,大量並行同時撲空、集中打向那一個點;雪崩是「一大片 key」同時失效(常因為都設一樣的 TTL,或 Redis 整台掛掉),造成大範圍崩塌。搞清楚是「不存在 / 一個熱點 / 一大片」,才知道該用哪種解法

對症下藥:三種破口,三種補法

差別搞懂了,解法就很自然——每一種破口,都對應一種「讓失敗別集中」的手法:

對症下藥:破口 → 補法 穿透 空值也快取(查不到也寫進 cache、短 TTL 擋住)+ 布隆過濾器:前置攔截「一定不存在」的 key 擊穿 互斥鎖:只放「一個」請求去重建 DB,其餘等它回填(或熱 key 邏輯過期 + 背景刷新,不設實體 TTL) 雪崩 TTL 加隨機抖動(jitter)打散過期時間+ 高可用 / 降級:別讓 Redis 單點全掛 共通精神:別讓失敗集中 —— 擋住 · 收斂 · 打散
穿透用「空值也快取」把不存在的查詢也擋在 cache(配短 TTL 避免髒資料),更嚴謹再加布隆過濾器在最前面攔掉一定不存在的 key;擊穿互斥鎖,讓熱 key 過期時只有一個請求去重建、其餘乖乖等,避免萬箭齊發;雪崩隨機 TTL 把過期時間錯開,別讓大家在同一秒到期——這招跟 可靠 cron 對付午夜驚群的 jitter,是同一個道理

三種解法的手法不同,但骨子裡是同一句話:別讓失敗集中在同一個時間、同一個點。 空值快取是「把打不到的擋在門外」、互斥鎖是「把並行收斂成一個」、隨機 TTL 是「把同時發生的打散開」——擋住、收斂、打散,對應三種集中的破口。至於擊穿要用的互斥鎖,牽涉到「怎麼在分散式下正確地搶一把鎖」,那本身是個大題目,留到下一篇的分散式鎖細講。

落成命令:三種補法怎麼下

三災難的解法,落成 redis-cli 就這幾條:

# cache-aside 讀:先查快取,miss 才回填(一定要帶 TTL)
GET article:42                       # miss → 打 DB 撈,再回填 ↓
SET article:42 "<json>" EX 300       # 存 5 分鐘;TTL 加點隨機 → 防雪崩同時到期
# 擊穿:互斥鎖,只放「一個」請求去重建熱點
SET lock:article:42 1 NX EX 10       # 搶到(回 OK)才查 DB,其餘等一下重試
# 穿透:對「查無」也快取一個空值(短 TTL),擋住重複打 DB
SET article:404 "" EX 30

三條命令對三種破口:回填帶隨機化 TTL 打散雪崩、SET NX互斥鎖收斂擊穿、空值短 TTL 擋穿透。共通的一句話是——別讓大量 miss 在同一刻砸向 DB

反思

三個名字很像,但本質是三種不同的集中

穿透、擊穿、雪崩,我剛學時被這三個中文名搞得暈頭轉向——它們聽起來就像同一件事的三種說法。真正把它們釐清,是我意識到差別只在一個維度:「打到 DB 的請求,為什麼集中?」 穿透是因為查的東西根本不存在、cache 這道牆天生擋不住(空間上的漏);擊穿是因為一個熱點在過期瞬間、並行全撞上來(時間上的一個點);雪崩是因為一大片 key 剛好同時到期(時間上的一大片)。把它拆成「不存在 / 一個點 / 一大片」,名字就不再需要死背,而是能從情境自己推出來。遇到一組容易混淆的術語,找到那個能區分它們的維度,比背定義有用一百倍。

這些解法的共通精神:消除「同步性」

寫這篇最大的收穫,是發現三種解法其實在解同一個更深的問題——同步性(synchronization)是災難的放大器。一萬個請求如果錯開,DB 輕鬆消化;但如果它們同時發生(同時過期、同時撲向一個熱點),就會瞬間壓垮系統。所以隨機 TTL 的 jitter、互斥鎖的收斂,本質都是在打破這種致命的同步。這個模式我後來到處都看得到:排程加抖動避免午夜驚群、重試加隨機退避(backoff)避免 retry storm、啟動時錯開暖機避免驚群——只要看到「大量東西同時做同一件事」,就要警覺它可能是下一場災難的引信。把同步的尖峰抹平成分散的緩坡,是可靠度工程裡反覆出現的一招。

快取的難,不在快取本身,在「miss 的那一刻」

用 Redis 當快取,大家的注意力都放在「命中時多快」,但真正會出事的,永遠是 miss 的那一刻——cache 沒擋住,壓力瞬間傳導到後面的資料庫。這三大災難,全部都發生在 miss 之後。所以我現在設計任何快取層,第一個想的不是「命中率多高」,而是「當它 miss、當它整個掛掉,後面的 DB 扛得住嗎?」 一個沒想清楚 miss 行為的快取,平常幫你擋住流量、歲月靜好,但它同時也養大了後面 DB 承受不起的真實流量——一旦快取失守,那些流量就會原形畢露、瞬間壓垮 DB(連鎖失效)。快取是加速器,但別忘了它也是一道你遲早要面對「失守會怎樣」的防線。