爛大街的緩存穿透、緩存擊穿和緩存雪崩,你真的懂了?
前言
對(duì)于從事后端開(kāi)發(fā)的同學(xué)來(lái)說(shuō),緩存已經(jīng)變成的項(xiàng)目中必不可少的技術(shù)之一。
沒(méi)錯(cuò),緩存能給我們系統(tǒng)顯著的提升性能。但如果你使用不好,或者缺乏相關(guān)經(jīng)驗(yàn),它也會(huì)帶來(lái)很多意想不到的問(wèn)題。
今天我們一起聊聊如果在項(xiàng)目中引入了緩存,可能會(huì)給我們帶來(lái)的下面這三大問(wèn)題??纯茨阒姓辛藳](méi)?
1. 緩存穿透問(wèn)題
大部分情況下,加緩存的目的是:為了減輕數(shù)據(jù)庫(kù)的壓力,提升系統(tǒng)的性能。
1.1 我們是如何用緩存的?
一般情況下,如果有用戶(hù)請(qǐng)求過(guò)來(lái),先查緩存,如果緩存中存在數(shù)據(jù),則直接返回。如果緩存中不存在,則再查數(shù)據(jù)庫(kù),如果數(shù)據(jù)庫(kù)中存在,則將數(shù)據(jù)放入緩存,然后返回。如果數(shù)據(jù)庫(kù)中也不存在,則直接返回失敗。
流程圖如下:
上面的這張圖小伙們肯定再熟悉不過(guò)了,因?yàn)榇蟛糠志彺娑际沁@樣用的。
1.2 什么是緩存穿透?
但如果出現(xiàn)以下這兩種特殊情況,比如:
用戶(hù)請(qǐng)求的id在緩存中不存在。
惡意用戶(hù)偽造不存在的id發(fā)起請(qǐng)求。
這樣的用戶(hù)請(qǐng)求導(dǎo)致的結(jié)果是:每次從緩存中都查不到數(shù)據(jù),而需要查詢(xún)數(shù)據(jù)庫(kù),同時(shí)數(shù)據(jù)庫(kù)中也沒(méi)有查到該數(shù)據(jù),也沒(méi)法放入緩存。也就是說(shuō),每次這個(gè)用戶(hù)請(qǐng)求過(guò)來(lái)的時(shí)候,都要查詢(xún)一次數(shù)據(jù)庫(kù)。
圖中標(biāo)紅的箭頭表示每次走的路線(xiàn)。
很顯然,緩存根本沒(méi)起作用,好像被穿透了一樣,每次都會(huì)去訪(fǎng)問(wèn)數(shù)據(jù)庫(kù)。
這就是我們所說(shuō)的:緩存穿透問(wèn)題。
如果此時(shí)穿透了緩存,而直接數(shù)據(jù)庫(kù)的請(qǐng)求數(shù)量非常多,數(shù)據(jù)庫(kù)可能因?yàn)榭覆蛔毫Χ鴴斓簟鑶鑶琛?/p>
那么問(wèn)題來(lái)了,如何解決這個(gè)問(wèn)題呢?
1.3 校驗(yàn)參數(shù)
我們可以對(duì)用戶(hù)id做檢驗(yàn)。
比如你的合法id是15xxxxxx,以15開(kāi)頭的。如果用戶(hù)傳入了16開(kāi)頭的id,比如:16232323,則參數(shù)校驗(yàn)失敗,直接把相關(guān)請(qǐng)求攔截掉。這樣可以過(guò)濾掉一部分惡意偽造的用戶(hù)id。
1.4 布隆過(guò)濾器
如果數(shù)據(jù)比較少,我們可以把數(shù)據(jù)庫(kù)中的數(shù)據(jù),全部放到內(nèi)存的一個(gè)map中。
這樣能夠非??焖俚淖R(shí)別,數(shù)據(jù)在緩存中是否存在。如果存在,則讓其訪(fǎng)問(wèn)緩存。如果不存在,則直接拒絕該請(qǐng)求。
但如果數(shù)據(jù)量太多了,有數(shù)千萬(wàn)或者上億的數(shù)據(jù),全都放到內(nèi)存中,很顯然會(huì)占用太多的內(nèi)存空間。
那么,有沒(méi)有辦法減少內(nèi)存空間呢?
答:這就需要使用布隆過(guò)濾器了。
布隆過(guò)濾器底層使用bit數(shù)組存儲(chǔ)數(shù)據(jù),該數(shù)組中的元素默認(rèn)值是0。
布隆過(guò)濾器第一次初始化的時(shí)候,會(huì)把數(shù)據(jù)庫(kù)中所有已存在的key,經(jīng)過(guò)一些列的hash算法(比如:三次hash算法)計(jì)算,每個(gè)key都會(huì)計(jì)算出多個(gè)位置,然后把這些位置上的元素值設(shè)置成1。
之后,有用戶(hù)key請(qǐng)求過(guò)來(lái)的時(shí)候,再用相同的hash算法計(jì)算位置。
- 如果多個(gè)位置中的元素值都是1,則說(shuō)明該key在數(shù)據(jù)庫(kù)中已存在。這時(shí)允許繼續(xù)往后面操作。
- 如果有1個(gè)以上的位置上的元素值是0,則說(shuō)明該key在數(shù)據(jù)庫(kù)中不存在。這時(shí)可以拒絕該請(qǐng)求,而直接返回。
使用布隆過(guò)濾器確實(shí)可以解決緩存穿透問(wèn)題,但同時(shí)也帶來(lái)了兩個(gè)問(wèn)題:
- 存在誤判的情況。
- 存在數(shù)據(jù)更新問(wèn)題。
先看看為什么會(huì)存在誤判呢?
上面我已經(jīng)說(shuō)過(guò),初始化數(shù)據(jù)時(shí),針對(duì)每個(gè)key都是通過(guò)多次hash算法,計(jì)算出一些位置,然后把這些位置上的元素值設(shè)置成1。
但我們都知道hash算法是會(huì)出現(xiàn)hash沖突的,也就是說(shuō)不通的key,可能會(huì)計(jì)算出相同的位置。
上圖中的下標(biāo)為2的位置就出現(xiàn)了hash沖突,key1和key2計(jì)算出了一個(gè)相同的位置。
如果有幾千萬(wàn)或者上億的數(shù)據(jù),布隆過(guò)濾器中的hash沖突會(huì)非常明顯。
如果某個(gè)用戶(hù)key,經(jīng)過(guò)多次hash計(jì)算出的位置,其元素值,恰好都被其他的key初始化成了1。此時(shí),就出現(xiàn)了誤判,原本這個(gè)key在數(shù)據(jù)庫(kù)中是不存在的,但布隆過(guò)濾器確認(rèn)為存在。
如果布隆過(guò)濾器判斷出某個(gè)key存在,可能出現(xiàn)誤判。如果判斷某個(gè)key不存在,則它在數(shù)據(jù)庫(kù)中一定不存在。
通常情況下,布隆過(guò)濾器的誤判率還是比較少的。即使有少部分誤判的請(qǐng)求,直接訪(fǎng)問(wèn)了數(shù)據(jù)庫(kù),但如果訪(fǎng)問(wèn)量并不大,對(duì)數(shù)據(jù)庫(kù)影響也不大。
此外,如果想減少誤判率,可以適當(dāng)增加hash函數(shù),圖中用的3次hash,可以增加到5次。
其實(shí),布隆過(guò)濾器最致命的問(wèn)題是:如果數(shù)據(jù)庫(kù)中的數(shù)據(jù)更新了,需要同步更新布隆過(guò)濾器。但它跟數(shù)據(jù)庫(kù)是兩個(gè)數(shù)據(jù)源,就可能存在數(shù)據(jù)不一致的情況。
比如:數(shù)據(jù)庫(kù)中新增了一個(gè)用戶(hù),該用戶(hù)數(shù)據(jù)需要實(shí)時(shí)同步到布隆過(guò)濾。但由于網(wǎng)絡(luò)異常,同步失敗了。
這時(shí)剛好該用戶(hù)請(qǐng)求過(guò)來(lái)了,由于布隆過(guò)濾器沒(méi)有該key的數(shù)據(jù),所以直接拒絕了該請(qǐng)求。但這個(gè)是正常的用戶(hù),也被攔截了。
很顯然,如果出現(xiàn)了這種正常用戶(hù)被攔截了情況,有些業(yè)務(wù)是無(wú)法容忍的。所以,布隆過(guò)濾器要看實(shí)際業(yè)務(wù)場(chǎng)景再?zèng)Q定是否使用,它幫我們解決了緩存穿透問(wèn)題,但同時(shí)了帶來(lái)了新的問(wèn)題。
1.5 緩存空值
上面使用布隆過(guò)濾器,雖說(shuō)可以過(guò)濾掉很多不存在的用戶(hù)id請(qǐng)求。但它除了增加系統(tǒng)的復(fù)雜度之外,會(huì)帶來(lái)兩個(gè)問(wèn)題:
布隆過(guò)濾器存在誤殺的情況,可能會(huì)把少部分正常用戶(hù)的請(qǐng)求也過(guò)濾了。
如果用戶(hù)信息有變化,需要實(shí)時(shí)同步到布隆過(guò)濾器,不然會(huì)有問(wèn)題。
所以,通常情況下,我們很少用布隆過(guò)濾器解決緩存穿透問(wèn)題。其實(shí),還有另外一種更簡(jiǎn)單的方案,即:緩存空值。
當(dāng)某個(gè)用戶(hù)id在緩存中查不到,在數(shù)據(jù)庫(kù)中也查不到時(shí),也需要將該用戶(hù)id緩存起來(lái),只不過(guò)值是空的。這樣后面的請(qǐng)求,再拿相同的用戶(hù)id發(fā)起請(qǐng)求時(shí),就能從緩存中獲取空數(shù)據(jù),直接返回了,而無(wú)需再去查一次數(shù)據(jù)庫(kù)。
優(yōu)化之后的流程圖如下:
關(guān)鍵點(diǎn)是不管從數(shù)據(jù)庫(kù)有沒(méi)有查到數(shù)據(jù),都將結(jié)果放入緩存中,只是如果沒(méi)有查到數(shù)據(jù),緩存中的值是空的罷了。
2. 緩存擊穿問(wèn)題
2.1 什么是緩存擊穿?
有時(shí)候,我們?cè)谠L(fǎng)問(wèn)熱點(diǎn)數(shù)據(jù)時(shí)。比如:我們?cè)谀硞€(gè)商城購(gòu)買(mǎi)某個(gè)熱門(mén)商品。
為了保證訪(fǎng)問(wèn)速度,通常情況下,商城系統(tǒng)會(huì)把商品信息放到緩存中。但如果某個(gè)時(shí)刻,該商品到了過(guò)期時(shí)間失效了。
此時(shí),如果有大量的用戶(hù)請(qǐng)求同一個(gè)商品,但該商品在緩存中失效了,一下子這些用戶(hù)請(qǐng)求都直接懟到數(shù)據(jù)庫(kù),可能會(huì)造成瞬間數(shù)據(jù)庫(kù)壓力過(guò)大,而直接掛掉。
流程圖如下:
那么,如何解決這個(gè)問(wèn)題呢?
2.2 加鎖
數(shù)據(jù)庫(kù)壓力過(guò)大的根源是,因?yàn)橥粫r(shí)刻太多的請(qǐng)求訪(fǎng)問(wèn)了數(shù)據(jù)庫(kù)。
如果我們能夠限制,同一時(shí)刻只有一個(gè)請(qǐng)求才能訪(fǎng)問(wèn)某個(gè)productId的數(shù)據(jù)庫(kù)商品信息,不就能解決問(wèn)題了?
答:沒(méi)錯(cuò),我們可以用加鎖的方式,實(shí)現(xiàn)上面的功能。
偽代碼如下:
- try {
- String result = jedis.set(productId, requestId, "NX", "PX", expireTime);
- if ("OK".equals(result)) {
- return queryProductFromDbById(productId);
- }
- } finally{
- unlock(productId,requestId);
- }
- return null;
在訪(fǎng)問(wèn)數(shù)據(jù)庫(kù)時(shí)加鎖,防止多個(gè)相同productId的請(qǐng)求同時(shí)訪(fǎng)問(wèn)數(shù)據(jù)庫(kù)。
然后,還需要一段代碼,把從數(shù)據(jù)庫(kù)中查詢(xún)到的結(jié)果,又重新放入緩存中。辦法挺多的,在這里我就不展開(kāi)了。
2.3 自動(dòng)續(xù)期
出現(xiàn)緩存擊穿問(wèn)題是由于key過(guò)期了導(dǎo)致的。那么,我們換一種思路,在key快要過(guò)期之前,就自動(dòng)給它續(xù)期,不就OK了?
答:沒(méi)錯(cuò),我們可以用job給指定key自動(dòng)續(xù)期。
比如說(shuō),我們有個(gè)分類(lèi)功能,設(shè)置的緩存過(guò)期時(shí)間是30分鐘。但有個(gè)job每隔20分鐘執(zhí)行一次,自動(dòng)更新緩存,重新設(shè)置過(guò)期時(shí)間為30分鐘。
這樣就能保證,分類(lèi)緩存不會(huì)失效。
此外,在很多請(qǐng)求第三方平臺(tái)接口時(shí),我們往往需要先調(diào)用一個(gè)獲取token的接口,然后用這個(gè)token作為參數(shù),請(qǐng)求真正的業(yè)務(wù)接口。一般獲取到的token是有有效期的,比如24小時(shí)之后失效。
如果我們每次請(qǐng)求對(duì)方的業(yè)務(wù)接口,都要先調(diào)用一次獲取token接口,顯然比較麻煩,而且性能不太好。
這時(shí)候,我們可以把第一次獲取到的token緩存起來(lái),請(qǐng)求對(duì)方業(yè)務(wù)接口時(shí)從緩存中獲取token。
同時(shí),有一個(gè)job每隔一段時(shí)間,比如每隔12個(gè)小時(shí)請(qǐng)求一次獲取token接口,不停刷新token,重新設(shè)置token的過(guò)期時(shí)間。
2.4 緩存不失效
此外,對(duì)于很多熱門(mén)key,其實(shí)是可以不用設(shè)置過(guò)期時(shí)間,讓其永久有效的。
比如參與秒殺活動(dòng)的熱門(mén)商品,由于這類(lèi)商品id并不多,在緩存中我們可以不設(shè)置過(guò)期時(shí)間。
在秒殺活動(dòng)開(kāi)始前,我們先用一個(gè)程序提前從數(shù)據(jù)庫(kù)中查詢(xún)出商品的數(shù)據(jù),然后同步到緩存中,提前做預(yù)熱。
等秒殺活動(dòng)結(jié)束一段時(shí)間之后,我們?cè)偈謩?dòng)刪除這些無(wú)用的緩存即可。
3. 緩存雪崩問(wèn)題
3.1 什么是緩存雪崩?
前面已經(jīng)聊過(guò)緩存擊穿問(wèn)題了。
而緩存雪崩是緩存擊穿的升級(jí)版,緩存擊穿說(shuō)的是某一個(gè)熱門(mén)key失效了,而緩存雪崩說(shuō)的是有多個(gè)熱門(mén)key同時(shí)失效。看起來(lái),如果發(fā)生緩存雪崩,問(wèn)題更嚴(yán)重。
緩存雪崩目前有兩種:
- 有大量的熱門(mén)緩存,同時(shí)失效。會(huì)導(dǎo)致大量的請(qǐng)求,訪(fǎng)問(wèn)數(shù)據(jù)庫(kù)。而數(shù)據(jù)庫(kù)很有可能因?yàn)榭覆蛔毫?,而直接掛掉?/li>
- 緩存服務(wù)器down機(jī)了,可能是機(jī)器硬件問(wèn)題,或者機(jī)房網(wǎng)絡(luò)問(wèn)題??傊?,造成了整個(gè)緩存的不可用。
歸根結(jié)底都是有大量的請(qǐng)求,透過(guò)緩存,而直接訪(fǎng)問(wèn)數(shù)據(jù)庫(kù)了。
那么,要如何解決這個(gè)問(wèn)題呢?
3.2 過(guò)期時(shí)間加隨機(jī)數(shù)
為了解決緩存雪崩問(wèn)題,我們首先要盡量避免緩存同時(shí)失效的情況發(fā)生。
這就要求我們不要設(shè)置相同的過(guò)期時(shí)間。
可以在設(shè)置的過(guò)期時(shí)間基礎(chǔ)上,再加個(gè)1~60秒的隨機(jī)數(shù)。
- 實(shí)際過(guò)期時(shí)間 = 過(guò)期時(shí)間 + 1~60秒的隨機(jī)數(shù)
這樣即使在高并發(fā)的情況下,多個(gè)請(qǐng)求同時(shí)設(shè)置過(guò)期時(shí)間,由于有隨機(jī)數(shù)的存在,也不會(huì)出現(xiàn)太多相同的過(guò)期key。
3.3 高可用
針對(duì)緩存服務(wù)器down機(jī)的情況,在前期做系統(tǒng)設(shè)計(jì)時(shí),可以做一些高可用架構(gòu)。
比如:如果使用了redis,可以使用哨兵模式,或者集群模式,避免出現(xiàn)單節(jié)點(diǎn)故障導(dǎo)致整個(gè)redis服務(wù)不可用的情況。
使用哨兵模式之后,當(dāng)某個(gè)master服務(wù)下線(xiàn)時(shí),自動(dòng)將該master下的某個(gè)slave服務(wù)升級(jí)為master服務(wù),替代已下線(xiàn)的master服務(wù)繼續(xù)處理請(qǐng)求。
3.4 服務(wù)降級(jí)
如果做了高可用架構(gòu),redis服務(wù)還是掛了,該怎么辦呢?
這時(shí)候,就需要做服務(wù)降級(jí)了。
我們需要配置一些默認(rèn)的兜底數(shù)據(jù)。
程序中有個(gè)全局開(kāi)關(guān),比如有10個(gè)請(qǐng)求在最近一分鐘內(nèi),從redis中獲取數(shù)據(jù)失敗,則全局開(kāi)關(guān)打開(kāi)。后面的新請(qǐng)求,就直接從配置中心中獲取默認(rèn)的數(shù)據(jù)。
當(dāng)然,還需要有個(gè)job,每隔一定時(shí)間去從redis中獲取數(shù)據(jù),如果在最近一分鐘內(nèi)可以獲取到兩次數(shù)據(jù)(這個(gè)參數(shù)可以自己定),則把全局開(kāi)關(guān)關(guān)閉。后面來(lái)的請(qǐng)求,又可以正常從redis中獲取數(shù)據(jù)了。
需要特別說(shuō)一句,該方案并非所有的場(chǎng)景都適用,需要根據(jù)實(shí)際業(yè)務(wù)場(chǎng)景決定。