緩存穿透、擊穿、雪崩什么的別在傻傻分不清楚?看了這篇文后,你就明白了

對(duì)于緩存,大家肯定都不陌生,不管是前端還是服務(wù)端開(kāi)發(fā),緩存幾乎都是必不可少的優(yōu)化方式之一。在實(shí)際生產(chǎn)環(huán)境中,緩存的使用規(guī)范也是一直備受重視的,如果使用的不好,很容易就遇到緩存擊穿、雪崩等嚴(yán)重異常情景,從而給系統(tǒng)帶來(lái)難以預(yù)料的災(zāi)害。

為了避免緩存使用不當(dāng)帶來(lái)的損失,我們有必要了解每種異常產(chǎn)生的原因和解決辦法,從而做出更好的預(yù)防措施。

緩存穿透

而緩存穿透是指緩存和數(shù)據(jù)庫(kù)中都沒(méi)有的數(shù)據(jù),這樣每次請(qǐng)求都會(huì)去查庫(kù),不會(huì)查緩存,如果同一時(shí)間有大量請(qǐng)求進(jìn)來(lái)的話(huà),就會(huì)給數(shù)據(jù)庫(kù)造成巨大的查詢(xún)壓力,甚至擊垮db系統(tǒng)。

image

比如說(shuō)查詢(xún)id為-1的商品,這樣的id在商品表里肯定不存在,如果沒(méi)做特殊處理的話(huà),攻擊者很容易可以讓系統(tǒng)奔潰,那我們?cè)撊绾伪苊膺@種情況發(fā)生呢?

一般來(lái)說(shuō),緩存穿透常用的解決方案大概有兩種:

一、緩存空對(duì)象

當(dāng)緩存和數(shù)據(jù)都查不到對(duì)應(yīng)key的數(shù)據(jù)時(shí),可以將返回的空對(duì)象寫(xiě)到緩存中,這樣下次請(qǐng)求該key時(shí)直接從緩存中查詢(xún)返回空對(duì)象,就不用走db了。當(dāng)然,為了避免存儲(chǔ)過(guò)多空對(duì)象,通常會(huì)給空對(duì)象設(shè)置一個(gè)比較短的過(guò)期時(shí)間,就比如像這樣給key設(shè)置30秒的過(guò)期時(shí)間:

redisTemplate.opsForValue().set(key, null, 30, TimeUnit.SECONDS);

這種方法會(huì)存在兩個(gè)問(wèn)題:

  • 如果有大量的key穿透,緩存空對(duì)象會(huì)占用寶貴的內(nèi)存空間。

  • 空對(duì)象的key設(shè)置了過(guò)期時(shí)間,這段時(shí)間內(nèi)可能數(shù)據(jù)庫(kù)剛好有了該key的數(shù)據(jù),從而導(dǎo)致數(shù)據(jù)不一致的情況。

這種情況下,我們可以用更好的解決方案,也就是 布隆過(guò)濾器

二、Bloom Filter

布隆過(guò)濾器(Bloom Filter)是1970年由一個(gè)叫布隆的小伙子提出的,是一種由一個(gè)很長(zhǎng)的二進(jìn)制向量和一系列隨機(jī)映射函數(shù)構(gòu)成的概率型數(shù)據(jù)結(jié)構(gòu),這種數(shù)據(jù)結(jié)構(gòu)的空間效率非常高,可以用于檢索集合中是否存在特定的元素。

設(shè)計(jì)思想

布隆過(guò)濾器由一個(gè)長(zhǎng)度為m比特的位數(shù)組(bit array)與k個(gè)哈希函數(shù)(hash function)組成的數(shù)據(jù)結(jié)構(gòu)。原理是當(dāng)一個(gè)元素被加入集合時(shí),通過(guò)K個(gè)散列函數(shù)將這個(gè)元素映射成一個(gè)位數(shù)組中的K個(gè)點(diǎn),把它們置為1。檢索時(shí),我們只要看看這些點(diǎn)是不是都是1就大約知道集合中有沒(méi)有它了,也就是說(shuō), 如果這些點(diǎn)有任何一個(gè)0,則被檢元素一定不在;如果都是1,則被檢元素很可能在。

