【Redis缓存机制】缓存更新、缓存穿透、缓存雪崩、缓存击穿

💐个人主页:初晴~

📚相关专栏:redis


目录

一、什么是缓存

二、redis缓存更新策略

什么是数据不一致性

缓存更新策略

实战演练

三、缓存穿透

什么是缓存穿透?

解决方案

(1)缓存空对象

(2)布隆过滤

布隆过滤器的原理

总结

四、缓存雪崩

五、缓存击穿

什么是缓存击穿

解决方案

实战演练


一、什么是缓存

缓存(Cache)是一种高速存储技术,用于存储数据,以便快速访问。它主要用于减少数据访问的延迟和提高数据访问的速度。

简单来说,缓存是用户和数据库进行数据交互的一个缓冲区。一般会使用redis来作为缓存。像mysql这样的数据库,数据都是存储在硬盘中的,而redis的数据是存储在内存中的,相比之下读写速度会快非常多。

将一些访问频次比较高的数据存储在redis中作为缓存,每次用户读取数据时就可以直接在redis中查询,一方面大幅提高了读写的效率,另一方面也减轻了数据库的访问压力。在一些需求量非常密集的高并发的场景下,就能大大减轻数据层的压力

使用缓存后客户端访问数据的业务流程就会变成下面这样:

客户端会先在redis进行查询,如果查到目标对象就直接进行返回。如果没查到,才会对数据库进行查询,查询到数据后,会将数据写入到缓存中,以便客户端下次查询时能直接在redis中查到数据。然后再将数据返回给客户端

缓存的作用:

  • 降低后端负载
  • 提高数据读写效率,降低响应时间

凡事都有两面性,缓存的确非常好用,但相应的成本也高了不少:

  • 数据一致性成本
  • 代码维护成本
  • 运维成本

因此实际开发中,没必要无脑冲redis缓存,一些用户量没那么大的小型企业或个人搭建缓存的性价比其实并不高。我们还是应当根据实际开发需求来选择合适的开发根据

二、redis缓存更新策略

什么是数据不一致性

当缓存区缓存了用户信息后,客户端就都会从redis中访问读取数据了。而如果这时候用户更新了数据库中的值,就会导致数据库存储的数据与redis存储数据不一致,在这之后进行读写操作就会发生各种意想不到的错误了

因此,当我们要对数据库数据进行更新时,也要尽可能让edis中的数据也能跟着变化,避免用户读到错误的信息

缓存更新策略

为了缓解数据不一致性问题,就需要及时对redis缓存的数据进行更新。常见的更新策略有以下三种:

为了尽可能提高数据一致性,保证用户数据读取安全。因此实际开发中我们一般都会采用主动更新的策略。这就需要我们程序员自己来编写业务逻辑了。这时候就会面临几个问题:

1、删除缓存还是更新缓存?

  • 更新缓存:每次更新数据库都需要更新缓存。无效的写操作过多
  • 删除缓存:更新数据库时删除对应缓存,这样在下次读取时就会自动更新缓存数据了

显然在每次读取时再更新数据效率会高的多。因此一般采用删除缓存的操作

2、如何保证数据库与缓存的操作同时成功或失败

单体系统:将数据库与缓存操作放在同一个事物下

分布式系统:利用TCC等分布式事务方案

3、先操作数据库还是先操作缓存?

让我们依次来看看:

(1)先删除缓存,后更新数据库

正常情况:

在第二个用户查完数据库后,就会将此时数据库的数据写入到缓存中,从而实现了缓存的更新。

但就跟线程安全问题一样,删除缓存与更新数据库是两个操作,也没有加锁,并不是原子的,这样在线程1执行完操作1时,就可能被其它线程插入进行操作了:

在线程1并未完成数据库更新操作的时候,就被线程2插入,此时缓存已被删除,线程2进行查询操作时,就会去读取此时还未被更改的数据库的值,并将该数写入到缓存中去。然后线程1才完成了对数据库的更新操作。

