一文彻底明白Redis缓存穿透、缓存击穿、缓存雪崩。如果看完还是不懂,那就来打死我吧

Spring Boot学习第七天

Redis作为当前市面上最流行的缓存中间件之一,很大的原因之一是因为在内存中有着极高的读写速度,作为后端开发人员,我们自然希望用户访问的数据都在内存而非一些持久化的数据库中(例如MySQL),这样会大大提高系统的响应速度。
但还是有一些特殊情况出现,拉跨系统性能甚至造成系统崩溃。
在这里插入图片描述

1.Redis缓存穿透

前面我们也说了,我们希望用户访问的数据都在内存中,这样就不用去磁盘中查询,提升了响应速度。如果用户想要访问的数据不在Redis中,只能去访问MySQL了,这是我们不希望看到的(Redis的读写速度优于MySQL)
假如发送的请求传进来的key是不存在Redis中的,那么就查不到缓存,查不到缓存就会去数据库查询。假如有大量这样的请求,这些请求像“穿透”了缓存一样直接打在数据库上,这种现象就叫做缓存穿透

假如有不怀好心的用户使用某种手段在短时间内大量请求不存在的key,那么大量的请求就会被直接打在数据库上,给数据库带来巨大压力

常见的解决方案有两种:

1)缓存空对象

在对于一个请求,如果发现这个请求的key是不存在的(即不再数据库也不在Redis中),我们就在Redis中创建这个key,值为空。这样在下一次请求这个key时,在缓冲中就一定会被命中,返回空值。
这样做的好处在于,避免请求直接打到数据库中,给数据库减少压力

在这里插入图片描述

2)布隆过滤

在收到请求的时候,我们可以提前判断这个Key存不存在(不管它在Redis还是数据库中),如果存在我们就放行该请求,即便这个Key不在Redis在MySQL中,由于缓存机制,这个数据也会被写入Redis,对于下一次同样的请求,就不会被打到数据中(因为在Redis已经命中了),这个判断Key是否存在的玩意,我们叫布隆过滤器
在这里插入图片描述

2.Redis缓存击穿

对于经常被访问的数据,我们称之为热点Key,显然热点Key会被写入Redis,写入缓存。但由于某种原因我们需要给它设置过期时间。缓存击穿就是热点 Key 的缓存过期后,短时间内大量请求同时查询该 Key,导致这些请求同时访问数据库,造成数据库压力骤增。

常见的解决方案有两种:

1)互斥锁

对于多个请求同一个Key的请求,同一时间内只能由一个请求被处理。显然这个请求也会被打到数据中,但是当这个请求被处理完成之后,由于缓存机制,请求的Key会被写入Redis,接下来的其他请求都会在Redis中命中,不会被打到数据库中,避免了缓存击穿
为了使得**“同一时间内只能由一个请求被处理”**,我们需要加锁,一旦某个请求加锁后,其他请求只能等该请求完成,释放锁之后才能加上自己的锁,完成自己的功能。这个就叫互斥锁
在这里插入图片描述

2)逻辑过期

如果Key永远不过期,我们就不用担心不被命中的问题,但是前面也说过了,由于某种原因我们需要给它设置过期时间。所以我们并不显示的指定永不过期,而是在数据内容上加上一段其他的内容,这个其他的内容就指的是过期时间。
当拿到数据时,首先判断该数据是否过期,如果过期了,就获取锁,开启一个新的线程,让这个新的线程重置过期时间并且写入缓存,而自己返回过期的数据

在这里插入图片描述
在这里插入图片描述

3.Redis缓存雪崩

当某一个时刻出现大规模的缓存失效(例如多个Key同时到期)的情况,那么就会导致大量的请求直接打在数据库上面,导致数据库压力巨大,如果在高并发的情况下,可能瞬间就会导致数据库宕机。这时候如果运维马上又重启数据库,马上又会有新的流量把数据库打死。这就是缓存雪崩。
和缓存击穿有点类似,缓存雪崩是大规模的key失效,而缓存击穿则是一个热点Key失效
一个粗糙的的解决方案是让这些Key过期时间随机,避免同一时刻出现大规模的缓存失效


具体实现

1.逻辑过期

1. queryWithLogicalExpire

public Shop queryWithLogicalExpire(Long id) {  

    String shopJson =  stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY+id);  
    if(StrUtil.isBlank(shopJson)&&!("empty".equals(shopJson))){  
        return null;  
    }  
  
    //存在 接着判断缓存是否过期  
    //首先获取过期参数expiretime  
    RedisData redisData = JSONUtil.toBean(shopJson,RedisData.class);  
    Shop shop = JSONUtil.toBean((JSONObject)redisData.getData(),Shop.class);  
    LocalDateTime expireTime = redisData.getExpireTime();  
  
  
    if(expireTime.isAfter(LocalDateTime.now())){  
        //未过期 返回店铺信息  
        return shop;  
    }  
  
    //过期,需要缓存重建  
  
    //接着获取互斥锁  
    String localKey = LOCK_SHOP_KEY+id;  
    boolean islock = trylook(localKey);  
  
    if(islock){  
        //成功获取,开启独立线程  
        CACHE_REBUILD_EXECUTOR.submit(()->{  
           try{  
               this.saveShop2Redis(id,20L);  
           }catch (Exception e){  
               throw new RuntimeException(e);  
           }finally {  
               //不管怎么样 都释放锁  
               unlock(localKey);  
           }  
        });  
    }  
    return shop;  
}
功能

