一、前言

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

為了克服上述的問題,專案通常會引入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中獲取,示意圖如下:

快取失效瞬間示意圖如下:

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

參考相關連結:

https://blog.csdn.net/zeb_perfect/article/details/54135506
https://blog.csdn.net/fanrenxiang/article/details/80542580
https://baijiahao.baidu.com/s?id=1619572269435584821&wfr=spider&for=pc
https://blog.csdn.net/xlgen157387/article/details/79530877

視訊資源獲取,可直進百度雲群:

https://pan.baidu.com/mbox/homepage?short=btNBJoN

本文在米兜公眾號連結

https://mp.weixin.qq.com/s/ksVC1049wZgPIOy2gGziNA

歡迎關注米兜Java,一個注在共享、交流的Java學習平臺。