至于說(shuō)為什么都是1的情況只是可能存在檢索元素,這是因?yàn)椴煌脑赜?jì)算的哈希值有可能一樣,會(huì)出現(xiàn)哈希碰撞,導(dǎo)致一個(gè)不存在的元素有可能對(duì)應(yīng)的比特位為1。

舉個(gè)例子:下圖是一個(gè)布隆過(guò)濾器,共有18個(gè)比特位,3個(gè)哈希函數(shù)。當(dāng)查詢(xún)某個(gè)元素w時(shí),通過(guò)三個(gè)哈希函數(shù)計(jì)算,發(fā)現(xiàn)有一個(gè)比特位的值為0,可以肯定認(rèn)為該元素不在集合中。

image

優(yōu)缺點(diǎn)

優(yōu)點(diǎn):

  • 節(jié)省空間:不需要存儲(chǔ)數(shù)據(jù)本身,只需要存儲(chǔ)數(shù)據(jù)對(duì)應(yīng)hash比特位

  • 時(shí)間復(fù)雜度低:基于哈希算法來(lái)查找元素,插入和查找的時(shí)間復(fù)雜度都為O(k),k為哈希函數(shù)的個(gè)數(shù)

缺點(diǎn):

  • 準(zhǔn)確率有誤:布隆過(guò)濾器判斷存在,可能出現(xiàn)元素不在集合中;判斷準(zhǔn)確率取決于哈希函數(shù)的個(gè)數(shù)

  • 不能刪除元素:如果一個(gè)元素被刪除,但是卻不能從布隆過(guò)濾器中刪除,這樣進(jìn)一步導(dǎo)致了不存在的元素也會(huì)顯示1的情況。

適用場(chǎng)景

  • 爬蟲(chóng)系統(tǒng)url去重

  • 垃圾郵件過(guò)濾

  • 黑名單

緩存擊穿

緩存擊穿從字面上看很容易讓人跟穿透搞混,這也是很多面試官喜歡埋坑的地方,當(dāng)然,只要我們對(duì)知識(shí)點(diǎn)了然于心的話(huà),面試的時(shí)候也不會(huì)那么被糊弄

簡(jiǎn)單來(lái)說(shuō),緩存擊穿是指一個(gè)key非常熱點(diǎn),在不停的扛著大并發(fā),大并發(fā)集中對(duì)這一個(gè)點(diǎn)進(jìn)行訪(fǎng)問(wèn),當(dāng)這個(gè)key在失效的瞬間,持續(xù)的大并發(fā)就穿破緩存,直接請(qǐng)求數(shù)據(jù)庫(kù),就好像堤壩突然破了一個(gè)口,大量洪水洶涌而入。

當(dāng)發(fā)生緩存擊穿的時(shí)候,數(shù)據(jù)庫(kù)的查詢(xún)壓力會(huì)倍增,導(dǎo)致大量的請(qǐng)求阻塞。

解決辦法也不難,既然是熱點(diǎn)key,那么說(shuō)明該key會(huì)一直被訪(fǎng)問(wèn),既然如此,我們就不對(duì)這個(gè)key設(shè)置失效時(shí)間了,如果數(shù)據(jù)需要更新的話(huà),我們可以后臺(tái)開(kāi)啟一個(gè)異步線(xiàn)程,發(fā)現(xiàn)過(guò)期的key直接重寫(xiě)緩存即可。

當(dāng)然,這種解決方案只適用于不要求數(shù)據(jù)嚴(yán)格一致性的情況,因?yàn)楫?dāng)后臺(tái)線(xiàn)程在構(gòu)建緩存的時(shí)候,其他的線(xiàn)程很有可能也在讀取數(shù)據(jù),這樣就會(huì)訪(fǎng)問(wèn)到舊數(shù)據(jù)了。

如果要嚴(yán)格保證數(shù)據(jù)一致的話(huà),可以用互斥鎖

互斥鎖

互斥鎖就是說(shuō),當(dāng)key失效的時(shí)候,讓一個(gè)線(xiàn)程讀取數(shù)據(jù)并構(gòu)建到緩存中,其他線(xiàn)程就先等待,直到緩存構(gòu)建完后重新讀取緩存即可。

