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中獲取,示意圖如下:
redis1.md

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

緩存失效時的雪崩效應對底層系統的衝擊非常可怕!大多數系統設計者考慮用加鎖或者隊列的方式保證來保證不會有大量的線程對數據庫一次性進行讀寫,從而避免失效時大量的併發請求落到底層存儲系統上。還有一個簡單方案就時講緩存失效時間分散開,比如我們可以在原有的失效時間基礎上增加一個隨機值,比如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持久化策略來保證一定情況下的數據安全。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章