这时,缓存里存的是修改前的数据,数据库中存的是修改后的数据,就发生数据一致性问题了。

并且事实上,这种问题出现的概率还是非常高的。

因为线程1删除缓存是一个非常快的操作,然后开始更新数据库信息,而对数据库的修改操作是非常慢的,这期间很容易就会被线程2这样的线程趁虚而入,而相比之下,线程2查询缓存与数据库的速度就快的多了,最后的写入缓存由于是对内存操作,其速度也是非常快的,能达到微秒级别。因此线程2插入的这一系列操作看着复杂,实际执行的时间是非常短的,因此会有很大的可能性在线程1更新数据库的期间完成这一系列操作,最终导致数据不一致


(2)先修改数据库,在删除缓存

正常情况:

同样,在第二个用户读取数据时会顺带将数据写入缓存,也就实现了更新缓存的目的

不过,这种情况下依然有可能会发生数据不一致性问题:

类似于上一种方式,在线程1执行执行完数据库查询,还未写入缓存前,也有可能会插入某个线程2,执行完了更新数据库的操作,并删除了缓存。此时线程1再执行写入缓存的操作,就会将老的数据存入缓存,而数据库内的数据已经被线程2修改过了,从而导致数据库与缓存出现数据不一致问题。

可以看出,这种方法也没办法百分百保证数据一致性。不过相比之下,这种方式出现上述异常情况的概率会小得多。

因为缓存写入的速度 回远高于数据库写入的速度 ,因此线程2很难在线程1写入缓存前那极短的时间内完成更新数据库与删除缓存这一系列操作。也就是说,发生上述这种异常情况的概率是非常小的,并且再与缓存的超时删除机制相结合,即使在低概率情况下真的发生数据不一致了,一段时间后也会自动更新缓存。


总结

显然,第二种方案带来的损失就比较小了。因此在上述这两种方案中,一般会采用第二种"先更新数据库,后删除缓存"的方案。

注意:

上述分析的前提是要保证对数据库与缓存的操作的原子性

实战演练

让我们根据上面的分析来编写一个ShopController,实现以下需求:

  • 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
  • 根据id修改店铺时,先修改数据库,再删除缓存

业务流程:

Controller

java 复制代码
    //根据id查询商铺信息
    @GetMapping("/{id}")
    public Result queryShopById(@PathVariable("id") Long id) {
        return shopService.queryById(id);
    }

    //更新商铺信息
    @PutMapping
    public Result updateShop(@RequestBody Shop shop) {
        return shopService.updateShop(shop);
    }

Service

