什么缓存穿透、雪崩、击穿?如何去解决这些场景?

1. 缓存穿透

1.1. 定义

缓存穿透:指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库

1.2. 解决方案

常见的解决方案由两种

第一种方案:缓存空对象

当我们线上业务发现缓存穿透的现象时,可以针对一个查询的数据,在缓存中设置一个空值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库

  • 优点:实现简单,维护方便
  • 缺点:额外的内存消耗,可能造成短期的不一致

第二种方案:布隆过滤器

使用布隆过滤器快速判断数据是否存在,避免通过查询数据来判断数据是否存在

我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在

即使发生了缓存穿透,大量请求只会查询Redis和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis自身也是支持布隆过滤器的

布隆过滤器的工作过程:

布隆过滤器由初始值都为0的位图数组N个哈希函数两部分组成,当我们在写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据��时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中

布隆过滤器会通过3个操作来完成标记:

  • 第一步:使用N个哈希函数分别对数据做哈希计算,得到N个哈希值
  • 第二步:将第一步得到的N个哈希函数值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置
  • 第三步,将每个哈希值在位图数组的对应位置的值设置为1

举例:

在数据库写入数据 x 后,把数据 x 标记在布隆过滤器时,数据 x 会被 3 个哈希函数分别计算出 3 个哈希值,然后在对这 3 个哈希值对 8 取模,假设取模的结果为 1、4、6,然后把位图数组的第 1、4、6 位置的值设置为 1。当应用要查询数据 x 是否数据库时,通过布隆过滤器只要查到位图数组的第 1、4、6 位置的值是否全为 1,只要有一个为 0,就认为数据 x 不在数据库中

布隆过滤器由于是基于哈希函数实现查找的,高效查找的同时存在哈希冲突的可能性,比如数据 x 和数据 y 可能都落在第 1、4、6 位置,而事实上,可能数据库中并不存在数据 y,存在误判的情况。

所以,查询布隆过滤器说数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据

1.3. 案例

kotlin 复制代码
 public Result queryShopById(Long id) {
     //1.从Redis查询缓存
     String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
     //2.如果存在,直接返回
     if(StrUtil.isNotBlank(shopJson)){
         Shop shop = JSONUtil.toBean(shopJson, Shop.class);
         return Result.ok(shop);
     }
     //判断是否为空对象
     if (shopJson != null) {
         //为空对象
         return Result.fail("店铺信息为空!");
     }
     //3.不存在,根据id查询数据库
     Shop shop = getById(id);
     if (shop == null) {
         // 设置空对象
         stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
         return Result.fail("店铺不存在");
     }
     //4.将店铺信息存入Redis,并设置超时时间
     stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
     //5.返回店铺信息
     return Result.ok(shop);
 }

2.缓存雪崩

2.1. 定义

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力

2.2. 解决方案

通过上面可以看到,发生缓存雪崩有两个原因:

  • 大量数据同时到期
  • Redis故障宕机

不同的诱因,对应的策略也会不同

2.2.1. 大量数据到期

第一种:均匀设置过期时间

如果要给缓存数据设置过期时间,应该避免大量的数据设置成同一个时间,我们可以对缓存数据设置时间时,给这些数据的过期时间加上一个随机数,这样就保证数据不会在同一时间内国企

第二种:互斥锁

当业务线程在处理用户请求时,如果发现访问的数据不在Redis里, 就加个互斥锁,保证同一时间内只有一个请求来构建缓存,当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值

实现互斥锁的时间,最好设置超时时间,不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞,一直不释放锁,这时其他请求也一直拿不到锁,整个系统就会出现无响应的现象

第三种:后台更新缓存

业务线程不在负责更新缓存,缓存也不设置有效期,而是让缓存永久有效,并将更新缓存的工作交由后台线程定时更新

事实上,缓存数据不设置有效期,并不是意味着数据一直能在内存里,因为当系统内存紧张的时候,有些缓存数据会被淘汰,而在缓存被淘汰到下一次后台定时更新缓存的这段时间内,业务线程读取缓存失败就返回空值,业务的视角意外时数据丢失了

解决上卖弄的问题的方式有两种:

1)后台线程不仅负责定时更新缓存,而且也负责频繁地检测缓存是否有效,检测缓存失效,原因可能是系统紧张而被淘汰的,于是就要马上从数据库读取数据,并且更新到缓存;

这种方式的检测时间不能太长,太长会导致用户获取的数据是一个空值而不是一个真正的数据

2)在业务线程发现缓存数据失效后(缓存数据被淘汰),通过消息队列发送一条消息通知后台更新缓存,后台线程收到消息后,在更新缓存前可以判断缓存是否存在,存在就不执行更新缓存操作;不存在就读取数据库数据,并将数据库数据加载到缓存。

在业务刚上线的时候,我们最好提前把数据缓起来,而不是等待用户访问才来触发缓存构建,这就是所谓的缓存预热,后台更新缓存的机制刚好也适合干这个事情

2.2.2. Redis故障宕机

第一种:服务熔断或请求限流机制

因为 Redis 故障宕机而导致缓存雪崩问题时,我们可以启动服务熔断 机制,暂停业务应用对缓存服务的访问,直接返回错误,不用再继续访问数据库,从而降低对数据库的访问压力,保证数据库系统的正常运行,然后等到 Redis 恢复正常后,再允许业务应用访问缓存服务。

