文章目录
一、核心思路
需求:修改根据id查询商铺的业务,基于互斥锁方式来解决缓存击穿问题
核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是 进行查询之后,如果从缓存没有查询到数据,则进行互斥锁的获取,获取互斥锁后,判断是否获得到了锁,如果没有获得到,则休眠,过一会再进行尝试,直到获取到锁为止,才能进行查询
如果获取到了锁的线程,再去进行查询,查询后将数据写入redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行操作数据库的逻辑,防止缓存击穿
要注意的是,这个锁并不是我们平常使用的锁,我们平时使用的都是 synchronized
或者 lock锁
,这两种,拿到锁了就执行,没拿到锁就需要一直等待。而我们当前这块,拿到锁和不拿到锁的执行逻辑是需要我们自己自定义的,因此我们不能使用之前的锁方式了,而是采用自定义的锁。我们我们应该使用什么方式来使用自定义的互斥锁呢?
互斥锁:多个线程并行执行的过程中,只能有一个成功,其他人失败。大家思考一下,在我们以前所学的知识里,有没有什么东西能够达成这样的一个效果?
在我们以前学习redis的string数据类型的时候,它里面就有一个命令跟这个效果是非常接近的。
二、操作锁
1)获取锁的原理
这个命令就是 setnx
,我们先通过帮助文档来查看一下这个命令的官方解释:Set the value of a key, only if the key does not exist
,如果key不存在才添加添加,存在就不添加。
那它为什么可以做到互斥呢?我们来演示一下:我们使用 setnx
,要设置的key叫 lock
,代表是一把锁。现在有一个人,假设叫 1
,它要来获取这把锁,可以看见这个时候返回值是 1
,这个返回值就代表是成功。
这个时候又有一个人,还想来 setnx
,来改一下这个值,会发现返回值是 0
,此时lock的值并没有发生改变,依然是原来的值,这就告诉我们 setnx
其实是在key不存在的时候才能往里面写,key如果已经存在了,是无法写的。如果现在并发的有数百上千的线程一起来执行 setnx
,那么此时只有第一个人才能成功,它成功写入了以后,其它线程再来执行 setnx
得到的一定是一个 0
,也就是失败的结果,是不是就类似于我们讲的 互斥
:只有成功,其他人都失败了,这就是我们自己设计锁的一个方案。
实际上这也是我们后期要讲到的 分布式锁
的一个基本原理。当然真正的分布式锁会比这个要复杂很多,后续会详细介绍,这里先简单使用一下。
2)释放锁的原理
获取锁的原理我们明白了,那释放锁其实非常简单,我们直接将锁删掉不就好了吗?
删掉后,再有其他人来执行这个 setnx操作
的时候就能成功了。
3)意外情况
当然这里会有一种意外的情况:我们 setnx
给它设置了一把锁,设置完了后,因为某种原因你这个程序出问题了,最后迟迟的没有人去执行删除或者释放的动作,那么将来很有可能这把锁永远都不会释放了。因此我们在利用 setnx
设置锁的时候,往往会给它加一个有效期作为兜底,避免锁永远都不会释放,产生死锁。例如设置有效期为10秒钟,一般我们的业务都执行在一秒钟以内,那么在业务执行的过程中,如果正常释放,没什么好说的;但是万一因为某种异常导致我的服务出现故障了,它永远不释放了,那也没关系,将阿里十秒钟一到,锁还会自动释放。
这里简单讲解了互斥锁的实现思路,但是跟真正的互斥锁还是会有一定的差距,不过这里我们先暂时这么去做。
三、锁代码实现
接下来会根据流程图来写业务。但是在真正写业务先写两个方法,代表获取锁和释放锁,这样我们使用起来就比较方便了。
核心思路就是利用redis的setnx方法来表示获取锁,该方法含义是redis中如果没有这个key,则插入成功,返回1。但是在spring中它帮我们转为了Boolean,因此在stringRedisTemplate中返回true, 如果有这个key则插入失败,则返回0,在stringRedisTemplate返回false,我们可以通过true,或者是false,来表示是否有线程成功插入key,成功插入的key的线程我们认为他就是获得到锁的线程。
ShopServiceImpl.java
// tryLock:尝试获取锁。锁就是redis中的一个key,所以key由使用者传给我们,我们就不在这写死了
private boolean tryLock(String key) {
// 执行setnx,ctrl + p查看参数,可以发现它在存的时候是可以同时设置有效期的
// 有效期的时长跟你的业务有关,一般正常你的业务执行时间是多少,你这个锁的有效期就比它长一点,长个10倍20倍(避免异常情况),例如这里就设置为10秒钟
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
// 这里不要直接将flag返回,因为直接返回它是会做拆箱的,在拆箱的过程中是有可能出现空指针的,因此这里建议大家使用一个工具类BooleanUtil,是hutool包中的,它可以帮你做一个判断(isTrue、isFalse方法),返回的是一个基本数据类型;或者它也可以直接帮你拆箱(isBollean方法)
return BooleanUtil.isTrue(flag);
}
// unlock:释放锁
private void unlock(String key) {
// 之前分析过了,方法锁就是将锁删掉
stringRedisTemplate.delete(key);
}
三、业务逻辑前置操作
现在这个代码是我们之前写的缓存穿透的一个解决方案,这段逻辑我们先将它封装起来,不要删除。
ctrl + alt + M 写成 queryWithPassThrough
,PassThrough
:穿透,方法返回值是店铺信息,参数是id。
PS:抽取成方法后,返回的是null或者数据本身。
封装方法后,代码也不会丢失,以后也方便自己参考。并且以后如果要实现缓存穿透,那么只需要调用 queryWithPassThrough函数
,也很方便。
四、缓存击穿代码实现
接下来就是写用互斥锁解决缓存击穿。
缓存击穿会在缓存穿透的基础上写,因此这两段逻辑可能会非常像。
将 queryWithPassThrough
方法复制,然后改名为 queryWithMutex
,Mutex
:互斥锁。最后根据流程图编写业务代码即可。
ShopServiceImpl.java
public Result queryById(Long id) {
// 缓存穿透
// Shop shop = queryWithPassThrough(id);
// 互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
return Result.ok(shop);
}
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1、从redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2、判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断命中的值是否是空值
if (shopJson != null) {
//返回一个错误信息
return null;
}
// 4.实现缓存重构,缓存重建业务比较复杂,不是一步两步就能搞定的
// 4.1 获取互斥锁,是一个key
String lockKey = "lock:shop:" + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2 判断否获取成功
if(!isLock){
// 4.3 失败,则休眠并重试
// 休眠不要花费太长时间,这里可以先休眠50毫秒试一试,这个方法有异常,最后解决它
Thread.sleep(50);
// 重试就是递归即可
return queryWithMutex(id);
}
// PS:获取锁成功应该再次检测redis缓存是否存在,做DoubleCheck。如果存在则无需重建缓存。但是这里先不检查了。
// 4.4 成功,根据id查询数据库
shop = getById(id);
// 5.不存在,返回错误 // 这个是解决缓存穿透的
if(shop == null){
//将空值写入redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
//返回错误信息
return null;
}
// 6.写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_NULL_TTL,TimeUnit.MINUTES);
// 最后ctrl + T用try-catch-finally将代码包起来
} catch (Exception e){
// 这里异常我们就不去做处理了,因为sleep是打断的异常,直接往外抛即可
throw new RuntimeException(e);
}
finally {
// 7.释放互斥锁,因为抛异常的情况下,也是需要执行unlock的,因此需要放到unlock
unlock(lockKey);
}
// 返回
return shop;
}
我们定义的方法返回值有可能为null,这样导致前端接受到的数据也有可能为 null
,因此如果你想返回的友好一点,你其实可以对shop的结果做一个判断,如果它是null的情况下,就返回一个 fail
,即表示失败。
@Override
public Result queryById(Long id) {
// 缓存穿透
// Shop shop = queryWithPassThrough(id);
// 互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
if (shop == null) {
return Result.fail("店铺不存在!");
}
return Result.ok(shop);
}
这样我们的功能基本上完成了,接下来就可以做测试了。
五、测试
不过在测试的时候大家需要注意:当时我们在讨论的时候,热点Key需要满足两个条件:1、高并发;2、缓存重建的时间比较久。
重建的时间越久,发生线程并发安全的可能性就越高。
而我们现在重建其实一下子就结束了,因为我们查询数据库是在本地,速度非常快,因此我们为了让它更容易的发生并发的冲突,在查询完数据库后休眠一下,这里就休眠200ms,模拟重建的延迟。
这样一来你重建的延迟越高,在这200毫秒内出现的线程就越多,如此之多的线程大家都来执行,很有可能就会出现安全问题了,此时就可以来检验所可不可靠的实践了。
重启服务器做测试。清除redis中id为1的缓存。
清除控制台,接下来再访问,它就会去重建缓存了,重建缓存的时候如果是在高并发的情况下是有可能出现问题的,我们就来检验一下这个锁到底安不安全。
那我们该如何来模拟这种高并发的场景呢?这里就要用到一个开发的工具了:jemeter
。
jemeter
是用来做一些并发测试 / 压力测试的。或者也可以使用 Apifox的自动化测试
,这里就使用前者了,这里定义了1000个线程,然后时间是5s,基本上QPS(Query Per Second每秒查询率)能达到200的,现在就做一个测试,看一看它能不能在高并发的情况下保证安全。
请求路径设置如下。
接下来就来试一下,直接发起请求
可以发现很快,这1000个线程在5s内就已经执行完毕了,这一块执行的结果都是成功的,也就证明数据也都查到了
QBS基本上也能做到每秒200
接下来回到IDEA看一下日志,可以发现对数据库的查询其实只触发了一次,证明在高并发的场景下,我们并没有出现所有的请求都打到数据库的情况,而是只有一个人进来了,说明我们这块基于互斥锁的解决方案已经成功了。