这节我们先来实现初级版本,也就是说在后续我们会去升级和扩展分布式锁的实现方案,让它变的更加的完善。
一、业务分析
需求:定义一个类,实现下面接口,利用Redis实现分布式锁功能。
ILock其实就是一个锁接口。
tryLock
:尝试获取锁。因为我们采用的是非阻塞,我去获取锁我只试一次,如果成功了,那我就返回true,代表成功;那如果失败了返回false,代表失败。我不会不断重试,也不会阻塞等待。
另外再获取锁的时候还需要指定一个超时时间 timeoutSec
,这个就等于 setex
![image-20240528102906803](https://i-blog.csdnimg.cn/blog_migrate/eaacb6663260c6aeb4e31dd5ebd23fa9.png)
接下来就是根据流程图来实现方法
![image-20240528104557196](https://i-blog.csdnimg.cn/blog_migrate/921f5c5af4d9a85e3eb8a992ccc2387e.png)
二、代码实现
首先找到我们要实现的接口,这个已经提前写好了
![image-20240528104118777](https://i-blog.csdnimg.cn/blog_migrate/f236fcd7dca0803fc8e809dd422e63bb.png)
接下来在utils中定义一个 SimpleRedisLock类
,让它去实现ILock。
SimpleRedisLock
首先需要拿到StringRedisTemplate,因为只有拿到了它,我们词啊可以执行这些reids操作。
public class SimpleRedisLock implements ILock{
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public boolean tryLock(long timeoutSec) {
}
public void unlock() {
}
}
另外我们在获取锁和将来释放锁的时候,其实这里都需要指定锁的key。之前我们分析过,锁的名称是不能再这个类中写死的,写死就意味着不管任何一个业务,它来获取锁的时候都是同一把锁,这肯定是不对的。我们将来希望的是不同的业务有不同的锁,因此这个锁的名称应该跟业务有关,因此这个锁的名称我们不应该写死,而是由使用的人传递给我们。
下面代码中 name
就是业务的名称,事实上也是将来我们锁的名称。当然你可以给锁加上一个统一前缀,让它看起来更专业一些。
但是这里的value就比较特殊了,因为这个value值需要加上线程的标识,例如之前我们做测试的时候使用的是 thread1(thread + id)
![image-20240528100418138](https://i-blog.csdnimg.cn/blog_migrate/81fd063b9d8457750b2b8270639f6bab.png)
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
public class SimpleRedisLock implements ILock{
private StringRedisTemplate stringRedisTemplate;
private String name;
private static final String KEY_PREFIX = "lock:";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
long threadId = Thread.currentThread().getId();
// 获取锁
// 可以发现它执行的是Boolean,但是在我们业务逻辑中,我们执行setnx后,返回值是ok和nil,但是我们这里要求的是Boolean。事实上spring在给我们封装函数的时候,它帮我们对结果做了判断,直接返回布尔值。
Boolean success = stringRedisTemplate.opsForValue()
// 不过由于获取到的线程id是long,这里直接""拼一下即可
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
// 因此我们这里直接返回success即可,但是返回值是boolean类型,因此返回的时候会有一个自动拆箱的过程,有自动拆箱就有可能会有空指针安全风险
// Boolean.TRUE:是个常量,它肯定不是null,调用它的equals方法,如果你是true,返回true;如果你是false,返回false;如果是你null,它返回的也是false,这样就可以避免空指针风险了
return Boolean.TRUE.equals(success);
}
// 释放锁
public void unlock() {
//通过del删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
三、修改业务代码
之前我们是采用synchronized做锁
![image-20240528161825746](https://i-blog.csdnimg.cn/blog_migrate/2fa7ba5fac2bb21fff18d4ea56263d0f.png)
现在我们不这么做了,我们需要自己来创建锁对象,自己加锁了。
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
//创建锁对象(新增代码)
// 这里需要注意锁的范围,如果你这么写,那就表示:凡是来下单的业务都会被锁定。但事实上我们锁定的范围应该是用户,同一个用户我们才要加限制,不同用户无所谓
// new SimpleRedisLock("order"
// 因此这里锁的范围应该是用户
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁对象,这里传入的锁超时时间跟业务执行的时间有关,例如我们这个下单业务耗时大概是500ms,那么这个地方的超时时间就可以设置为5s,如果你执行超时,再长也不可能超过5s,但是由于这里我们代码要做测试,断点什么的都耗费时间比较长,因此这里给个长一点的,例如1200秒
boolean isLock = lock.tryLock(1200); // 由于这里查到不一定成功,因此需要判断
//加锁失败
if (!isLock) {
// 获取锁失败,解决办法一般是返回错误信息或重试。但这里我们是为了一个用户避免重复下单,因此直接返回错误信息即可
return Result.fail("不允许重复下单");
}
try {
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
// 由于上面代码不管会不会产生异常,都需要释放锁,因此放到finally中做
lock.unlock();
}
}
四、测试
在方法中打一个断点,然后重启代码
![image-20240528163527433](https://i-blog.csdnimg.cn/blog_migrate/bbab67b6c545bc81994ffa62cb45184b.png)
同样我们会启动两个tomcat,模拟两台服务器。在之前的测试中因为这两个是独立的进程,因此每个进程内都会有自己的一个锁对象,这样就导致当我们并发的去访问时,两个进程就会有两把锁,以至于会出现并发安全问题。
现在我们来测试一下我们一旦使用了这种自定义的redis锁,它还会不会有这种并发的安全漏洞。
首先将数据库库存恢复为100,然后删掉所有订单
接下来依旧用Postman来发请求,发送两个同样的请求。
回到IDEA,可以发现两个服务都进断点了,但是你会发现 8082
得到的 isLock
为 true
![image-20240528164437719](https://i-blog.csdnimg.cn/blog_migrate/95a1223bf43a176cd18a193700c40fe4.png)
而 8081
的 isLock
为 false
那就证明现在其实只有一个人获取锁成功了,这是因为我们虽然有两个进程,但是它们都是去同一台redis机器上获取锁,而且获取锁的名字是一样的。
此时我们到redis中刷新一下,可以看见它获取锁的时候会记录用户id1010,表示是1010这个用户来的,然后value记录的是当前线程。
因此由于是同一个用户来争抢,所以只能有一个线程拿到锁,另外一个线程是拿不到的。
这就是redis分布式锁实现的一个思路,只要锁只有一把,就不会出现同时执行的情况。