天天看點

緩存穿透、緩存擊穿和緩存雪崩原因+解決方案REDIS緩存穿透,緩存擊穿,緩存雪崩原因+解決方案快速了解緩存穿透與緩存雪崩緩存穿透緩存擊穿緩存雪崩

REDIS緩存穿透,緩存擊穿,緩存雪崩原因+解決方案

一、前言

在我們日常的開發中,無不都是使用資料庫來進行資料的存儲,由于一般的系統任務中通常不會存在高并發的情況,是以這樣看起來并沒有什麼問題,可是一旦涉及大資料量的需求,比如一些商品搶購的情景,或者是首頁通路量瞬間較大的時候,單一使用資料庫來儲存資料的系統會因為面向磁盤,磁盤讀/寫速度比較慢的問題而存在嚴重的性能弊端,一瞬間成千上萬的請求到來,需要系統在極短的時間内完成成千上萬次的讀/寫操作,這個時候往往不是資料庫能夠承受的,極其容易造成資料庫系統癱瘓,最終導緻服務當機的嚴重生産問題。

為了克服上述的問題,項目通常會引入NoSQL技術,這是一種基于記憶體的資料庫,并且提供一定的持久化功能。

redis技術就是NoSQL技術中的一種,但是引入redis又有可能出現緩存穿透,緩存擊穿,緩存雪崩等問題。本文就對這三種問題進行較深入剖析。

二、初認識

  • 緩存穿透:key對應的資料在資料源并不存在,每次針對此key的請求從緩存擷取不到,請求都會到資料源,進而可能壓垮資料源。比如用一個不存在的使用者id擷取使用者資訊,不論緩存還是資料庫都沒有,若黑客利用此漏洞進行攻擊可能壓垮資料庫。
  • 緩存擊穿:key對應的資料存在,但在redis中過期,此時若有大量并發請求過來,這些請求發現緩存過期一般都會從後端DB加載資料并回設到緩存,這個時候大并發的請求可能會瞬間把後端DB壓垮。
  • 緩存雪崩:當緩存伺服器重新開機或者大量緩存集中在某一個時間段失效,這樣在失效的時候,也會給後端系統(比如DB)帶來很大壓力。

三、緩存穿透解決方案

一個一定不存在緩存及查詢不到的資料,由于緩存是不命中時被動寫的,并且出于容錯考慮,如果從存儲層查不到資料則不寫入緩存,這将導緻這個不存在的資料每次請求都要到存儲層去查詢,失去了緩存的意義。

有很多種方法可以有效地解決緩存穿透問題,最常見的則是采用布隆過濾器,将所有可能存在的資料哈希到一個足夠大的bitmap中,一個一定不存在的資料會被 這個bitmap攔截掉,進而避免了對底層存儲系統的查詢壓力。另外也有一個更為簡單粗暴的方法(我們采用的就是這種),如果一個查詢傳回的資料為空(不管是資料不存在,還是系統故障),我們仍然把這個空結果進行緩存,但它的過期時間會很短,最長不超過五分鐘。

粗暴方式僞代碼:

//僞代碼
public object GetProductListNew() {
    int cacheTime = 30;
    String cacheKey = "product_list";

    String cacheValue = CacheHelper.Get(cacheKey);
    if (cacheValue != null) {
        return cacheValue;
    }

    cacheValue = CacheHelper.Get(cacheKey);
    if (cacheValue != null) {
        return cacheValue;
    } else {
        //資料庫查詢不到,為空
        cacheValue = GetProductListFromDB();
        if (cacheValue == null) {
            //如果發現為空,設定個預設值,也緩存起來
            cacheValue = string.Empty;
        }
        CacheHelper.Add(cacheKey, cacheValue, cacheTime);
        return cacheValue;
    }
}
           

四、緩存擊穿解決方案

key可能會在某些時間點被超高并發地通路,是一種非常“熱點”的資料。這個時候,需要考慮一個問題:緩存被“擊穿”的問題。

使用互斥鎖(mutex key)

業界比較常用的做法,是使用mutex。簡單地來說,就是在緩存失效的時候(判斷拿出來的值為空),不是立即去load db,而是先使用緩存工具的某些帶成功操作傳回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一個mutex key,當操作傳回成功時,再進行load db的操作并回設緩存;否則,就重試整個get緩存的方法。

SETNX,是「SET if Not eXists」的縮寫,也就是隻有不存在的時候才設定,可以利用它來實作鎖的效果。