如果是單機(jī)系統(tǒng),用JDK本身的同步工具Synchronized或ReentrantLock就可以實(shí)現(xiàn),但一般來(lái)說(shuō),都達(dá)到防止緩存擊穿的流量了誰(shuí)還搞什么單機(jī)系統(tǒng),肯定是分布式高大上點(diǎn)啊,這種情況我們就可以用分布式鎖來(lái)做互斥效果。

為了你們能更懂流程,作為暖男的我還是一如既往的給你們準(zhǔn)備了偽代碼啦:

public String getData(String key){
    String data = redisTemplate.opsForValue().get(key);
    if (StringUtils.isNotEmpty(data)){
        return data;
    }
    String lockKey = this.getClass().getName() + ":" + key;
    RLock lock = redissonClient.getLock(lockKey);
    try {
        boolean boo = lock.tryLock(5, 5, TimeUnit.SECONDS);
        if (!boo) {
            // 休眠一會(huì)兒,然后再請(qǐng)求
            Thread.sleep(200L);
            data = getData(key);
        }
        // 讀取數(shù)據(jù)庫(kù)的數(shù)據(jù)
        data = getDataByDB(key);
        if (StringUtils.isNotEmpty(data)){
            // 把數(shù)據(jù)構(gòu)建到緩存中
            setDataToRedis(key,data);
        }
    } catch (InterruptedException e) {
        // 異常處理,記錄日志或者拋異常什么的
    }finally {
        if (lock != null && lock.isLocked()){
            lock.unlock();
        }
    }
    return data;
}

當(dāng)然,采用互斥鎖的方案也是有缺陷的,當(dāng)緩存失效的時(shí)候,同一時(shí)間只有一個(gè)線(xiàn)程讀數(shù)據(jù)庫(kù)然后回寫(xiě)緩存,其他線(xiàn)程都處于阻塞狀態(tài)。如果是高并發(fā)場(chǎng)景,大量線(xiàn)程阻塞勢(shì)必會(huì)降低吞吐量。這種情況該如何處理呢?我只能說(shuō)沒(méi)什么設(shè)計(jì)是完美的,你又想數(shù)據(jù)一致,又想保證吞吐量,哪有那么好的事,為了系統(tǒng)能更加健全,必要的時(shí)候犧牲下性能也是可以采取的措施,兩者之間怎么取舍要根據(jù)實(shí)際業(yè)務(wù)場(chǎng)景來(lái)決定,萬(wàn)能的技術(shù)方案什么的根本不存在。

緩存雪崩

緩存雪崩也是key失效后大量請(qǐng)求打到數(shù)據(jù)庫(kù)的異常情況,不過(guò),跟緩存擊穿不同的是,緩存擊穿因?yàn)橹敢粋€(gè)熱點(diǎn)key失效導(dǎo)致的情況,而緩存雪崩是指緩存中 大批量的數(shù)據(jù) 同時(shí)過(guò)期,巨大的請(qǐng)求量直接落到db層,引起db壓力過(guò)大甚至宕機(jī),這也符合字面上的“雪崩”說(shuō)法。

image

解決方案

緩存雪崩的解決方案和擊穿的思路一致,可以 設(shè)置key不過(guò)期 或者 互斥鎖 的方式。

除此之外,因?yàn)槭穷A(yù)防大面積的key同時(shí)失效,可以 給不同的key過(guò)期時(shí)間加上隨機(jī)值,讓緩存失效的時(shí)間點(diǎn)盡量均勻 ,這樣可以保證數(shù)據(jù)不會(huì)在同一時(shí)間大面積失效 。

redisTemplate.opsForValue().set(Key, value, time + Math.random() * 1000, TimeUnit.SECONDS); 

同時(shí)還可以結(jié)合 主備緩存策略 來(lái)讓互斥鎖的方式更加的可靠,

主緩存:有效期按照經(jīng)驗(yàn)值設(shè)置,設(shè)置為主讀取的緩存,主緩存失效后從數(shù)據(jù)庫(kù)加載最新值。

備份緩存:有效期長(zhǎng),獲取鎖失敗時(shí)讀取的緩存,主緩存更新時(shí)需要同步更新備份緩存。

一般來(lái)說(shuō),上面三種緩存異常場(chǎng)景問(wèn)的比較多,了解這幾種基本就夠了,但有些面試官可能喜歡劍走偏鋒,進(jìn)一步延伸其他的異常情景做詢(xún)問(wèn),以防萬(wàn)一,我們也加個(gè)菜,介紹下另外兩種常見(jiàn)緩存異常。

