Redis实战项目(一)
本篇博客总结了我在学习redis中做的实战项目,实现了短信登录和添加缓存,并对缓存使用时出现的各种问题做了总结,其中业务部分代码和注释都是我手敲的,前端代码和图片来自黑马程序员
虽然这个项目已经烂大街,但是对于学习项目开发和redis来说仍是一份不错的教材,感谢黑马程序员
课程链接:https://www.bilibili.com/video/BV1cr4y1671t?p=1&vd_source=40ac0553f204ea9791dc385431e71f1c
项目结构:
短信登录功能
基于session实现发送手机验证码
实现流程:
代码实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41/**
* Controller包下
* 发送手机验证码
*/
public Result sendCode( { String phone, HttpSession session)
// 发送短信验证码并保存验证码
return userService.sendCode(phone, session);
}
/**
* Service包下的发送手机验证码接口
*
* @param phone 验证码
* @param session 将结果保存到session
* @return 返回成功或失败
*/
Result sendCode(String phone, HttpSession session);
/**
* 业务代码 service.impl包下
* @param phone 验证码
* @param session 将结果保存到session
* @return 返回成功或失败
*/
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
//2.如果不符合,返回错误信息
return Result.fail("手机号格式错误");
}
//3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
//4.保存验证码到session
session.setAttribute("code", code);
//5.发送验证码
log.debug("发送验证码成功:{}", code);
//6.返回ok
return Result.ok();
}
实现短信登录和注册
- 实现流程:
- 代码实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65/**
* Controller包下
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
public Result login( { LoginFormDTO loginForm, HttpSession session)
// 实现登录功能
return userService.login(loginForm, session);
}
/**
* Service包下的登录接口
* @param loginForm 登陆参数
* @param session 保存结果
* @return 返回成功或失败
*/
Result login(LoginFormDTO loginForm, HttpSession session);
/**
* 业务代码 service.impl包下
* @param loginForm 登陆参数
* @param session 保存结果
* @return
*/
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误!");
}
//2.校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.toString().equals(code)) {
//3.不一致,直接报错
return Result.fail("验证码错误!");
}
//4.一致,根据手机号查询用户(Mybatis-plus)
User user = query().eq("phone", phone).one();
//5.判断用户是否存在
if (user == null) {
//6.不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
//7.保存用户信息到session
session.setAttribute("user", user);
return Result.ok();
}
/**
* 更具手机号创建用户
* @param phone 手机号
* @return 用户信息
*/
private User createUserWithPhone(String phone) {
//1.创建用户
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
//2.保存用户
save(user);
return user;
}
实现登录校验拦截器
实现流程
代码实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63/**
* utils包下
* 拦截器功能实现
*/
public class LoginInterceptor implements HandlerInterceptor {
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取session
HttpSession session = request.getSession();
//2.获取session中的用户
Object user = session.getAttribute("user");
//3.判断用户是否存在
if (user == null) {
//4.不存在,拦截
response.setStatus(401);
return false;
}
//5.存在,保存到ThreadLocal
UserHolder.saveUser((User) user);
//6.放行
return true;
}
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除用户,避免内存泄漏
UserHolder.removeUser();
}
}
/**
* Config包下
* 拦截器生效
*/
public class MyConfig implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
"/user/code",
"/user/login",
"/blog/log",
"shop/**",
"shop-type/**",
"/upload/**",
"voucher/**"
);
}
}
/**
* Controller包下
* 获取当前登录状态并返回
* @return 成功或失败
*/
public Result me() {
// 获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
隐藏用户敏感信息
由于之前的业务逻辑是将获取到的User
对象一股脑丢进session
中,一方面存入多余的信息会增加服务器负担,另一方面也会有泄露风险,如图所示:
所以从一开始就不应该存入用户的完整信息,定义UserDTO类
:
1 | /** |
代码中凡是涉及User
对象的都应该转为UserDTO
。
- 优化后效果:
这样传到前端的数据就不在携带敏感信息,因为数据量的减少从而降低了内存的压力。
关于ThreadLocal
-TheadLocal数据结构:
- Thread类有一个类型为
ThreadLocal.ThreadLocalMap
的实例变量threadLocals
,也就是说每个线程有一个自己的ThreadLocalMap
。 ThreadLocalMap
有自己的独立实现,可以简单地将它的key
视作ThreadLocal
,value
为代码中放入的值(实际上key
并不是ThreadLocal
本身,而是它的一个弱引用)。- 每个线程在往
ThreadLocal
里放值的时候,都会往自己的ThreadLocalMap
里存,读也是以ThreadLocal
作为引用,在自己的map
里找对应的key
,从而实现了线程隔离。 ThreadLocalMap
有点类似HashMap
的结构,只是HashMap
是由数组+链表/红黑树实现的,而ThreadLocalMap
中并没有链表结构。
由于ThreadLocal中的key是弱引用,所以在gc时会被回收,而value是强引用则不会,如果不做任何措施,value就永远无法被gc,这时就有可能出现内存泄露问题,这就是我们为什么在拦截器的afterCompletion()方法中手动removeUser()的原因
代码中的UserHolder
类如下:
1 | public class UserHolder { |
使用redis代替session
session共享问题
当将来数据量变大,我们肯定需要配置多台Tomcat来进行负载均衡,而此时session的弊端就显现了出来,由于每个Tomcat都会有自己的sessionId,所以当另一台Tomcat想要获取数据时就拿不到,从而降低用户体验,如图所示:
想要解决这个问题,就必须满足三个条件:
- 数据共享:解决数据丢失问题
- 内存存储:保证读写效率以应对高并发需求
- key-value结构:存储的数据应该间接且方便维护
综上,Redis闪亮登场!终于到了我学习这个项目的主题!
整体业务流程:
基于redis实现短信登录
代码实现
1 | //注入API |
登录拦截器优化
我们在拦截器中写的更新token操作并不能完全达到目的,因为目前的登录拦截器只拦截需要登录的路径,反之,当用户浏览不用登录的页面如首页、商铺详情页时就不会触发拦截器,从而导致用户明明一直活跃却还是被踢出,为了解决这个问题,我们使用拦截器链:
- 第一个拦截器拦截所有页面,只放行,确保用户所有操作都会触发拦截器
- 第二个拦截器做实际的拦截操作
代码如下:
在utils包下创建RefreshTokenInterceptor
类
1 | /** |
添加缓存
缓存就是数据交换的缓冲区,时存储数据的临时地方,用于提升读写效率。
优点:
- 降低后端负载:请求进来直接查缓存,不用再查数据库
- 提高读写效率,降低响应时间:使用redis缓存直接在内存层面读写,省去磁盘IO的时间
缺点:
- 数据的一致性:缓存和数据库中的数据可能不一致
- 代码维护:为了解决一致性问题,需要增加业务代码
- 运维:为了解决如缓存雪崩等问题需要搭建集群从而提高运维成本
缓存工作模型如下:
- 客户端发送请求到缓存,如果命中则直接返回
- 否则就查数据库,将结果返回,并把数据写入缓存
添加商户缓存
实现流程:
代码实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41/**
* Controller包下
* 根据id查询商铺信息
* @param id 商铺id
* @return 商铺详情数据
*/
public Result queryShopById( { Long id)
return shopService.queryById(id);
}
//service.impl包下
Result queryById(Long id);
/**
* 通过id查询商铺信息
* @param id 商铺id
* @return 商铺信息
*/
public Result queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(id);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//4.不存在,根据id查询数据库
Shop shop = getById(id);
//5.不存在,返回错误
if (shop == null) {
return Result.fail("店铺不存在!");
}
//6.存在,把数据写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
//7.返回
return Result.ok(shop);
}
缓存更新策略
由于缓存和数据库中的数据存在一致性问题,用户可能会查到旧数据,所以应该选择合适的缓存更新策略,这里选择主动更新策略,如图所示:
业务场景:
- 低一致性需求:使用内存淘汰机制,如店铺类型的查询缓存
- 高一致性需求:主动更新,并以超时时间作为兜底方案。如店铺详情查询的缓存。
主动更新策略有以下三种方式:
这里选择第一种,因为第一种虽然需要我们自己编码,但可控性更高,而第二种虽然简化了一些,但对维护成本有较高要求,而第三种在异步存储之前还是有可能生一致性问题。
注意事项
删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都要更新缓存,无效写操作较多
- 删除缓存:更新数据库时让缓存失效,查询时才更新缓存
如何保证缓存与数据库操作同时成功或失败?
- 单体系统:由于缓存和数据库在一个项目当中,所以将缓存与数据库放在一个事务中
- 分布式系统:利用TCC等分布式事务方案
先操作缓存还是先操作数据库?
仅对异常情况来说,如果先删缓存再擦做数据库,如图所示:
在数据规模较大的前提下这种情况是很有可能发生的,因为redis是基于内存的,它远比需要进行磁盘IO的数据库快。
而先操作数据库,再删缓存如下:
由图可见,在内存查询中途插上一次磁盘IO,相对于前者来说发生的概率是较低的,所以我们选择先操作数据库再删缓存。
业务中添加策略
给查询商铺的缓存添加超时剔除和主动更新策略:
修改ShopController中的业务逻辑,满足以下条件:
- 根据id查询店铺时,如果缓存未命中,则查询数据库,写入缓存,设置超时时间
- 根据id修改店铺时,先操作数据库,再删除缓存
1 | //将queryById方法中的第六步改为以下代码 |
缓存穿透
概念
缓存穿透是指客户端请求的数据在缓存和数据库中都不存在,这样缓存永远都不会生效,每次请求都会打到数据库。
解决办法
缓存空对象
事先把空值存入数据库,当发送一个缓存和数据库都不存在的请求时,直接把数据库中的空值返回,可以解决请求变化不频繁的情况,如图所示:
优点:
实现简单,便于维护缺点:
- 额外的内存消耗:当黑客恶意攻击时,每次构建不同的请求key,会导致redis中缓存大量的无效key
解决方案:可以对无效key设置一个较短的TTL起到一定的保护作用 - 可能造成短期的不一致:如果用户请求了一个刚好不存在的id,那么redis中会缓存null值,此时我们给商铺插入一条数据,那么用户再查也依旧是null,只有当TTL过期才会更新
解决方案:尽量将TTL的时间设置的短一些,或者在数据库插入数据时主动覆盖redis中的null值
布隆过滤
在客户端和redis之间再加一层过滤器,其他逻辑不变,如图所示:
布隆过滤器可能会误判,如果它说某个元素不存在,那么它一定不存在,但它说存在,那么这个元素不一定存在。
要解释这种情况,需要理解布隆过滤器的工作原理:
- 使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。
- 根据得到的哈希值,在位数组中把对应下标的值置为 1。
判断元素是否存在的操作:
- 对给定元素再次进行相同的哈希计算;
- 得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。
当不同的字符串哈希出来的位置相同,就会出现误判,发生的概率取决于数组大小和哈希函数的优劣
- 优点:
内存占用小,没有多余key:不用缓存多余的空数据 - 缺点:
- 实现复杂:可以使用redis自带的
BitMap
简化开发 - 存在误判可能
项目实现
使用缓存空对象的方式解决查询商铺页面的缓存穿透问题。
- 业务流程:
两个修改:
- 从redis中查询缓存时,判断是否为空
- 商铺不存在时,将空值写入redis
- 代码实现:
修改queryById()
中的第2步和第5步:1
2
3
4
5
6
7
8
9
10
11
12
13//2.判断是否存在
//只有当shopJson有真实数据才返回true
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//5.不存在,返回错误,并且将空值写入redis
if (shop == null) {
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在!");
}
总结
缓存穿透产生的原因是什么?
- 用户请求的数据在缓存和数据库中都不存在,不断发起这样的请求会给数据库带来巨大压力
缓存穿透的解决方案有哪些?
被动方案:
- 缓存null值
- 布隆过滤
主动方案:
- 增加id复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数限流
缓存雪崩
概念
缓存雪崩是指同一时间大量的缓存key同时失效或者redis服务宕机,导致大量请求到数据库,带来巨大压力。
解决方法
- 给不同的key的TTL添加随机值:
使批量缓存的数据分布在一个时间段内失效,而不是同时失效,缓解数据库压力。 - 利用redis集群提高服务的可用性:
哨兵机制可以实现服务的监控,如果主宕机,哨兵可以自动从从机选出来一个替代原来的主,而且主从还可以实现一种数据同步,如果主宕机,从上面还会有数据,避免数据丢失。 - 给缓存业务添加降级和限流策略:
当redis出现故障时,及时做服务降级,比如快速失败、拒绝服务,而不是继续压入数据库,牺牲部分服务,从而保护数据库的健康。 - 给业务添加多级缓存:
在多个层面建立缓存,比如在Nginx和JVM中都建立缓存,如果redis崩了,还有其他缓存可以弥补
缓存击穿
概念
缓存击穿问题也叫热点key问题,就是一个被高并发访问并且缓存重建业务较为复杂的key突然失效了,无数的请求会在瞬间给数据库带来巨大冲击。
解决方法
互斥锁
当很多线程同时查询缓存且未命中,只有一个线程能获取锁,在把数据重新写入redis之前,其他线程全部等待,如图所示:
因为有大量的线程在等待释放锁,因此这种方案的效率比较低。
逻辑过期
之前产生问题的原因是我们设置了TTL,导致缓存突然失效,那现在不设置TTL了,我们需要在存储数据时设置expire
(过期时间,并不是TTL,而是在当前时间基础上再加上过期时间),理论上它是永不过期的,一般在双11等活动前预热完成。
它的业务流程如图所示:
为了避免线程等待时间过长,缓存重构交给独立线程去做,线程1和线程三只要发现逻辑过期,返回旧数据即可,直到线程4进来,自然可以获取更新后的数据。
方案对比
根据业务需求,在保证可用性和一致性之间做出抉择。
项目实现
基于互斥锁解决缓存击穿
修改根据id查询商铺的业务,基于互斥锁方式解决缓存击穿问题。
业务流程
获取锁:setnx key value
释放锁:del key
互斥锁代码实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51/**
* 利用互斥锁解决缓存击穿
* @param id 店铺id
* @return 店铺信息
*/
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(id);
//2.判断是否存在
//只有当shopJson有真实数据才返回true
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断命中的是否为空值
if (shopJson == null) {
//返回错误信息
return null;
}
//4.实现缓存重建
//4.1获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
try {
boolean isLock = tryLock(lockKey);
//4.2是否获取成功
if (!isLock) {
//4.3失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4成功,根据id查询数据库
Shop shop = getById(id);
//模拟重建的演示
Thread.sleep(200);
//5.不存在,返回错误,并且将空值写入redis
if (shop == null) {
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//6.存在,把数据写入redis,添加过期时间
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
//8.返回
return shop;
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//7.释放互斥锁
unlock(lockKey);
}
}将缓存穿透,获取锁,释放锁封装:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70/**
* 利用互斥锁解决缓存击穿
* @param id 店铺id
* @return 店铺信息
*/
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(id);
//2.判断是否存在
//只有当shopJson有真实数据才返回true
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断命中的是否为空值
if (shopJson == null) {
//返回错误信息
return null;
}
//4.实现缓存重建
//4.1获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
try {
boolean isLock = tryLock(lockKey);
//4.2是否获取成功
if (!isLock) {
//4.3失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4成功,根据id查询数据库
Shop shop = getById(id);
//模拟重建的演示
Thread.sleep(200);
//5.不存在,返回错误,并且将空值写入redis
if (shop == null) {
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//6.存在,把数据写入redis,添加过期时间
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
//8.返回
return shop;
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//7.释放互斥锁
unlock(lockKey);
}
}
/**
* 尝试获取锁
* @param key 锁
* @return 是否获取成功
*/
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
//不要直接返回,可能出现空指针
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
* @param key 锁
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}调用主方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17/**
* 通过id查询商铺信息
* @param id 商铺id
* @return 商铺信息
*/
public Result queryById(Long id) {
//缓存穿透
//Shop shop = queryWithPassThrough(id);
//互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
if (shop == null) {
return Result.fail("店铺不存在!");
}
return Result.ok(shop);
}
基于逻辑过期解决缓存击穿
修改根据id查询商铺的业务,基于逻辑过期方式解决缓存击穿问题。
- 业务流程
- 代码实现:
在utils包下创建:
1
2
3
4
5
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}在实现类封装方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16/**
* 封装店铺信息逻辑过期时间
* 没有添加TTL,key可以视为永久有效
* @param id 店铺id
* @param expireSeconds 逻辑过期时间
*/
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));
}逻辑过期主要业务代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 利用逻辑过期解决缓存击穿
* @param id
* @return
*/
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(id);
//2.判断是否存在
if (StrUtil.isBlank(shopJson)) {
//3.未命中,直接返回
return null;
}
//4.命中,json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.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) {
//6.3成功,开启独立线程实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
//重建
this.saveShop2Redis(id, CACHE_SHOP_TTL);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unlock(lockKey);
}
});
}
//7.返回过期的商铺信息
return shop;
}