帶你搞明白什麼是緩存穿透、緩存擊穿、緩存雪崩

在這裏插入圖片描述

文章開始之前先做個找工作的介紹吧,由於這次疫情影響,我一個UI朋友的公司破產之後他現在處於找工作階段,一直沒有找到自己合適的,三年工作經驗左右,座標深圳,如果有招UI的朋友可以聯繫我。

作品: http://yiming.zcool.com.cn/

緩存是互聯網開發中必不可少的一部分,它能降低我們數據庫的併發數,提高我們系統的性能,比如我們經常使用的redis、emCached等等,其中redis應該是大部分的人選,爲什麼?因爲速度快,易上手,是很多開發者的首選,但是緩存同樣存在這問題,如果使用的不恰當,也可能會造成非常嚴重的後果,這時候你可能就會有疑問,緩存只是存儲一些數據而已,怎麼會造成嚴重的後果呢?下面我就帶大家一起來分析分析。

什麼是緩存

緩存(cache),原始意義是指訪問速度比一般隨機存取存儲器(RAM)快的一種高速存儲器,通常它不像系統主存那樣使用DRAM技術,而使用昂貴但較快速的SRAM技術。緩存的設置是所有現代計算機系統發揮高性能的重要因素之一。

比如我們的redis、他就是緩存中比較常見的一種,他的併發讀寫能力能達到10w/s左右的速度,這個速度是相當不錯的,相對於傳統的數據存儲來說,比如數據庫,快了不知道多少倍,傳統的數據庫(mysql)操作的都是磁盤,而redis操作的是內存(ram),所以他們的速度肯定是沒法比較的,由於傳統數據庫的讀寫較慢,所以併發較高的時候就會造成性能瓶頸問題,這也是爲什麼需要引入緩存的原因之一。

人在地上走,鍋從天上來

我是一個快樂的程序狗,每天最快樂的事情就是codding,最大的願望就是能準時6點下班,然後回家,但是今天肯定是走不了了,現在是17:30,我們的測試小哥哥給我提了一個很詭異的bug,難受啊,我的準時下班夢,但是作爲一個程序狗,肯定都有着一顆和bug戰鬥到底的決心,究竟是什麼bug呢?

bug是這樣的:併發請求訂單信息,沒過幾秒就拋出系統錯誤。這個bug看着沒幾個字,但是一看就知道不好解決,尤其是像這種併發bug,能讓人瞬間白了頭,隨後我找到了測試,讓他們復現了這個神祕的bug,而我也找到了產生這個bug的來源,並且快速的修復了他,到底是什麼問題呢?是因爲小明(同事)在編寫代碼的時候考慮的不是很周全導致的,所以開發一定要想仔細了再動手,否則吃虧的就是自己啊。

緩存穿透

什麼是緩存穿透

緩存穿透指的是:同一時刻,大量的併發請求數據庫中不存在的信息,他既不會命中緩存,也不會命中數據庫,但是他會查找數據庫。

上面的bug也是因爲它產生的,測試的小哥哥查詢的訂單都是數據庫不存在的,所以這個時候這些併發請求都不會命中緩存(redis),將直達數據庫(mysql),由於大量的併發請求到達數據庫,而數據庫承受不住這麼高的併發,從而導致數據庫奔潰,這就是緩存穿透

重現bug

1.新建數據表:訂單表,結構如下:
在這裏插入圖片描述
2.編寫測試代碼

OrderBo.java

package com.ymy.bo;

import lombok.Data;

import java.io.Serializable;
import java.math.BigDecimal;

@Data
public class OrderBo implements  Serializable {

    /**
     * 自增id
     */
    private Long id;

    /**
     *訂單編號
     */
    private Long orderCode;

    /**
     *訂單價格
     */
    private BigDecimal orderPrice;

    /**
     *商品名稱
     */
    private String  peoductName;

    /**
     *創建時間
     */
    private String createTime;
}

OrderController.java

package com.ymy.controller;