緩存預(yù)熱

緩存預(yù)熱就是系統(tǒng)上線(xiàn)后,先將相關(guān)的數(shù)據(jù)構(gòu)建到緩存中,這樣就可以避免用戶(hù)請(qǐng)求的時(shí)候直接查庫(kù)。

這部分預(yù)熱的數(shù)據(jù)主要取決于訪(fǎng)問(wèn)量和數(shù)據(jù)量大小,如果數(shù)據(jù)的訪(fǎng)問(wèn)量不大的話(huà),那么就沒(méi)必要做預(yù)熱,都沒(méi)什么多少請(qǐng)求了,直接按正常的緩存讀取流程執(zhí)行就好。

訪(fǎng)問(wèn)量大的話(huà),也要看數(shù)據(jù)的大小來(lái)做預(yù)熱措施。

  • 數(shù)據(jù)量不大的時(shí)候,工程啟動(dòng)的時(shí)候進(jìn)行加載緩存動(dòng)作,這種數(shù)據(jù)一般可以是電商首頁(yè)的運(yùn)營(yíng)位之類(lèi)的信息;

  • 數(shù)據(jù)量大的時(shí)候,設(shè)置一個(gè)定時(shí)任務(wù)腳本,進(jìn)行緩存的刷新;

  • 數(shù)據(jù)量太大的時(shí)候,優(yōu)先保證熱點(diǎn)數(shù)據(jù)進(jìn)行提前加載到緩存,并且確保訪(fǎng)問(wèn)期間不能更改緩存,比如用定時(shí)器在秒殺活動(dòng)前30分鐘就把商品信息之類(lèi)的刷新到緩存,同時(shí)規(guī)定后臺(tái)運(yùn)營(yíng)人員不能在秒殺期間更改商品屬性。

緩存降級(jí)

緩存降級(jí)是指緩存失效或緩存服務(wù)器掛掉的情況下,不去訪(fǎng)問(wèn)數(shù)據(jù)庫(kù),直接返回默認(rèn)數(shù)據(jù)或訪(fǎng)問(wèn)服務(wù)的內(nèi)存數(shù)據(jù)。

在項(xiàng)目實(shí)戰(zhàn)中通常會(huì)將部分熱點(diǎn)數(shù)據(jù)緩存到服務(wù)的內(nèi)存中,類(lèi)似HashMap、Guava這樣的工具,一旦緩存出現(xiàn)異常,可以直接使用服務(wù)的內(nèi)存數(shù)據(jù),從而避免數(shù)據(jù)庫(kù)遭受巨大壓力。

當(dāng)然,這樣的操作對(duì)于業(yè)務(wù)是有損害的,分布式系統(tǒng)中很容易就出現(xiàn)數(shù)據(jù)不一致的問(wèn)題,所以,一般這種情況下,我們都優(yōu)先保證從運(yùn)維角度確保緩存服務(wù)器的高可用性,比如Redis的部署采用集群方式,同時(shí)做好備份,總之,盡量避免出現(xiàn)降級(jí)的影響。

最后

關(guān)于緩存的幾大異常處理我們就講解到這了,雖然每種異常我們都給出了解決的方案,但不是說(shuō)這玩意直接套上就能用了?,F(xiàn)實(shí)開(kāi)發(fā)過(guò)程中還是要根據(jù)實(shí)際情況來(lái)針對(duì)緩存做相應(yīng)措施,比如用布隆過(guò)濾器預(yù)防緩存穿透雖然很有效,但并不算特別常用,這年頭,防止惡意攻擊什么的都是先在運(yùn)維層面做限制,業(yè)務(wù)代碼層面更多的是對(duì)參數(shù)和數(shù)據(jù)做校驗(yàn)。

如果每個(gè)使用緩存的地方都要考慮的這么復(fù)雜的話(huà),那工作量無(wú)疑會(huì)更加繁雜,過(guò)度設(shè)計(jì)只會(huì)讓代碼維護(hù)起來(lái)也麻煩,而且實(shí)用性還不一定強(qiáng),沒(méi)必要啊。程序員嘛,給自己增添煩惱的事情越少越好,畢竟我們最大的敵人不是996,而是那珍貴的發(fā)量啊。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀(guān)點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容