Redis:原理速成+项目实战——Redis实战5(互斥锁、逻辑过期解决缓存击穿问题)

👨‍🎓作者简介:一位大四、研0学生,正在努力准备大四暑假的实习
🌌上期文章:Redis:原理速成+项目实战——Redis实战4(解决Redis缓存穿透、雪崩、击穿)
📚订阅专栏:Redis速成
希望文章对你们有所帮助

上一篇文章讲解了缓存击穿问题,以及解决缓存击穿问题的2种解决思路,即互斥锁与逻辑过期,这里将分别用这两种方式解决缓存击穿问题。

互斥锁解决缓存击穿

根据上次讲解的互斥锁解决缓存击穿问题的方式,我们可以将客户端查询数据的流程修改为如下:
在这里插入图片描述
这里有比较关键的点,这里的锁并不是我们平常用的锁,平常的锁如果没有获取到的话就一直等待,而这边我们需要自定义逻辑。
我们该如何自定义获取锁跟释放锁的逻辑?我们可以利用Redis的一些数据结构的特性。
我们可以联想到之前学习Redis数据结构的时候,用到的setnx,当且仅当key不存在的时候,setnx才会执行set操作:
在这里插入图片描述
可以看到,当我们的key已经是存在的情况下,我们用setnx指令无法对其value进行修改。所以如果有无数线程进行setnx操作的时候,只有第一个进行操作的线程可以写入value,其他线程都会失败。
所以,这就满足了我们所说的互斥锁的实现方式,其实这也是分布式锁的一个基本原理(当然真正的分布式锁还是比较复杂的)。
而释放锁只需要执行del操作即可,这样其他线程就可以获取到这个锁了:
在这里插入图片描述
当然了,最好别把锁永远加在那,因为如果加了锁的线程,他的工作没办法完成,他就永远没办法释放锁,因此我们在执行setnx操作的时候需要设置一下有效期。

获取锁与释放锁方法封装

进行业务逻辑编写之前,我们可以先把获取锁与释放锁的方法给自定义一下:

private boolean tryLock(String key){
   //尝试获取锁
    //opsForValue里面没有真正的setnx,而是setIfAbsent,表示如果不存在就执行set
    //值就随便设定一下,重点是要获取到锁,但是设定了TTL为10s
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    /**
     * 如果是直接返回flag,可能会有拆箱操作,造成空指针,需要用BooleanUtil工具类
     * 因为Boolean不是基本类型的boolean,是boolean的封装类
     */
    return BooleanUtil.isTrue(flag);
}

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

业务逻辑修改

我们可以先把之前会出现缓存穿透的代码给封装起来,封装成返回值为Shop类型的,防止代码丢失(之后章节的代码优化会解决缓存穿透问题):

public Shop queryWithPassThrough(Long id){
   
    String key = CACHE_SHOP_KEY + id;
    //从Redis中查询商铺缓存,存储对象可以用String或者Hash,这里用String
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
   
        //存在,直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return shop;
    }
    //判断命中的是否是null
    if (shopJson != null){
   
        return null;
    }
    //不存在,根据id查询数据库
    Shop shop = getById(id);
    if (shop == null){
   
        //存一个null到Redis中
        //这种没用的信息,TTL没必要设置太长了,这里我设置成了2min
        stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
        return null;
    }
    //存在,写入Redis
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    //返回
    return shop;
}

同样的,我们也可以把互斥锁解决缓存击穿的代码给单独封装起来,注意,我们不管获得互斥锁的线程有没有正常执行,即便执行不成功,也要在最后进行unlock,因此我们要利用try…catch…finally:

public Shop queryWithMutex(Long id){
   
    String key = CACHE_SHOP_KEY + id;
    //从Redis中查询商铺缓存,存储对象可以用String或者Hash,这里用String
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
   
        //存在,直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return shop;
    }
    //判断命中的是否是null
    if (shopJson != null){
   
        //返回错误信息
        return null;
    }
    //未命中,开始实现缓存重建
    //尝试获取互斥锁,锁的key跟缓存的key不一样
    String lockKey = "lock:shop:" + id;
    Shop shop = null;
    try {
   
        boolean isLock = tryLock(lockKey);
        //判断是否获取成功
        if (!isLock){
   //失败则休眠并重试(递归)
            Thread.sleep(50);//单位:ms
            //如果担心递归造成爆栈,可以用循环,一样的
            return queryWithMutex(id);
        }
        //成功就直接开始重建(查询数据库并处理后保存到Redis)
        shop = getById(id);
        //由于在这里我们的数据库在本地,重建很快,所以添加休眠时间来模拟数据库重建过程的耗时长,方便测试
        Thread.sleep(200);
        //不存在,返回错误
        if (shop == null){
   
            //存一个null到Redis中
            //这种没用的信息,TTL没必要设置太长了,这里我设置成了2min
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        //存在,写入Redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);

    } catch (InterruptedException e) {
   
        throw new RuntimeException(e);
    }finally {
   
        //释放互斥锁
        unlock(lockKey);
    }
    //返回
    return shop;
}

