Marco's Java【面試系列篇之 如何設計緩存系統避免緩存雪崩和擊穿】

前言

Redis緩存中心是我們日常開發中必不可少的工具,使用緩存使得我們的數據能夠更快返回給用戶,緩解數據庫的壓力,可以應對類似搶票、商品秒殺的高併發的場景。但是任何產品都不是十全十美的,當我們在設計一個緩存系統時不得不面對幾個問題:緩存穿透、緩存擊穿以及緩存雪崩。

設計緩存系統的三大難題

正如前言所述,在設計緩存系統之初,我們會遇到三大難題,在第一期文章中,我們已經分析過如何使用布隆過濾器來解決緩存穿透的問題,很多初學者會將這三個問題混淆,因此在這裏我們稍加回顧一下。

緩存系統面臨的問題 解釋
緩存穿透 查詢的key對應的數據在數據庫並不存在,每次針對此key的請求從緩存獲取不到,大量請求都會到數據庫,進而壓垮數據庫
緩存擊穿 key存在於數據庫,但在Redis中過期,此時若有大量併發請求過來,並發現Redis查找不到,因此所有請求會去數據庫查找數據,進而壓垮數據庫
緩存雪崩 在某一個時刻,Redis中有大批量的key在某一時間段失效,並瞬時增加數據庫壓力,可能導致數據庫崩潰

之前我們分析過如何避免緩存穿透 ,一般可以將所有查詢到的值緩存起來,查詢不到的值可以緩存一個null值,並設置合理的緩存失效時間(雖然這種方式簡單粗暴,但是憑空的添加了很多無用的數據),或者使用布隆過濾器,將所有可能存在的數據哈希到一個足夠大的bitmap中(譬如在商城中,每一個商品都會有一個商品id,此時可以將數據庫中存在的商品id置入map中),一個一定不存在的數據會被這個bitmap攔截掉,沒有被攔截的數據在數據庫中必定存在,此時會先去查詢Redis,倘若Redis中不存在(可能這個key剛好過期了),再去查詢數據庫,進而減輕數據庫的壓力。
在這裏插入圖片描述
另外兩個問題特別容易混淆,都是key在Redis中過期導致的,只不過緩存擊穿場景下,是針對於某一個key的數據訪問,並且key可能很早就已經過期了,但是一直沒有請求進來並去刷新緩存(惰性刷新),當某一時刻,針對於這個key有大批量的請求訪問進來,那麼同一時刻所有的請求必定會滲透到數據庫中,那麼瞬時的高併發請求可能會壓垮數據庫。
而緩存雪崩的則是針對於某一個時刻,大量的緩存同時失效了,失效的面比較大,那麼當不同的請求進來時,發現這些key並不在Redis中,進而全部去請求數據庫。

瞭解了緩存擊穿以及緩存雪崩的場景之後,接下來我們具體分析一下它們對應的解決方案。

如何應對緩存擊穿

結合剛纔的分析,很容易能夠想到緩存擊穿的本質就是,當某一個key失效時,針對於該key瞬時的請求壓垮數據庫,那麼我們可以試想一下,既然只是一個key失效,那麼我們只讓其中一個線程去數據庫拉取數據,其他的線程等待,待數據庫中的數據同步到了緩存,再讓其他的線程去緩存獲取數據,這樣是不是會大大的減少數據庫的訪問次數,進而減輕數據庫的壓力呢?

因此,這裏給到的第一個方案就是使用互斥鎖(mutex key)的方式控制某一個時刻的線程訪問數據庫的次數。

使用互斥鎖(mutex key)

當緩存失效時,此時有大量的請求去獲取商品id爲10的商品信息,此時先使用Redis的setnx(Set if not exists)方法,或者在set方法中指定過期時間設置一個互斥鎖,當設置鎖成功時,去數據庫加載數據,返回對應的數據並將該數據刷到緩存中,否則,就重試該操作。實現代碼如下。
在這裏插入圖片描述

// 根據key獲取對應的數據
public String get(key) {
	// 先從緩存中獲取數據
    String value = redis.get(key);
    // 若緩存中數據不存在,或者該緩存值過期
    if (value == null) { 
        // 設置超時時間爲5min,避免當鎖remove失敗時,鎖被一直佔用
        // 後續的線程無法執行從數據庫中獲取數據
        if (redis.set(key_mutex, 1, 5 * 60) == 1) {  
				// 當鎖設置成功時,從數據庫中獲取數據
        		value = db.get(key);	
        		// 將數據置入redis中,並設置過期時間
              	redis.set(key, value, expire_secs);
              	// 釋放鎖
              	redis.remove(key_mutex);
         } else {   
         		// 若設置鎖失敗,說明之前已經有線程去數據庫拉取數據了 
         		sleep(100);
         		// 重試獲取緩存值`在這裏插入代碼片`
                get(key);
         }
    } 
    return value;            
}

當然,我們也可以使用memcache做處理,邏輯同上面是一樣的,將加鎖部分代碼替換爲如下代碼

// 當緩存中的數據爲空時
if (memcache.get(key) == null) {  
    // 嘗試去加鎖,並設置加鎖時常爲5min,與redis不同的是memcache的返回值爲true
    // 代表加鎖成功,而redis的setnx方式返回值爲1時代表加鎖成功
    if (memcache.add(key_mutex, 5 * 60 * 1000) == true) {  
        value = db.get(key);  
        memcache.set(key, value);  
        memcache.delete(key_mutex);  
    } else {  
        sleep(50);  
        // 若未獲取到鎖,則重試之前的操作
        retry();  
    }  
}
互斥鎖配合雙重過期時間

