前言
緩存
在我們日常的開發中,都要資料庫來進行資料的存儲,當系統的使用者量上來之後,系統需要承受大量的并發操作,特别是對資料庫的操作,是首頁通路量瞬間較大的時候,單一使用資料庫來儲存資料的系統會因為面向磁盤,磁盤讀/寫速度比較慢的問題而存在嚴重的性能弊端,一瞬間成千上萬的請求到來,需要系統在極短的時間内完成成千上萬次的讀/寫操作,這個時候往往不是資料庫能夠承受的,極其容易造成資料庫系統癱瘓,最終導緻服務當機的嚴重生産問題。
為了克服這些高并發的問題,系統通常會引入緩存技術,将一些經常通路的熱點資料放在緩存中,
由于緩存是基于記憶體的資料庫,能夠承載大量的并發請求,并且提供一定的持久化功能。
Redis非關系型資料庫就是緩存技術中的一種
引入緩存後的系統架構圖
- 請求資料時,先去檢視緩存中有沒有需要的資料
- 如果緩存中有(緩存命中),就直接傳回
- 如果緩存中沒有,就去請求資料庫,并将結果緩存,然後傳回
但是引入Redis緩存技術又有可能出現緩存穿透,緩存擊穿,緩存雪崩等問題。
緩存穿透
定義
緩存穿透是指請求一個一定不存在的資料,由于緩存中沒有,系統就會去查詢資料庫,而資料庫也沒有,從資料庫查不到資料則不寫入緩存,這将導緻這個不存在的資料每次請求都要到資料庫去查詢
惡意的攻擊者可能利用這個這個漏洞,不斷高并發的請求這個沒有的資料,導緻資料庫無法承載,甚至當機。
解決方案
空結果緩存
- 即使資料庫中沒有這個資料,系統也将這個這個結果進行緩存,并設定短暫的過期時間
- 當下一個請求進來是,就可以在緩存中名字這個資料,并将空結果傳回
- 如果後續資料更新,這個資料存在資料庫中了,由于外面設定了過期時間,緩存中很快就會有這個資料了
public ResponseVo<List<CategoryVo>> selectAll() {
String categoryJson = stringRedisTemplate.opsForValue().get("category");
//如果緩存中沒有,查資料庫
if (StringUtils.isEmpty(categoryJson)) {
//查詢資料庫
List<CategoryVo> categoryVoList = selectAllFromDb();
if (StringUtils.isEmpty(categoryVoList)) {
//庫中沒有此資料,存入一個空值,過期時間為5分鐘,解決緩存穿透問題
stringRedisTemplate.opsForValue().set("category","",5, TimeUnit.MINUTES);
}
return ResponseVo.success(categoryVoList);
}
List<CategoryVo> categoryVoList = gson.fromJson(categoryJson,new TypeToken<List<CategoryVo>>(){}.getType());
return ResponseVo.success(categoryVoList);
}
緩存雪崩
定義
- 緩存雪崩是指在我們設定緩存預設采用了相同的過期時間,導緻緩存在某一時刻全部失效
- 大量的請求全部轉發到資料庫,資料庫的瞬時流量過大,導緻資料庫無法承載而當機。
解決方案
将資料放入緩存時,設定随機過期時間,避免緩存的資料同時失效
緩存擊穿
定義
- 對于一些設定了過期時間的資料,在某些時間節點被超高并發地通路,是一種非常“熱點”的資料。
- 這個時候,緩存資料突然過期,大量的請求高并發的查詢資料庫,導緻資料庫瞬時流量過大
- 這個和緩存雪崩的差別在于這裡針對某一個資料緩存,而緩存雪崩是是很多很多資料同時失效。
解決方案
- 加鎖,給查詢資料庫的操作加鎖,大量的并發請求同時需要查詢資料庫,同時競争一個鎖
- 隻有得到鎖的請求,才能去查詢資料庫
- 當請求查詢資料庫後,将資料緩存,其他請求就可以命中緩存
業務流程
- 先去緩存中判斷緩存中有沒有
- 沒有就去查資料庫,隻有一個線程可以獲得鎖
- 查資料庫,獲得結果
- 将結果放入緩存
public List<CategoryVo> selectAllFromDb() {
//加本地鎖,解決緩存擊穿
synchronized (this){
List<Category> categories = categoryMapper.selectList(null);
List<CategoryVo> categoryVoList = new ArrayList<>();
for(Category category : categories){
if(category.getParentId().equals((ROOT_PARENT_ID))){
CategoryVo categoryVo = new CategoryVo();
BeanUtils.copyProperties(category,categoryVo);
categoryVoList.add(categoryVo);
}
}
//查詢子目錄
findSubCategory(categoryVoList,categories);
//查到結果後将結果序列化,寫入緩存,并設定一個随機的過期時間,解決緩存雪崩問題
//生成5-15之間的一個随機數,設定緩存随機在5-15分鐘内過期
Random random = new Random();
int randomNum = random.nextInt(10)+5;
stringRedisTemplate.opsForValue().set("category",
gson.toJson(categoryVoList),randomNum,TimeUnit.MINUTES);
return categoryVoList;
}
}
鎖不住
- 這種情況其實是鎖不住的,我們可以想象這樣一種場景:
- 大量的請求都在競争鎖,隻有一個線程獲得了鎖,去執行查詢資料庫的操作
- 其他線程被阻塞,待到鎖釋放,這些線程還是會一一競争鎖,去查詢資料庫
- 導緻緩存并沒有生效,所有我們應該再線程獲得鎖之後,再去緩存中判斷
- 緩存中确實沒有,我們才去查資料庫
盡管把判斷緩存,和查資料庫都放在一個同步代碼塊中
仍然不能保證隻查一次資料庫,再來想象一種場景
- 當第一個競争到鎖的線程查詢資料庫完成,釋放鎖
- 還沒來得及将結果放入緩存,第二個線程競争到了鎖
- 判斷緩存中沒有(第一個線程沒來得及放入緩存)
- 第二個線程再查詢了一次資料庫,或者還有第三個,第四個
- 所有我們應該把放入緩存的操作都放在同一個同步代碼塊中
- 這樣就可以保證隻查了一次資料庫
總結
- 以上隻是業務較為簡單的情況下的解決方案,而且使用synchronized會導緻性能大幅下降