redis的实战项目02_缓存、缓存更新策略、穿透、雪崩、击穿、缓存工具封装

本文详细介绍了Redis缓存的实战应用,包括缓存的添加、一致性问题及更新策略。针对缓存击穿、穿透和雪崩问题,提出了具体的解决方案,如内存淘汰、超时剔除、主动更新、缓存空对象、布隆过滤、互斥锁和逻辑过期等策略。此外,还提供了缓存工具类的封装方法,以实现更高效、安全的缓存管理。
摘要由CSDN通过智能技术生成

一、什么是缓存?

缓存就是数据交换的缓冲区(称作Cache [ kæʃ ] ),是存贮数据的临时地方,一般读写性能较高

例如:web应用
浏览器:【浏览器可以作为缓存:例如页面静态资源】
Tomcat:添加应用层缓存【map、redis】
数据库:数据库缓存【索引】
在这里插入图片描述

缓存的作用:

  • 减低后端的负载
    请求进入Tomcat后,之前是访问数据库,由于数据库要做磁盘读写,相对效率较低。数据压力较大。
    加入缓存后,从缓存中拿到数据,返回前端。
  • 提高读写效率,降低相应时间
    数据库读写相应时间长。
    缓存redis,读写延迟是微妙级别,可以应对更高的并发问题

缓存的成本:

  • 数据一致性成本
    数据库和缓存的数据一致性
  • 代码维护成本
  • 运维成本
    搭建集群,需要增加人工成本

二、添加redis缓存:

在这里插入图片描述

最最普通添加缓存案例:
前端穿过来一个id,根据id查询商铺信息。

------controller----
    /**
     * 根据id查询商铺信息
     * @param id 商铺id
     * @return 商铺详情数据
     */
    @GetMapping("/{id}")
    public Result queryShopById(@PathVariable("id") Long id) {
        Result shop = shopService.selectById(id);
        return  shop;
    }

