天天看點

如何解決緩存穿透,緩存擊穿,緩存雪崩前言

前言

緩存

在我們日常的開發中,都要資料庫來進行資料的存儲,當系統的使用者量上來之後,系統需要承受大量的并發操作,特别是對資料庫的操作,是首頁通路量瞬間較大的時候,單一使用資料庫來儲存資料的系統會因為面向磁盤,磁盤讀/寫速度比較慢的問題而存在嚴重的性能弊端,一瞬間成千上萬的請求到來,需要系統在極短的時間内完成成千上萬次的讀/寫操作,這個時候往往不是資料庫能夠承受的,極其容易造成資料庫系統癱瘓,最終導緻服務當機的嚴重生産問題。

為了克服這些高并發的問題,系統通常會引入緩存技術,将一些經常通路的熱點資料放在緩存中,

由于緩存是基于記憶體的資料庫,能夠承載大量的并發請求,并且提供一定的持久化功能。

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會導緻性能大幅下降