基于 逻辑过期 的思想,避免缓存击穿问题。当缓存过期时,不立即删除数据,而是采用异步重建缓存的方式,保证系统的高可用性和一致性。

实现逻辑
  1. 从 Redis 查询缓存数据:

    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);

    • 如果缓存中没有数据且不是占位符,返回 null
    • 如果数据存在,将其解析为 RedisData 对象。
  2. 判断数据是否过期:

    • 检查缓存中数据的逻辑过期时间 expireTime 是否已经超过当前时间:

      if (expireTime.isAfter(LocalDateTime.now())) { return shop; }

      • 如果未过期,直接返回缓存中的数据。
      • 如果已过期,继续执行缓存重建流程。
  3. 缓存重建:

    • 通过获取互斥锁(分布式锁)保证只有一个线程进行缓存重建。

    • 获取锁后,通过独立线程异步重建缓存:

      CACHE_REBUILD_EXECUTOR.submit(() -> { try { this.saveShop2Redis(id, 20L); } catch (Exception e) { throw new RuntimeException(e); } finally { unlock(localKey); } });

    • 即使缓存过期,但在锁未释放之前,仍会返回旧缓存数据,保证高并发情况下的系统稳定性。

适用场景
  • 热点数据:某些高频访问的数据(例如商品详情页),缓存即使过期,也需要保证高可用性。
  • 不允许缓存击穿:需要保证缓存总是存在,不直接查库。

2. queryWithMutex

public Shop queryWithMutex(Long id) {   
    String shopJson =  stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY+id);  
  
    if(StrUtil.isNotBlank(shopJson)&&!("empty".equals(shopJson))){    
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);  
        return shop;  
    }  
    
    if ("empty".equals(shopJson)) {  
        return null;  
    }  
  
    //尝试获取互斥锁  
    String lockKey = "lock:shop" + id;  
    Shop shop = null;  
    try{  
       boolean isLock = trylook(lockKey);  
 
       if(!isLock){  
           Thread.sleep(50);  
           return queryWithMutex(id);  
       }  
  
       shop = getById(id);  
       log.info("-----------------------------------------------------");  
  
       //不存在,根据Id查询数据库  
       if (shop == null) {  
           // 将 "empty" 作为占位符存入 Redis,避免缓存击穿  
           stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "empty", CACHE_NULL_TTL, TimeUnit.MINUTES);  
           return null;  
       }  

       stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);  
   }catch (Exception e){  
       throw  new RuntimeException(e);  
   }finally {  
       //释放互斥锁  
       unlock(lockKey);  
   }  
  
    //返回  
    return null;  
}
功能

通过 互斥锁 的机制解决缓存击穿问题。缓存数据过期后,使用分布式锁确保只有一个线程去查询数据库并更新缓存,其余线程等待。

实现逻辑
  1. 从 Redis 查询缓存:

    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);

    • 如果缓存命中,直接返回数据。
    • 如果缓存未命中且不是占位符,继续执行缓存重建逻辑。
  2. 尝试获取分布式锁:

    • 使用 trylook(lockKey) 尝试获取互斥锁:

      boolean isLock = trylook(lockKey);

      • 如果获取失败,线程休眠一段时间并递归重试。
    • 如果获取成功,则查询数据库并更新缓存:

      shop = getById(id); stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);

  3. 处理空值:

    • 如果数据库中没有对应数据,将 "empty" 作为占位符写入缓存,设置较短的过期时间,避免缓存穿透。
  4. 释放锁:

    • 无论成功与否,均在 finally 块中释放锁。
适用场景
  • 适用于对一致性要求较高的场景,尤其是缓存频繁过期时的高并发查询。
  • 不需要复杂的逻辑过期机制,且可以承受一定程度的线程等待。

3. queryWithPassThrough

public Shop queryWithPassThrogh(Long id) {    
    String shopJson =  stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY+id);  

    if(StrUtil.isNotBlank(shopJson)&&!("empty".equals(shopJson))){    
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);  
        return shop;  
    }  

    if ("empty".equals(shopJson)) {  
        return null;  
    }  
    Shop shop = getById(id);  
  
    if (shop == null) {  
        // 将 "empty" 作为占位符存入 Redis,避免缓存击穿  
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "empty", CACHE_NULL_TTL, TimeUnit.MINUTES);  
        return null;  
    }  
  
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);  
    //返回  
    return null;  
}
功能

直接从数据库查询数据,并写入缓存,属于最简单的缓存访问逻辑设计。主要用于防止缓存穿透。

实现逻辑
  1. 从 Redis 查询缓存:

    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);

    • 如果缓存命中,直接返回数据。
    • 如果缓存未命中且不是占位符,继续查询数据库。
  2. 查询数据库:

    • 如果数据库中存在数据,将其写入缓存:

      stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);

    • 如果数据库中不存在数据,将 "empty" 写入缓存,并设置较短的过期时间,避免缓存穿透。

  3. 直接返回查询结果。

适用场景
  • 数据库访问量低、缓存更新频率较高的场景。
  • 对一致性要求不高的简单场景。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值