import com.ymy.bo.OrderBo;
import com.ymy.service.OrderService;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class OrderController {

    private OrderService orderService;

    public OrderController(OrderService orderService){
        this.orderService = orderService;
    }

    @RequestMapping(value = "/detail",method = RequestMethod.GET)
    public OrderBo getDetail(@RequestParam("id") Long id){

        return orderService.getDetail(id);
    }


}

OrderService.java

package com.ymy.service;

import com.ymy.bo.OrderBo;
import com.ymy.mapper.OrderMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class OrderService {

    private RedisTemplate redisTemplate;

    private OrderMapper orderMapper;

    public OrderService(RedisTemplate redisTemplate,OrderMapper orderMapper){
        this.redisTemplate = redisTemplate;
        this.orderMapper = orderMapper;
    }
    /**
     * 通過id查詢訂單詳情
     * @param id
     * @return
     */
    public OrderBo getDetail(Long id) {

        //緩存中查詢詞詞訂單
        OrderBo  orderBo = (OrderBo) redisTemplate.opsForValue().get("order:" + id);
        if(orderBo != null ){
            log.info("緩存中查詢到了信息,直接返回:{}",orderBo);
            return orderBo;
        }
        log.info("前往數據庫查詢");
        orderBo =  orderMapper.getDetail(id);
        if(orderBo != null ){
           //將數據保存到數據庫,有效時間一小時
           redisTemplate.opsForValue().set("order:" + id,orderBo,3600,TimeUnit.SECONDS);
       	   log.info("數據已經存入緩存");
        }
        return orderBo;
    }
}

OrderMapper.java

package com.ymy.mapper;

import com.ymy.bo.OrderBo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

@Mapper
public interface OrderMapper {
    /**
     * 通過訂單id查詢訂單信息
     * @param id
     * @return
     */
    @Select(" select id,order_code as orderCode,order_price as orderPrice,peoduct_name as peoductName,create_time as createTime from orders where id = #{id} ")
    OrderBo getDetail(Long id);
}

RedisConfig.java

package com.ymy.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);

        // 使用Jackson2JsonRedisSerialize 替換默認序列化
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);

        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);

        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

        // 設置value的序列化規則和 key的序列化規則
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}

上面的代碼實現的功能很簡單,通過訂單id查詢訂單詳情,不過查詢的循序是先查緩存,如果緩存沒有數據,在查詢數據庫,大致流程圖如下:
在這裏插入圖片描述
這個過程很簡單,看上去沒有什麼問題,如果你仔細觀察的話就會發現一個致命的問題,就是剛剛說的緩存穿透問題,我們來做個實驗。

我在數據庫提前添加了一條數據,信息如下:
在這裏插入圖片描述
正常情況:查詢id等於1的訂單信息。

第一次:
在這裏插入圖片描述

2020-04-19 15:55:35.564  INFO 20188 --- [nio-9900-exec-1] com.ymy.service.OrderService             : 前往數據庫查詢
2020-04-19 15:55:35.675  INFO 20188 --- [nio-9900-exec-1] com.ymy.service.OrderService             : 數據已經存入緩存

由於是第一次查詢,所以緩存中不會存在數據,請求直接到達了數據庫,並且獲取到了id爲1的數據,並且將數據添加到了緩存。
在這裏插入圖片描述

第二次查詢id等於1的數據

2020-04-19 15:57:47.879  INFO 20188 --- [nio-9900-exec-5] com.ymy.service.OrderService             : 緩存中查詢到了信息,直接返回:OrderBo(id=1, orderCode=202004191416, orderPrice=3299.00, peoductName=iphone se2, createTime=2020-04-19 14:17:07)

我們發現他直接命中了緩存,直接返回,這是正常情況,那如果非正常情況呢?比如查詢的訂單id=-1呢?這個時候會發生什麼事情?

http://localhost:9900/detail?id=-1
在這裏插入圖片描述
看到沒有,請求全都進入數據庫了,這種情況是肯定不被允許的,如果你的程序中存在這種情況,一定要趕緊修改,否則有可能會讓一些心懷不軌的人直接將數據庫的服務搞宕機,那這種問題如何解決呢?

解決方案

將空數據存入緩存