queryById方法调用这个方法即可:

    @Override
    public Result queryById(Long id) {
   
        //缓存穿透的代码调用
        //Shop shop = queryWithPassThrough(id);
        //互斥锁解决缓存击穿
        Shop shop = queryWithMutex(id);
        if (shop == null) {
   
            return Result.fail("店铺不存在");
        }
        //返回
        return Result.ok(shop);
    }

测试

我们先把缓存清空,然后运行代码,再进行测试,我们可以使用一个很好用的测试工具jmeter,下载可以看下面这个博主的文章:
jmeter下载教程
官网下载的实在是太慢了,建议大家直接去这个博主的网盘里面下载。
我们可以这样设定,我们设定我们的并发量为1000,即有1000个线程同时访问:
在这里插入图片描述
并配置好我们的http请求:
在这里插入图片描述
运行完以后打开查看结果树:
在这里插入图片描述
数据都查到了。
打开汇总报告可以看到我们的吞吐量接近200:
在这里插入图片描述
如此大的数据量打下去,但是我们的日志显示我们的数据库只执行了一次的查询语句:
在这里插入图片描述
说明我们已经使用互斥锁成功避免了缓存击穿。

逻辑过期解决缓存击穿

代码实现

根据上次讲解的逻辑过期解决缓存击穿问题的方式,我们可以将客户端查询数据的流程修改为如下:
在这里插入图片描述
其实通常情况,因为是热点key,一般都是会出现在Redis里面的,且因为我们没有设置TTL,所以热点key是一定会一直存在的,但为了严谨起见,还是在判定缓存未命中的时候返回空。
我们主要解析一下主要的流程:

1、我们判断一下缓存是否逻辑过期了,如果没有过期,我们直接返回信息到客户端即可
2、如果缓存逻辑过期了,这个线程就尝试获取互斥锁,如果获取成功,说明它是第一个访问Redis的这个过期key的线程,那么这个线程要做2件事:
(1)返回这个旧数据给客户,虽然数据是旧的,但是这是一种暂时的牺牲
(2)开辟新的线程来进行缓存数据的重建,重建完毕就释放这个互斥锁
3、除了第2种情况说的这个线程,其他线程在知道自己访问的数据过期之后,获取互斥锁都会失败,那么这时候只需要直接返还数据给客户就好了,可能是旧数据,也可能是新数据(第一个线程释放锁或者缓存数据重建成功了)

首先我们要对Shop类增加逻辑过期时间这样一个字段,一种方案是直接添加,这种会违背开闭原则,一种是可以新增加一个类,并且类中包含了逻辑过期时间expireTime,但是该怎么把这个属性添加到Shop里面呢?可以让Shop继承这个类,就可以获得这个类中的属性,但同样会修改Shop这个类的源代码,同样违背开闭原则,所以最好的方法就是用关联来代替继承:
在这里插入图片描述
如果不是很清楚的话可以先去学一下Java设计模式。

为了方便演示,我们首先要进行一个数据预热,我们需要有这个一个带着逻辑过期时间的店铺信息,因此这里就先用单元测试的方式,来让带着expireTime属性的数据存入Redis里面。

1、先编写一个类,用于将带着逻辑过期时间的Shop存入Redis里面:

    public void saveShop2Redis(Long id, Long expireSeconds){
   
        //查询店铺数据
        Shop shop = getById(id);
        //封装逻辑过期时间
        RedisData redisData = new RedisData();
        //注入Shop对象
        redisData.setData(shop);
        //注入逻辑过期时间
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        //写入Redis,不要设置TTL
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
    }

2、编写单元测试代码:

@SpringBootTest
class HmDianPingApplicationTests {
   

    @Resource
    private ShopServiceImpl shopService;

    @Test
    void testSaveShop(){
   
        shopService.saveShop2Redis(1L, 10L);
    }

}

我们运行这个单元测试后,打开Redis客户端:
在这里插入图片描述
证明已经存储成功。

