關於Redis經常被問到的問題:緩存雪崩、緩存穿透、緩存預熱、緩存更新、緩存降級、緩存熱點 key等概念的入門及簡單解決方案。
一、緩存雪崩
緩存雪崩我們可以簡單的理解為:由於原有緩存失效,新緩存未到期間(例如:我們設置緩存時采用了相同的過期時間,在同一時刻出現大面積的緩存過期),所有原本應該訪問緩存的請求都去查詢數據庫了,而對數據庫CPU和內存造成巨大壓力,嚴重的會造成數據庫宕機。從而形成一系列連鎖反應,造成整個系統崩潰。
緩存正常從Redis中獲取,示意圖如下:
緩存失效瞬間示意圖如下:
緩存失效時的雪崩效應對底層系統的沖擊非常可怕!大多數系統設計者考慮用加鎖或者隊列的方式保證來保證不會有大量的線程對數據庫一次性進行讀寫,從而避免失效時大量的並發請求落到底層存儲系統上。還有一個簡單方案就時講緩存失效時間分散開,比如我們可以在原有的失效時間基礎上增加一個隨機值,比如1-5分鍾隨機,這樣每一個緩存的過期時間的重復率就會降低,就很難引發集體失效的事件。
以下簡單介紹兩種實現方式的偽代碼:
(1)碰到這種情況,一般並發量不是特別多的時候,使用最多的解決方案是加鎖排隊,偽代碼如下:
//偽代碼
public object GetProductListNew() { i nt 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個都在阻塞的。同樣會導致用戶等待超時,這是個治標不治本的方法!
注意:加鎖排隊的解決方式分布式環境的並發問題,有可能還要解決分布式鎖的問題;線程還會被阻塞,用戶體驗很差!因此,在真正的高並發場景下很少使用!
(2)還有一個解決辦法解決方案是:給每一個緩存數據增加相應的緩存標記,記錄緩存的是否失效,如果緩存標記失效,則更新數據緩存,實例偽代碼如下:
//偽代碼 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; } }
解釋說明:
1、緩存標記:記錄緩存數據是否過期,如果過期會觸發通知另外的線程在后台去更新實際key的緩存;
2、緩存數據:它的過期時間比緩存標記的時間延長1倍,例:標記緩存時間30分鍾,數據緩存設置為60分鍾。 這樣,當緩存標記key過期后,實際緩存還能把舊數據返回給調用端,直到另外的線程在后台更新完成后,才會返回新緩存。
關於緩存崩潰的解決方法,這里提出了三種方案:使用鎖或隊列、設置過期標志更新緩存、為key設置不同的緩存失效時間,還有一各被稱為“二級緩存”的解決方法,有興趣的讀者可以自行研究。
二、緩存穿透
緩存穿透是指用戶查詢數據,在數據庫沒有,自然在緩存中也不會有。這樣就導致用戶查詢的時候,在緩存中找不到,每次都要去數據庫再查詢一遍,然后返回空(相當於進行了兩次無用的查詢)。這樣請求就繞過緩存直接查數據庫,這也是經常提的緩存命中率問題。
有很多種方法可以有效地解決緩存穿透問題,最常見的則是采用布隆過濾器,將所有可能存在的數據哈希到一個足夠大的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進行預先校驗,然后再放行給后面的正常緩存處理邏輯。
三、緩存預熱
緩存預熱這個應該是一個比較常見的概念,相信很多小伙伴都應該可以很容易的理解,緩存預熱就是系統上線后,將相關的緩存數據直接加載到緩存系統。這樣就可以避免在用戶請求的時候,先查詢數據庫,然后再將數據緩存的問題!用戶直接查詢事先被預熱的緩存數據!
解決思路:
2、數據量不大,可以在項目啟動的時候自動進行加載;
3、定時刷新緩存;
四、緩存更新
除了緩存服務器自帶的緩存失效策略之外(Redis默認的有6中策略可供選擇),我們還可以根據具體的業務需求進行自定義的緩存淘汰,常見的策略有兩種:
(1)定時去清理過期的緩存;
(2)當有用戶請求過來時,再判斷這個請求所用到的緩存是否過期,過期的話就去底層系統得到新數據並更新緩存。
兩者各有優劣,第一種的缺點是維護大量緩存的key是比較麻煩的,第二種的缺點就是每次用戶請求過來都要判斷緩存失效,邏輯相對比較復雜!具體用哪種方案,大家可以根據自己的應用場景來權衡。
五、緩存降級
當訪問量劇增、服務出現問題(如響應時間慢或不響應)或非核心服務影響到核心流程的性能時,仍然需要保證服務還是可用的,即使是有損服務。系統可以根據一些關鍵數據進行自動降級,也可以配置開關實現人工降級。
降級的最終目的是保證核心服務可用,即使是有損的。而且有些服務是無法降級的(如加入購物車、結算)。
在進行降級之前要對系統進行梳理,看看系統是不是可以丟卒保帥;從而梳理出哪些必須誓死保護,哪些可降級;比如可以參考日志級別設置預案:
(1)一般:比如有些服務偶爾因為網絡抖動或者服務正在上線而超時,可以自動降級;
(2)警告:有些服務在一段時間內成功率有波動(如在95~100%之間),可以自動降級或人工降級,並發送告警;
(3)錯誤:比如可用率低於90%,或者數據庫連接池被打爆了,或者訪問量突然猛增到系統能承受的最大閥值,此時可以根據情況自動降級或者人工降級;
(4)嚴重錯誤:比如因為特殊原因數據錯誤了,此時需要緊急人工降級。
六、緩存熱點 key
使用緩存 + 過期時間的策略既可以加速數據讀寫,又保證數據的定期更新,這種模式基本能夠滿足絕大部分需求。但是有兩個問題如果同時出現,可能就會對應用造成致命的危害:
- 當前 key 是一個熱點 key( 可能對應應用的熱賣商品、熱點新聞、熱點評論等),並發量非常大。
- 重建緩存不能在短時間完成,可能是一個復雜計算,例如復雜的 SQL、多次 IO、多個依賴等。
在緩存失效的瞬間,有大量線程來重建緩存 ( 如下圖),造成后端負載加大,甚至可能會讓應用崩潰。
熱點 key 失效后大量線程重建緩存
要解決這個問題也不是很復雜,但是不能為了解決這個問題給系統帶來更多的麻煩,所以需要制定如下目標:
- 減少重建緩存的次數
- 數據盡可能一致
- 較少的潛在危險
1)互斥鎖 (mutex key)
此方法只允許一個線程重建緩存,其他線程等待重建緩存的線程執行完,重新從緩存獲取數據即可,整個過程如圖 ,使用互斥鎖重建緩存
下面代碼使用 Redis 的 setnx 命令實現上述功能,偽代碼:
String get(String key) { //從redis中獲取key String value = redis.get(key); //如果value為空則開始重構緩存 if (value == null) { //只允許一個線程重構緩存,使用nx,並設置過期時間ex String mutexKey = "mutex:key" + key; if (redis.set(mutexKey, "1", "ex 180", "nx")) { //從數據源獲取數據 value = db.get(key); //回寫redis並設置過期時間 redis.set(key, value, timeout); //刪除mutexKey redis.del(mutexKey); } else { //其他線程睡眠50秒再重試 Thread.sleep(50); get(key); } } return value; }
- 從 Redis 獲取數據,如果值不為空,則直接返回值。
- 如果 set(nx 和 ex) 結果為 true,說明此時沒有其他線程重建緩存,那么當前線程執行緩存構建邏輯。
- 如果 setnx(nx 和 ex) 結果為 false,說明此時已經有其他線程正在執行構建緩存的工作,那么當前線程將休息指定時間 (例如這里是 50 毫秒,取決於構建緩存的速度 ) 后,重新執行函數,直到獲取到數據。
2)永遠不過期
永遠不過期”包含兩層意思:
從緩存層面來看,確實沒有設置過期時間,所以不會出現熱點 key 過期后產生的問題,也就是“物理”不過期。
從功能層面來看,為每個 value 設置一個邏輯過期時間,當發現超過邏輯過期時間后,會使用單獨的線程去構建緩存。
” 永遠不過期 ” 策略,整個過程如下圖所示:
從實戰看,此方法有效杜絕了熱點 key 產生的問題,但唯一不足的就是重構緩存期間,會出現數據不一致的情況,這取決於應用方是否容忍這種不一致。下面代碼使用 Redis 進行模擬:
String get(final String key) { V v = redis.get(key); String value = v.getValue(); //邏輯過期時間 final Long logicTimeout = v.getLogicTimeout(); //如果邏輯時間小於當前時間,開始重建緩存 if (logicTimeout <= System.currentTimeMillis()) { final String mutexKey = "mutex:key" + key; if (redis.set(mutexKey, "1", "ex 180", "nx")) { //重建緩存 threadPool.execute(new Runnable() { @Override public void run() { String dbValue = db.get(key); redis.set(key, (dbValue, newLogicTimeout)); redis.del(mutexKey); } }); } } return value; }
作為一個並發量較大的應用,在使用緩存時有三個目標:第一,加快用戶訪問速度,提高用戶體驗。第二,降低后端負載,減少潛在的風險,保證系統平穩。第三,保證數據“盡可能”及時更新。下面將按照這三個維度對上述兩種解決方案進行分析。
互斥鎖 (mutex key):這種方案思路比較簡單,但是存在一定的隱患,如果構建緩存過程出現問題或者時間較長,可能會存在死鎖和線程池阻塞的風險,但是這種方法能夠較好的降低后端存儲負載並在一致性上做的比較好。
” 永遠不過期 “:這種方案由於沒有設置真正的過期時間,實際上已經不存在熱點 key 產生的一系列危害,但是會存在數據不一致的情況,同時代碼復雜度會增大