一、Redis分布式锁
1.Redis分布式锁的基本实现
分布式锁是用于解决集群线程不同步的问题,当有多个tomcat服务器同时运行,如果按照之前的办法只使用Java内置的锁就不行了,因为对于一个服务器来说,锁的监视器只能在其服务器内部起作用,也就是只能在内部解决单个服务器的一人一单问题(超卖问题不会出现,因为解决超卖问题使用的mysql乐观锁不是内部锁)。
当多个服务器组成集群工作时,锁的管控范围就不够了,所以需要一个凌驾于所有服务器之上的锁监视器,像解决超卖时使用的mysql,但是这里我们使用redis,因为redis的性能更好(但安全性略差,需要通过设置超时来释放锁)
我们的想法是让redis存储锁,每次尝试获取锁的时候去redis中查询锁的键,如果存在锁的键,那么就表明别的线程已经拿过锁了,这个时候就需要返回错误(重试)了,如果发现锁的键不存在,那么就说明可以获取锁了,所以就将键值对添加上去。
代码如下:
java
synchronized (userId.toString().intern()) {
//获取锁对象
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁
boolean isLock = lock.tryLock(1200);
//判断是否获取锁成功
if (!isLock) {
//获取锁失败,返回错误或重试
return Result.fail("不允许重复下单");
}
try {
//拿到代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} catch (IllegalStateException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
java
@RequiredArgsConstructor
public class SimpleRedisLock implements ILock {
private final String name;
private final StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
@Override
public boolean tryLock(long timeoutSec) {
//获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, null, timeoutSec, TimeUnit.SECONDS);
return BooleanUtil.isTrue(success);
}
@Override
public void unlock() {
//释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
2.分布式锁的误删问题
redis释放锁的方式分为两种:
1.业务结束主动释放
2.业务阻塞超时释放
第一种是正常情况,而第二种就是异常情况,通常我们设置的超时释放(redis键值对过期时间)是远大于业务完成所需要的时间的,但是如果业务阻塞了(不是抛异常)就有可能会出发超时释放。
假想一个情景:线程1 获取锁成功,开始进行线程1业务 ,但是线程1业务阻塞,于是触发了超时释放,这时线程2 恰好尝试获取锁,当然也能成功,因为线程1上的锁已经被超时释放了,然后进行线程2业务,此时线程1的业务突然阻塞结束,将主动释放锁,但是线程2的业务此时还在进行。于是其他线程可能就会与线程2同时进行操作了,线程就不同步了。
针对这个问题,我们就需要增加一个限定条件------只能释放自己上的锁,而如何确定是自己的锁就是通过比对redis中锁的值了,在获取锁时赋上唯一id,在主动释放时再判断锁的唯一id是不是自己的id(这里我们通过UUID来保证id唯一性)。
业务不作改动:
java
synchronized (userId.toString().intern()) {
//获取锁对象
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁
boolean isLock = lock.tryLock(1200);
//判断是否获取锁成功
if (!isLock) {
//获取锁失败,返回错误或重试
return Result.fail("不允许重复下单");
}
try {
//拿到代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} catch (IllegalStateException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
锁的工具类如下:
java
@RequiredArgsConstructor
public class SimpleRedisLock implements ILock {
private final String name;
private final StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
//获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();//uuid为前缀使id唯一
//获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return BooleanUtil.isTrue(success);
}
@Override
public void unlock() {
//获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();//uuid为前缀使id唯一
//获取锁中的标示
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
//判断标示是否一致
if(threadId.equals(id)){
//释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
3.原子性问题
先前我们在使用乐观锁时讲到:mysql底层有行级锁 ,所以下面的代码是线程安全的,也就是说判断CAS和执行库存-1的操作本质上是一步 ,这就相当于判断和执行是一个原子,绝对不会分开。这就是保障线程安全的原理。
java
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)//乐观锁CAS
.update();
然而我们现在使用的是redis,对于下面的两步操作:
1.获取锁中的标示并是否判断一致
2.释放锁
下面的代码可就不一定线程安全了,假设线程1判断后有阻塞 ,那么下一步释放锁的动作就会推迟,万一在这个过程中触发了超时释放锁,让线程2 拿到了新的锁,当阻塞结束后线程1将主动释放锁(而且不会判断,因为之前已经判断过了),那么此时线程2在执行自己业务期间就没有锁了,其他线程可能会侵入,所以线程就不安全了。
java
@Override
public void unlock() {
//获取线程标示
String threadId = ID_PREFIX + Thread.currentThread().getId();//uuid为前缀使id唯一
//获取锁中的标示
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
//判断标示是否一致
if(threadId.equals(id)){
//释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
在这里我们就很希望让redis像mysql一样了,能不能将这两步操作粘在一起呢?
是可以的,这里就需要通过Lua脚本来实现了。
Lua脚本:KEYS[ ] 就是键数组,ARGV[ ] 就是参数数组,后续我们将向其中传键和参,那么KEYS[1] 就是指传的第一个key,**ARGV[1]**就是指传的第一个参数了(Lua语言中数组以1开头)
Lua
--比较线程标示与锁中的标示是否一致
if(redis.call('get',KEYS[1]) == ARGV[1])then
--释放锁 del key
return redis.call('del',KEYS[1])
end
return 0
使用redis调用Lua脚本,传键和参数进去:
java
@Override
public void unlock() {
//调用lua脚本 原子化释放锁流程
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId()
);
}
于是就将判断和释放原子化了。
二、Redisson
首先介绍一下Redisson吧,Redisson是Redis的高级客户端,也可以看作是基于Redis封装的高级工具包,里面提供了许多锁可以直接使用。
1.修改项目
我们尝试用Redisson来代替我们自己写的SimpleRedisLock。
首先需要注入Redisson客户端:
java
private final RedissonClient redissonClient;
直接替换即可:
java
synchronized (userId.toString().intern()) {
//获取锁对象
//SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//redisson的锁
RLock lock = redissonClient.getLock("lock:order:" + userId);
//获取锁
boolean isLock = lock.tryLock();
//判断是否获取锁成功
if (!isLock) {
//获取锁失败,返回错误或重试
return Result.fail("不允许重复下单");
}
try {
//拿到代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} catch (IllegalStateException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
这里面的tryLock()方法中还有两个参数,后续我们将详细介绍:
|-----------------|-------------------------|
|waitTime| 等待获取锁的最大时间(超时则放弃) |
|leaseTime| 获取锁成功后,锁自动释放的时间(租约) |
2.可重入锁的理论部分
首先,为什么要使用Redisson?
因为我们自己写的工具类中的锁是很单一的,并且功能很局限,我们的锁还不可重入性、不可重试,而可重入恰恰又是一个很有优势的性质。
为啥要使用可重入锁?什么是可重入?情景演示:一个类中有两个方法,这两个方法在单独调用时都存在线程安全问题,所以都必须得上锁,好在这两个方法都是获取的同一个锁(比如用户id,保证同一个人的两个线程不能同时执行方法),然而更复杂的是,方法1 中调用了方法2 ,也就是有锁的嵌套。
这里一提到锁的嵌套就会想到死锁 的场景,死锁场景的本质是两个方法互相都在等待对方释放锁,所以无限期阻塞,导致当前线程无限阻塞,从而形成自我死锁。
**可重入锁就能够避免这种情况发生,可重入的意思就是指:同一个线程可以多次获得同一把锁。**比如在刚刚的情景中,方法1调用了方法2,而这两个方法都是分别上了同一把锁的,如果这把锁不可重入,那么方法2被调用时就会去尝试去拿这把锁,但是会发现这把锁被方法一拿了并且还没有释放,于是就获取锁失败,从而导致整个方法1都无法继续进行了。
但是如果这把锁是可重入的锁,那么当方法2被调用时方法2就可以成功拿到这把锁了,于是整个方法1就可以继续进行下去了。
怎么实现可重入?以往我们的锁在redis中都是以string类型存储的,这就会导致我们无法识别拿锁的是不是我们这同一个线程(是不是自己人),但是如果我们使用hash来存储就不一样了,我们通过存储Field,就能够知道是我们同一个线程做的了。
比如:当方法1开始调用时,将获取这把锁,让锁的Field设置为当前线程的唯一标识并且value设置为1 ,当调用方法2时,我们去比对线程的Field,这时就能判断我们是同一个线程 了,此时再将value+1 ,表示这把锁被获取过两次了,当方法2业务执行完毕,就可以去释放锁了,但是注意,我们不能直接删除锁,因为方法1在调用完方法2后还有业务,依旧需要保证线程安全,所以这里我们就将value-1 ,这样value的值就又回到了1,最终方法1的业务结束,释放锁,再将value-1 ,value就变为0了,我们在方法1最后判断value是否为0 ,为0就表示拿过这把锁的方法全部都释放结束了,这样就可以放心地删除锁了。
3.看门狗理论部分
刚刚我们有一行代码:
java
//redisson的锁
RLock lock = redissonClient.getLock("lock:order:" + userId);
//获取锁
boolean isLock = lock.tryLock();
对比我们之前写的:
java
//获取锁对象
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁
boolean isLock = lock.tryLock(1200);
可以发现我们竟然没有向lock里面传参数!这个参数之前是用于我们设置锁的自动过期时间的,用来保证业务执行后锁自动过期的参数(如果没有主动释放)。
那为啥现在不用传这个参数了呢?
这就要引入看门狗的概念了:
看门狗是一种机制,当锁快要结束但业务还没有结束时,这个方法会自动递归,并且重新计时,相当于给业务续期(注意,这里真的是无限续期,不主动释放就永远阻塞)。
另外注意:这个机制是 tryLock() 中针对获取到锁的线程而言的。
我们看看源码:这是 tryLock() 方法,是看门狗和锁重试机制的入口,注意,两个机制的入口都在这里:
java
@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return true;
}
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
current = System.currentTimeMillis();
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
if (!subscribeFuture.cancel(false)) {
subscribeFuture.onComplete((res, e) -> {
if (e == null) {
unsubscribe(subscribeFuture, threadId);
}
});
}
acquireFailed(waitTime, unit, threadId);
return false;
}
try {
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
while (true) {
long currentTime = System.currentTimeMillis();
ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return true;
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
// waiting for message
currentTime = System.currentTimeMillis();
if (ttl >= 0 && ttl < time) {
subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
}
} finally {
unsubscribe(subscribeFuture, threadId);
}
// return get(tryLockAsync(waitTime, leaseTime, unit));
}
这一行就是尝试获取锁的方法,可以看到又调用了tryAcquire方法,我们跟进去看看:
java
Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
当源码转到这里之后我们就能看到有一个scheduleExpirationRenewal(threadId) ,这个就是看门狗机制的启动方法了**:**

点进去查看这个 scheduleExpirationRenewal() 方法:

上面代码中的renewExpiration() 就是看门狗的核心机制了,我们点击去看看它实现看门狗机制的原理:从map中把锁拿出来,然后续期,每10s递归一次,将redis中的时间重置:

点进 renewExpirationAsync() 中:下面就是延续redis中锁过期时间的Lua脚本了,采用Lua脚本原子化执行自动续期:

好了,看到这里可能会有人有问题,那我怎么知道他业务结束了呢?业务结束了我才能结束续期从而释放锁啊。
那就要看看unlock的源码了,直接转实现可以转到下面的方法,这里就有取消续期的方法了:


4.锁重试机制理论部分
锁重试机制是在 tryLock() 中对于那些获取锁失败的线程而言的,当线程获取锁失败,那么它就要一直重试,而这个重试是可以有时间限制的,在trylock方法中有waitTime参数就是用于设置这个的。
回到看门狗和锁重试机制的入口 tryLock() 方法 ,这里面的while循环是个死循环,想退出这个死循环有三个办法:
1.获取锁成功
2.超时退出
3.中断退出
除此之外将一直死循环尝试。
当获取锁失败,线程就会去等待通知(监听锁释放),如果超过设置的等待时间就将直接退出。
java
@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {//获取锁成功
return true;
}
//下面的都是失败后的流程
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
current = System.currentTimeMillis();
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
if (!subscribeFuture.cancel(false)) {
subscribeFuture.onComplete((res, e) -> {
if (e == null) {
unsubscribe(subscribeFuture, threadId);
}
});
}
acquireFailed(waitTime, unit, threadId);
return false;
}
try {
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
while (true) {
long currentTime = System.currentTimeMillis();
ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {//获取锁成功
return true;
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
// waiting for message
currentTime = System.currentTimeMillis();
if (ttl >= 0 && ttl < time) {
subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
}
} finally {
unsubscribe(subscribeFuture, threadId);
}
// return get(tryLockAsync(waitTime, leaseTime, unit));
}
上面的代码中有如下一行,这就是订阅机制,可以简单理解为监听锁释放的机制,一旦收到锁释放的通知,就将唤醒这个线程,然后通过继续循环来竞争锁。下面的代码表示开始订阅:
java
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
而后面的这几行代码就是将线程阻塞,等待唤醒,唤醒后就从这个地方继续开始循环:
java
if (ttl >= 0 && ttl < time) {
subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
假设等待太久超时了就会直接退出循环:
java
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
最后取消订阅:
java
unsubscribe(subscribeFuture, threadId);
5.多锁机制理论部分
这里我就不讲源码了,不像刚刚的可重入锁(包括底层的看门狗、锁重试机制),多锁机制当前难以实践,所以就讲一下概念就是了
多锁multiLock是指Redis集群中部署多个主节点,每个主节点又可以连接一个从节点
每次获取锁时需要同时在多个节点尝试获取,释放锁时也需要在所有节点中删除,这样就避免了主从一致性的干扰。
红锁RedLock 也是在这个基础上改造的,要求在客户端获取锁时,需要在超过半数(N/2+1)的主节点上成功获取锁,才算获取成功;释放锁时,也需要在所有已加锁的节点上执行释放操作。