什麼意思呢?簡單點來說,不管數據庫中有沒有查詢到數據,都往緩存中添加一條數據,這樣下次請求的時候就會直接在緩存中返回,這種方式比較簡單粗暴,我們一起看看如何實現。

代碼改造:

OrderService.java

 public OrderBo getDetail(Long id) {
        //緩存中查詢詞詞訂單
        Object obj =  redisTemplate.opsForValue().get("order:" + id);
        if(obj != null ){
            String data = obj.toString();
            log.info("緩存中查詢到了信息,直接返回:{}",data);
            return  "".equals(data)  ? null : (OrderBo) obj;
        }
        log.info("前往數據庫查詢");
        OrderBo orderBo =  orderMapper.getDetail(id);
        if(orderBo != null ){
            //將數據保存到數據庫,有效時間一小時
           redisTemplate.opsForValue().set("order:" + id,orderBo,3600,TimeUnit.SECONDS);
           log.info("數據已經存入緩存");
        }else {
            redisTemplate.opsForValue().set("order:" + id,"",300,TimeUnit.SECONDS);
            log.info("數據庫中不存在此數據,但是爲了防止緩存穿透,存入一條空數據到緩存中");
        }
        return orderBo;
    }

往緩存中添加數據的時候一定要注意值的問題,請看我這裏,我添加的是一個空字符串,並不是null,是因爲我判斷的條件是緩存中!=null就直接返回,如果你往緩存中添加一條null的數據,這個時候就會和你的判斷起衝突,又會進入到數據庫了,所以這點需要特別注意,我們來看測試:

第一次請求:http://localhost:9900/detail?id=-1

2020-04-19 16:23:21.520  INFO 16596 --- [nio-9900-exec-6] com.ymy.service.OrderService             : 前往數據庫查詢
2020-04-19 16:23:21.577  INFO 16596 --- [nio-9900-exec-6] com.ymy.service.OrderService             : 數據庫中不存在此數據,但是爲了防止緩存穿透,存入一條空數據到緩存中

第二次請求:http://localhost:9900/detail?id=-1

2020-04-19 16:24:25.855  INFO 16596 --- [nio-9900-exec-9] com.ymy.service.OrderService             : 緩存中查詢到了信息,直接返回:

這個時候請求命中了緩存,就不會前往數據庫中了,但是這個需要注意一點:空值的過期時間不能設置的太長,什麼意思呢?設想一下,我們現在數據庫中只有id=1的數據,我們查詢id=2也會往緩存中插入一條數據,但是這個時候數據庫中新增了一條訂單id=2,用戶下次查詢的時候看到你存儲在緩存中中的數據,接直接回了空,但是數據庫中明明已經添加了這條數據,這就是爲什麼過期時間不要設置太久的原因,當然了,我們也需要分情況考慮,比如查詢id<=0的,我們都可以考慮永久存入緩存或者設置很長的過期時間,推薦設置很長的過期時間,爲什麼呢?因爲訂單id不存在會<=0,但是對於>=0,我們可以將過期時間設置爲30秒等等,這個看業務需求即可。

布隆過濾器

布隆過濾器(Bloom Filter)是1970年由布隆提出的。它實際上是一個很長的二進制向量和一系列隨機映射函數。布隆過濾器可以用於檢索一個元素是否在一個集合中。它的優點是空間效率和查詢時間都比一般的算法要好的多,缺點是有一定的誤識別率和刪除困難。

這個算法實現起來比上面第一種稍微複雜一點,這裏就不具體說明了,如果感興趣的話可以百度自行了解一下,不是很難。

緩存擊穿

什麼是緩存擊穿

緩存擊穿是指熱點key在某個時間點過期的時候,而恰好在這個時間點對這個Key有大量的併發請求過來,從而大量的請求打到db(數據庫)

這個也不難理解,和緩存穿透有點像但是性質又不相同,都是緩存中沒有數據,請求命中數據庫,緩存穿透指的是數據庫中不存在的數據,緩存擊穿則是指緩存失效的問題。,這種情況不太好模擬,我們可以直接將緩存中數據清空,替代緩存數據過期。

