一篇學(xué)會緩存穿透、緩存擊穿、緩存雪崩
大家好,我是樓仔!
今天寫的這個主題內(nèi)容,其實非常基礎(chǔ),但是作為高并發(fā)非常重要的幾個場景,絕對繞不開,估計大家面試時,也經(jīng)常會遇到。
這個主題的文章,網(wǎng)上非常多,本來想直接轉(zhuǎn)載一篇,但是感覺沒有合適的,要么文章不夠精煉,要么就是精簡過頭,所以還是自己寫一篇吧。
內(nèi)容雖然基礎(chǔ),但我還是秉承以往的寫作風(fēng)格,參考眾多優(yōu)秀的博客后,打算寫一篇能通俗易懂,又不失全面的文章。
前言
我們先看一下正常情況的查詢過程:
- 先查詢 Redis,如果查詢成功,直接返回,查詢不存在,去查詢 DB;
- 如果 DB 查詢成功,數(shù)據(jù)回寫 Redis,查詢不存在,直接返回。
緩存穿透
定義:當(dāng)查詢數(shù)據(jù)庫和緩存都無數(shù)據(jù)時,因為數(shù)據(jù)庫查詢無數(shù)據(jù),出于容錯考慮,不會將結(jié)果保存到緩存中,因此每次請求都會去查詢數(shù)據(jù)庫,這種情況就叫做緩存穿透。
紅色的線條,就是緩存穿透的場景,當(dāng)查詢的 Key 在緩存和 DB 中都不存在時,就會出現(xiàn)這種情況。
可以想象一下,比如有個接口需要查詢商品信息,如果有惡意用戶模擬不存在的商品 ID 發(fā)起請求,瞬間并發(fā)量很高,估計你的 DB 會直接掛掉。
可能大家第一反應(yīng)就是對入?yún)⑦M行正則校驗,過濾掉無效請求,對!這個沒錯,那有沒有其它更好的方案呢?
緩存空值
當(dāng)我們從數(shù)據(jù)庫中查詢到空值時,我們可以向緩存中回種一個空值,為了避免緩存被長時間占用,需要給這個空值加一個比較短的過期時間,例如 3~5 分鐘。
不過這個方案有個問題,當(dāng)大量無效請求穿透過來時,緩存內(nèi)就會有有大量的空值緩存,如果緩存空間被占滿了,還會因剔除掉一些已經(jīng)被緩存的用戶信息,反而會造成緩存命中率的下降,所以這個方案,需要評估緩存容量。
如果緩存空值不可取,這時你可以考慮使用布隆過濾器。
布隆過濾器
布隆過濾器是由一個可變長度為 N 的二進制數(shù)組與一組數(shù)量可變 M 的哈希函數(shù)構(gòu)成,說的簡單粗暴一點,就是一個 Hash Map。
原理相當(dāng)簡單:比如元素 key=#3,假如通過 Hash 算法得到一個為 9 的值,就存在這個 Hash Map 的第 9 位元素中,通過標記 1 標識該位已經(jīng)有數(shù)據(jù),如下圖所示,0 是無數(shù)據(jù),1 是有數(shù)據(jù)。
所以通過該方法,會得到一個結(jié)論:在 Hash Map 中,標記的數(shù)據(jù),不一定存在,但是沒有標記的數(shù)據(jù),肯定不存在。
為什么“標記的數(shù)據(jù),不一定存在”呢?因為 Hash 沖突!
比如 Hash Map 的長度為 100,但是你有 101 個請求,假如你運氣好到爆,這 100 個請求剛好均勻打在長度為 100 的 Hash Map 中,此時你的 Hash Map 已經(jīng)全部標記為 1。
當(dāng)?shù)?101 個請求過來時,就 100% 出現(xiàn) Hash 沖突,雖然我沒有請求過,但是得到的標記卻為 1,導(dǎo)致布隆過濾器沒有攔截。
如果需要減少誤判,可以增加 Hash Map 的長度,并選擇卻分度更高的 Hash 函數(shù),比如多次對 key 進行 hash。
除了 Hash 沖突,布隆過濾器其實會帶來一個致命的問題:布隆過濾器更新失敗。
比如有一個商品 ID 第一次請求,當(dāng) DB 中存在時,需要在 Hash Map 中標記一下,但是由于網(wǎng)絡(luò)原因,導(dǎo)致標記失敗,那么下次這個商品 ID 重新發(fā)起請求時,請求會被布隆過濾器攔截,比如這個是雙11的爆款商品庫存,明明有 10W 件商品,你卻提示庫存不存在,領(lǐng)導(dǎo)可能會說“明天你可以不用來了”。
所以如果使用布隆過濾器,在對 Hash Map 進行數(shù)據(jù)更新時,需要保證這個數(shù)據(jù)能 100% 更新成功,可以通過異步、重試的方式,所以這個方案有一定的實現(xiàn)成本和風(fēng)險。
緩存擊穿
定義:某個熱點緩存在某一時刻恰好失效,然后此時剛好有大量的并發(fā)請求,此時這些請求將會給數(shù)據(jù)庫造成巨大的壓力,這種情況就叫做緩存擊穿。
這個其實和“緩存穿透”流程圖一樣,只是這個的出發(fā)點是“某個熱點緩存在某一時刻恰好失效”,比如某個非常熱門的爆款商品,緩存突然失效,流量直接全部打到 DB,造成某一時刻數(shù)據(jù)庫請求量過大,更強調(diào)瞬時性。
解決問題的方法主要有 2 種:
- 分布式鎖:只有拿到鎖的第一個線程去請求數(shù)據(jù)庫,然后插入緩存,當(dāng)然每次拿到鎖的時候都要去查詢一下緩存有沒有,這種在高并發(fā)場景下,個人不太建議用分布式鎖,會影響查詢效率;
- 設(shè)置永不過期:對于某些熱點緩存,我們可以設(shè)置永不過期,這樣就能保證緩存的穩(wěn)定性,但需要注意在數(shù)據(jù)更改之后,要及時更新此熱點緩存,不然就會造成查詢結(jié)果的誤差,比如熱門商品,都先預(yù)熱到數(shù)據(jù)庫,后續(xù)再下線掉。
網(wǎng)上還有“緩存續(xù)期”的方式,比如緩存 30 分鐘失效,可以搞個定時任務(wù),每 20 分鐘跑一次,感覺這種方式不倫不類,僅供大家參考。
緩存雪崩
定義:在短時間內(nèi)有大量緩存同時過期,導(dǎo)致大量的請求直接查詢數(shù)據(jù)庫,從而對數(shù)據(jù)庫造成了巨大的壓力,嚴重情況下可能會導(dǎo)致數(shù)據(jù)庫宕機的情況叫做緩存雪崩。
如果說“緩存擊穿”是單兵反抗,那“緩存雪崩”就是集體起義了,那什么情況會出現(xiàn)緩存雪崩呢?
- 短時間內(nèi)有大量緩存同時過期;
- 緩存服務(wù)宕機,導(dǎo)致某一時刻發(fā)生大規(guī)模的緩存失效。
那么有哪些解決方案呢?
- 緩存添加隨機時間:可在設(shè)置緩存時添加隨機時間,比如 0~60s,這樣就可以極大的避免大量的緩存同時失效;
- 分布式鎖:加一個分布式鎖,第一個請求將數(shù)據(jù)持久化到緩存后,其它的請求才能進入;
- 限流和降級:通過限流和降級策略,減少請求的流量;
- 集群部署:Redis 通過集群部署、主從策略,主節(jié)點宕機后,會切換到從節(jié)點,保證服務(wù)的可用性。
緩存添加隨機時間示例:
// 緩存原本的失效時間
int exTime = 10 * 60;
// 隨機數(shù)生成類
Random random = new Random();
// 緩存設(shè)置
jedis.setex(cacheKey, exTime + random.nextInt(1000) , value);