【本篇文章基于redisson-3.17.6版本源码进行分析】
目录
为了解决这个问题,redis引入了红锁的概念。
需要准备多台redis实例,这些redis实例指的是完全互相独立的Redis节点,这些节点之间既没有主从,也没有集群关系。客户端申请分布式锁的时候,需要向所有的redis实例发出申请,只有超过半数的redis实例报告获取锁成功,才能算真正获取到锁。
具体的红锁算法主要包括如下步骤:
在Redisson框架中,实现了红锁的机制,Redisson的RedissonRedLock对象实现了Redlock介绍的加锁算法。该对象也可以用来将多个RLock对象关联为一个红锁,每个RLock对象实例可以来自于不同的Redisson实例。当红锁中超过半数的RLock加锁成功后,才会认为加锁是成功的,这就提高了分布式锁的高可用。
使用的步骤如下:引入Redisson的maven依赖
<!-- JDK 1.8+ compatible -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.9.0</version>
</dependency>
编写单元测试:
@Test
public void testRedLock() {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient client1 = Redisson.create(config);
RLock lock1 = client1.getLock("lock1");
RLock lock2 = client1.getLock("lock2");
RLock lock3 = client1.getLock("lock3");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {
/**
* 4.尝试获取锁
* redLock.tryLock((long)waitTimeout, (long)leaseTime, TimeUnit.SECONDS)
* waitTimeout 尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败
* leaseTime 锁的持有时间,超过这个时间锁会自动失效(值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完)
*/
// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = redLock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
//成功获得锁,在这里处理业务
System.out.println("成功获取到锁...");
}
} catch (Exception e) {
throw new RuntimeException("aquire lock fail");
} finally {
// 无论如何, 最后都要解锁
redLock.unlock();
}
}
RedissonRedLock红锁继承自RedissonMultiLock联锁,简单介绍一下联锁:
基于Redis的Redisson分布式联锁RedissonMultiLock对象可以将多个RLock对象关联为一个联锁,每个RLock对象实例可以来自于不同的Redisson实例,所有的锁都上锁成功才算成功。
RedissonRedLock的加锁、解锁代码都是使用RedissonMultiLock中的方法,只是其重写了一些方法,如:
protected int failedLocksLimit() {
return locks.size() - minLocksAmount(locks);
}
protected int minLocksAmount(final List<RLock> locks) {
// 最小的获取锁成功数:n/2 + 1。 过半机制
return locks.size()/2 + 1;
}
在RedissonMultiLock中,则必须全部都加锁成功才算成功,所以允许加锁失败节点个数为0,其实现为:
protected int failedLocksLimit() {
return 0;
}
接下来,我们以tryLock()方法为例,详细分析红锁是如何加锁的,具体代码如下:
org.redisson.RedissonMultiLock#tryLock(long, long, java.util.concurrent.TimeUnit)
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
// try {
// return tryLockAsync(waitTime, leaseTime, unit).get();
// } catch (ExecutionException e) {
// throw new IllegalStateException(e);
// }
long newLeaseTime = -1;
if (leaseTime > 0) {
if (waitTime > 0) {
newLeaseTime = unit.toMillis(waitTime)*2;
} else {
newLeaseTime = unit.toMillis(leaseTime);
}
}
// 获取当前系统时间,单位:毫秒
long time = System.currentTimeMillis();
long remainTime = -1;
if (waitTime > 0) {
remainTime = unit.toMillis(waitTime);
}
long lockWaitTime = calcLockWaitTime(remainTime);
// 允许加锁失败节点个数限制(N - ( N / 2 + 1 ))
// 假设有三个redis节点,则failedLocksLimit = 1
int failedLocksLimit = failedLocksLimit();
// 存放调用tryLock()方法加锁成功的那些redis节点
List<RLock> acquiredLocks = new ArrayList<>(locks.size());
// 循环所有节点,通过EVAL命令执行LUA脚本进行加锁
for (ListIterator<RLock> iterator = locks.listIterator(); iterator.hasNext();) {
// 获取到其中一个redis实例
RLock lock = iterator.next();
String lockName = lock.getName();
System.out.println("lockName = " + lockName + "正在尝试加锁...");
boolean lockAcquired;
try {
// 未指定锁超时时间和获取锁等待时间的情况
if (waitTime <= 0 && leaseTime <= 0) {
// 调用tryLock()尝试加锁
lockAcquired = lock.tryLock();
} else {
// 指定了超时时间的情况,重新计算获取锁的等待时间
long awaitTime = Math.min(lockWaitTime, remainTime);
// 调用tryLock()尝试加锁
lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
}
} catch (RedisResponseTimeoutException e) {
// 如果抛出RedisResponseTimeoutException异常,为了防止加锁成功,但是响应失败,需要解锁所有节点
unlockInner(Arrays.asList(lock));
// 表示获取锁失败
lockAcquired = false;
} catch (Exception e) {
// 表示获取锁失败
lockAcquired = false;
}
if (lockAcquired) {
// 如果当前redis节点加锁成功,则加入到acquiredLocks集合中
acquiredLocks.add(lock);
} else {
// 计算已经申请锁失败的节点是否已经到达 允许加锁失败节点个数限制 (N-(N/2+1)), 如果已经到达,就认定最终申请锁失败,则没有必要继续从后面的节点申请了。因为 Redlock 算法要求至少N/2+1 个节点都加锁成功,才算最终的锁申请成功
if (locks.size() - acquiredLocks.size() == failedLocksLimit()) {
break;
}
if (failedLocksLimit == 0) {
unlockInner(acquiredLocks);
if (waitTime <= 0) {
return false;
}
failedLocksLimit = failedLocksLimit();
acquiredLocks.clear();
// reset iterator
while (iterator.hasPrevious()) {
iterator.previous();
}
} else {
failedLocksLimit--;
}
}
// 计算 目前从各个节点获取锁已经消耗的总时间,如果已经等于最大等待时间,则认定最终申请锁失败,返回false
if (remainTime > 0) {
// remainTime: 锁剩余时间,这个时间是某个客户端向所有redis节点申请获取锁的总等待时间, 获取锁的中耗时时间不能大于这个时间。
// System.currentTimeMillis() - time: 这个计算出来的就是当前redis节点获取锁消耗的时间
remainTime -= System.currentTimeMillis() - time;
// 重置time为当前时间,因为下一次循环的时候,方便计算下一个redis节点获取锁消耗的时间
time = System.currentTimeMillis();
// 锁剩余时间减到0了,说明达到最大等待时间,加锁超时,认为获取锁失败,需要对成功加锁集合 acquiredLocks 中的所有锁执行锁释放
if (remainTime <= 0) {
unlockInner(acquiredLocks);
// 直接返回false,获取锁失败
return false;
}
}
}
if (leaseTime > 0) {
// 重置锁过期时间
acquiredLocks.stream()
.map(l -> (RedissonBaseLock) l)
.map(l -> l.expireAsync(unit.toMillis(leaseTime), TimeUnit.MILLISECONDS))
.forEach(f -> f.toCompletableFuture().join());
}
// 如果逻辑正常执行完则认为最终申请锁成功,返回true
return true;
}
从源码中可以看到,红锁的加锁,其实就是循环所有加锁的节点,挨个执行LUA脚本加锁,对于加锁成功的那些节点,会加入到acquiredLocks集合中保存起来;如果加锁失败的话,则会判断已经申请锁失败的节点是否已经到达允许加锁失败节点个数限制 (N-(N/2+1)), 如果已经到达,就认定最终申请锁失败,则没有必要继续从后面的节点申请了。
并且,每个节点执行完tryLock()尝试获取锁之后,无论是否获取锁成功,都会判断目前从各个节点获取锁已经消耗的总时间,如果已经等于最大等待时间,则认定最终申请锁失败,需要对成功加锁集合 acquiredLocks 中的所有锁执行锁释放,然后返回false。
假设一共有5个Redis节点:A, B, C, D, E:
客户端1成功锁住了A, B, C,获取锁成功,但D和E没有锁住。
节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了。
节点C重启后,客户端2锁住了C, D, E,获取锁成功。
这样,客户端1和客户端2同时获得了锁(针对同一资源)。
红锁其实也并不能解决根本问题,只是降低问题发生的概率。完全相互独立的redis,每一台至少也要保证高可用,还是会有主从节点。既然有主从节点,在持续的高并发下,master还是可能会宕机,从节点可能还没来得及同步锁的数据。很有可能多个主节点也发生这样的情况,那么问题还是回到一开始的问题,红锁只是降低了发生的概率。
其实,在实际场景中,红锁是很少使用的。这是因为使用了红锁后会影响高并发环境下的性能,使得程序的体验更差。所以,在实际场景中,我们一般都是要保证Redis集群的可靠性。同时,使用红锁后,当加锁成功的RLock个数不超过总数的一半时,会返回加锁失败,即使在业务层面任务加锁成功了,但是红锁也会返回加锁失败的结果。另外,使用红锁时,需要提供多套Redis的主从部署架构,同时,这多套Redis主从架构中的Master节点必须都是独立的,相互之间没有任何数据交互。