代碼還是上面的代碼,不做任何修改,不過我們不再使用postman測試,而是採用jemter,首先我們刪除緩存中的數據,模擬key已經過期,我們查詢id=1的訂單詳細信息,但需要注意的是,我並不是發一個請求,而是100個同時請求,會發生什麼呢?

線程數:
在這裏插入圖片描述
Http請求信息
在這裏插入圖片描述
聚合報告
在這裏插入圖片描述
我們發現100個併發請求全部成功,異常率爲0,接下來就是重點了,控制檯會打印什麼呢?
在這裏插入圖片描述
這就是緩存擊穿,是不是很恐怖,雖然命中數據庫的次數不是很多,那是因爲我們的併發請求不是很大,像雙十一這種併發,如果存在這種問題,數據庫可能撐不過3秒就炸了。

解決方案

自動更新

什麼是自動更新呢?這個有點類似與jwt的自動刷新token機制,jwt的自動刷新token實現原理大致爲:請求的時候判斷一下token的剩餘有效時間,如果有效時間小於設定的時間,那麼jwt將生成一個新的token,然後再將次token重新設置過期時間,並將新的token返回給前端使用,這個也可以參考一下,redis是支持查詢某個key剩餘有效時間,所以這裏我們只需要設定一個時間差,比如3分鐘,請求的時候查詢的有效時間如果小於3分鐘,那麼刷新這個key的有效時間,刷新這個操作可以使用異步實現(提高性能)。

可能你想到了,這種方式存在缺陷,沒錯,如果再快失效的3分鐘內沒有請求,那麼緩存中的key將不會被刷新,還是會存在緩存擊穿的問題,所以這種方式不是特別推薦。

定時刷新

定時刷新有兩種方案

第一種:定時任務
查詢快要過期的key,更新內容,並刷新有效時間,這種比較消耗服務器性能,也不是特別推薦。

第二種:延遲隊列
如果大家瞭解它的話可能一下就知道我說的是什麼意思了,將數據存入緩存的那一刻同時發送一個延遲隊列(安指定時間消費),時間小於緩存中key的過期時間,到了指定時間,消費者刷新key的有效時間再發送一個延遲隊列,以此循環,這種方式還是不錯的,但是實現方式相對於第一種來說就要複雜一點了,他需要依靠消息中間件來完成,如果消息中間件某個時間宕機,那就gg了,雖然這種方式雖然比較推薦,但是成本偏高,因爲爲了防止消息中間件宕機,我們有可能需要對消息中間件做集羣處理。

程序加鎖

我個人推薦使用這個,爲什麼呢?因爲它不需要額外的服務器開銷,也不需要額外的資源消耗,他僅僅只是讓線程串行而已,但是這個時候你可能就會有疑問了,加鎖不是會嚴重影響程序的效率嗎?爲什麼你還推薦這種方式呢?

其實並不是所有的鎖都會很大的降低程序的性能,這裏我們當然不能使用synchronized,原因很簡單,他的效率比較慢,不太適合這種情況,我要介紹的這種鎖名字爲:讀寫鎖

什麼是讀寫鎖?請參考我的另外一篇博客:【併發編程】java併發編程之ReentrantReadWriteLock讀寫鎖

好了,我們一起來改造一下之前的代碼

OrderService.java

package com.ymy.service;

import com.ymy.bo.OrderBo;
import com.ymy.mapper.OrderMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

@Service
@Slf4j
public class OrderService {

    private RedisTemplate redisTemplate;

    private OrderMapper orderMapper;

    private static  final AtomicInteger count = new AtomicInteger(0);


    private static final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();


    public OrderService(RedisTemplate redisTemplate, OrderMapper orderMapper) {
        this.redisTemplate = redisTemplate;
        this.orderMapper = orderMapper;
    }


