Redis实战-缓存篇(万字总结)
前言:
今天结合黑马点评这个项目,讲下有关Redis缓存的一些内容,例如缓存更新策略,缓存穿透,雪崩和击穿等。
今日所学:
- 什么是缓存
- 缓存更新策略
- 缓存穿透
- 缓存雪崩
- 缓存击穿
- 缓存工具封存
目录
1.什么是缓存
1.1 概念
1.2 Java项目中添加缓存
2.缓存更新策略
2.1 介绍
2.2 分类
2.3 数据不一致性解决方案
3. 缓存穿透
3.1 介绍
3.2 代码实现
3.3 总结
4. 缓存雪崩
5. 缓存击穿
5.1 介绍
5.2 互斥锁解决
5.2.1 具体项目操作思路
5.3 逻辑过期解决
5.3.1 具体项目操作思路
5.4 总结
6. 封装Redis工具类
需求分析:
6.1 方法1实现
6.2 方法2实现
6.3 方法3实现
6.4 方法4实现
1.什么是缓存
1.1 概念
缓存就是数据交换的缓冲区(称作Cache [ kæʃ ] ),是存贮数据的临时地方,一般读写性能较高。
缓存的优点:
- 降低后端负载
- 提高读写效率,降低响应时间
缓存的缺点:
- 数据一致性成本
- 代码维护成本
- 运维成本
如何使用缓存:
实际开发中,会构筑多级缓存来使系统运行速度进一步提升,例如:本地缓存与redis中的缓存并发使用。
- 浏览器缓存:主要是存在于浏览器端的缓存
- 应用层缓存:可以分为tomcat本地缓存,比如之前提到的map,或者是使用redis作为缓存
- 数据库缓存:在数据库中有一片空间是 buffer pool,增改查数据都会先加载到mysql的缓存中
- CPU缓存:当代计算机最大的问题是 cpu性能提升了,但内存读写速度没有跟上,所以为了适应当下的情况,增加了cpu的L1,L2,L3级的缓存
1.2 Java项目中添加缓存
缓存作用模型:
客户端也请求redis,如果命中,直接返回结果。如果没有命中,才去查询数据库。并把数据库返回的结果写入到redis中,以方便下次查询
具体项目逻辑:
2.缓存更新策略
2.1 介绍
缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适。
2.2 分类
缓存更新策略分为以下几类:
-
内存淘汰:redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)
-
超时剔除:当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存
-
主动更新:我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题
内存淘汰 | 超时剔除 | 主动更新 | |
一致性 | 差 | 一般 | 好 |
维护成本 | 无 | 低 | 高 |
可以看到,数据一致性越好,维护成本就越高,那么我们该怎么去决定使用哪个缓存更新策略呢?
这里我们分为低一致性需求和高一致性需求:
- 低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存
- 高一致性需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存
2.3 数据不一致性解决方案
什么是数据不一致性?
是由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在。
有什么后果?
用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等
怎么解决呢(尤其针对高一致性需求业务):
-
Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案
-
Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理
-
Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致
其中我们采用第一种人工编码方式,即在更新完数据库后手动更新缓存。
那么问题又来了:
第一.当数据库数据有变更时,我们是更新缓存数据呢还是直接删除相应缓存呢?这时候我们不得不考虑,如果我们在一段时间频繁的进行了更新,但是中间并没有用户进行访问,那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,以此我们可以把缓存删除,等待再次查询时,将缓存中的数据加载出来
第二.如何保证缓存和数据库操作同时成功或者失败?这里在单体系统,我们将缓存与数据库操作放在一个事务。在分布式系统,利用TCC等分布式事务方案。
第三.我们是先操作缓存还是先操作数据库?这里我们考虑两种方案。
- 先删除缓存,再操作数据库
- 先操作数据库,再删除缓存
该选择哪个,我们只要考虑一点就行了:更新所耗费的时间要大于查询时间
如果我们先删除缓存,在更新数据库。那么在更新的途中,有线程2过来查询数据库。此时数据库未更新完成。就会导致将旧数据写入缓存
反之,如果我们先操作数据库,再操作缓存的话,虽然也会导致一定的问题,但是总体上概率比先删除再操作要低的多。
因此,综上,我们选择人工编码方式。先操作数据库,再删除缓存。
3. 缓存穿透
3.1 介绍
缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库.
就比如说,客户随机输入一个ID,因为缓存中不存在,就会一直查询数据库。如果在一定时间内,这样的ID多了,就会给数据库造成巨大的压力
怎么解决缓存穿透呢?
常见的解决方案有两种:
1.缓存空对象
优点是:实现简单,维护方便
缺点:可能找到额外的内存消耗,和短期的数据不一致
2.布隆过滤
这个原理是给redis和数据库中储存的数据设置一个哈希值(二进制数据),输入数据的哈希值只有在过滤器中存在,才会访问redis层
优点:内存占用较少,没有多余key
缺点: 实现复杂, 存在误判可能(比如说哈希冲突)
这里我们使用缓存空对象的方法解决缓存穿透的问题
3.2 核心思路
核心思路如下:
在原来的逻辑中,我们如果发现这个数在Mysql中不存在,就直接返回404了,这是会导致缓存穿透问题的(没有将空值储存在缓存中)
现在的逻辑中,如果数据不存在,我们不会返回404,还是会把数据写入到redis中,并且把value设置为null,当再次发生查询时,我们发现如果命中后,判断这个value是否是null,如果是Null,则是之前写过的数据,证明是缓存穿透数据,如果不是,则直接返回数据.
3.2 代码实现
具体逻辑思路:
1.从redis中查询数据,如果查询到了,直接返回结果(注意这里isNotBlank将空字符串“”也是视为false的,所以如果查到,一定是真实的数据,而不是储存的空值)
2.判断是不是储存的空值,是的话就直接返回(第一次没有查询到数据,直接null和""两种情况)
3.进一步的查询数据库,如果不存在,在redis中设置空值(value=“”)
4.存在,向redis中写入数据,返回
public Shop queryPassThrough(Long id){String key = CACHE_SHOP_KEY + id;// 1.从redis查询商铺缓存String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);// 2.判断是否存在if (StrUtil.isNotBlank(shopJson)) {// 3.存在,直接返回Shop shop = JSONUtil.toBean(shopJson, Shop.class);return null;}// 判断是否是空值if(shopJson != null){return null;}// 4.不存在,根据id查询数据库Shop shop = getById(id);// 5.不存在,返回错误if(shop == null) {// 向redis中储存null值stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);return null;}// 6.存在,写入redisstringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);// 7.返回return shop; }
3.3 总结
缓存穿透产生的原因是什么?
用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力
缓存穿透的解决方案有哪些?
- 缓存null值
- 布隆过滤
- 增强id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
4. 缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力
解决方案:
* 给不同的Key的TTL添加随机值
* 利用Redis集群提高服务的可用性
* 给缓存业务添加降级限流策略
* 给业务添加多级缓存
5. 缓存击穿
5.1 介绍
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
问题分析:
假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大
常见的解决方案有两种:
* 互斥锁
* 逻辑过期
接下来我们将逐一介绍这两种解决方法
5.2 互斥锁解决
解决思路:
因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用tryLock方法 + double check来解决这样的问题。
假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。
那么这么去模拟这种互斥锁呢
核心思路就是利用redis的setnx方法来表示获取锁,该方法含义是redis中如果没有这个key,则插入成功,返回1,在stringRedisTemplate中返回true, 如果有这个key则插入失败,则返回0,在stringRedisTemplate返回false,我们可以通过true,或者是false,来表示是否有线程成功插入key,成功插入的key的线程我们认为他就是获得到锁的线程。
代码:
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);
}
5.2.1 具体项目操作思路
核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是 进行查询之后,如果从缓存没有查询到数据,则进行互斥锁的获取,获取互斥锁后,判断是否获得到了锁,如果没有获得到,则休眠,过一会再进行尝试,直到获取到锁为止,才能进行查询
如果获取到了锁的线程,再去进行查询,查询后将数据写入redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行操作数据库的逻辑,防止缓存击穿
代码逻辑:
1.查询redis,判断是否命中(有数据)
2.命中直接返回数据
3.如果未命中,查看是否是空值(缓存穿透)
4.不是,则尝试获取互斥锁
5. 获取失败,休眠,递归重复上面的过程,试着重新获取互斥锁
6. 获取成功,先进行double check,判断是否已经写入缓存,写入的话直接返回(考虑到线程1完成后其他线程获得锁就不用再缓存重建了)
7.如果没有,再查询数据库,不存在,设空值(穿透)
8.存在,写入redis中,释放锁
public Shop queryWithMutex(Long id){String key = CACHE_SHOP_KEY + id;// 1.从redis查询商铺缓存String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);// 2.判断是否存在if (StrUtil.isNotBlank(shopJson)) {// 3.存在,直接返回Shop shop = JSONUtil.toBean(shopJson, Shop.class);return null;}// 判断是否是空值if(shopJson != null){return null;}// 4. 实现缓存重建// 4.1 获取互斥锁String lockKey = LOCK_SHOP_KEY + id;boolean isLock = tryLock(lockKey);Shop shop = null;try {// 4.2 判断是否获取成功if(!isLock){// 4.3 失败,则休眠并重试Thread.sleep(50);return queryWithMutex(id);}// 4.4 进行double checkString shopJson1 = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);if(StrUtil.isNotBlank(shopJson1)){return JSONUtil.toBean(shopJson1, Shop.class);}// 4.5 成功,根据id查询数据库shop = getById(id);// 5.不存在,返回错误if(shop == null) {// 向redis中储存null值stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);return null;}// 6.存在,写入redisstringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);} catch (InterruptedException e) {e.printStackTrace();} finally {// 7.释放锁unlock(lockKey);}
5.3 逻辑过期解决
方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。
我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。
这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。
5.3.1 具体项目操作思路
思路分析:当用户开始查询redis时,判断是否命中,如果没有命中则直接返回空数据,不查询数据库,而一旦命中后,将value取出,判断value中的过期时间是否满足,如果没有过期,则直接返回redis中的数据,如果过期,则在开启独立线程后直接返回之前的数据,独立线程去重构数据,重构完成后释放互斥锁。
但是问题来了,我们该如何给value设置上过期时间呢,我们知道原来的实体类shop是没有过期时间这个字段的。但是如果直接修改实体类,对原本的代码也有影响,不好管理。这里我们可以再建造一个类,用来储存过期时间还有数据data
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
代码逻辑:
1.判断redis是否命中(有数据)
2.如果没有,直接返回null,结束(所以这样不用判断是否有缓存穿透的问题,采用这个方法,在redis中存储的数据是一直存在的)
3.命中,将传入的expireTime和shop的数据封装到redisData类中,判断是否过期
4. 未过期,直接返回店铺信息
5.已过期,获取互斥锁(原理跟互斥锁解决的原理是一样的)
6.没有获取到,直接返回旧数据
7.获取到了,开启一个新线程,由他执行redis的数据更新(这里不用加check double,旧数据是存在于redis中的,加了check第一个抢到锁的线程拿到直接就是返回旧数据了,不会进行后续逻辑)
8.更新完后释放锁,后续线程返回的就是新数据了
private static final ExecutorService executorService = Executors.newFixedThreadPool(10);public Shop queryWithLogicalExpire(Long id){String key = CACHE_SHOP_KEY + id;// 1.从redis查询商铺缓存String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);// 2.判断是否存在if (StrUtil.isBlank(shopJson)) {// 3.未命中,直接返回return null;}// 命中,需要先把JSON反序列成对象RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);JSONObject data = (JSONObject) redisData.getData();Shop shop = JSONUtil.toBean(shopJson, Shop.class);LocalDateTime expireTime = redisData.getExpireTime();// 判断是否过期if(expireTime.isAfter(LocalDateTime.now())) {// 未过期,直接返回店铺信息return shop;}// 已过期, 尝试获取互斥锁String lockKey = LOCK_SHOP_KEY + id;boolean isLock = tryLock(lockKey);if(isLock) {// 获得锁,开启线程executorService.submit(() -> {try {// 重建缓存this.saveShop2Redis(id, 20L);} catch (Exception e) {throw new RuntimeException(e);}finally {// 释放锁unlock(lockKey);}});}// 没有获得锁,返回店铺信息return shop; }
下面是具体执行数据更新的方法:
查询数据库,更新data,更新逻辑时间
public void saveShop2Redis(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));// 写入RedisstringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));}
需要注意的是,如果要测试逻辑过期解决缓存击穿的代码,我们先需要有这个缓存数据,所以要先在测试类中把数据添加好
为了方便测试,这里逻辑时间设置的是添加10s后过期
5.4 总结
**互斥锁方案:由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响
**逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦
6. 封装Redis工具类
需求分析:
基于StringRedisTemplate封装一个缓存工具类,满足下列需求:
* 方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
* 方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
* 方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
* 方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
先创建一个redisClient类,交给IOC容器管理,必要一些方法写好
@Component @Slf4j public class CacheClient {private final StringRedisTemplate stringRedisTemplate;public CacheClient(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}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);} }
6.1 方法1实现
实现目标:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
实现逻辑:很简单的redis添加功能,这边传入的value值是不确定的,所以传入Object
public void set(String key, Object value, Long time, TimeUnit timeUnit) {stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, timeUnit); }
6.2 方法2实现
实现目标:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
实现逻辑:这里比方法1多的一步是要设置一个逻辑过期时间,并跟value值一起封装进redisData,最后写入redis
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit timeUnit) {// 设置逻辑过期RedisData redisData = new RedisData();redisData.setData(value);redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(time)));// 写入redisstringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData)); }
6.3 方法3实现
实现目标:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
实现逻辑:跟目录3.缓存穿透的逻辑实现是一样的。
这里我主要讲下用到的泛型和方法设计。
1.<R, ID>
这是方法级别的泛型声明,表示这个方法使用了两个泛型类型参数
2.后一个R表示方法的返回类型,例如,如果调用时传入Class<User>
,那么返回的就是User
类型
3.ID
参数id
的类型是ID
,这是一个泛型类型,表示可以接受任意类型的ID(如Long
、String
、Integer
等)。
4.Class<R> type
,这是一个Class
对象,表示返回类型R
的运行时类型信息。例如,如果R
是User
,那么type
就是User.class
5.Function<ID, R> dbFallback
这是一个函数式接口参数,表示一个从ID
到R
的转换函数。例如,如果ID
是Long
,R
是User
,那么dbFallback
就是一个能根据Long id
查询并返回User
的函数。
public <R, ID> R queryWithThrough(String keyPrefix, ID id, Class<R> type,Function<ID, R> dbFallback, Long time, TimeUnit timeUnit){String key = keyPrefix + id;// 从redis查询商铺缓存String json = stringRedisTemplate.opsForValue().get(key);// 判断是否存在if(StrUtil.isNotBlank(json)){// 存在,直接返回return JSONUtil.toBean(json, type);}// 判断是否为空值if(json != null){return null;}// 不存在,根据id查询数据库R r = dbFallback.apply(id);// 不存在,返回错误if(r == null){// 将空值写入redisstringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);// 返回错误信息return null;}// 存在,写入redisthis.setWithLogicalExpire(key, r, time, timeUnit);return r; }
调用传参如下
// 缓存穿透 Shop shop1 = cacheClient.queryWithThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
6.4 方法4实现
实现目标:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
实现逻辑:跟目录5.缓存击穿的逻辑过期实现那一样的,方法参数传递跟方法3是一样的
private static final ExecutorService executorService = Executors.newFixedThreadPool(10);public <R,ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type,Function<ID, R> dbFallback, Long time, TimeUnit timeUnit){String key = keyPrefix + id;// 1.从redis查询商铺缓存String shopJson = stringRedisTemplate.opsForValue().get(key);// 2.判断是否存在if (StrUtil.isBlank(shopJson)) {// 3.未命中,直接返回return null;}// 命中,需要先把JSON反序列成对象RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);JSONObject data = (JSONObject) redisData.getData();R r = JSONUtil.toBean(data, type);LocalDateTime expireTime = redisData.getExpireTime();// 判断是否过期if(expireTime.isAfter(LocalDateTime.now())) {// 未过期,直接返回店铺信息return r;}// 已过期, 尝试获取互斥锁String lockKey = LOCK_SHOP_KEY + id;boolean isLock = tryLock(lockKey);if(isLock) { // String shopJson1 = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id); // if(StrUtil.isNotBlank(shopJson1)){ // return JSONUtil.toBean(shopJson1, type); // }// 获得锁,开启线程executorService.submit(() -> {try {// 重建缓存Thread.sleep(200);// 先查数据库R r1 = dbFallback.apply(id);// 再写入redisthis.setWithLogicalExpire(key, r1, time, timeUnit);} catch (Exception e) {throw new RuntimeException(e);}finally {// 释放锁unlock(lockKey);}});}// 没有获得锁,返回店铺信息return r;}
最后:
今天的分享就到这里。如果我的内容对你有帮助,请点赞,评论,收藏。创作不易,大家的支持就是我坚持下去的动力!(๑`・ᴗ・´๑)