緩存的一些常見的坑,你遇到過哪些,怎么解決的?
為什么使用緩存
在高并發請求時,我們會頻繁提到使用緩存技術,最直接的原因是,磁盤IO及網絡開銷是直接請求內存IO的千百上千倍
做個簡單計算,如果我們需要某個數據,該數據從數據庫磁盤讀出來需要0.0045S,經過網絡請求傳輸需要0.0005S,那么每個請求完成最少需要0.005S,該數據服務器每秒最多只能響應200個請求,而如果該數據存于本機內存里,讀出來只需要100us,那么每秒能夠響應10000個請求。通過將數據存儲到離CPU更近的未位置,減少數據傳輸時間,提高處理效率,這就是緩存的意義。
什么場景下適合使用緩存
-
讀密集型的應用
-
存在熱數據的應用
-
對響應時效要求高的應用
-
對一致性要求不嚴格的場景
-
需要實現分布式鎖的時候
什么場景下不適合使用緩存
-
對一致性要求嚴格的場景
-
更新頻繁,更新數據頻率過高的場景,頻繁同步緩存中的數據所花費的代價可能比不使用緩存的代價還要高
-
讀少的場景,對于讀少的系統而言,使用緩存就完全沒有意義了,比較使用緩存是為了讀取數據更高效
緩存收益成本對比
收益
-
加速讀寫。因為緩存通常都是全內存的系統,通過緩存的使用可以有效提高用戶的訪問速度同時優化用戶體驗。
-
降低后端負載。通過添加緩存,如果程序沒有什么問題,在命中率還可以的情況下,可以幫助后端減少訪問量和復雜計算,很大程度降低了后端的負載。
成本
-
數據不一致性。無論設計做的多么好,緩存數據與真實數據源一定存在著一定時間窗口的數據不一致性
-
代碼維護成本。有緩存后,代碼就會在原數據源基礎上加入緩存的相關代碼,例如原來只是一些sql,現在要加入緩存,必然增加代碼維護成本。
-
架構復雜度。有緩存后,需要專門的管理人員來維護主從緩存系統,同時也增加了架構的復雜度和維護成本。
高并發場景下帶來的常見問題
在高并發場景下,緩存主要會帶來下面幾個問題:
1.緩存一致性
2.緩存并發(緩存擊穿)
3.緩存穿透
4.緩存雪崩(緩存失效)
打個比方,你是個很有錢的人,開滿了百度云,騰訊視頻各種雜七雜八的會員,但是你就是沒有netflix的會員,然后你把這些賬號和密碼發布到一個你自己做的網站上,然后你有一個朋友每過十秒鐘就查詢你的網站,發現你的網站沒有Netflix的會員后打電話向你要。你就相當于是個數據庫,網站就是Redis。這就是緩存穿透。
大家都喜歡看騰訊視頻上 周星馳的《喜劇之王》 ,但是你的會員突然到期了,大家在你的網站上看不到騰訊視頻的賬號,紛紛打電話向你詢問,這就是緩存擊穿
你的各種會員突然同一時間都失效了,那這就是緩存雪崩了。
下面一一介紹!
緩存一致性問題
當數據時效性要求很高時,需要保證緩存中的數據與數據庫中的保持一致,而且需要保證緩存節點和副本中的數據也保持一致,不能出現差異現象。這就比較依賴緩存的過期和更新策略。一般會在數據發生更改的時,主動更新緩存中的數據或者移除對應的緩存。
下圖情況都會導致數據一致性問題
緩存擊穿問題
對于一些設置了過期時間的key,可能這些key會在某些時間點被超高并發地訪問,是一種非常“熱點”的數據。這個時候,需要考慮緩存被“擊穿”的問題,和緩存雪崩的區別在于這里針對某一key緩存,而緩存雪崩則是很多key。
如圖所示:
解決方案:
業界比較常用的做法,是使用mutex(互斥鎖)。
1.使用互斥鎖(mutex key)
該方案思路比較簡單,就是只讓一個線程構建緩存,其他線程等待構建緩存的線程執行完,重新從緩存獲取數據就可以
如果是單機可以用synchronized或者lock來處理,如果是分布式環境可以用分布式鎖(redis的setnx, zookeeper的添加節點操作)
redis偽代碼如下:
- public String get(String key) {
- String value = storeClient.get(key);
- StoreKey key_mutex = new MutexStoreKey(key);
- if (value == null) {//代表緩存值過期
- //設置2min的超時,防止刪除緩存操作失敗的時候,下次緩存過期一直不能獲取DB數據
- if (storeClient.setnx(key_mutex, 1, 2 * 60)) { //代表設置成功
- value = db.get(key);
- storeClient.set(key, value, 3 * 3600);
- storeClient.delete(key_mutex);
- } else {
- sleep(1000); //這個時候代表同時候的其他線程已經獲取DB數據并回設到緩存了,這時候重試獲取緩存值即可
- return get(key); //重試
- }
- }
- return value;
- }
2.“提前”使用互斥鎖(mutex key)
即在value內部設置1個超時值(timeout1),timeout1比實際的redis timeout(timeout2)小。
當從cache讀取到timeout1發現它已經過期時候,馬上延長timeout1并重新設置到cache。然后再從數據庫加載數據并設置到cache中。
方案2和方案1的區別在于,如果緩存有數據,但是已經過期,會提前使用互斥鎖,查詢DB最新數據再緩存起來
偽代碼如下,注意代碼else里面的邏輯。
- public String get(String key) {
- MutexDTO value = storeClient.get(key);
- StoreKey key_mutex = new MutexStoreKey(key);
- if (value == null) {
- if (storeClient.setnx(key_mutex, 3 * 60 * 1000)) {
- value = db.get(key);
- storeClient.set(key, value);
- storeClient.delete(key_mutex);
- } else {
- sleep(50);
- get(key);
- }
- } else {
- if (value.getTimeout() <= System.currentTimeMillis()) {
- if (storeClient.setnx(key_mutex, 3 * 60 * 1000)) {
- value.setTimeout(value.getTimeout() + 3 * 60 * 1000);
- storeClient.set(key, value, 3 * 3600 * 2);
- value = db.get(key);//獲取最近DB更新數據
- value.setTimeout(value.getTimeout() + 3 * 60 * 1000);
- storeClient.set(key, value, 3 * 3600 * 2);
- storeClient.delete(key_mutex);
- } else {
- sleep(50);
- get(key);
- }
- }
- }
- return value.getValue();
- }
3.緩存”永不過期“
這里的“永遠不過期”包含兩層意思:
-
從redis上看,不設置過期時間,這就保證了,不會出現熱點key過期問題,也就是“物理”不過期。
-
從功能上看,如果不過期,那不就成靜態的了嗎?所以我們把過期時間存在key對應的value里,如果發現要過期了,通過一個后臺的異步線程進行緩存的構建,也就是“邏輯”過期。
- public String get(String key) {
- MutexDTO mutexDTO = storeClient.get(key);
- String value = mutexDTO.getValue();
- if (mutexDTO.getTimeout() <= System.currentTimeMillis()) {
- ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor();// 異步更新后臺異常執行
- singleThreadExecutor.execute(new Runnable() {
- public void run() {
- StoreKey mutexKey = new MutexStoreKey(key);
- if (storeClient.setnx(mutexKey, "1")) {
- storeClient.expire(mutexKey, 3 * 60);
- String dbValue = db.get(key);
- storeClient.set(key, dbValue);
- storeClient.delete(mutexKey);
- }
- }
- });
- }
- return value;
- }
三種方法如下比較:
解決方案 | 優點 | 缺點 |
---|---|---|
使用互斥鎖 | 1.思路簡單2.保證一致性 | 1. 代碼復雜度增大2. 存在死鎖的風險 |
提前使用互斥鎖 | 1. 保證一致性 | 同上 |
緩存永不過期 | 1. 異步構建緩存,不會阻塞線程池 | 1. 不保證一致性。2. 代碼復雜度增大(每個value都要維護一個timekey)。3. 占用一定的內存空間(每個value都要維護一個timekey)。 |
緩存穿透問題
緩存穿透是指查詢一個一定不存在的數據,由于緩存是不命中時被動寫的,并且出于容錯考慮,如果從存儲層查不到數據則不寫入緩存,這將導致這個不存在的數據每次請求都要到存儲層去查詢,失去了緩存的意義。
在流量大時,要是DB無法承受瞬間流量沖擊,DB可能就掛了。
如圖所示:
解決方案:
有多種方法可以有效解決緩存穿透問題,一種比較簡單粗暴的方法采用緩存空數據,如果一個查詢返回的數據為空(數據庫中不存在該數據),仍然把這個空結果進行緩存(過期時間一般較短)。
另一種方法則是采用常用的布隆過濾器,將所有可能存在的數據哈希到一個足夠大的bitmap中,一個一定不存在的數據會被這個bitmap攔截掉,從而避免了對底層存儲系統的查詢壓力。
1.緩存空數據
當Client請求MISS后,仍然將空對象保留到Cache中(可能是保留一段時間,具體問題具體分析),下次新的Request(同一個key)將會從Cache中獲取到數據,保護了后端的DB。偽代碼如下:
- public class CacheNullService {
- private Cache cache = new Cache();
- private Storage storage = new Storage();
- /**
- * 模擬正常模式
- * @param key
- * @return
- */
- public String getNormal(String key) {
- // 從緩存中獲取數據
- String cacheValue = cache.get(key);
- // 緩存為空
- if (StringUtils.isBlank(cacheValue)) {
- // 從存儲中獲取
- String storageValue = storage.get(key);
- // 如果存儲數據不為空,將存儲的值設置到緩存
- if (StringUtils.isNotBlank(storageValue)) {
- cache.set(key, storageValue);
- }
- return storageValue;
- } else {
- // 緩存非空
- return cacheValue;
- }
- }
- /**
- * 模擬防穿透模式
- * @param key
- * @return
- */
- public String getPassThrough(String key) {
- // 從緩存中獲取數據
- String cacheValue = cache.get(key);
- // 緩存為空
- if (StringUtils.isBlank(cacheValue)) {
- // 從存儲中獲取
- String storageValue = storage.get(key);
- cache.set(key, storageValue);
- // 如果存儲數據為空,需要設置一個過期時間(300秒)
- if (StringUtils.isBlank(storageValue)) {
- cache.expire(key, 60 * 5);
- }
- return storageValue;
- } else {
- // 緩存非空
- return cacheValue;
- }
- }
- }
2.布隆過濾器
BloomFilter 是一個非常有意思的數據結構,不僅僅可以擋住非法 key 攻擊,還可以低成本、高性能地對海量數據進行判斷,比如一個系統有數億用戶和百億級新聞 feed,就可以用 BloomFilter 來判斷某個用戶是否閱讀某條新聞 feed
在訪問所有資源(cache, DB)之前,將存在的key用布隆過濾器提前保存起來,做第一層攔截。算法的簡單圖解如下:
用布隆過濾器實際只需要判斷客戶端傳過來的userCode是否存在就可以,圖中的hash1,hash2,hash3分別代表三種hash算法,不同的userCode對應著不同的數據位,當需要校驗的時候,判斷每一種算法的得出來的byte位是否相同,只要一位不同,那么我們可以認為這個userCode不存在。
一般BloomFilter 要緩存全量的 key,這就要求全量的 key 數量不大,10 億條數據以內最佳,因為 10 億條數據大概要占用 1.2 GB 的內存。也可以用 BloomFilter 緩存非法 key,每次發現一個 key 是不存在的非法 key,就記錄到 BloomFilter 中,這種記錄方案,會導致 BloomFilter 存儲的 key 持續高速增長,為了避免記錄 key 太多而導致誤判率增大,需要定期清零處理
布隆使用案例如下:
- import com.google.common.base.Charsets;
- import com.google.common.hash.BloomFilter;
- import com.google.common.hash.Funnels;
- import java.util.*;
- public class BFDemo {
- private static final int insertions = 1000000;//100W
- public static void main(String[] args) {
- BloomFilter bf = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), insertions);
- Set sets = new HashSet(insertions);
- List<String> lists = new ArrayList(insertions);
- for (int i = 0; i < insertions; i++) {
- String uuid = UUID.randomUUID().toString();
- bf.put(uuid);
- sets.add(uuid);
- lists.add(uuid);
- }
- int wrong = 0;
- int right = 0;
- for (int i = 0; i < 10000; i++) {
- String test = i % 100 == 0 ? lists.get(i / 100) : UUID.randomUUID().toString(); //隨機生成1W字符串
- if (bf.mightContain(test)) {
- if (sets.contains(test)) {
- right++;
- } else {
- wrong++;
- }
- }
- }
- System.out.println("========right=======" + right);
- System.out.println("========wrong=======" + wrong);
- }
- }
上文中提到的兩種方法,比較:
解決方案 | 適用場景 | 優缺點 |
---|---|---|
緩存空數據 | 1. 數據命中不高2. 數據頻繁變化實時性高 | 1.維護簡單2.需要更多的緩存空間3.可能數據不一致 |
布隆過濾器 | 1. 數據命中不高2. 數據相對固定實時性低 | 1.代碼維護復雜2.緩存空間占用少 |
緩存雪崩問題
緩存雪崩一般是由兩個原因導致的
第一個原因是:緩存中有大量數據同時過期,導致大量請求無法得到處理。
如圖所示:
解決方案:
設計不同的過期時間
我們可以避免給大量的數據設置相同的過期時間。如果業務層的確要求有些數據同時失效,你可以在用EXPIRE命令給每個數據設置過期時間時,給這些數據的過期時間增加一個較小的隨機數(例如,隨機增加1~3分鐘),這樣一來,不同數據的過期時間有所差別,但差別又不會太大,既避免了大量數據同時過期,同時也保證了這些數據基本在相近的時間失效,仍然能滿足業務需求
服務降級處理
發生緩存雪崩時,針對不同的數據采取不同的處理方式。
-
當業務應用訪問的是非核心數據時,暫時停止從緩存中查詢這些數據,而是直接返回預定義信息、空值或是錯誤信息;
-
當業務應用訪問的是核心數據時,仍然允許查詢緩存,如果緩存缺失,也可以繼續通過數據庫讀取。
對緩存增加多個副本
緩存異常或請求 miss 后,再讀取其他緩存副本,而且多個緩存副本盡量部署在不同機架,從而確保在任何情況下,緩存系統都會正常對外提供服務
對緩存體系進行實時監控
當請求訪問的慢速比超過閥值時,及時報警,通過機器替換、服務替換進行及時恢復;也可以通過各種自動故障轉移策略,自動關閉異常接口、停止邊緣服務、停止部分非核心功能措施,確保在極端場景下,核心功能的正常運行。
第二個原因是:
如果Cache層由于某些原因(宕機、cache服務掛了或者不響應了)整體crash掉了,也就意味著所有的請求都會達到DB層,所有DB調用量會暴增,所以它有點扛不住了,甚至也會掛掉。
如圖所示:
雪崩過后緩存已經crash,解決方案無非是讓DB能承受更大數據壓力,我們可以DB擴容解決,但不是長久之計,最好解決辦法是如何預防緩存雪崩。
如何預防我們可以從以下方面入手:
保證Cache服務高可用性。
如果我們的cache也是高可用的,即使個別實例掛掉了,影響不會很大(主從切換或者可能會有部分流量到了后端),實現自動化運維。
例如:memcache的一致性hash、redis的sentinel和cluster機制
依賴隔離組件為后端限流。
其實無論是cache或者是mysql、hbase、甚至別人的RPC都會出現問題,我們可以將這些視同為資源,作為并發量較大的系統,假如有一個資源不可訪問了,即使設置了超時時間,依然會hold住所有線程,造成其他資源和接口也不可以訪問。
提前演練預估。
在項目上線前,通過演練,觀察cache crash后,整體系統和DB的負載,提前做好預案。