public String get(key) {
      String value = redis.get(key);
      if (value == null) { //代表緩存值過期
          //設定3min的逾時,防止del操作失敗的時候,下次緩存過期一直不能load db
      if (redis.setnx(key_mutex, 1, 3 * 60) == 1) {  //代表設定成功
               value = db.get(key);
                      redis.set(key, value, expire_secs);
                      redis.del(key_mutex);
              } else {  //這個時候代表同時候的其他線程已經load db并回設到緩存了,這時候重試擷取緩存值即可
                      sleep(50);
                      get(key);  //重試
              }
          } else {
              return value;      
          }
 }
           

memcache代碼:

if (memcache.get(key) == null) {  
    // 3 min timeout to avoid mutex holder crash  
    if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {  
        value = db.get(key);  
        memcache.set(key, value);  
        memcache.delete(key_mutex);  
    } else {  
        sleep(50);  
        retry();  
    }  
}
           

其它方案:待各位補充。

五、緩存雪崩解決方案

與緩存擊穿的差別在于這裡針對很多key緩存,前者則是某一個key。

緩存正常從Redis中擷取,示意圖如下:

緩存穿透、緩存擊穿和緩存雪崩原因+解決方案REDIS緩存穿透,緩存擊穿,緩存雪崩原因+解決方案快速了解緩存穿透與緩存雪崩緩存穿透緩存擊穿緩存雪崩

緩存失效瞬間示意圖如下:

緩存穿透、緩存擊穿和緩存雪崩原因+解決方案REDIS緩存穿透,緩存擊穿,緩存雪崩原因+解決方案快速了解緩存穿透與緩存雪崩緩存穿透緩存擊穿緩存雪崩

緩存失效時的雪崩效應對底層系統的沖擊非常可怕!大多數系統設計者考慮用加鎖或者隊列的方式保證來保證不會有大量的線程對資料庫一次性進行讀寫,進而避免失效時大量的并發請求落到底層存儲系統上。還有一個簡單方案就時講緩存失效時間分散開,比如我們可以在原有的失效時間基礎上增加一個随機值,比如1-5分鐘随機,這樣每一個緩存的過期時間的重複率就會降低,就很難引發集體失效的事件。

加鎖排隊,僞代碼如下:

//僞代碼
public object GetProductListNew() {
    int cacheTime = 30;
    String cacheKey = "product_list";
    String lockKey = cacheKey;

    String cacheValue = CacheHelper.get(cacheKey);
    if (cacheValue != null) {
        return cacheValue;
    } else {
        synchronized(lockKey) {
            cacheValue = CacheHelper.get(cacheKey);
            if (cacheValue != null) {
                return cacheValue;
            } else {
              //這裡一般是sql查詢資料
                cacheValue = GetProductListFromDB(); 
                CacheHelper.Add(cacheKey, cacheValue, cacheTime);
            }
        }
        return cacheValue;
    }
}
           

加鎖排隊隻是為了減輕資料庫的壓力,并沒有提高系統吞吐量。假設在高并發下,緩存重建期間key是鎖着的,這是過來1000個請求999個都在阻塞的。同樣會導緻使用者等待逾時,這是個治标不治本的方法!

注意:加鎖排隊的解決方式分布式環境的并發問題,有可能還要解決分布式鎖的問題;線程還會被阻塞,使用者體驗很差!是以,在真正的高并發場景下很少使用!

随機值僞代碼:

//僞代碼
public object GetProductListNew() {
    int cacheTime = 30;
    String cacheKey = "product_list";
    //緩存标記
    String cacheSign = cacheKey + "_sign";

    String sign = CacheHelper.Get(cacheSign);
    //擷取緩存值
    String cacheValue = CacheHelper.Get(cacheKey);
    if (sign != null) {
        return cacheValue; //未過期,直接傳回
    } else {
        CacheHelper.Add(cacheSign, "1", cacheTime);
        ThreadPool.QueueUserWorkItem((arg) -> {
      //這裡一般是 sql查詢資料
            cacheValue = GetProductListFromDB(); 
          //日期設緩存時間的2倍,用于髒讀
          CacheHelper.Add(cacheKey, cacheValue, cacheTime * 2);                 
        });
        return cacheValue;
    }
} 
           