    /**
     * 通過id查詢訂單詳情
     *
     * @param id
     * @return
     */
    public  OrderBo getDetail(Long id) {
        int num = count.incrementAndGet();
        //獲取讀鎖
        Lock readLock = readWriteLock.readLock();
        try {
            readLock.lock();
            //緩存中查詢訂單信息
            log.info("前往緩存中查詢信息,第一次,這是第:{}次請求",num);
            Object obj = redisTemplate.opsForValue().get("order:" + id);
            if (obj != null) {
                String data = obj.toString();
                log.info("緩存中查詢到了信息,直接返回:{}", data);
                return "".equals(data) ? null : (OrderBo) obj;
            }
            log.info("沒有在緩存中獲取到數據,即將前往數據庫獲取,這是第:{}次請求",num);
        } finally {
            //釋放讀鎖
            readLock.unlock();
        }
        //獲取寫鎖
        Lock writeLock = readWriteLock.writeLock();
        try{
            writeLock.lock();
            //緩存中查詢訂單信息
            log.info("第二次前往緩存中查詢信息,這是第:{}次請求",num);
            Object obj = redisTemplate.opsForValue().get("order:" + id);
            if (obj != null) {
                String data = obj.toString();
                log.info("緩存中查詢到了信息,直接返回:{}", data);
                return "".equals(data) ? null : (OrderBo) obj;
            }
            log.info("前往數據庫查詢,這是第:{}次請求",num);
            OrderBo orderBo = orderMapper.getDetail(id);
            log.info("數據庫返回的數據:{},這是第:{}次請求",orderBo,num);
            if (orderBo != null) {
                //將數據保存到數據庫,有效時間一小時
                redisTemplate.opsForValue().set("order:" + id, orderBo, 3600, TimeUnit.SECONDS);
                log.info("數據已經存入緩存,這是第:{}次請求",num);
            } else {
                redisTemplate.opsForValue().set("order:" + id, "", 300, TimeUnit.SECONDS);
                log.info("數據庫中不存在此數據,但是爲了防止緩存穿透,存入一條空數據到緩存中,這是第:{}次請求",num);
            }
            return orderBo;
        }finally {
            writeLock.unlock();
        }
    }
}

加了讀寫鎖之後我們一起來看看控制檯的輸出結果:

在這裏插入圖片描述
這只是其中一部分,由於輸出的內容過長我就不全部展示出來了,我們這裏需要關注的只有一個,數據庫查詢了多少次?

我們將控制檯日誌拷貝到notepad++中,搜索“數據庫返回的數據”,請看結果:
在這裏插入圖片描述我們可以看到,查詢數據庫的操作只有一處,但是查詢緩存的確實併發執行的,這就是爲什麼我推薦使用讀寫鎖的原因,讀寫鎖中讀鎖和寫鎖是互斥的,你覺得這樣速度還是不夠快,能不能讀鎖和寫鎖並行?答案是肯定的,請參考我的另外一篇博客:【併發編程】面試官:有沒有比讀寫鎖更快的鎖?

緩存雪崩

什麼是緩存雪崩

緩存雪崩是指緩存中數據大批量到過期時間,而查詢數據量巨大,引起數據庫壓力過大甚至宕機

爲什麼會出現大批量的key過期呢?是不是我們設置了相同的過期時間導致的?

解決方案

隨機設置過期時間

這個隨機時間並不是真正的隨機時間,而是指在原來過期時間的基礎上生成一個隨機時間,這個隨機時間比較小,然後兩者相加即可。

設置永久有效

將一些常用的數據設置成爲永久有效,注意哦,是經常使用的而不是全部,這點需要特別注意。

總結

什麼是緩存穿透?
同一時刻,大量的併發請求數據庫中不存在的信息,他既不會命中緩存,也不會命中數據庫,但是他會查找數據庫

什麼是緩存擊穿?
緩存擊穿是指熱點key在某個時間點過期的時候,而恰好在這個時間點對這個Key有大量的併發請求過來,從而大量的請求打到db(數據庫)

什麼是緩存雪崩?
緩存中數據大批量到過期時間,而查詢數據量巨大,引起數據庫壓力過大甚至宕機

並不是只有上面幾種解決方案,這裏我只是講解了幾種常用的解決方案,在日常開發中我們可以根據實際的業務需求進行選擇,沒有最好的,只有最適合自己的,所以不一定要選擇最牛逼的解決方案,但是一定要選擇最適合項目的解決方案。
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章