糟糕!緩存擊穿,商詳頁進不去了
故事
對于小貓來講,最近的一段日子是不好過的,縱使聽著再有節(jié)拍的音樂,也換不起他對生活的熱情。由于上一次“冪等事件”躺槍,他已經有幾天沒有休息好了。他感覺人生到了低谷。
當接手這個商城項目之后,他感覺他一直沒有好過。他的內心彷徨,在工位上邊寫著事故報告,邊嘀咕著“今年到底是犯了啥沖...為什么...”
然而屋漏又遭連夜雨,船破偏遇當頭風,好像壞事兒又找上了他。
坐他旁邊的哥們在一旁抱怨,“啥情況,我就想給公司助助力,買點咱自家公司的產品,咋商詳頁咋點來點去進不了,你看看你的呢?”。
“你自己手機不行了吧,你瞧你那老破爛也該換了,iphone15 pmax搞起...” 旁邊的老六開涮道。
然而卻觸動了小貓的神經,他趕緊打開app,發(fā)現自己的也進不去了。他趕緊打開Grafana,臉色蒼白,口干舌燥....數據庫連接全部打滿。
不到一會組長的電話也收到了客服反饋的客訴,組長向小貓投來質疑的目光。
小貓無辜而又無奈:“我真的沒有動過代碼......”。
經過一輪徹徹底底地摸排,事情的原因也終于水落石出。
大概如下圖這么回事兒:
流程
上次A公司對接商城服務之后開始在他們商城平臺推拼團售賣活動,活動中的幾個商品的緩存被刪除之后沒有恢復,原因是因為第三方對接的API商品大量推送,隊列有堆積,導致redis緩存并沒有及時更新。大批量的用戶在進行購買活動商品的時候,請求全部打到了DB上。
反正也不曉得是哪個挨千刀的設計的技術方案,緩存到redis中的時候用的居然是異步消息隊列。商品發(fā)布量小,訪問量小的時候可能沒有什么問題。
但是偏偏各種巧合發(fā)生在了一起就產生了這樣一個事故,這可能就是所謂的墨菲定律吧。
小貓已經走到了絕望的邊緣......(下次就不說小貓踩坑了,讓他緩幾天,哈哈)
二、聊聊緩存擊穿
在此我們對小貓表示同情,但是這是一個很好的例子,老貓還是和大家一起聊聊緩存擊穿雪崩的話題。
咱們就從以下幾個方面聊聊緩存雪崩擊穿的問題。
概覽圖
在上圖中可以看見,我們會對布隆過濾器做一個重點的介紹,因為這個也是比較常用的方式緩存擊穿的方式。
1.什么是緩存擊穿?是什么原因導致的?
從上面小貓的案例中,其實就已經很明了了,所謂緩存擊穿就是原本由于緩存組件抗住的流量結果全部打到了數據庫層,給數據庫帶來了巨大的壓力,甚至嚴重的情況下直接把數據庫干跨。導致緩存失效的原因也是很顯然易見的,由于緩存在一個無法預期的一個場景下緩存失效了。
在小貓的案例中可以看到是熱賣的商品在redis中Key值全部同時失效導致的。當然這是一種常見的技術方案有問題導致的。那么還有一種導致緩存失效的原因就是緩存中間件直接宕機。這種情況是運維層面需要解決的,可能要求對緩存中間件做好高可用,如何做高可用,我們在此不做深究。
下面是咱們的緩存用法,大家可以看一下下面的流程。
緩存流程
從上述的流程圖中我們可以看到,當有請求過來的時候,首先會嘗試從緩存中去讀取,當緩存中沒有讀到的時候,這個時候咱們就會回源到數據庫再次查找,當查到相關商品數據之后返回給客戶端,之后并將該數據繼續(xù)緩存到數據庫中。
接下來咱們來看一下發(fā)生雪崩之后的解決方案。
2.無效key值處理
這種情況其實很簡單了,既然由于沒有在緩存數據庫中找到數據,那么咱們也不應該直接將redis中那些Key值直接刪除,這樣找不到key的話,肯定還是會打到數據庫,所以我們只要避免請求去查數據庫就可以了。所以我們就把無效的Key也保存到redis中即可。這種值的話,我們都保存成“null”。這樣的話redis中的key一定就是全量的了,客戶端查詢數據的時候頂多也就是查不到。
這種方案可能會影響到用戶體驗,我們對這個方案其實也可以做一下改造,就是利用異步定時任務重新檢測緩存中為null的數據,異步去刷新數據到redis中。但是還是沒有從根本解決客戶體驗的問題,只是盡量降低客戶的不滿意度。大概流程如下。
無效key+定時任務
這種方案的缺點就是存在用戶體驗問題。
另外的這種方案還有一個缺點,大家思考一下,我們將失效的key以null值的形式緩存到redis中,但是如果有個惡意攻擊的行為,專門挑一些隨機的key去攻擊我們的接口的時候,請求是不是還是最終會打到數據庫,所以這種方案不是萬無一失的也不是最好的,提到的布隆過濾器則不會存在這樣的問題,后面我們再看。
3.互斥鎖方案
我們看到導致數據庫雪崩是由于請求太大穿透到數據庫,那么我們可以在訪問數據庫的時候動動腦筋。我們可以在訪問數據這個環(huán)節(jié)中加鎖,雖然影響性能,但是對于系統來說是安全的。這種方案和無效key方案進行組合之后其實可以用來作為兜底方案,搭配使用其實效果也不錯的。我們來看下圖。
緩存回源加鎖
這種情況就是在上面的標準緩存流程中回溯數據庫的地方利用redis的特性 setNx加了一把互斥鎖,這樣的話,咱們能夠盡量保證少量的請求打到數據庫中。
大白話可以這么理解,張三李四去同時去訪問同一個商品的時候,他們兩只有一個能成功,成功拒絕了并發(fā)時候針對同一個熱門商品的訪問。
4.熱點數據限流訪問
關于這個,我想其實可以不做太多展開,思路很簡單,既然是由于請求量太大,導致不走緩存走到了DB,從而將DB打垮,那么咱們就限流量唄。請求量少了,那么自然不會打垮數據庫了。關于限流的一些措施以及算法,其實老貓以前也寫過文章進行介紹過。所以在此也不做贅述。
5.布隆過濾器
如果用上布隆過濾器的話,那么我們方案如下調整。
系統在初始化的時候將key初始化到布隆過濾器中。當查詢的時候,把布隆過濾器放到查詢redis緩存之前,如果發(fā)現存在Key在布隆過濾器中,那么就繼續(xù)后續(xù)的流程,如果不存在則根本就連摸緩存的機會都沒有,更別提數據庫了,這樣就成功避免了惡意采用無用的key值進行攻擊。
6.什么是布隆過濾器
關于布隆過濾器,老貓在此也展開說一下,因為老貓覺得這個可能是這些防雪崩方案中最好的一種方案。那么咱們來看一下什么是布隆過濾器。
這種過濾器是一個叫做Bloom的哥們于1970年提出的,咱們可以把它看做由二進制向量(或者說位數組)和一系列隨機映射函數(哈希函數)兩部分組成的數據結構。位數組即(bit數組),bit是計算機中最小的單位,也就是我們常說的計算機中的0和1,這種就是用一個位來表示的。
Bit數組大概長的就是如下這樣的。
bit數組
位數組中的每個元素都只占用 1 bit , 并且每個元素只能是0 或 1 。這樣申請一個 100w 個元素的位數組只占用 1000000Bit / 8 = 125000 Byte = 125000 / 10214 kb ≈ 122kb 的空間。所以占用空間極小。
7.布隆過濾器原理
其基本原理是利用多個哈希函數,將一個元素映射成多個位,然后將這些位設置為 1。當查詢一個元素時,如果這些位都被設置為 1,則認為元素可能存在于集合中,否則肯定不存在。所以,布隆過濾器可以準確的判斷一個元素是否一定不存在,但是因為哈希沖突的存在,所以他沒辦法判斷一個元素一定存在。只能判斷可能存在。
如下圖:
添加元素的流程:
- 針對相同的key通過不同的hash計算,算出不同的值。
- 分別更新數組中的數據值為1。
查詢元素的流程。例如上圖,當查詢一個不存在的Key3的時候,調用hash1以及hash2函數,恰好命中了之前key1和key2的hash值,此處我們就有可能誤判覺得Key3值也是存在的。這樣的話也會打到后續(xù)流程中去做查詢的業(yè)務動作。
8.手擼一個簡單的java布隆過濾器
丐版的布隆過濾器的實現方式其實還是比較容易的。如下源碼:
/**
* @author 公眾號:程序員老貓
* @date 2024/1/18 23:30
*/
public class BloomFilter {
/**
* 初始化大小
*/
private static final int DEFAULT_SIZE = 2 << 24;
/**
* 位數組。數組中到元素只能是 0 和 1
*/
private BitSet bits = new BitSet(DEFAULT_SIZE);
/**
* 計算hash值
* @param key
* @return
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : Math.abs((h = key.hashCode()) ^ (h >>> 16));
}
/**
* 添加元素到位數組
*/
public void add(Object value) {
bits.set(hash(value), true);
}
/**
* 判斷指定元素是否存在于位數組
*/
public boolean contains(Object value) {
return bits.get(hash(value));
}
}
寫個main函數測試一下:
/**
* @author 公眾號:程序員老貓
* @date 2024/1/18 23:33
*/
public class Test {
public static void main(String[] args) {
String value1 = "https://blog.ktdaddy.com/";
String value2 = "kdaddy2";
BloomFilter filter = new BloomFilter();
System.out.println(filter.contains(value1));
System.out.println(filter.contains(value2));
filter.add(value1);
filter.add(value2);
System.out.println(filter.contains(value1));
System.out.println(filter.contains(value2));
}
}
結果輸出也很簡單:
false
false
true
true
9.其他第三方布隆過濾器
當然Java中還可以使用第三方庫來實現布隆過濾器,常見的有Google Guava庫和Apache Commons庫以及Redis。關于這兩種過濾器的用法,老貓在此就不做贅述了,篇幅過長,大家可能都會喪失讀下去的欲望了,所以就到此打住,感興趣的小伙伴可以自行去找一下相關的資料,然后寫個demo玩玩。
寫在最后
上述基于小貓的痛苦之上給大家分享了緩存擊穿的一系列的解決方案,如果大家還有補充的話,也歡迎大家能夠在評論區(qū)留言。有個問題想問一下大家,當你新接手一個你不熟悉的項目的時候,你做的第一件事情是什么?
先說一下老貓自己吧,我一般會將現有的業(yè)務模型梳理一下,即相關的表結構,然后將核心的流程畫一畫,繼而通過一些列新的迭代慢慢熟悉整個系統,當然在此期間其實也會遇到小貓這樣的各種各樣的坑,無論是技術方案的坑還是說代碼的坑。其實老貓還是比較喜歡“早發(fā)現早治療”這種方式,發(fā)現問題,盡早解決掉,個人還是比較抵觸現在網上比較流行的說法“防御式編程”。因為如果有問題等到真的爆發(fā)的時候,有可能就是災難性的。