解釋說明:

  • 緩存标記:記錄緩存資料是否過期,如果過期會觸發通知另外的線程在背景去更新實際key的緩存;
  • 緩存資料:它的過期時間比緩存标記的時間延長1倍,例:标記緩存時間30分鐘,資料緩存設定為60分鐘。這樣,當緩存标記key過期後,實際緩存還能把舊資料傳回給調用端,直到另外的線程在背景更新完成後,才會傳回新緩存。

關于緩存崩潰的解決方法,這裡提出了三種方案:使用鎖或隊列、設定過期标志更新緩存、為key設定不同的緩存失效時間,還有一種被稱為“二級緩存”的解決方法。

六、小結

針對業務系統,永遠都是具體情況具體分析,沒有最好,隻有最合适。

于緩存其它問題,緩存滿了和資料丢失等問題,大夥可自行學習。最後也提一下三個詞LRU、RDB、AOF,通常我們采用LRU政策處理溢出,Redis的RDB和AOF持久化政策來保證一定情況下的資料安全。

快速了解緩存穿透與緩存雪崩

緩存穿透

DB承受了沒有必要的查詢流量.

緩存系統,一般流程都是按照key去查詢緩存,如果不存在對應的value,就去後端系統(例如:持久層資料庫)查找。如果key對應的value是一定不存在的,并且對該key并發請求量很大,就會對後端系統造成很大的壓力,這就叫做緩存穿透。

緩存擊穿

熱點Key,大量并發讀請求引起的小雪崩.

在高并發下,對一個特定的值進行查詢,但是這個時候緩存正好過期了,緩存沒有命中,導緻大量請求直接落到資料庫上,如活動系統裡面查詢活動資訊,但是在活動進行過程中活動緩存突然過期了。

正常請求:

緩存穿透、緩存擊穿和緩存雪崩原因+解決方案REDIS緩存穿透,緩存擊穿,緩存雪崩原因+解決方案快速了解緩存穿透與緩存雪崩緩存穿透緩存擊穿緩存雪崩

正常請求

緩存擊穿時:

緩存穿透、緩存擊穿和緩存雪崩原因+解決方案REDIS緩存穿透,緩存擊穿,緩存雪崩原因+解決方案快速了解緩存穿透與緩存雪崩緩存穿透緩存擊穿緩存雪崩

緩存擊穿-如何避免

1. 緩存空結果

對查詢結果為空的情況進行緩存,緩存時間設定短一點,或者該key對應的資料insert了之後清理緩存。

2. 布隆過濾器

采用布隆過濾器,guava有實作api,或者使用redis的bitmap。将所有可能存在的資料哈希到一個足夠大的bitmap中,一個一定不存在的資料會被這個bitmap攔截掉,進而避免了對底層存儲系統的查詢壓力。布隆過濾器對于固定的資料可以起到很好的效果,但是對于頻繁更新的資料,布隆過濾器的建構會面臨很多問題。另外布隆過濾器是有判斷誤差的,網上有很多詳細的介紹,請讀者自行搜尋即可。

緩存穿透、緩存擊穿和緩存雪崩原因+解決方案REDIS緩存穿透,緩存擊穿,緩存雪崩原因+解決方案快速了解緩存穿透與緩存雪崩緩存穿透緩存擊穿緩存雪崩

布隆過濾器

緩存雪崩

緩存設定同一過期時間,引起的DB洪峰.

當緩存伺服器重新開機或者大量緩存集中在某一個時間段失效,這樣在失效的時候,也會給後端系統(比如DB)帶來很大壓力。

如何避免

1. 互斥鎖

在緩存失效後,通過加鎖或者隊列來控制讀資料庫寫緩存的線程數量。比如對某個key隻允許一個線程查詢資料和寫緩存,其他線程等待。

緩存穿透、緩存擊穿和緩存雪崩原因+解決方案REDIS緩存穿透,緩存擊穿,緩存雪崩原因+解決方案快速了解緩存穿透與緩存雪崩緩存穿透緩存擊穿緩存雪崩

互斥鎖

如果是單機,可以用synchronized或者lock來處理,如果是分布式環境就需要使用分布式鎖。

使用互斥鎖,代碼如下,僅适用redis2.6.1以後支援setnx的版本。在緩存失效的時候(判斷拿出來的值為空),不是立即去load db,而是先使用redis的setnx操作去set一個mutex key。當操作傳回成功時,再進行load db的操作并回設緩存,否則,就重試整個get緩存的方法。

