【Redis】利用互斥锁解决缓存击穿问题

一、核心思路

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

核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是 进行查询之后,如果从缓存没有查询到数据,则进行互斥锁的获取,获取互斥锁后,判断是否获得到了锁,如果没有获得到,则休眠,过一会再进行尝试,直到获取到锁为止,才能进行查询

如果获取到了锁的线程,再去进行查询,查询后将数据写入redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行操作数据库的逻辑,防止缓存击穿

image-20240526152337180

要注意的是,这个锁并不是我们平常使用的锁,我们平时使用的都是 synchronized 或者 lock锁,这两种,拿到锁了就执行,没拿到锁就需要一直等待。而我们当前这块,拿到锁和不拿到锁的执行逻辑是需要我们自己自定义的,因此我们不能使用之前的锁方式了,而是采用自定义的锁。我们我们应该使用什么方式来使用自定义的互斥锁呢?

互斥锁:多个线程并行执行的过程中,只能有一个成功,其他人失败。大家思考一下,在我们以前所学的知识里,有没有什么东西能够达成这样的一个效果?

在我们以前学习redis的string数据类型的时候,它里面就有一个命令跟这个效果是非常接近的。


二、操作锁

1)获取锁的原理

这个命令就是 setnx,我们先通过帮助文档来查看一下这个命令的官方解释:Set the value of a key, only if the key does not exist,如果key不存在才添加添加,存在就不添加。

image-20240526160428200

那它为什么可以做到互斥呢?我们来演示一下:我们使用 setnx,要设置的key叫 lock,代表是一把锁。现在有一个人,假设叫 1,它要来获取这把锁,可以看见这个时候返回值是 1,这个返回值就代表是成功。

image-20240526160444123

这个时候又有一个人,还想来 setnx,来改一下这个值,会发现返回值是 0,此时lock的值并没有发生改变,依然是原来的值,这就告诉我们 setnx 其实是在key不存在的时候才能往里面写,key如果已经存在了,是无法写的。如果现在并发的有数百上千的线程一起来执行 setnx,那么此时只有第一个人才能成功,它成功写入了以后,其它线程再来执行 setnx 得到的一定是一个 0,也就是失败的结果,是不是就类似于我们讲的 互斥:只有成功,其他人都失败了,这就是我们自己设计锁的一个方案。

image-20240526160520552

实际上这也是我们后期要讲到的 分布式锁 的一个基本原理。当然真正的分布式锁会比这个要复杂很多,后续会详细介绍,这里先简单使用一下。


2)释放锁的原理

获取锁的原理我们明白了,那释放锁其实非常简单,我们直接将锁删掉不就好了吗?

删掉后,再有其他人来执行这个 setnx操作 的时候就能成功了。

image-20240526160710504

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 写成 queryWithPassThroughPassThrough:穿透,方法返回值是店铺信息,参数是id。

PS:抽取成方法后,返回的是null或者数据本身。

封装方法后,代码也不会丢失,以后也方便自己参考。并且以后如果要实现缓存穿透,那么只需要调用 queryWithPassThrough函数,也很方便。

image-20240526163956728


四、缓存击穿代码实现

接下来就是写用互斥锁解决缓存击穿。

缓存击穿会在缓存穿透的基础上写,因此这两段逻辑可能会非常像。

queryWithPassThrough 方法复制,然后改名为 queryWithMutexMutex:互斥锁。最后根据流程图编写业务代码即可。

image-20240526152337180

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毫秒内出现的线程就越多,如此之多的线程大家都来执行,很有可能就会出现安全问题了,此时就可以来检验所可不可靠的实践了。

image-20240526181443730

重启服务器做测试。清除redis中id为1的缓存。

image-20240526173232423

清除控制台,接下来再访问,它就会去重建缓存了,重建缓存的时候如果是在高并发的情况下是有可能出现问题的,我们就来检验一下这个锁到底安不安全。

那我们该如何来模拟这种高并发的场景呢?这里就要用到一个开发的工具了:jemeter

jemeter 是用来做一些并发测试 / 压力测试的。或者也可以使用 Apifox的自动化测试,这里就使用前者了,这里定义了1000个线程,然后时间是5s,基本上QPS(Query Per Second每秒查询率)能达到200的,现在就做一个测试,看一看它能不能在高并发的情况下保证安全。

image-20240526175410529

请求路径设置如下。

接下来就来试一下,直接发起请求

image-20240526175757243

可以发现很快,这1000个线程在5s内就已经执行完毕了,这一块执行的结果都是成功的,也就证明数据也都查到了

image-20240526180527064

QBS基本上也能做到每秒200

image-20240526180610571

接下来回到IDEA看一下日志,可以发现对数据库的查询其实只触发了一次,证明在高并发的场景下,我们并没有出现所有的请求都打到数据库的情况,而是只有一个人进来了,说明我们这块基于互斥锁的解决方案已经成功了。

image-20240526180745826

在处理Redis缓存击穿问题时,可以采用互斥锁的方式进行解决互斥锁是一种机制,用于保护共享资源的访问,确保同一时刻只有一个线程能够获取锁来执行操作。在Redis缓存击穿的场景中,当一个高并发的请求发生时,如果缓存中不存在所需的数据,那么这个请求就会触发数据库的查询操作。 针对这种情况,我们可以引入互斥锁的概念来解决Redis缓存击穿问题。具体做法是,在查询缓存之前,先尝试获取互斥锁。如果获取到了锁,表示当前线程是第一个发起请求的线程,它可以继续执行查询数据库的操作,并将结果写入缓存。而其他线程在获取锁之前,只能等待一段时间后再次尝试获取锁。 为了实现这个逻辑,我们可以定义获取互斥锁的方法和释放锁的方法。获取互斥锁的方法可以使用Redis提供的setIfAbsent()方法来进行操作,设置一个键值对,如果该键不存在,则设置成功,并返回true;否则,返回false。释放锁的方法则是删除相应的键值对。 通过使用互斥锁,能够在高并发的情况下有效地控制对数据库的访问,避免出现缓存击穿问题。这种方案在逻辑上实现了对缓存穿透和击穿的处理,并且可以封装为一个工具类,适用于各种场景。 引用内容: 代码片段,定义了获取互斥锁和释放锁的方法 Redis缓存穿透和击穿的处理方案 描述了使用互斥锁解决缓存击穿问题的过程 提到了缓存击穿问题的本质和影响,引用了一个Redis教程中的PPT内容<span class="em">1</span><span class="em">2</span><span class="em">3</span><span class="em">4</span>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值