上面使用互斥鎖的方式完全可以解決緩存擊穿的問題,但是必須等到所有的請求去緩存中獲取這個key的時候我們才能發現Redis中的這個key是否是已經過期了,那麼有什麼辦法可以提前預知這個Redis中的key有沒有過期呢?
試想我們在key對應的value上設置一個timeout字段,單獨記錄value的過期時間,且value的過期時間短於Redis設置的key的過期時間,那麼每次獲取value成功的時候都去檢測一下這個value的timeout時間是否小於當前時間(value是否過期),如果說value過期了,那麼就重新從數據庫中拉取最新的數據,並且延長過期時間,重新載入緩存中,這樣做的好處就是每次拿到的值基本上都是最新的,並且保證值基本不過期。

因此,我們可以在上面的基礎上我們可以做一些優化。

// 根據key獲取對應的數據
public String get(key) {
	// 先從緩存中獲取數據
    v = redis.get(key);
    // 若緩存中數據不存在,或者該緩存值過期
    if (v == null) { 
        // 設置超時時間爲5min,避免當鎖remove失敗時,鎖被一直佔用
        // 後續的線程無法執行從數據庫中獲取數據
        if (redis.set(key_mutex, 1, 5 * 60) == 1) {  
				// 當鎖設置成功時,從數據庫中獲取數據
        		v = db.get(key);	
        		// 將數據置入redis中
              	redis.set(key, v);
              	// 釋放鎖
              	redis.remove(key_mutex);
         } else {   
         		// 若設置鎖失敗,說明之前已經有線程去數據庫拉取數據了 
         		sleep(100);
         		// 重試獲取緩存值`在這裏插入代碼片`
                get(key);
         }
     } else {
     	// 如果redis中的數據不爲空,判斷該數據是否超時
     	if (v.timeout() <= now()) {
	     	if (redis.set(key_mutex, 1, 5 * 60) == 1) {  
					// 從數據庫中載入數據
	        		v = db.get(key);	
	        		v.timeout += expire_secs;
	        		// 將數據置入redis中,並設置過期時間
	              	redis.set(key, v, expire_secs * 2);
	              	// 釋放鎖
	              	redis.remove(key_mutex);
	         } else {   
	         		// 若設置鎖失敗,說明之前已經有線程去數據庫拉取數據了 
	         		sleep(100);
	         		// 重試獲取緩存值`在這裏插入代碼片`
	                get(key);
	         }
     	}     
     }   
     return v;   
}
設置標誌位提前過期

我們可以通過預設一個緩存標誌位,設置標誌位的過期時間爲實際緩存數據的一半(這一點和上面的操作有些類似),當我們每次去緩存中取值的時候,都會先去查看之前設置的標誌位是否過期,如果沒有過期,則可以確定實際緩存數據也沒有過期,反之重新給標誌位加緩存,並通過fork子線程的方式去數據庫拉去數據,更新實際的緩存數據,並返回舊數據。這樣做的好處就是會使得緩存"永不過期",但是有一定機率會拉取舊的數據。並且每一個實際存儲數據都需要額外維護一個標誌位,會佔用額外的內存空間。
這種方式相較上一種,在性能方面會有很大的優勢,如果你的產品能夠接受一定程度會讀取到髒數據(如微博等),那麼使用這種方式還是可行。
在這裏插入圖片描述

// 根據key獲取對應的數據
public String get(key) {
    int cacheTime = 30;
    String cacheSign = key + "sign";
    // 獲取緩存標記
    String sign = redis.get(cacheSign);
    //獲取緩存值
    String value = redis.get(key);
    if (sign != null) {
        // 當緩存的值未過期時,直接返回
        return value;
    } else {
        redis.add(sign, "1", cacheTime);
        ThreadPool.workItem((arg) -> {
            // 這裏一般是 sql查詢數據
            value = db.get(key);
            // 過期時間設置爲緩存時間的2倍,用於髒讀
            redis.add(key, value, cacheTime * 2);
        });
        return cacheValue;
    }
}

如何應對緩存雪崩

剛纔我們也提到了何爲緩存雪崩,並與緩存擊穿做了對比,加以區分,雪崩問題本質上就是某一時刻大量的緩存(key)同時失效,請求統統轉發到數據庫的問題。因此解決緩存雪崩的思路就是儘可能的減少同一個時刻失效緩存的數量。
在這裏插入圖片描述
因此我們首當其衝能夠想到的解決方案就是將緩存的失效時間離散開,在原有的緩存失效時間上加一個隨機值,當然具體的隨機值的大小該定義多少,還是得看數據的量級,數據量越大,隨機值的浮點後的值要越精確,這樣做的話,緩存的過期時間的重複率會大大降低,偶爾幾個重複值也不會引起雪崩效應的產生。

在這裏插入圖片描述
當然,應對緩存雪崩問題也可以通過加鎖的方案(見緩存擊穿的方案)來解決,亦或是通過隊列的方式,保證緩存是單線程寫入的,那麼此時緩存的失效時間就必定不會重複,避免因緩存雪崩引發的"血案"。

發佈了117 篇原創文章 · 獲贊 10 · 訪問量 7581
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章