需求:修改根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题
文章目录
一、核心逻辑
顾名思义,逻辑过期不是真正的过期,它要求我们在存储数据到redis的时候,额外的要添加一个过期时间的字段,这个key本身是不用去设置ttl的,所以它的过期时间不是由redis控制的,而是由我们自己去判断它是否过期,这样我们的业务上就会复杂很多,因此我们先来看一下整个业务流程上有什么变化。
思路分析:当用户提交id到服务端,我们拿着id肯定要去reids中查询缓存的,理论上讲这个是不会出现未命中的情况,首先我们的key是不会过期的,因此我们可以认为,一旦这个key添加到了缓存里面,它应该会是永久存在的,除非活动结束,然后我们再删除。像这种热点key往往是一些参加活动的一些商品,我们会提前给它们加入缓存,在那个时候就会给它设置一下逻辑时间。
因此理论上将这些热点Key都会提前添加好,并且一直存在,直到活动结束,因此我们去查询的时候,其实可以不用再去判断它有没有命中,如果说你真的查到这个缓存不存在,那只能说明一个问题,即这个商品它不在活动当中,不属于一个热点key。
但是在这为了健壮性考虑,还是判断一下它有没有命中,真的未命中我们也不需要去做一些击穿、穿透这样的一些解决方案,我们直接给它返回空即可。
我们的核心逻辑其实就是默认它命中了,在命中的情况下,我们需要判断的是它有没有过期,也就是它的逻辑过期时间,这个结果有两种:过期和不过期。如果没有过期,则直接返回redis中的数据,如果过期,那就说明它需要重新加载,去做缓存处理。但是我们不是说任何人来了都可以去重建,因此这里需要有一个争抢,即它需要先尝试去获取互斥锁,然后判断获取是否成功,如果获取失败,说明在你之前有人去获取数据库数据,那这个更新我们就不用管了,直接返回旧的即可。而获取锁成功的人,就需要执行缓存重建,但是也不是自己去执行,而是开启一个独立的线程,由这个线程去执行缓存重建,它自己也是返回旧的数据先用着。
二、设置逻辑过期时间
那怎么样才能设置逻辑过期时间呢?我们现在写入redis中的其实是店铺的信息(shop)
但是这个shop里面本身是没有一个逻辑过期的字段的,所以现在我们要添加逻辑过期时间,我们该怎么办?
有同学会说了:直接在Shop实体类中加一个逻辑过期时间不就行了?但是这种方案其实不够友好,因为你对原来的代码和业务逻辑做了修改,这样其实是不太推荐的。
步骤一
我们直接新建一个实体类,在里面定义逻辑过期时间的变量。
如果Shop实体类想要具备这个逻辑过期时间的变量
方案一:让Shop继承它;但是这种方案它还是要去修改源代码,因此还是有一定的侵入性。
方案二:在 RedisData
中添加一个Object属性,也就是 RedisData
它自己带有过期时间,并且它里面带有数据,这个数据就是你想存进redis的数据,例如Shop、或者其他的数据,因此它是一个万能的存储对象。这种方案就完全不用对原来的实体类做任何修改
java
package com.hmdp.utils;
@Data
public class RedisData {
// 设置的逻辑过期时间
private LocalDateTime expireTime;
private Object data;
}
这里就选择方案二了,因为这种稍微复杂一些,继承就简单一些了。
步骤二
之前说过,像这种热点数据,我们是需要提前将缓存导入进去的,那么在实际开发中你可能会有一个后台管理系统,你可以把某一些热点提前在后台添加到缓存中,但由于我们现在没有一个后台管理的系统,因此我们这会基于一个单元测试的方式来把我们的店铺数据加入到缓存中,等于是提前做一个缓存的预热
ShopServiceImpl
在 新增此方法,
java
// saveShop2Redis:将shop添加到redis中
public void saveShop2Redis(Long id, Long expireSeconds) {
// 1.查询店铺数据
Shop shop = getById(id);
// 2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
// 过期时间由参数传进来
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3.写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
此时我们就封装好了存储店铺逻辑过期时间的函数了,需要注意的是,我们在向redis中set数据的时候,并没有设置ttl的过期时间,这样的话这个key就可以认为是永久有效了,它真正的过期时间就是由我们控制的逻辑过期时间了。
接下来通过单元测试来测一下,看是不是真的能将输入写入redis中。
三、缓存预热
然后利用单元测试进行缓存预热
java
@Test
void testSaveShop() {
shopService.saveShop2Redis(1L, 10L);
}
运行单元测试,可以发现它绿了,证明方法应该是执行成功了
接下来打开redis客户端看一下,可以发现确实是已经将数据存入了,而且数据格式其实是一个大的对象,里面有两个属性,一个是 data
,一个是 expireTime
,data
就是店铺信息,expireTime
就是过期时间,完全符合我们的预期。
这样我们就实现了向redis中写入一个店铺数据,并且设置逻辑过期时间。
数据预热完成了,下面我们就真正去解决缓存击穿的问题了。
四、解决缓存击穿问题
代码和缓存穿透还是有很多一样的代码,因此复制 queryWithPassThrough(缓存穿透的代码)
,然后将方法名改为 queryWithLogicalExpire
(逻辑过期)
RedisConstants.java
java
public static final String LOCK_SHOP_KEY = "lock:shop:"; // 店铺获取的锁(key)的前缀
public static final Long LOCK_SHOP_TTL = 10L; // 锁的过期时间
ShopServiceImpl
java
@Override
public Result queryById(Long id) {
// 缓存穿透
// Shop shop = queryWithPassThrough(id);
// 互斥锁解决缓存击穿
// Shop shop = queryWithMutex(id);
// 逻辑过期解决缓存击穿
Shop shop = queryWithLogicalExpire(id);
if (shop == null) {
return Result.fail("店铺不存在!");
}
return Result.ok(shop);
}
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire( Long id ) {
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否命中
if (StrUtil.isBlank(json)) {
// 3.未命中,直接返回null
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
// redisData.getData()返回的是Object类型,因为RedisData中的data类型是Object,所以使用JSON工具在做反序列化的时候,它并不知道你的类型是不是店铺Shop。此时redisData.getData()的返回值的本质其实是JSONObject,因此这里可以直接强转
JSONObject data = (JSONObject) redisData.getData();
// 当拿到JSONObject类型后,依旧使用JSON工具类,toBean除了可以接收JSON字符串以外,还可以接收JSONObject,然后告诉它我的实际类型是店铺,此时它就能返回给你一个店铺结果了
Shop shop = JSONUtil.toBean(data, Shop.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.已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock){
// 获取锁成功应该再次检测redis缓冲是否过期,做DoubleCheck。如果存在则无需重建缓存。
// 6.3 成功,开启独立线程实现缓存重建。建议:使用线程池,不要自己去写一个线程,那一定话性能不太好,经常的创建和销毁。
// 提交任务,这个任务我们可以写成一个Lambda表达式的形式
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
// 重建缓存,直接调用之前封装好的方法即可。
// 这里过期时间准确来讲应该设置为30分钟,但是我们为了等一会测试,就先设置成20秒,我们期待的是缓存到底了,然后看看它会不会触发缓存重建的线程安全问题,因此设置短一点,方便我们观察效果
this.saveShop2Redis(id, 20L);
} catch (Exception e){
throw new RuntimeException(e);
} finally {
// 重建缓存一定要释放锁,并且释放锁的动作最好写到finally中
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return shop;
}
重建缓存为了让它有延迟,我们可以在这里给它休眠一下,这里设置延迟为200ms,延迟越长,将来越容易出现这种线程安全问题。
五、测试
由于我们在单元测试的时候已经向redis中插入了一条数据了,并且这条数据当时设置的过期时间也比较短,大概是10秒钟的样子,因此理论上这些数据应该早就过期了,但是它依然还在这里,这就是逻辑过期。
因此代码我们发送请求,一判断发现以过期,就需要做缓存的重建了。
我们现在要测试的是:在高并发的情况下
第一:它会不会出现大家一起都来做重建的情况,即并发的安全问题;
第二:一致性的问题,也就是在缓存重建完成之前,那么我们查询到的是不是旧的数据;
为了测试缓存的一致性,现在先去将数据库改一下:将 102茶餐厅
改为 103茶餐厅
现在数据库就跟缓存中的不一致了。
接下来使用 JMeter
测试,在测试的时候我们就不要使用1000个线程了,太多了,等会它结果中写不下,就会将前面的结果覆盖掉。
因此这里整100个就行了,然后100个让它在1s中执行完,这样QPS也是在1秒100个左右,也非常高了。
清空IDEA控制台,然后执行,
可以看见很快就结束了,可以看见一开始查到的是 102茶餐厅
,即旧的。
那么多长时间才能查询到新数据呢?我们当初设置的缓存重建睡眠时间是200ms,因此应该是从第一次请求开始,就会触发缓存重建,然后过200ms,此时查到的数据就是103了
因此它会有一段时间的不一致,后面就一直了,那么我们到底执行了多少次重建呢?打开IDEA。
可以发现它只执行了一条SQL语句,所以可以证明我们的并发是安全的,只会有一次重建。但是数据的一致性会存在一定的问题
这样我们就实现了基于逻辑过期的方式解决我们的缓存击穿问题了,实现起来相对来将比较复杂一点。