现在正式开始用逻辑过期思想去解决缓存击穿的问题,编写这个方法:

	private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
    public Shop queryWithLogicalExpire(Long id){
   
        String key = CACHE_SHOP_KEY + id;
        //从Redis中查询商铺缓存,存储对象可以用String或者Hash,这里用String
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //判断是否存在
        if (StrUtil.isBlank(shopJson)) {
   
            //未命中,直接返回
            return null;
        }
        //命中,先把json反序列化成对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        JSONObject data = (JSONObject) redisData.getData();//注意我们获取到了data信息以后,返回的会是一个JSONObject的格式
        Shop shop = JSONUtil.toBean(data, Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        //判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())){
   
            //未过期,直接返回店铺信息
            return shop;
        }
        //已过期,缓存重建
        //获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;//LOCK_SHOP_KEY="lock:shop:"
        boolean isLock = tryLock(lockKey);
        //判断是否获取锁成功
        if (isLock){
   
            //成功获取锁,开启独立线程来实现缓存重建,用线程池来做
            CACHE_REBUILD_EXECUTOR.submit(() -> {
   
                try {
   
                    // 重建缓存
                    this.saveShop2Redis(id, 20L);
                } catch (Exception e) {
   
                    throw new RuntimeException(e);
                }finally {
   
                    //释放锁
                    unlock(lockKey);
                }
            });
        }
        //没有成功获取,直接返回过期商铺信息
        return shop;
    }

为了方便验证,之前的缓存重建函数我们给他增加一个模拟重建时间200ms:

    public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
   
        //查询店铺数据
        Shop shop = getById(id);
        Thread.sleep(200);
        //封装逻辑过期时间
        RedisData redisData = new RedisData();
        //注入Shop对象
        redisData.setData(shop);
        //注入逻辑过期时间
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        //写入Redis,不要设置TTL
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
    }

之前存储在Redis的数据,其实早就已经逻辑过期了,只是因为我们没有设置TTL,它依旧存在于Redis里面,只是可能已经过期了。
queryById直接改成调用该方法,接着运行代码。

测试

1、我们直接将数据库中的餐厅名从101修改为102,使得数据库数据与缓存不一致:
在这里插入图片描述

2、打开jmeter进行测试,这里把线程数改小一点,不然不好观察:
在这里插入图片描述
运行后,观察结果树,可以发现我们一开始查到的就是旧数据,且这个过程会持续一段时间:
在这里插入图片描述
到后面,查询到的数据就是正常的数据了:
在这里插入图片描述
查看idea后台,可以发现我们只执行了一次重构,说明只有一个线程操作了数据库,其他数据库都被互斥锁拦住了:
在这里插入图片描述
这也证明了逻辑过期方法会造成短暂的数据不一致的情况。

综上,缓存击穿问题的两种解决思想,其demo都已经调通。

最近更新

  1. TCP协议是安全的吗?

    2024-01-06 17:52:01       18 阅读
  2. 阿里云服务器执行yum,一直下载docker-ce-stable失败

    2024-01-06 17:52:01       19 阅读
  3. 【Python教程】压缩PDF文件大小

    2024-01-06 17:52:01       19 阅读
  4. 通过文章id递归查询所有评论(xml)

    2024-01-06 17:52:01       20 阅读

热门阅读

  1. Doris 数仓使用规范(经验版)

    2024-01-06 17:52:01       73 阅读
  2. 鸡兔同笼问题加强版

    2024-01-06 17:52:01       35 阅读
  3. Mysql 查看表注释或字段注释

    2024-01-06 17:52:01       40 阅读
  4. Flutter GetX 之 路由管理

    2024-01-06 17:52:01       55 阅读
  5. openssl ans1定义的实体

    2024-01-06 17:52:01       27 阅读
  6. 【leetcode100-031】【链表】k个一组翻转链表

    2024-01-06 17:52:01       39 阅读
  7. SRS服务器RTMP2WebRTC外网拉流配置

    2024-01-06 17:52:01       50 阅读
  8. LeetCode[62] 不同路径

    2024-01-06 17:52:01       32 阅读
  9. react:ffcreator中FFCreatorCenter视频队例

    2024-01-06 17:52:01       36 阅读
  10. 【注解】@FeignClient 用于微服务通信

    2024-01-06 17:52:01       31 阅读
  11. 树莓派4B 入门

    2024-01-06 17:52:01       42 阅读
  12. react高阶成分(HOC)

    2024-01-06 17:52:01       45 阅读
  13. 写你的第一个Vue程序

    2024-01-06 17:52:01       43 阅读