緩存穿透,緩存擊穿,緩存雪崩處理

爲了應對越來越大的流量,緩存便成爲系統服務必不可少的一部分,但使用緩存就會出現緩存擊穿和緩存穿透的威脅。

一、背景介紹

       互聯網應用逐步深入到生活的各個角落,爲了滿足越來越多用戶使用互聯網應用的需求,幾乎所有互聯網公司都採用緩存的方案來解決瞬時流量超高,或者長期流量過高的問題。但使用緩存存在風險——緩存穿透和緩存擊穿:簡單的講就是如果該數據原本就不存在,那麼就會發生緩存穿透;如果緩存內容因爲各種原因失效,那麼就會發生緩存擊穿。
 
       具體一點來說,如果緩存中不存在需要查詢的內容,一般情況下需要再深入一層進行查詢,一般爲不能承受壓力的關係型數據庫(承壓能力爲緩存的1%,甚至更低),如果數據庫中不存在,則叫做緩存穿透;反之,如果數據庫中存在這個數據,則叫做緩存擊穿(如果同一時刻大量的緩存失效叫做緩存雪崩,本文暫不討論該問題)。這種查詢在流量不高的情況下,不會出現問題,如果查詢數據庫的流量過高,尤其是數據庫中不存在的情況下,嚴重時會導致數據庫不可用,連帶影響使用數據庫的其他業務,本業務也有很大的可能性受到影響。

二、緩存穿透常見的處理方式

2.1 空值緩存

       既然該數據本身就不存在,最簡單粗暴的方式就是直接將不存在的值定義爲空(視具體業務和緩存的方式定義爲null或者””)。具體方式是每次查詢完數據庫,我們可以將key在緩存中設置對應的值爲空,短期內再次查詢這個key的時候就不用查詢數據庫了。
通常的簡單做法:

這裏需要強調注意:爲了系統的最終一致性,這些key必須設置過期時間,或者必須存在更新方式,防止這個key的數據後期真實存在,但改key始終爲空,導致數據不一致的情況出現。

 public String getById(String key) {
        String value = jimdbClient.get(key);
        if (value == null) {
            value = testMapper.get(key);
            if (value == null) {
                jimdbClient.set(key, null, 60, TimeUnit.SECONDS, false);
                return null;
            }
        }
    }

這種方式的缺點也十分的明顯:如果key數量巨大且分散無任何規律,就會浪費大量緩存空間,並且不能抗住瞬時流量衝擊(尤其是遇到惡意的攻擊的時候,有可能將緩存空間打爆,影響範圍更大),需要額外配置降級開關(查詢數據庫的開關或者限流),這時本方案就顯得沒想象的那麼美好。針對不能抗住瞬時流量的情況,常見的處理方式是使用計數器,對不存在的key進行計數,當某個key在一定時間達到一定的量級,就查詢一次數據庫,按照數據庫的返回值對key進行緩存。未達指定閾值數量之前,按照商定的空值返回。

 
       故這種解決方案的建議使用場景爲:key全集數據數據量級較小,並且完全可預測,可以通過提前填充的方式直接將數據緩存。

2.2 布隆過濾器(BloomFilter)

       本質上布隆過濾器是一種數據結構,比較巧妙的概率型數據結構(probabilistic data structure),特點是高效地插入和查詢,可以用來告訴你 “某樣東西一定不存在或者可能存在”。相比於傳統的 List、Set、Map 等數據結構,它更高效、佔用空間更少,但是缺點是其返回的結果是概率性的,而不是確切的。實際應用中,Google BigTable,Apache HBbase 和 Apache Cassandra 使用布隆過濾器減少對不存在的行和列的查找。
 
       布隆過濾器的原理如下:

       假如“京東”經過hash後佔位爲618,加入後,如下所示:

       假如大促經過hash後佔位爲爲678,加入後,如下所示:

       假如某寶hash後佔位256,則可以完全判斷該key不存在,直接返回null即可。但某某多hash後佔位167,則不能判斷是否存在,需要進行查庫操作進行判斷。
 public void put(String rediskey, String key) {
        long[] indexs = getIndexs(key);
        for (long index : indexs) {
            jimdbClient.setBit(rediskey, index,true);
        }
    }
    
    /**
     * 根據key獲取bitmap下標
     */
    private long[] getIndexs(String key) {
        long hash1 = hash(key);
        long hash2 = hash1 >>> 16;
        long[] result = new long[numHashFunctions];
        for (int i = 0; i < numHashFunctions; i++) {
            long combinedHash = hash1 + i * hash2;
            if (combinedHash < 0) {
                combinedHash = ~combinedHash;
            }
            result[i] = combinedHash % numBits;
        }
        return result;
    }

    /**
     * 獲取一個hash值
     */
    private long hash(String key) {
        Charset charset = Charset.forName("UTF-8");
        return Hashing.murmur3_128().hashObject(key, Funnels.stringFunnel(charset)).asLong();
    }

