一批 key 同一秒過期,瞬間把 DB 打爆——我在高併發下的快取取捨
後端工程師,做過高併發系統,被一批同時過期的快取 key 打爆過一次 DB。
同一秒過期的那批 key
那次 DB 被打爆,來得又快又猛。
監控上 DB 的 QPS 在幾秒內從平常的水位直接飆到頂,連線瞬間被打滿,一堆請求開始逾時。查下去,原因蠢得讓人有點不甘心:我們有一大批快取,是在同一個時間點被一起寫進去的(一次批次預熱),於是它們的過期時間也幾乎一模一樣。時間一到,這批 key 在同一秒集體失效,那一瞬間所有本來被快取擋住的流量,全部穿過去直接砸到 DB。
這就是所謂的「雪崩」。這篇講我從這類事故裡,對快取一致性跟穿透、擊穿、雪崩三個坑的實戰取捨。先講結論裡最重要的一句:快取的每一個設計,本質上都是在拿一致性換效能,你要清楚自己換了什麼。
先講清楚:快取一致性不是「強一致」
很多災難的源頭,是一開始就對快取有錯誤的期待。
只要你用了快取,DB 跟快取之間就一定會有那麼一小段時間對不上——你更新了 DB,快取還是舊的,直到它被更新或過期。這是快取的本質,不是 bug。所以我做快取的第一件事,是先跟業務確認:這個資料能容忍多久的不一致? 商品描述晚幾秒更新沒差,帳戶餘額就完全是另一回事。想清楚這個,才知道快取能用到什麼程度。
Cache Aside 為什麼是主流
我大部分場景用的是 Cache Aside:讀的時候先看快取,沒有再去讀 DB、然後回填快取;寫的時候更新 DB、然後把快取刪掉。它不是最完美的,但它簡單、好推理,出事的時候你腦子裡能清楚地跑一遍發生了什麼。這點在半夜救火的時候特別重要。
更新時為什麼是「刪快取」而不是「更新快取」
這個細節我一開始也覺得無所謂,後來才懂。寫的時候應該是「刪掉快取」,讓下次讀的時候重新從 DB 載,而不是「順手把新值寫進快取」。
原因是:如果兩個請求幾乎同時在更新,各自把自己算出來的值寫進快取,很可能後寫的反而是舊的,造成錯亂。刪快取則單純得多——反正下次要用再從 DB 撈最新的就好,省掉並發覆蓋的麻煩,也省掉「更新了快取結果那個值根本沒人再讀」的浪費。
穿透、擊穿、雪崩,其實是三種不同的破法
大家常把這三個混在一起講,但它們是三種不一樣的失敗:
- 穿透:一直查一個「DB 裡根本不存在」的東西。快取永遠是空的、擋不住,每次都打到 DB。惡意攻擊常這樣搞。我的解法是把「查無此物」這個結果本身也快取起來(存一個空值、給短一點的過期),或用布隆過濾器先擋掉一定不存在的 key。
- 擊穿:某一個「熱門」的 key 過期的瞬間,大量請求同時發現沒快取、同時湧去 DB 重建。解法是重建快取的時候加個鎖,只讓一個請求去查 DB、其他的等它回填就好。
- 雪崩:就是我那次遇到的——大量 key 在同一時間一起過期。解法很簡單但我當初沒做:給過期時間加一個隨機值,讓它們錯開失效,不要約好了一起死。
穿透是「查不存在的」,擊穿是「單一熱點過期」,雪崩是「大量 key 同時過期」。破法不同,補法也不同,別一招想打天下。
那次之後我改了什麼
止血當下,我先手動把那批 key 分批、錯開重新載入,DB 的 QPS 就降下來了。事後真正的修法,是把所有批次預熱的快取,過期時間一律加上一個隨機抖動,讓它們的失效時間散開。就這麼一個小小的隨機值,成本幾乎是零,卻能擋掉一整類的雪崩。
小結
那次同一秒過期的 key 教我的,不是什麼艱深的演算法,是一個很樸素的意識:快取幫你擋住的流量有多大,它一旦失效的瞬間,那股流量就會多兇猛地反撲。 快取不是加了就沒事的萬靈丹,它是一層你要好好照顧的擋牆——牆後面擋著的水有多深,你心裡要有數。穿透、擊穿、雪崩,說到底都是在提醒你:想清楚這道牆破掉的那一刻會發生什麼,然後在它破掉之前,先把那個場景處理好。
留言討論
有想法、有不同經驗、或想糾正我?歡迎在下面留言,免註冊,填個暱稱就能留。