Redis緩存雪崩、緩存穿透、熱點Key解決方案和分析
緩存穿透
緩存系統,按照KEY去查詢VALUE,當KEY對應的VALUE一定不存在的時候並對KEY併發請求量很大的時候,就會對後端造成很大的壓力。
(查詢一個必然不存在的數據。比如文章表,查詢一個不存在的id,每次都會訪問DB,如果有人惡意破壞,很可能直接對DB造成影響。)
由於緩存不命中,每次都要查詢持久層。從而失去緩存的意義。
解決方法:
1、緩存層緩存空值。
–緩存太多空值,佔用更多空間。(優化:給個空值過期時間)
–存儲層更新代碼了,緩存層還是空值。(優化:後臺設置時主動刪除空值,並緩存把值進去)
2、將數據庫中所有的查詢條件,放到布隆過濾器中。當一個查詢請求來臨的時候,先經過布隆過濾器進行檢查,如果請求存在這個條件中,那麼繼續執行,如果不在,直接丟棄。
備註:
比如數據庫中有10000個條件,那麼布隆過濾器的容量size設置的要稍微比10000大一些,比如12000.
對於誤判率的設置,根據實際項目,以及硬件設施來具體決定。但是一定不能設置爲0,並且誤判率設置的越小,哈希函數跟數組長度都會更多跟更長,那麼對硬件,內存中間的要求就會相應的高。
private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), size, 0.0001);
有了size跟誤判率,那麼布隆過濾器就會產生相應的哈希函數跟數組。
綜上:我們可以利用布隆過濾器,將redis緩存擊穿控制在一個可容忍的範圍內。
緩存雪崩(緩存失效)
如果緩存集中在一段時間內失效,發生大量的緩存穿透,所有的查詢都落在數據庫上,造成了緩存雪崩。
緩存層宕掉後,流量會像奔逃的野牛一樣,打向後端存儲
解決方法:
- 在緩存失效後,通過加鎖或者隊列來控制讀數據庫寫緩存的線程數量。比如對某個key只允許一個線程查詢數據和寫緩存,其他線程等待。
- 可以通過緩存reload機制,預先去更新緩存,再即將發生大併發訪問前手動觸發加載緩存
- 不同的key,設置不同的過期時間,讓緩存失效的時間點儘量均勻
- 做二級緩存,或者雙緩存策略。A1爲原始緩存,A2爲拷貝緩存,A1失效時,可以訪問A2,A1緩存失效時間設置爲短期,A2設置爲長期。
熱點key
(1) 這個key是一個熱點key(例如一個重要的新聞,一個熱門的八卦新聞等等),所以這種key訪問量可能非常大。
(2) 緩存的構建是需要一定時間的。(可能是一個複雜計算,例如複雜的sql、多次IO、多個依賴(各種接口)等等)
於是就會出現一個致命問題:在緩存失效的瞬間,有大量線程來構建緩存(見下圖),造成後端負載加大,甚至可能會讓系統崩潰 。
解決方法:
1. 使用互斥鎖(mutex key):這種解決方案思路比較簡單,就是隻讓一個線程構建緩存,其他線程等待構建緩存的線程執行完,重新從緩存獲取數據就可以了
2. "提前"使用互斥鎖(mutex key):在value內部設置1個超時值(timeout1), timeout1比實際的memcache timeout(timeout2)小。當從cache讀取到timeout1發現它已經過期時候,馬上延長timeout1並重新設置到cache。然後再從數據庫加載數據並設置到cache中。
3. "永遠不過期":
這裏的“永遠不過期”包含兩層意思:
(1) 從redis上看,確實沒有設置過期時間,這就保證了,不會出現熱點key過期問題,也就是“物理”不過期。
(2) 從功能上看,如果不過期,那不就成靜態的了嗎?所以我們把過期時間存在key對應的value裏,如果發現要過期了,通過一個後臺的異步線程進行緩存的構建,也就是“邏輯”過期
4. 資源保護:可以做資源的隔離保護主線程池,如果把這個應用到緩存的構建也未嘗不可。
四種方案對比:
作爲一個併發量較大的互聯網應用,我們的目標有3個:
1. 加快用戶訪問速度,提高用戶體驗。
2. 降低後端負載,保證系統平穩。
3. 保證數據“儘可能”及時更新(要不要完全一致,取決於業務,而不是技術。)
所以第二節中提到的四種方法,可以做如下比較,還是那就話:沒有最好,只有最合適。
解決方案 | 優點 | 缺點 |
簡單分佈式鎖(Tim yang) |
1. 思路簡單 2. 保證一致性 |
1. 代碼複雜度增大 2. 存在死鎖的風險 3. 存在線程池阻塞的風險 |
加另外一個過期時間(Tim yang) | 1. 保證一致性 | 同上 |
不過期(本文) |
1. 異步構建緩存,不會阻塞線程池 |
1. 不保證一致性。 2. 代碼複雜度增大(每個value都要維護一個timekey)。 3. 佔用一定的內存空間(每個value都要維護一個timekey)。 |
資源隔離組件hystrix(本文) |
1. hystrix技術成熟,有效保證後端。 2. hystrix監控強大。
|
1. 部分訪問存在降級策略。 |
總結
1. 熱點key + 過期時間 + 複雜的構建緩存過程 => mutex key問題
2. 構建緩存一個線程做就可以了。
3. 四種解決方案:沒有最佳只有最合適。
分析和相關解決
什麼是緩存穿透
一般的緩存系統,都是按照key值去緩存查詢,如果不存在對應的value,就應該去DB中查找 。這個時候,如果請求的併發量很大,就會對後端的DB系統造成很大的壓力。這就叫做緩存穿透。關鍵詞:緩存value爲空;併發量很大去訪問DB。
造成的原因
1.業務自身代碼或數據出現問題;2.一些惡意攻擊、爬蟲造成大量空的命中,此時會對數據庫造成很大壓力。
解決方法
1.設置布隆過濾器,將所有可能存在的數據哈希到一個足夠大的bitmap中,一個一定不存在的數據會被這個bitmap攔截掉,
從避免了對底層存儲系統的查詢壓力。
2. 如果一個查詢返回的數據爲空,不管是數據不存在還是系統故障,我們仍然把這個結果進行緩存,但是它的過期時間會很短
最長不超過5分鐘。
二、雪崩
1.什麼是雪崩
因爲緩存層承載了大量的請求,有效的保護了存儲 層,但是如果緩存由於某些原因,整體不能夠提供服務,於是所有的請求,就會到達存儲層,存儲層的調用量就會暴增,造成存儲層也會掛掉的情況。緩存雪崩的英文解釋是奔逃的野牛,指的是緩存層當掉之後,併發流量會像奔騰的野牛一樣,大量後端存儲。
存在這種問題的一個場景是:當緩存服務器重啓或者大量緩存集中在某一個時間段失效,這樣在失效的時候,大量數據會去直接訪問DB,此時給DB很大的壓力。
2.解決方法
(1)設置redis集羣和DB集羣的高可用,如果redis出現宕機情況,可以立即由別的機器頂替上來。這樣可以防止一部分的風險。
(2)使用互斥鎖
在緩存失效後,通過加鎖或者隊列來控制讀和寫數據庫的線程數量。比如:對某個key只允許一個線程查詢數據和寫緩存,其他線程等待。單機的話,可以使用synchronized或者lock來解決,如果是分佈式環境,可以是用redis的setnx命令來解決。
(3)不同的key,可以設置不同的過期時間,讓緩存失效的時間點不一致,儘量達到平均分佈。
(4)永遠不過期
redis中設置永久不過期,這樣就保證了,不會出現熱點問題,也就是物理上不過期。
(5)資源保護
使用netflix的hystrix,可以做各種資源的線程池隔離,從而保護主線程池。
3.使用
四種方案,沒有最佳只有最合適, 根據自己項目情況使用不同的解決策略。
懂的越多,不會的也就越多,知識之路是不斷進取的
把redis作爲緩存使用已經是司空見慣,但是使用redis後也可能會碰到一系列的問題,尤其是數據量很大的時候,經典的幾個問題如下:
(一)緩存和數據庫間數據一致性問題
分佈式環境下(單機就不用說了)非常容易出現緩存和數據庫間的數據一致性問題,針對這一點的話,只能說,如果你的項目對緩存的要求是強一致性的,那麼請不要使用緩存。我們只能採取合適的策略來降低緩存和數據庫間數據不一致的概率,而無法保證兩者間的強一致性。合適的策略包括 合適的緩存更新策略,更新數據庫後要及時更新緩存、緩存失敗時增加重試機制,例如MQ模式的消息隊列。
(二)緩存擊穿問題
緩存擊穿表示惡意用戶模擬請求很多緩存中不存在的數據,由於緩存中都沒有,導致這些請求短時間內直接落在了數據庫上,導致數據庫異常。這個我們在實際項目就遇到了,有些搶購活動、秒殺活動的接口API被大量的惡意用戶刷,導致短時間內數據庫c超時了,好在數據庫是讀寫分離,同時也有進行接口限流,hold住了。
解決方案的話:
方案1、使用互斥鎖排隊
業界比價普遍的一種做法,即根據key獲取value值爲空時,鎖上,從數據庫中load數據後再釋放鎖。若其它線程獲取鎖失敗,則等待一段時間後重試。這裏要注意,分佈式環境中要使用分佈式鎖,單機的話用普通的鎖(synchronized、Lock)就夠了。
1 public String getWithLock(String key, Jedis jedis, String lockKey, String uniqueId, long expireTime) {
2 // 通過key獲取value
3 String value = redisService.get(key);
4 if (StringUtil.isEmpty(value)) {
5 // 分佈式鎖,詳細可以參考https://blog.csdn.net/fanrenxiang/article/details/79803037
6 //封裝的tryDistributedLock包括setnx和expire兩個功能,在低版本的redis中不支持
7 try {
8 boolean locked = redisService.tryDistributedLock(jedis, lockKey, uniqueId, expireTime);
9 if (locked) {
10 value = userService.getById(key);
11 redisService.set(key, value);
12 redisService.del(lockKey);
13 return value;
14 } else {
15 // 其它線程進來了沒獲取到鎖便等待50ms後重試
16 Thread.sleep(50);
17 getWithLock(key, jedis, lockKey, uniqueId, expireTime);
18 }
19 } catch (Exception e) {
20 log.error("getWithLock exception=" + e);
21 return value;
22 } finally {
23 redisService.releaseDistributedLock(jedis, lockKey, uniqueId);
24 }
25 }
26 return value;
27 }
這樣做思路比較清晰,也從一定程度上減輕數據庫壓力,但是鎖機制使得邏輯的複雜度增加,吞吐量也降低了,有點治標不治本。
方案2、接口限流與熔斷、降級
重要的接口一定要做好限流策略,防止用戶惡意刷接口,同時要降級準備,當接口中的某些服務不可用時候,進行熔斷,失敗快速返回機制。
方案3、布隆過濾器
bloomfilter就類似於一個hash set,用於快速判某個元素是否存在於集合中,其典型的應用場景就是快速判斷一個key是否存在於某容器,不存在就直接返回。布隆過濾器的關鍵就在於hash算法和容器大小,下面先來簡單的實現下看看效果,我這裏用guava實現的布隆過濾器:
1 <dependencies>
2 <dependency>
3 <groupId>com.google.guava</groupId>
4 <artifactId>guava</artifactId>
5 <version>23.0</version>
6 </dependency>
7 </dependencies>
8 public class BloomFilterTest {
9
10 private static final int capacity = 1000000;
11 private static final int key = 999998;
12
13 private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), capacity);
14
15 static {
16 for (int i = 0; i < capacity; i++) {
17 bloomFilter.put(i);
18 }
19 }
20
21 public static void main(String[] args) {
22 /*返回計算機最精確的時間,單位微妙*/
23 long start = System.nanoTime();
24
25 if (bloomFilter.mightContain(key)) {
26 System.out.println("成功過濾到" + key);
27 }
28 long end = System.nanoTime();
29 System.out.println("布隆過濾器消耗時間:" + (end - start));
30 int sum = 0;
31 for (int i = capacity + 20000; i < capacity + 30000; i++) {
32 if (bloomFilter.mightContain(i)) {
33 sum = sum + 1;
34 }
35 }
36 System.out.println("錯判率爲:" + sum);
37 }
38 }
成功過濾到999998
布隆過濾器消耗時間:215518
錯判率爲:318
可以看到,100w個數據中只消耗了約0.2毫秒就匹配到了key,速度足夠快。然後模擬了1w個不存在於布隆過濾器中的key,匹配錯誤率爲318/10000,也就是說,出錯率大概爲3%,跟蹤下BloomFilter的源碼發現默認的容錯率就是0.03:
1 public static <T> BloomFilter<T> create(Funnel<T> funnel, int expectedInsertions /* n */) {
2 return create(funnel, expectedInsertions, 0.03); // FYI, for 3%, we always get 5 hash functions
3 }
我們可調用BloomFilter的這個方法顯式的指定誤判率:
private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), capacity,0.01);
我們斷點跟蹤下,誤判率爲0.02和默認的0.03時候的區別:
對比兩個出錯率可以發現,誤判率爲0.02時數組大小爲8142363,0.03時爲7298440,誤判率降低了0.01,BloomFilter維護的數組大小也減少了843923,可見BloomFilter默認的誤判率0.03是設計者權衡系統性能後得出的值。要注意的是,布隆過濾器不支持刪除操作。用在這邊解決緩存穿透問題就是:
1 public String getByKey(String key) {
2 // 通過key獲取value
3 String value = redisService.get(key);
4 if (StringUtil.isEmpty(value)) {
5 if (bloomFilter.mightContain(key)) {
6 value = userService.getById(key);
7 redisService.set(key, value);
8 return value;
9 } else {
10 return null;
11 }
12 }
13 return value;
14 }
(三)緩存雪崩問題
緩存在同一時間內大量鍵過期(失效),接着來的一大波請求瞬間都落在了數據庫中導致連接異常。
解決方案:
方案1、也是像解決緩存穿透一樣加鎖排隊,實現同上;
方案2、建立備份緩存,緩存A和緩存B,A設置超時時間,B不設值超時時間,先從A讀緩存,A沒有讀B,並且更新A緩存和B緩存;
方案3、設置緩存超時時間的時候加上一個隨機的時間長度,比如這個緩存key的超時時間是固定的5分鐘加上隨機的2分鐘,醬紫可從一定程度上避免雪崩問題;
1 public String getByKey(String keyA,String keyB) {
2 String value = redisService.get(keyA);
3 if (StringUtil.isEmpty(value)) {
4 value = redisService.get(keyB);
5 String newValue = getFromDbById();
6 redisService.set(keyA,newValue,31, TimeUnit.DAYS);
7 redisService.set(keyB,newValue);
8 }
9 return value;
10 }
(四)緩存併發問題
這裏的併發指的是多個redis的client同時set key引起的併發問題。其實redis自身就是單線程操作,多個client併發操作,按照先到先執行的原則,先到的先執行,其餘的阻塞。當然,另外的解決方案是把redis.set操作放在隊列中使其串行化,必須的一個一個執行,具體的代碼就不上了,當然加鎖也是可以的,至於爲什麼不用redis中的事務,留給各位看官自己思考探究。
一、緩存
Redis做緩存是最常見的應用場景。客戶端請求在緩存層命中就直接返回,如果miss就去讀取存儲層,存儲層讀取到就寫入緩存層,然後再返回到客戶端。
優點:
加速讀寫
降低後端負載
缺點:
數據的不一致性
代碼維護成本
運維成本
二、緩存穿透優化
然而緩存可能會遇到這種問題:請求cache拿不到數據,就會去存儲層拿,都拿不到時,返回空值(可能會返回大量空值)。或者代碼有問題,拿不到數據。就會一直請求數據。導致後端打崩。
優化方法:
1、緩存層緩存空值。
–緩存太多空值,佔用更多空間。(優化:給個空值過期時間)
–存儲層更新代碼了,緩存層還是空值。(優化:後臺設置時主動刪除空值,並緩存把值進去)
三、緩存雪崩優化
redis掛了,客戶端直接請求到數據庫裏面。數據庫負載非常高。甚至數據庫拖掛了。
優化方法:
1、保持緩存層服務器的高可用。
–監控、集羣、哨兵。當一個集羣裏面有一臺服務器有問題,讓哨兵踢出去。
2、依賴隔離組件爲後端限流並降級。
比如推薦服務中,如果個性化推薦服務不可用,可以降級爲熱點數據。
3、提前演練。
演練 緩存層crash後,應用以及後端的負載情況以及可能出現的問題。
對此做一些預案設定。
四、熱點key 重建優化:
A、B、C、D同時請求一個資源,不存在時都要去請求存儲層,有可能會拖掛。
優化方法:
1、互斥鎖:
只允許一個請求重建緩存。
其他請求等待緩存重建執行完,重新從緩存獲取數據即可。
2、用戶過期
–“物理”不過期
–邏輯設置過期時間(根據上一次更新時間,構建一個隊列,主動去更新)