服务熔断机制是保护数据库的正常允许,但是暂停了业务应用访问缓存服系统,全部业务都无法正常工作

为了减少对业务的影响,我们可以启用请求限流 机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等到 Redis 恢复正常并把缓存预热完后,再解除请求限流的机制。

第二种:构建 Redis 缓存高可靠集群

服务熔断或请求限流机制是缓存雪崩发生后的应对方案,我们最好通过主从节点的方式构建 Redis 缓存高可靠集群

如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的缓存雪崩问题

3.缓存击穿

3.1. 定义

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问在瞬间给数据库带来巨大的冲击力

3.2. 解决方法

两种解决方案:

第一种:互斥锁

保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值

优点:

  • 没有额外的内存消耗
  • 保证一致性
  • 时间简单

缺点:

  • 线程需要等待,性能受影响
  • 可能由死锁风险

第二种:逻辑过期

查询缓存对象,拿出缓存对象的逻辑过期时间,如果没有过期,则直接返回,如果过期,则尝试获取互斥锁,如果获取互斥锁失败则直接返回过期对象,如果获取成功,则重构缓存

优点:

  • 线程无需等待,性能较好

缺点:

  • 不保证一致性
  • 由额外内存消耗(需要存储过期时间)
  • 实现复杂

3.3. 案例

3.3.1. 互斥锁

基于互斥锁方式解决缓存击穿问题

需求:修改根据id查询商铺的业务,基于互斥锁方式来解决缓存击穿问题

核心代码:

kotlin 复制代码
  private Shop queryWithMutex(Long id){
      String key = CACHE_SHOP_KEY + id;
      Shop cacheShop = getCacheShop(key);
      if (cacheShop != null) {
          return cacheShop;
      }
      //4.获取互斥锁
      String lockKey = LOCK_SHOP_KEY + id;
 ​
      Shop shop = null;
      try {
          // 4.1 获取锁失败
          if (!getLock(lockKey)) {
              // 4.1 获取锁失败,则休眠重试
              Thread.sleep(50);
              return queryWithMutex(id);
          }
          // 4.2 获取成功
          // 4.3 重新check Redis缓存
          cacheShop = getCacheShop(key);
          if (cacheShop != null) {
              return cacheShop;
          }
          //3.不存在,根据id查询数据库
          shop = getById(id);
          if (shop == null) {
              // 设置空对象
              stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
              return null;
          }
          //4.将店铺信息存入Redis,并设置超时时间
          stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
      }catch (Exception e){
          e.printStackTrace();
      }finally {
          unLock(lockKey);
      }
      //5.返回店铺信息
      return shop;
  }

3.3.2. 逻辑过期

需求:修改根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题

核心代码:

scss 复制代码
 ​
 private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
 ​
 /**
      * 逻辑过期
      * @param id
      * @return
      */
 ​
 private Shop queryWithLogicalExpire(Long id) {
     String key = CACHE_SHOP_KEY + id;
     //1.获取缓存
     String json = stringRedisTemplate.opsForValue().get(key);
 ​
     //2.判断缓存是否存在
     if(StrUtil.isBlank(json)){
         //3.不存在,返回null
         return null;
     }
     //4.命中判断缓存是否过期
     RedisData redisData = JSONUtil.toBean(json, RedisData.class);
     Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
     if(redisData.getExpireTime().isAfter(LocalDateTime.now())){
         //5.缓存未过期,直接返回
         return shop;
     }
 ​
     //6.缓存过期重建
     //6.1尝试获取锁
     String lockKey = LOCK_SHOP_KEY + id;
     if(getLock(lockKey)){
         //6.2 获取锁成功,开启独立线程,实现缓存重建
         CACHE_REBUILD_EXECUTOR.submit( ()->{
 ​
             try{
                 //重建缓存
                 this.saveShop2Redis(id,20L);
             }catch (Exception e){
                 throw new RuntimeException(e);
             }finally {
                 unLock(lockKey);
             }
         });
     }
     //6.2 返回过期信息
     return shop;
 }
 ​
 //可以做提前缓存预热
 private void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
     //1.从数据库查询shop
     Shop shop = getById(id);
     //2.写入redis
     RedisData redisData = new RedisData();
     redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
     redisData.setData(shop);
     stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData), expireSeconds, TimeUnit.SECONDS);
 }
相关推荐
程序研9 分钟前
JAVA中的内部比较器Comparable
java·开发语言·后端·comparable
weixin_lizhao11 分钟前
Dubbo源码深度解析(三)
java·网络·后端·spring·dubbo
浪淘沙jkp20 分钟前
智慧水务项目(二)django(drf)+angular 18 创建通用model,并对orm常用字段进行说明
后端·python·django
codelife32139 分钟前
Spring Boot 整合 RestTemplate:详解与实战
java·spring boot·后端
曲阳1 小时前
详解线程池
后端
向往风的男子2 小时前
【redis】redis持久化学习
数据库·redis·学习
萧曵 丶2 小时前
Spring Boot 常用设计模式
spring boot·后端·设计模式
码--到成功2 小时前
Django 实现连续请求
后端·python·django
掘金一周3 小时前
【动画进阶】单标签实现多行文本随滚动颜色变换 | 掘金一周 8.7
android·前端·后端
Jasonakeke3 小时前
Flask 介绍
后端·python·flask