------------serviceImpl-------
	
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public Result selectById(Long id) {
        //1. 通过id 到redis查询
        String shopKey = CACHE_SHOP_KEY+id;
        String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
        //2. 判断有没有查询到. 不是空的--》有数据则返回结果
        if (StrUtil.isNotBlank(shopJson)){
            // 3. 如果查到了则直接返回结果,说明查到了JSON,需要转换为实体类
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        //4. 如果没有查到,到数据查询
        Shop shop = getById(id);
        // 5. 数据库没有查询到,返回错误信息
        if (shop == null){
            return Result.fail("店铺不存在");
        }
        //6. 数据库查询到了,则写入redis
        String shopStr = JSONUtil.toJsonStr(shop);
        stringRedisTemplate.opsForValue().set(shopKey,shopStr);
        //7. 返回数据结果
        return Result.ok(shop);
    }

在这里插入图片描述
非常好用的工具包:例如下面格式转换就是用的该工具包

<!--hutool-->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.7.17</version>
</dependency>

在这里插入图片描述

三、缓存一致性问题,更新策略【重点】

1、场景的缓存更新策略

在这里插入图片描述

1. 内存淘汰:

说明:
redis是基于内存来存储的,内存数据有限。原本是redis解决内存不足的问题。redis自身的淘汰机制。当内存不足时,就会触发该机制,根据机制将一部分数据淘汰掉。

一致性:差
是因为,当数据库信息发生了改变,缓存的数据并没有变。当用户请求时,还是会查到旧的数据。

维护成本:无。配置即可

2.超时剔除

说明:添加在缓存中的时间。到期后自动删除。下次查询时,再次更新
一致性:一般
维护成本:低

3. 主动更新

自己编写业务逻辑,修改数据库的同时,修改缓存。

4. 业务场景:

低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存
高一致性需求:使用主动更新机制,并以超时剔除作为兜底方案。例如店铺详情查询的缓存

2、主动更新的策略的实现:

1.主动更新需要考虑的3点问题:

主动更新:缓存调用者,在更新数据库的同时更新缓存。

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

更新缓存:每次更新数据库都更新缓存。无效的写操作较多,不推荐。
例如:更新了一百次数据库,从而也就更新了一百次缓存。如果一次都没有查询缓存。那么无效写缓存操作较多。

推荐:删除缓存:更新数据库时,让缓存删除,查询时在更新缓存。
例如:更新了一百次数据库,从而也就更新了一百次缓存。 当有用户查询该用户时,才会更新缓存。

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

单体系统,将缓存与数据库操作放在一个事务
分布式系统:利用TCC等分布式事务方案

3、先操作缓存还是先操作数据库?
先删除缓存,在操作数据库
推荐:先操作数据库,在删除缓存。

在这里插入图片描述

在先操作数据库,在删除缓存时,导致缓存和数据库不一致问题:
1.当线程1来查询数据库时,恰好缓存失效了。失效的同时,就会到数据库查询数据。比如id=10
2.在微妙级写入缓存时,这时:
3.线程2插入了进来,要更新数据库变为20,并且删除空缓存【是空的,在查询时就已经失效了】
4.这时将数据库查询到的旧数据放到了缓存当中。id=10

在这里插入图片描述

2. 具体实现-案例

给查询商铺的缓存添加超时剔除和主动更新的策略
修改ShopController中的业务逻辑,满足下面的需求:
① 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
② 根据id修改店铺时,先修改数据库,再删除缓存
在这里插入图片描述

在这里插入图片描述

3. 测试:

在这里插入图片描述

当执行了更新数据库后,发现缓存中以及没有了数据。
当再次查看商品信息时,
第一:数据库和缓存都有了全新数据
第二:缓存中增加了超时剔除策略。
在这里插入图片描述

四、缓存穿透

1、介绍:

缓存穿透:用户查询的数据,在数据库和缓存中都不存在。从而当用户请求过来,如果不加入措施的情况下,会直接访问数据库。
如果不断的发起请求,那么会给数据库带来巨大压力

6个解决方法:

  1. 缓存null值
  2. 布隆过滤
  3. 增加id复杂度,避免被猜测id的规律
  4. 做好数据的基础格式校验
  5. 加强用户权限校验
  6. 做好热点参数的限流

2、解决方法一:缓存空对象

说明
当用户请求数据时,redis中没有,就会访问数据库,数据库也没有,就会返回空值,先写入redis缓存中,然后返回客户端。
当客户再次访问该数据时,就直接从redis中拿到空值。从而避免访问数据库。

优点
简单、维护方便

缺点
1.额外的内存消耗。redis缓存了一些没有用的值。
【解决方法:设置有效期】

2.可能造成短期的不一致。
比如:请求了某一个id,请求时不存在,当把空值写入缓存时,这时将该id,添加到了数据库。从而缓存和数据库中的值不一致。
【解决办法:添加数据库时,覆盖redis缓存】
在这里插入图片描述

3、解决方法二:布隆过滤

说明
在客户端与redis之间,加入一层布隆过滤器。
当用户请求过来以后,先到布隆过滤器,根据计算是否存在该key,如果存在则访问redis。如果不存在拒绝访问。

布隆过滤器原理:是一个bit数组,存放的是二进制位。判断数据库中的数据是否存在时,并不是把数据放到了布隆过滤器,而是通过hash算法,计算出hash值,再将hash值转换为二进制位,保存到布隆过滤器中。

优点
空间占用小

缺点
实现复杂、存在误判可能
不存在是真的不存在,存在不一定存在。

在这里插入图片描述

4、案例实现:缓存穿透-缓存空对象

需要改动的是:
1.当请求是id在缓存和数据库中都不存在的值时,数据库返回null,写入redis中。并返回null
2.用户从redis缓存中拿数据时,判断是否为空,是空则直接结束。不是空,则返回信息。

在这里插入图片描述

-------service代码------------
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public Result selectById(Long id) {
        //1. 通过id 到redis查询
        String shopKey = CACHE_SHOP_KEY+id;
        String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
        //2. 判断有没有查询到. 不是空的--》有数据则返回结果
        // isNOtBlank只有一种情况,真正有数据才能为true。 比如空字符串是false
        if (StrUtil.isNotBlank(shopJson)){
            // 3. 如果查到了则直接返回结果,说明查到了JSON,需要转换为实体类
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        //  缓存穿透后,将空字符串保存在缓存中,再次查询时,将缓存返回空数据
        if (shopJson == null){
            return Result.fail("店铺信息不存在!!");
        }
        //4. 如果没有查到,到数据查询
        Shop shop = getById(id);
        // 5. 数据库没有查询到,返回错误信息
        if (shop == null){
            // 前端要查id=shopKey, 如果数据库没有,则向缓存中保存“”空字符串,并过期时间2分钟。
            stringRedisTemplate.opsForValue().set(shopKey,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
            return Result.fail("店铺不存在");
        }
        //6. 数据库查询到了,则写入redis
        String shopStr = JSONUtil.toJsonStr(shop);
        //  设置超时剔除,CACHE_SHOP_TTL=30L分钟后自动从缓存中删除
        stringRedisTemplate.opsForValue().set(shopKey,shopStr,CACHE_SHOP_TTL, TimeUnit.MINUTES);
        //7. 返回数据结果
        return Result.ok(shop);
    }

在这里插入图片描述

解释:isNotBlank方法:
在这里插入图片描述

ps:此消息【店铺信息不存在】是缓存到了浏览器页面。所以第二次访问该id也进行了提示。
在这里插入图片描述

五、缓存雪崩

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

在这里插入图片描述

解决雪崩的4个方法:

  1. 同时失效解决方法:给不同的key的TTL添加一个随机值。比如1-5分钟。

比如说可能做缓存的预热,那么批量将数据导入到缓存中。如果给所有数据设置的是同一个TTL值,那么所有的数据将会同一时间过期。造成雪崩。
从而:在缓存预热,批量导入数据时,可以给TTL后面添加一个随机数。这样这些数据,就可以在时间内过期,避免同一时间内失效。

  1. redis宕机解决方法:利用redis集群提高服务的可用性

reids哨兵机制:可以实现对服务的监控。当某个主节点宕机后,哨兵会从从节点中选出一个节点成为主节点。主从还可以实现数据同步,保证数据不会丢失。从而提高服务的可用性、数据安全性。

  1. 给缓存业务添加降级限流策略

当出现人们无法抗拒的事故,比如整个集群宕机了。那么可以给服务添加一种降级限流策略。比如sentinel快速失败、拒绝服务。从而避免对数据库的访问。
这样牺牲了部分服务,但是能保护整个数据库的服务。

  1. 给业务添加多级缓存

浏览器缓存【缓存静态数据】
反向代理Nginx【做本地缓存】
reids缓存
JVM内部建立缓存
数据库

六、缓存击穿

缓存雪崩是大量key同时过期,导致大量访问到数据库,从而给数据库带来巨大压力。
击穿是部分key过期,导致给数数据库带来巨大压力。

缓存击穿也叫热点key问题,就是一个被高并发访问并且缓存重建业务较复杂(可以理解为查询语句比较耗时的SQL语句)的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大压力。

  1. 热点key:就是访问的非常多的某个key。比如秒杀商品。
  2. 缓存重建:某个key的缓存时间到期了。就会从新去数据库进行查询,写入redis中。

有些查询数据的时间比较长,比如设计多个表、做运算。从而达到毫秒级别。从而redis中一直都没有缓存。在查询数据库中的这段时间内,就会有无数个请求该key,这些都在redis中未命中,都会去访问数据库。

例如:
当一个热点key失效了,这时有100个请求访问该key。

  1. 在缓存中查询未命中,要进行缓存重建
  2. 查询数据库,重建缓存数据。【这段时间比较耗时】
  3. 查询数据库结束后,写入缓存中
  4. 那么:::当第一个请求在查询数据这段时间内,突然来了100个请求该key,那么请求缓存也是未命中,从而只要是在第一个请求写入缓存之前,来访问该key的请求,就都会去数据库查询。重复以上操作,并且重复查询数据库N次,重复更新缓存N次。
    在这里插入图片描述

1、解决方法一:互斥锁

无数个请求都去做重建缓存,利用锁的机制。只允许一个请求去查询数据库。

  1. 当请求发现缓存未命中。
  2. 去获取互斥锁,
  3. 只有获取互斥锁成功的请求,才能去数据库进行查询数据。
  4. 当写入缓存后
  5. 释放锁

在第三步,没有获取互斥锁的请求,
1.会睡眠一会儿
2.然后在重试访问缓存,如果缓存失败
3.再去获取锁,锁也失败,在进行睡眠。
直到第一个请求写入缓存成功后,请求2才能在缓存中拿到数据。

在这里插入图片描述

缺点:互相等待,从而性能差
例如构建缓存时间比较久,比如200毫秒。在这一段时间内,涌入的所有线程只能做等待。

2、解决方法二:逻辑过期

缓存击穿的原因是:热点key缓存时间过期了。所以导致未命中,从而重建缓存。
既然如此,那么当在redis中存入数据时,不设置TTL过期时间。
而是:在插入数据时不加入真正的TTL过期时间,加一个字段为过期时间。并不是真正过期时间,而是某个字段内容是过期时间。

  1. 线程一:查询缓存发现逻辑时间过期,获取互斥锁成功后,
  2. 开启线程二,线程二进行缓存重建。释放互斥锁
  3. 线程一直接返回逻辑过期的数据。
  4. 当线程三来访问缓存时,发现缓存时间过期、并且互斥锁也获取失败,那么也会返回逻辑过期数据。
  5. 直到线程二,缓存重建成功,并且释放互斥锁后。就可以拿到正常缓存数据了。

在这里插入图片描述

3、互斥锁和逻辑过期对比

互斥锁可以保证数据一致性,当数据过期后,会一直等待锁释放比较耗时,但是拿到最新数据。
逻辑过期可以性能好,不需要等待释放锁。但是拿到的是过期后的数据。

要求一致性:互斥锁
要求性能:逻辑过期
在这里插入图片描述

4、代码实现互斥锁:【案例】

1.代码流程:

在这里插入图片描述

  1. 根据id查询数据库
  2. redis缓存查询数据
  3. 判断是否命中
  4. 如果命中,则返回数据
  5. 如果没有命中,尝试获取互斥锁
  6. 判断是否拿到锁,
  7. 如果没有拿到锁,进入休眠,在重新去redis缓存中查询数据。(可以做递归处理,调用本方法)
  8. 如果拿到锁,那么进行缓存重建。去数据库查询数据
  9. 查询到的数据写入redis中
  10. 释放锁
  11. 返回数据。

2.对于锁的介绍:

setnx命令:给某个key赋值,当这个key不存在的时候才能写入该key。 也就是说key存在,就不执行。
释放锁删除、获取锁就是赋值。
在利用setnx时,往往加入一个有效期TTL,10秒钟。往往一个SQL查询业务,在1秒内完成。从而避免某些原因锁得不到释放。
在这里插入图片描述

3.具体代码:

------service层代码:----------
	/**
	 *  controller 调用的就是该类。
     * 根据id,返回数据。
     * queryWithMutex:【缓存穿透-空字符串返回】 + 【缓存击穿-互斥锁】
     * @param id
     * @return
     */
    @Override
    public Result selectById(Long id) {
        //1.缓存穿透,直接调用下面方法【queryWithPassThrough】即可
        //Shop shop = queryWithPassThrough(id);

        //2. 缓存击穿:利用互斥锁
        Shop shop = queryWithMutex(id);
        // 在方法内,由于返回值是Shop类型,所以返回直接设为了null。
        // 那么在这里做一下处理,当返回空时,后端友好提示一下返回结果。
        if (shop ==null) {
            return Result.fail("店铺不存在");
        }
        //7. 返回数据结果
        return Result.ok(shop);
    }

    /**
     * mutex:互斥锁的意思
     * @param id
     * @return
     */
    public Shop queryWithMutex(Long id){
        //1. 通过id 到redis查询
        String shopKey = CACHE_SHOP_KEY+id;
        String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
        //2. 判断有没有查询到. 不是空的--》有数据则返回结果
        // isNOtBlank只有一种情况,真正有数据才能为true。 比如空字符串是false
        if (StrUtil.isNotBlank(shopJson)){
            // 3. 如果查到了则直接返回结果,说明查到了JSON,需要转换为实体类
            return JSONUtil.toBean(shopJson, Shop.class);
        }
        //  缓存穿透后,将空字符串保存在缓存中,再次查询时,将缓存返回空数据
        if (shopJson != null){
            return null;
        }
        // 应该给锁设置try,即使抛异常,也必须要要释放锁。
        String lockKey= null;
        Shop shop = null;

        try {
            //4. 如果缓存没有查询到,进入缓存重建
            // 4.1 获取互斥锁
            lockKey = "lock:shop:"+id;
            boolean isLock = tryLock(lockKey);
            // 4.2 判断互斥锁是否获取成功
            if (!isLock){
                // 4.3 如果没有拿到互斥锁,休眠等待+ 然后从新去缓存中查数据
                Thread.sleep(50);
                return queryWithMutex(id);
            }
            // 4.4 如果拿到了互斥锁,去数据进行查询数据
            shop = getById(id);
            // 模拟提高缓存重建时间。提高查询数据的时间
            Thread.sleep(200);
            // 5. 判断有没有在数据库中,取到数据。
            if (shop == null){
                // 缓存穿透问题:如果没有拿到数据: 前端要查id=shopKey, 如果数据库没有,则向缓存中保存“”空字符串,并过期时间2分钟。
                stringRedisTemplate.opsForValue().set(shopKey,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
                return null ;
            }
            //6. 数据库查询到了,则写入redis
            String shopStr = JSONUtil.toJsonStr(shop);
            //  设置超时剔除,CACHE_SHOP_TTL=30L分钟后自动从缓存中删除
            stringRedisTemplate.opsForValue().set(shopKey,shopStr,CACHE_SHOP_TTL, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            //是打断的异常
            throw new RuntimeException(e);
        } finally {
            // 7.释放互斥锁
            unlock(lockKey);
        }
        //8. 返回数据结果
        return shop;
    }

4.测试缓存击穿、并发访问

热点key需要满足两个方面:
1.高并发。用Apache JMeter工具来模拟高并发。
2.缓存重建时间比较久。重建时间越久,发生线程并发安全问题就越高。也就是说缓存中并没有数据。
目前测试阶段:为了提高缓存重建时间,在查询数据时,添加睡眠时间:
在这里插入图片描述
测试要求:
在这里插入图片描述

测试结果:当reids缓存失效时,1000个请求来访问该接口时,只有一个请求线程拿到了锁,并访问了一次数据库。剩下的请求都在睡眠中。 当拿到锁的请求,进行了更新缓存,并且释放锁后。其他请求都在缓存中获取数据
在这里插入图片描述

5、代码实现逻辑过期【案例】

1.代码流程:

逻辑过期并不是真正的过期,要求在存储数据到redis的时候,不添加过期时间TTL,而是额外添加一个过期时间的字段。当调用该数据时,通过业务代码来判断该数据是否过期。

  1. 前端通过id查询数据
  2. 在redis查询缓存。
  3. 判断有没有命中。【其实理论上热点key是肯定能命中的,因为设置了永不过期】
  4. 如果没有命中,直接返回null
  5. 核心 :命中后,该key在逻辑上有没有过期
  6. 如果没有过期,可用状态。直接返回数据即可
  7. 如果过期数据:并发请求争抢互斥锁。
  8. 判断获取互斥锁是否成功
  9. 如果没有拿到互斥锁,直接返回旧数据。说明缓存数据已经开始做缓存重建了。
  10. 如果拿到了互斥锁,返回旧数据,开启新线程进行缓存重建。
  11. 新线程:查询数据库id,写到redis,并设置逻辑过期时间。
  12. 新线程:释放锁。

在这里插入图片描述

2. 逻辑过期时间问题

再把数据写入到redis中,如何将逻辑过期时间添加到数据中呢?
在写一个实体类:其中包括 过期时间字段、object类型的变量,用来作为所有数据的类型。
到时候存入逻辑时间过期的值时,将RedisData存入即可。

@Data
public class RedisData {
    //逻辑过期时间
    private LocalDateTime expireTime;
    //data:就是要存入到redis中的数据。也就是shop实例
    private Object data;
}

3.缓存预热,将热点key加入到redis中

------------test---
@SpringBootTest
class HmDianPingApplicationTests {
    @Resource
    private ShopServiceImpl shopService;
    @Test
    public void saveShop(){
        shopService.save2ShopRedis(1L,10L);
    }
}
-----------只写在了service层代码
  /**
   * 释放锁
   * @parm key
   */
  private void unlock(String key){
      stringRedisTemplate.delete(key);
  }

  /**
   * 模拟缓存预热功能,将一个热点key,设为逻辑过期时间,写入到redis缓存中。
   * @param id
   * @param expireSeconds
   */
  public void save2ShopRedis(Long id,Long expireSeconds){
      //商铺的实体类
      Shop shop = getById(id);
      // 用来封装商铺实体类和逻辑过期时间
      RedisData redisData = new RedisData();
      // 设置数据
      redisData.setData(shop);
      //设置逻辑过期时间
      redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
      //存入redis中
      stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
  }

在这里插入图片描述

4. 所有代码:

以下代码包括:逻辑过期、加锁、释放锁、缓存预热
其实整体代码还包括:逻辑时间类【其中包括object数据、过期时间成员变量】

--------sevice层:
 /**
     * 根据id,返回数据。
     * queryWithMutex:【缓存穿透-空字符串返回】 + 【缓存击穿-互斥锁】
     * @param id
     * @return
     */
    @Override
    public Result selectById(Long id) {
 
        // 3. 缓存击穿 逻辑过期。 不考虑缓存穿透,所有的值都存在。
        Shop shop = queryWithLogicalExpire(id);

         /*
         在方法内,由于返回值是Shop类型,所以返回直接设为了null。
         那么在这里做一下处理,当返回空时,后端友好提示一下返回结果。
         */
        if (shop ==null) {
            return Result.fail("店铺不存在");
        }
        // . 返回数据结果
        return Result.ok(shop);
    }


    /**
     * 用逻辑过期---代码实现;
     * @param id
     * @return
     */
    public Shop queryWithLogicalExpire(Long id){
        //1. 通过id 到redis查询
        String shopKey = CACHE_SHOP_KEY+id;
        String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
        //2. 判断redis中是否命中
        if (StrUtil.isBlank(shopJson)){
            //3.如果 未命中,直接返回
            return null;
        }
        // 4.如果查到了数据:判断是否过期。那么就需要先把JSON反序列化为对象
        /*得到的bean是RedisData*/
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        JSONObject data = (JSONObject)redisData.getData();
        //得到了数据,并转为了bean
        Shop shop = JSONUtil.toBean(data, Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        // 5.判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())){
            // 5.1如果没过期,直接返回数据
            return shop;
        }
        //5.2 数据已经过期,进行缓存重建 。
        //6.1 尝试获取互斥锁.
        String lockKey =LOCK_SHOP_KEY+id;
        boolean isLock = tryLock(lockKey);
        // 6.2 判断是否拿到了互斥锁
        if (isLock){
            // 6.3 成功,开启独立线程,实现缓存重建(去数据库读取数据,并更新数据,释放线程锁)
            CACHE_REBUILD_EXECUTOR.submit(()->{
                //为了方便测试,添加商品数据过期时间为20秒。
                try {
                    this.save2ShopRedis(id,20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // 必须要释放锁
                    unlock(lockKey);
                }
            });
        }
        // 6.4 获取互斥锁失败,返回过期的商品信息
        return shop;
    }

    // 创建线程池:
    private static final ExecutorService CACHE_REBUILD_EXECUTOR= Executors.newFixedThreadPool(10);



/**
     * 获取锁
     * @param key
     * @return
     */
    private boolean tryLock(String key){
        // setnx: setIfAbsent 如果不存在,才会存的意思。 10秒内过期
        Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        /**
         * 如果直接返回 return aboolean; 会进行拆箱,那么可能空指针
         */
        return BooleanUtil.isTrue(aBoolean);

    }
    /**
     * 释放锁
     * @parm key
     */
    private void unlock(String key){
        stringRedisTemplate.delete(key);
    }

    /**
     * 模拟缓存预热功能,将一个热点key,设为逻辑过期时间,写入到redis缓存中。
     * @param id
     * @param expireSeconds
     */
    public void save2ShopRedis(Long id,Long expireSeconds) throws InterruptedException {
        //商铺的实体类
        Shop shop = getById(id);
        // 缓存重建有一定的延迟时间。
        Thread.sleep(200);
        // 用来封装商铺实体类和逻辑过期时间
        RedisData redisData = new RedisData();
        // 设置数据
        redisData.setData(shop);
        //设置逻辑过期时间
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        //存入redis中
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
    }

5.测试:

缓存击穿的特点:

  1. 热点key逻辑过期。【提前将数据插入到缓存中,然后在进行测试】
  2. 高并发测试。1秒查询100次访问该热点key。

预先得知结论:当高并发访问该热点数据时,如果判断数据逻辑过期后,会有另外一个新线程进行更新数据,在更新数据前,所有的请求都是拿到旧数据。
为了能模拟上述情况:先将数据缓存到redis中,并等待逻辑过期。 然后只把数据库中的信息进行修改。 然后在看测试回馈结果,就会得出上述结论。从而达到测试目的。
在这里插入图片描述

可以看到前面部分数据查询出来的是旧数据:
后面部分是新数据。
在这里插入图片描述
在这里插入图片描述

七、缓存工具封装。看完直接实现击穿、穿透

基于StringRedisTemplate封装一个缓存工具类,满足下列需求:
✓ 方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
✓ 方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存
击穿问题
✓ 方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
✓ 方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题

1、工具类的代码:

package com.hmdp.utils;

import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;

import static com.hmdp.utils.RedisConstants.CACHE_NULL_TTL;
import static com.hmdp.utils.RedisConstants.LOCK_SHOP_KEY;

@Slf4j
@Component
public class CacheClient {

    private final StringRedisTemplate stringRedisTemplate;

    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public void set(String key, Object value, Long time, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
        // 设置逻辑过期
        RedisData redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        // 写入Redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    public <R,ID> R queryWithPassThrough(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(json)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(json, type);
        }
        // 判断命中的是否是空值
        if (json != null) {
            // 返回一个错误信息
            return null;
        }

        // 4.不存在,根据id查询数据库
        R r = dbFallback.apply(id);
        // 5.不存在,返回错误
        if (r == null) {
            // 将空值写入redis
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回错误信息
            return null;
        }
        // 6.存在,写入redis
        this.set(key, r, time, unit);
        return r;
    }

    public <R, ID> R queryWithLogicalExpire(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isBlank(json)) {
            // 3.存在,直接返回
            return null;
        }
        // 4.命中,需要先把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        LocalDateTime expireTime = redisData.getExpireTime();
        // 5.判断是否过期
        if(expireTime.isAfter(LocalDateTime.now())) {
            // 5.1.未过期,直接返回店铺信息
            return r;
        }
        // 5.2.已过期,需要缓存重建
        // 6.缓存重建
        // 6.1.获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        // 6.2.判断是否获取锁成功
        if (isLock){
            // 6.3.成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 查询数据库
                    R newR = dbFallback.apply(id);
                    // 重建缓存
                    this.setWithLogicalExpire(key, newR, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }finally {
                    // 释放锁
                    unlock(lockKey);
                }
            });
        }
        // 6.4.返回过期的商铺信息
        return r;
    }

    public <R, ID> R queryWithMutex(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(shopJson, type);
        }
        // 判断命中的是否是空值
        if (shopJson != null) {
            // 返回一个错误信息
            return null;
        }

        // 4.实现缓存重建
        // 4.1.获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        R r = null;
        try {
            boolean isLock = tryLock(lockKey);
            // 4.2.判断是否获取成功
            if (!isLock) {
                // 4.3.获取锁失败,休眠并重试
                Thread.sleep(50);
                return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
            }
            // 4.4.获取锁成功,根据id查询数据库
            r = dbFallback.apply(id);
            // 5.不存在,返回错误
            if (r == null) {
                // 将空值写入redis
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                // 返回错误信息
                return null;
            }
            // 6.存在,写入redis
            this.set(key, r, time, unit);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            // 7.释放锁
            unlock(lockKey);
        }
        // 8.返回
        return r;
    }

    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

    private void unlock(String key) {
        stringRedisTemplate.delete(key);
    }
}

2、如何使用工具类?使用案例:

缓存穿透:参数(key的前缀,key的id,实体的类型,查询数据库的函数,过期时间,和单位)

-----使用案例-----
------service层代码:-------
 
@Resource
private CacheClient cacheClient;
//该方法就是controller层调用的方法
@Override
public Result queryById(Long id) {
参数(key的前缀,key的id,实体的类型,查询数据库的函数,过期时间,和单位)
   // 解决缓存穿透
   Shop shop = cacheClient
           .queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

   // 互斥锁解决缓存击穿
   // Shop shop = cacheClient
   //         .queryWithMutex(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

   // 逻辑过期解决缓存击穿
   // Shop shop = cacheClient
   //         .queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);

   if (shop == null) {
       return Result.fail("店铺不存在!");
   }
   // 7.返回
   return Result.ok(shop);
}

在这里插入图片描述

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值