java 复制代码
    @Override
    public Result queryById(Long id) {
        String key = CACHE_SHOP_KEY + id;
        //1.从redis中根据id查询用户
        String shopJson = redisTemplate.opsForValue().get(key);
        //2.判断用户是否存在
        if(StrUtil.isNotBlank(shopJson)){
            //3.存在,直接返回用户
            //反序列化
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        //3.不存在,在数据库根据id查询用户
        Shop shop = getById(id);
        //4.不存在,报错   
        if(shop==null){
            log.debug("商铺不存在");
            return Result.fail("商铺不存在");
        }
        //5.存在,将用户信息存入redis
        redisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
        //6.返回
        return Result.ok(shop);
    }

    @Override
    @Transactional  //事务回滚
    public Result updateShop(Shop shop) {
        Long id = shop.getId();
        if(id==null){
            return Result.fail("店铺id不能为空!");
        }
        //先更新数据库
        updateById(shop);
        //删除缓存
        redisTemplate.delete(CACHE_SHOP_KEY+id);
        return Result.ok();
    }

功能测试:

开始前,redis中只存储了登录的token。接着点击luffy茶餐厅:

此时redis中就会缓存该店铺的信息了:

然后用apifox模拟对店铺信息的更新操作,将店铺名称改为"jay茶餐厅":

这时我们就会发现redis中存储的店铺信息就被删除了:

接着刷新页面,就发现客户端数据修改成功了:

同时redis缓存中的数据也得到了更新:

运行的结果与预期一致,说明我们的代码成功实现了设计的执行流程。

三、缓存穿透

什么是缓存穿透?

缓存穿透 是指客户端请求的数据在缓存 中和数据库 中都不存在,这样每次查询缓存都不会命中,最后都会对数据库进行查询。就像是缓存被穿透了一样,每次请求都会直接到达数据库

如果遇到有人恶意攻击,不断地查询不存在的用户,就会导致数据库压力非常大,很可能直接把数据库搞垮掉。

解决方案

常见的解决方案有以下两种:

  • 缓存空对象
  • 布隆过滤

(1)缓存空对象

即当查询数据库后发现请求的数据不存在时,也会往redis缓存中写入对应的null值,然后再返回给客户端。这样,即使后面被恶意攻击,之后请求的数据也都会被缓存拦截,直接返回一个null,不会查询到数据库,从而避免了缓存穿透的发生。

但是这种方式也是有代价的。

  • 比如当用户真的往数据库中插入了对应id的数据,而在这之前由于数据不存在,已经在缓存中写入了null值,就会导致数据库与缓存数据不一致。
  • 同时当大量查询各种不存在的数据时,都会往缓存中写入对应的null值,这会导致缓存中会存储大量无用的信息,浪费内存空间。本来缓存空间就寸土寸金,这更是雪上加霜了。

不过,可以通过对缓存设置合适的有效期来缓解上述问题。并且由于这种操作实现相对比较容易,因此也能作为缓解缓存穿透的一种方案。

接下来就来简单实现一下这种方案,业务逻辑如下:

代码实现:

java 复制代码
    @Override
    public Result queryById(Long id) {
        String key = CACHE_SHOP_KEY + id;
        //1.从redis中根据id查询用户
        String shopJson = redisTemplate.opsForValue().get(key);
        //2.判断用户是否存在
        if(StrUtil.isNotBlank(shopJson)){
            //3.存在,直接返回用户
            //反序列化
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        //判断命中的是否为null值
        if(shopJson==null){
            //返回错误信息
            return Result.fail("店铺信息不存在!");
        }
        //3.不存在,在数据库根据id查询用户
        Shop shop = getById(id);
        //4.不存在,报错
        if(shop==null){
            log.error("商铺不存在");
            //向redis里写入对应null值
            redisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
            return Result.fail("商铺不存在");
        }
        //5.存在,将用户信息存入redis
        redisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
        //6.返回
        return Result.ok(shop);
    }

(2)布隆过滤

顾名思义,就是在客户端与缓存之间再加一层布隆过滤器。先由布隆过滤器判断该请求对象是否存在,若不存在则直接拒绝这次请求,存在才会放行,继续执行原有的查询逻辑。这就从根本上避免了缓存穿透的发生。

显然,这种方案的关键在于要通过布隆过滤器来判断请求对象存在与否。那么它到底是如何做到这一点的呢?

布隆过滤器的原理

布隆过滤器本质上就是一个非常长的二进制向量,通过二进制的 01 来表示数据是否存在。开始的时候,每一位的初始值都为 0

当要往布隆过滤器中存入数据时,会通过多个的Hash算法求出多个不同的数值,然后将这些数值映射到布隆过滤器,即将对应位置的数值赋值为 1

(1)添加数据

比如下图中存入 "jay" 时,通过三个Hash函数分别求出了 2、3、7 的值,然后就会将数组中下标为 2、3、7的位置赋值为 1 :

(2)查询数据

比如当查询 "luffy" 这个数据时,就会通过同样的三个Hash函数计算出三个值,当这三个下标位置的值都为1时,我们就认为该数据是存在的。否则就会任务该数据不存在。

下图中我们发现2、7的值为1,但 4 的值为0,这时我们就会认为该数据不存在了。

(3)删除数据

事实上,布隆过滤器想要实现删除功能是非常困难的。就像我们上述局的例子中的jay和luffy两个数据,在进行Hash运算后都会有 2、7 这两个值。也就是说,当布隆过滤器中记录的数据越来越多时,每个二进制位关联的数据也会越来越多,比如当我们要删除 "jay" 这个数据,计算出坐标 2、3、7时,我们无法保证这三个位置只关联了 "jay" 这一条数据,如果盲目删除,将会对其它数据的查询造成影响。因此,我们一般不推荐对布隆过滤器执行删除操作。

(4)Hash冲突

通过上面的分析我们可以知道,布隆过滤器是完全依赖于Hash算法的,而使用Hash算法就难免会发生Hash冲突的问题。

比如当存储了数据 jay 关联 2、4、7,zhou 关联3、4、7时,此时再查询数据 luffy,就会发现其所需的 2、3、7的值都为 1,不管数据 "luffy" 是否存在,布隆过滤器都会认为它是存在的,这样就会存在误判的风险。

无论我们如何优化,这种冲突都是无法避免 的。我们可以通过增加Hash函数的数量来一定程度上减小误判率。不过随着Hash函数的增加,其时间复杂度也会越来越高,反而会影响执行的效率。因此,我们应该根据实际开发需求来控制Hash函数的数量,将误判率控制在一个能够承受的范围内即可。

不过,有一点可以肯定的是,虽然通过布隆过滤器查询数据如果存在,由于Hash冲突,其在redis或数据库中也有可能不存在;但是如果一个数据经过布隆过滤器查询后不存在,那么该数据在redis或数据库中也一定不存在。

也就是说,被布隆过滤器过滤掉的数据一定是不存在的数据,不会 过滤掉正常存在的数据。而且由于Hash冲突的概率相对降低,布隆过滤器没有过滤掉的脏数据也会比较少。因此使用布隆过滤器可以很大程度上缓解缓存穿透问题,像是一块盾牌守护着缓存与数据库

布隆过滤器的简单运用:

java 复制代码
public class RedissonBloomFilter { 
    public static void main(String[] args) {
        Config config = new Config();
        config.useSingleServer().setAddress("你的redis ip");
        config.useSingleServer().setPassword("你的 redis 密码");
        // 构造Redisson
        RedissonClient redisson = Redisson.create(config);
 
        RBloomFilter<String> bloomFilter = redisson.getBloomFilter("phoneList");
        // 初始化布隆过滤器:预计元素为100000000L,误差率为3%
        bloomFilter.tryInit(100000000L,0.03);
        // 将号码10086插入到布隆过滤器中
        bloomFilter.add("10086");
 
        // 判断下面号码是否在布隆过滤器中
        System.out.println(bloomFilter.contains("123456"));//false
        System.out.println(bloomFilter.contains("10086"));//true
    }
}

总结

解决缓存穿透的两种方案的优劣势:

缓存空对象:

优点:实现简单,维护方便

缺点:

  • 额外的内存消耗
  • 可能造成短暂的数据不一致

布隆过滤:

优点:内存占用较少,不用存储多余的缓存

缺点:

  • 实现复杂
  • 存在误判可能

除了上述两种方案,我们还可以通过以下几种方案来解决缓存穿透问题:

  • 增强id的复杂度,避免被猜测到id规律
  • 做好数据的基础格式校验,剔除非法的请求
  • 加强用户权限校验,根据权限对用户进行合理限流
  • 做好热点数据限流,对短期内发送大量请求的用户进行限流

四、缓存雪崩

缓存雪崩是指在某一时刻,大量缓存同时失效,导致大量请求直接查询数据库,从而对数据库造成了巨大的压力,严重情况下可能会导致数据库宕机的情况。

可能会由以下几种情况导致:

  • 大规模缓存失效:多个缓存在同一时间过期或被清空,导致大量请求直接访问后端数据库。
  • 缓存层故障:如redis宕机,导致无法使用缓存,所有请求都必须访问后端数据源。

解决方案:

  • 在设置缓存的时候,随机初始化缓存的失效时间,避免大量缓存在同一时间段失效
  • 利用redis集群,将热点的key放在不同的节点上,让热点的key平均分布在不同的redis节点上。提高服务的可用性
  • 设置定时任务,在缓存失效时就立即去刷新缓存,从而达到持久化缓存的效果
  • 给缓存业务添加降级限流策略,在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量,从而避免失效时大量的并发请求落到底层存储系统上
  • 给业务添加多级缓存,使用Nginx缓存+Redis缓存+其他缓存(如ehcache等)构建多级缓存架构,以提高系统的可靠性和容错性。

五、缓存击穿

什么是缓存击穿

缓存击穿也叫热点key问题。当缓存中存储的某个热点数据在某一时刻失效,而此时有大量并发请求同时访问这个热点数据,就会导致所有请求打到数据库,造成数据库压力骤增,甚至直接崩溃。

解决方案

常见的解决方案有:

(1)互斥锁机制

所谓互斥锁机制,就是为重建缓存数据的操作加上锁。这样即使在缓存失效时,大量的高并发请求查询缓存未命中时,只有抢到互斥锁的线程才会继续进行查询数据库重建缓存的操作。其它线程由于获取互斥锁失败,就会进行休眠,过一段时间后再重新尝试获取缓存。

这样,在缓存失效的瞬间,即使有无数的请求打过来,最后也只会有一个线程进行数据库查询操作,不会出现大量请求同时打到数据库的情况,也就避免了缓存击穿的发生:

不过,这种方案也是有代价的。短时间内无数个请求中只有一个线程在执行操作,其它线程都在阻塞等待,这会导致执行效率大幅下降,也会给大多数用户造成系统卡顿的现象。

(2)逻辑过期机制

所谓的逻辑过期,就是指在逻辑上给缓存记录过期时间字段,但实际上并没有给缓存设定TTL。这样,即使到了逻辑上的过期时间,实际上缓存也是依然存在的,对缓存更新时只需要再更新过期时间字段即可。从结果上来看,达到了缓存永不失效的效果,从根本上避免了缓存击穿的发生。

这样,每个请求在查询缓存时,都需要判断逻辑时间是否过期。若发现逻辑时间过期,就会尝试获取互斥锁,若获取成功,则会创建一个新的线程执行数据库查询重建缓存的操作。由于此时缓存只是逻辑上的失效,在重建缓存完成之前,实际上还是可以查询到过期数据的,这时我们也不让线程傻傻等待,而是先直接返回缓存中查询到的过期数据。同样,其它未抢到互斥锁的请求,也会先直接返回过期数据。

这样,起码在执行上,不会出现大量线程进入阻塞等待状态的现象:

不过,这样做显然也是有代价的,这会导致短期内用户查询到的数据与实际数据不一致的问题。因此这种方式适用于那些对数据一致性没那么敏感的场景。

比如我们在抢票时,就会出现页面上显示仍有余票,但实际上票已经买完的情况。

总结

简单来说,互斥锁方案选择了保证数据的一致性,但是牺牲服务的可用性,让性能大幅下降;而逻辑过期方案则是选择了保证服务的可用性,但是牺牲了数据的一致性,短时间内读取到的可能是旧的数据。

在实际开发中,数据一致性与服务可用性也是很难同时兼顾的。还是需要根据具体需求来做出抉择。没有最好的方案,只有最合适的方案


实战演练

1、互斥锁方案

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

流程:

不过,这里并不能用我们之前常使用的synchronized来进行加锁。因为如果用synchronized加锁,线程没获取到锁就会进入阻塞等待,无法自定义执行逻辑来实现上述业务需求。我们应当尝试去自定义一个互斥锁。

redis中的setnx操作,在key不存在的时候可以写入,key如果存在就将无法写入:

这样我们就可以尝试用setnx操作来自己实现一个互斥锁:

java 复制代码
    //加锁
    private  boolean tryLock(String key){
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, "lock", LOCK_SHOP_TTL, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }
    //解锁
    private void unlock(String key){
        redisTemplate.delete(key);
    }

接着就可以根据业务修改代码。主要是在获取缓存未命中后尝试获取互斥锁。

  • 若获取失败,则休眠一段时间,之后再重新尝试查询缓存;
  • 若获取成功,此时还不能直接进行重建缓存操作,因为在该线程获取锁的过程中,其它线程可能已经完成重建缓存操作,就有可能导致重复操作影响性能了。

因此此时仍需重新尝试获取缓存,并判断缓存是否存在

  • 若此时缓存存在,则说明其它线程已完成重建缓存操作,则无需重复操作,直接返回缓存内容即可
  • 若此时缓存仍然不存在,才正常进行数据库查询与重建缓存的操作,并在执行完毕后则释放互斥锁:
java 复制代码
    @Override
    public Result queryById(Long id) {
        //互斥锁解决缓存击穿
        Shop shop = queryWithMutex(id);
        if(shop==null){
            return Result.fail("店铺不存在!");
        }
        return Result.ok();
    }
    public Shop queryWithMutex(Long id) {
        String key = CACHE_SHOP_KEY + id;
        //1.从redis中根据id查询用户
        String shopJson = redisTemplate.opsForValue().get(key);
        //2.判断用户是否存在
        if(StrUtil.isNotBlank(shopJson)){
            //3.存在,直接返回用户
            //反序列化
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return shop;
        }
        //判断命中的是否为空值
        if(shopJson==null){
            return null;
        }
        String lockKey= null;
        Shop shop = null;
        try {
            //4.实现缓存重建
            //4.1.获取互斥锁
            lockKey = LOCK_SHOP_KEY+id;
            boolean isLock = tryLock(lockKey);
            //4.2.判断是否获取成功
            if(!isLock){
                //4.3.获取失败,则休眠并重试
                //休眠
                Thread.sleep(50);
                //重试
                return queryWithMutex(id);
            }
            //4.4.获取成功,二次判断此时的缓存是否过期,防止出现多次重建缓存操作
            //从redis中根据id查询用户
            String newShopJson = redisTemplate.opsForValue().get(key);
            //判断用户是否存在
            if(StrUtil.isNotBlank(newShopJson)){
                //3.存在,直接返回用户
                return JSONUtil.toBean(newShopJson, Shop.class);
            }
            //判断命中的是否为空值
            if(shopJson==null){
                return null;
            }
            //4.4.用户任然不存在,才重新进行缓存重建操作
            //在数据库根据id查询用户
            shop = getById(id);
            //5.不存在,报错
            if(shop==null){
                log.error("商铺不存在");
                //向redis里写入对应null值
                redisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }
            //6.存在,将用户信息存入redis
            redisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            //7.释放互斥锁
            unlock(lockKey);
        }
        //8.返回
        return shop;
    }

2、逻辑过期方案

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

业务流程:

我们需要就需要手动记录redis过期时间,而原来的Shop类中并没有对应属性,为了尽量不改变原有类型,我们需要专门定义一个类来记录缓存信息:

java 复制代码
@Data
public class RedisData {
    //过期时间
    private LocalDateTime expireTime;
    //实体类
    private Object data;
}

再定义一个类实现缓存的写入操作,并不设置TTL过期时间,也就是说这个缓存是永久有效的。管理端可以通过这个方法实现数据预热:

java 复制代码
    private void saveShop2Redis(Long id,Long expireTime){
        //1.查询店铺数据
        Shop shop=getById(id);
        //2.封装过期时间
        RedisData redisData=new RedisData();
        //设置实体类对象
        redisData.setData(shop);
        //设置逻辑过期时间
        redisData.setExpireTime(LocalDateTime.now().plusMinutes(expireTime));
        //写入缓存
        redisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
    }

接着就可以根据上述流程实现代码了

java 复制代码
    //创建一个容量为10的线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR= Executors.newFixedThreadPool(10);
    @Override
    public Result queryById(Long id) {
        //逻辑过期解决缓存击穿
        Shop shop = queryWithLogicalExpire(id);
        if(shop==null){
            return Result.fail("店铺不存在!");
        }
        return Result.ok();
    }
    public Shop queryWithLogicalExpire(Long id){
        String key=CACHE_SHOP_KEY+id;
        //1.从redis中根据id查询用户
        String shopJson = redisTemplate.opsForValue().get(key);
        //2.判断是否命中
        if(StrUtil.isBlank(shopJson)){
            //3.未命中 说明用户一定不存在,直接返回空
            return null;
        }

        //4.反序列化
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        //获取店铺信息
        Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
        //获取缓存过期时间
        LocalDateTime expireTime = redisData.getExpireTime();
        //5.判断缓存是否过期
        if(expireTime.isAfter(LocalDateTime.now())){
            //5.1.未过期,直接返回店铺信息
            return shop;
        }
        //5.2.已过期,尝试获取互斥锁
        String lockKey=LOCK_SHOP_KEY+id;
        boolean isLock = tryLock(lockKey);
        //6.判断是否获取成功
        if(isLock){
            //6.1成功,二次判断此时的缓存是否过期,防止出现多次重建缓存操作
            //获取此时缓存的过期时间
            RedisData newRedisData = JSONUtil.toBean(redisTemplate.opsForValue().get(key), RedisData.class);
            LocalDateTime newExpireTime =newRedisData.getExpireTime() ;
            //判断当前缓存是否过期
            if(LocalDateTime.now().isAfter(newExpireTime)){
                //缓存未过期,说明此时缓存已被重建,缓存未过期,直接返回结果即可
                return JSONUtil.toBean((JSONObject) newRedisData.getData(), Shop.class);
            }
            //缓存仍然过期,则开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(()->{
                try {
                    //重建缓存
                    this.saveShop2Redis(id,30L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    //释放锁
                    unlock(lockKey);
                }
            });
        }
        //6.2返回过期的商铺信息
        return shop;
    }

那么本篇文章就到此为止了,如果觉得这篇文章对你有帮助的话,可以点一下关注和点赞来支持作者哦。如果有什么讲的不对的地方欢迎在评论区指出,希望能够和你们一起进步✊

相关推荐
yuzhiboyouye7 分钟前
内连接,左连接,右连接怎么区别开来?
数据库
笨拙的老猴子14 分钟前
[特殊字符] Java GC机制详解:G1、ZGC、Shenandoah全面解析与版本演进对比
java·开发语言
qq_2651533721 分钟前
Redis在游戏服务器中怎么实现开合服数据同步?
服务器·redis·游戏·游戏服务器
铭毅天下23 分钟前
Easysearch 版本进化全图——从 ES 国产替代到 AI Native 搜索数据库
大数据·数据库·人工智能·elasticsearch·搜索引擎
muddjsv29 分钟前
SQL 最常用技能详解与实战示例
数据库·sql·mysql
砍材农夫44 分钟前
物联网 基于netty构建mqtt协议规范(遗嘱与保留消息)
java·开发语言·物联网·netty
DFT计算杂谈1 小时前
KPROJ编译教程
java·前端·python·算法·conda
重生之我是Java开发战士1 小时前
【笔试强训】Week5:空调遥控, kotor和气球,走迷宫,主持人调度II,体操队形,二叉树的最大路径和,排序子序列,消减整数
java·算法·动态规划
郑重其事,鹏程万里1 小时前
表达式计算器(mvel2)
java
其实防守也摸鱼1 小时前
软件安全与漏洞--软件安全编码
java·前端·网络·安全·网络安全·web·工具