緩存穿透、緩存擊穿和緩存雪崩原因+解決方案REDIS緩存穿透,緩存擊穿,緩存雪崩原因+解決方案快速了解緩存穿透與緩存雪崩緩存穿透緩存擊穿緩存雪崩

互斥鎖

public String get(key) {
 List<String> resultList = (List<String>)redisTemplate.opsForValue().get(key);
 if(CollectionUtils.isEmpty(resultList)){
 final String mutexKey = key + "_lock";
 boolean isLock = (Boolean) redisTemplate.execute(new RedisCallback() {
 @Override
 public Object doInRedis(RedisConnection connection) throws DataAccessException {
 //隻在鍵key不存在的情況下,将鍵key的值設定為value,若鍵key已經存在,則 SETNX 指令不做任何動作
 //指令在設定成功時傳回 1 , 設定失敗時傳回 0
 return connection.setNX(mutexKey.getBytes(),"1".getBytes());
 }
 });
 if(isLock){
 //設定成1秒過期
 redisTemplate.expire(mutexKey, 1000, TimeUnit.MILLISECONDS);
 resultList = getValueBySql(key);
 redisTemplate.opsForValue().set(key, resultList, 1000, TimeUnit.SECONDS);
 redisTemplate.delete(mutexKey);
 }else{
 //線程休息50毫秒後重試
 Thread.sleep(50);
 retryCount--;
 System.out.println("=====進行重試,目前次數:" + retryCount);
 if(retryCount == 0){
 System.out.println("====這裡發郵件或者記錄下擷取不到資料的日志,并為key設定一個空置防止重複擷取");
 List<String> list = Lists.newArrayList("no find");
 redisTemplate.opsForValue().set(key, list, 1000, TimeUnit.SECONDS);
 return list;
 }
 return getCacheSave2(key,retryCount);
 }
 }
 return resultList;
}
      

2. 設定随機過期時間

不同的key,設定不同的過期時間,讓緩存失效時間分散開,比如可以在原有的失效時間基礎上增加一個随機值,比如1-5分鐘随機,這樣每一個緩存的過期時間的重複率就會降低。

3. 設定二級緩存

做二級緩存,A1為原始緩存,A2為拷貝緩存,A1失效時,可以通路A2,A1緩存失效時間設定為短期,A2設定為長期

4. “永遠不過期”

“永遠不過期”包含兩層意思:

  1. 從redis上看,确實沒有設定過期時間,這就保證了,不會出現熱點key過期問題,也就是“實體”不過期。
  2. 從功能上看,把過期時間存在key對應的value裡,如果發現要過期了,通過一個背景的異步線程進行緩存的建構,也就是“邏輯”過期。
緩存穿透、緩存擊穿和緩存雪崩原因+解決方案REDIS緩存穿透,緩存擊穿,緩存雪崩原因+解決方案快速了解緩存穿透與緩存雪崩緩存穿透緩存擊穿緩存雪崩

“永遠不過期”

這種方法對于性能非常友好,唯一不足的就是建構緩存時候,其餘線程(非建構緩存的線程)可能通路的是老資料,但是對于一般的網際網路功能來說這個還是可以忍受。

緩存預熱

有效應對緩存的擊穿和雪崩的一種方式是緩存預熱。

緩存預熱就是系統上線前,将相關的緩存資料直接加載到緩存系統。這樣就可以避免在使用者請求的時候,先查詢資料庫,然後再将資料緩存的問題,使用者直接查詢事先被預熱的緩存資料。

解決思路

  1. 直接寫個緩存重新整理頁面,上線時手工操作下。
  2. 資料量不大,可以在項目啟動的時候自動進行加載。
  3. 定時重新整理緩存。

限流

有效應對緩存的擊穿和雪崩的另一種方式是限流。

在緩存失效後,通過隊列來控制讀資料庫寫緩存的線程數量。比如對某個key隻允許一個線程查詢資料和寫緩存,其他線程等待。

常見的限流算法

  1. 固定時間視窗算法(計數器)
  2. 滑動時間視窗算法
  3. 令牌桶算法
  4. 漏桶算法

有關限流算法的詳細介紹,請點選檢視高并發系統的限流算法與實作

總結

緩存穿透、擊穿和雪崩是以預防為主、補救為輔,而在應對緩存的問題其實也沒有一個完全完美的方案,隻有最适合自己業務系統的方案。

其他補充說明參考:

https://blog.csdn.net/zeb_perfect/article/details/54135506

繼續閱讀