這裏需要強調注意的是必須使用高效的hash算法,否則這種方式會嚴重影響系統的性能,建議的算法包括MurmurHash、Fnv的穩定高效的算法。

       使用過程中,通常把有數據的key都放到BloomFilter中,每次查詢的時候都先去BloomFilter判斷,如果沒有就直接返回空。由於布隆過濾器不支持刪除操作(具體結合上圖推算刪除一個就可以得知),對於刪除的key,查詢就會經過BloomFilter然後查詢緩存再查詢數據庫,所以BloomFilter建議結合緩存空值用,對於刪除的key,可以在緩存中緩存空。(當然有同學自行實現了可刪除的布隆過濾器——Counting Bloom Filter,原理較爲簡單,本文不做具體分析,個人感覺將簡單的問題複雜化了)。
 
       同樣它也不支持擴容操作,這就要求布隆過濾器建立初期必須進行嚴格的推算,確保後期不需要擴容,否則重建布隆過濾器的成本可能會超乎想象。具體的推算公式如下(具體的推算過程非本文重點,請各位自行查找):

       k 爲哈希函數的個數,m 爲布隆過濾器的長度,n 爲插入元素的個數(需要處理的數據個數),p 爲誤報率。
       布隆過濾器的使用場景受key的狀態限制,如果key是動態無規律的,不建議使用該方式。作者使用布隆過濾器作爲用戶是否參與活動的過濾,布隆過濾器在活動期間最大值爲目前的會員數量,完全可控。
       考慮真實情況下,緩存的存儲空間及性能問題,在真實使用中,爲了避免熱key和大key的問題,首先對用戶標識進行了Hash,首先對hash按照布隆過濾器的數量進行取餘,確定使用哪個布隆過濾器,然後使用布隆過濾器。具體情況如下:

三、緩存擊穿常見的處理方式

3.1 互斥鎖(mutex key)

        這是比較常見的做法,是在緩存失效的時候,不是立即去查詢數據庫,先搶互斥鎖(比如Redis的SETNX一個mutex key),當操作返回成功時(即獲取到互斥鎖),再進行查詢數據庫的操作並回設緩存;否則,就重試整個獲取緩存的方法或者直接返回空。SETNX,官方的解釋是隻在鍵 key 不存在的情況下,將鍵 key 的值設置爲 value 。若鍵 key 已經存在,則 SETNX 命令不做任何動作。SETNX 是『SET if Not eXists』(如果不存在,則SET)的簡寫。
        但這種方式顯然存在問題,Redis的SETNX命令是當key不存在時設置key,但SETNX不能同時完成EXPIRE設置失效時長,不能保證SETNX和EXPIRE的原子性。這會導致SETNX成功但EXPIRE失敗時,鎖永遠不會釋放,下面提供了一種可行的代碼,供大家討論:
public String getById(String key) {
        String value = jimdbClient.get(key);
        if (value == null) {
            boolean nx = jimdbClient.set(lock_key, "test-lock", 2, TimeUnit.SECONDS, false);
            if (nx) {
                value = testMapper.get(key);
                jimdbClient.set(key, value, 60 * 60 * 24, TimeUnit.SECONDS, false);
                return value;
            } else {
                Thread.sleep(100);
                return getById(key);
            }
        }
    }

       Redis還是善解人意的,從 2.6.12 起,我們可以使用SET命令完成SETNX和EXPIRE的操作,並且這種操作是原子操作,可以完全替代上述的代碼了。

       這種簡單粗暴的方式有着嚴格是使用場景(換句話說就是有着嚴重的缺陷),如果緩存大量失效(緩存雪崩),那麼對於數據庫是一場災難;如果數據庫查詢緩慢,不僅對數據是一場災難,對於使用該緩存的接口會造成線程阻塞,接口性能又開啓了另外一場災難!一般情況下,這種方式適用於永久緩存的key,或者key偶爾丟失的情況下,其他情況請各位讀者慎重考慮或增加其他機制保護數據庫和接口(如使用Hystrix限流&降級等)。

3.2 異步構建緩存

        當緩存失效時,不是立刻去查詢數據庫,而是先創建緩存更新的異步任務,然後直接返回空值。這種做法不會阻塞當前線程,並且對於數據庫的壓力基本可控,但犧牲了整體數據的一致性。從實際的使用看,這種方法對於性能非常友好,唯一不足的就是構建緩存時候,所有查詢返回的內容均爲空值,但是對於一致性要求不高的互聯網功能來說這個還是可以忍受。這種情況一般通過接二進制的binlog,異步構建緩存。

四、緩存雪崩的常見的處理方式

緩存雪崩是指緩存中數據大批量到過期時間,而查詢數據量巨大,引起數據庫壓力過大甚至down機。和緩存擊穿不同的是,        緩存擊穿指併發查同一條數據,緩存雪崩是不同數據都過期了,很多數據都查不到從而查數據庫。

緩存數據的過期時間設置隨機,防止同一時間大量數據過期現象發生。

 public String getById(String key) {
        Random random = new Random();
        int r = random.nextInt(100);
        String value = jimdbClient.get(key);
        if (value == null) {
            boolean nx = jimdbClient.set(lock_key, "test-lock", 2, TimeUnit.SECONDS, false);
            if (nx) {
                value = testMapper.get(key);
                jimdbClient.set(key, value, r, TimeUnit.SECONDS, false);
                return value;
            } else {
                Thread.sleep(100);
                return getById(key);
            }
        }
    }

五、總結

綜上所述,針對常見的緩存穿透和緩存擊穿的問題,各自的優缺點如下:

       實戰過程中,緩存穿透和緩存擊穿是必須考慮的問題,如果基礎建設不夠穩固(Redis集羣),或者緩存時間設置的存在問題,那就必須考慮緩存雪崩(大批量key失效或者集羣不可用)的問題,解決的思路大同小異。筆者工作過程中,不同的場景下,會混合使用上面的幾種方式,確保系統的穩定和安全。
 
       條條大路通羅馬,希望讀者能從本文得到一點點啓發,開發出符合自己業務的解決方案
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章