分布式锁架构设计方案 -02
在上一篇博文:分布式锁架构设计方案 -01,我为大家详细介绍了如何实现一个完善、高性能的基于Redis的分布式锁方案,相信大家应该都能有所裨益。然后,在实际开发过程中,之前的方案还是存在一些问题,虽然我们习惯性的将它称之为分布式锁,但从严格意义上来说却并不算,因为这仅仅只是一个依托于分布式组件作为载体来实现的单点锁,换句话说,锁的容错性较差,一旦目标Redis节点宕机,或者产生网络抖动都有可能导致客户端无法顺利加锁而导致业务异常;甚至还有可能因主/从切换引发安全问题(比如客户端A、B同时获取到目标锁资源)的情况出现(因此不建议增加slave节点),因为这是一个彻彻底底的单点问题,是无法简单通过横扩Redis集群节点就能够解决的。
那么实现一个完善的分布式锁,则必须要同时满足以下3个特性:
容错性:避免单点问题;
可用性:避免产生死锁;
互斥性:锁资源未释放之前,其它客户端不得进行加锁。
RedLock架构
我们都知道Zookeeper是一个典型的CP系统,是一个基于ZAB(Zookeeper Atomic Broadcast,原子广播)协议的强一致性中间件,当我们向leader写入数据时,会由leader负责向集群中的其他follower节点同步数据,当 > 半数以上集群节点同步确认完成后,一次数据写入操作才算是成功。那么基于类似这样的思想,Redis社区提出了RedLock算法。
RedLock的核心思想是什么?简而言之,我们首先需要部署N个单点Redis,务必保证这些Redis节点之间是完全相互独立的,没必要存在任何主/从复制,以及集群协调机制的介入;当客户端在尝试加锁时,需要满足同时至少
>=(N>>1)+1
个Redis节点上都顺利加锁成功,才代表一次分布式加锁操作是成功的,反之加锁失败。通过这样的保障机制,则可以有效提升分布式锁的容错性和满足安全性,以及互斥性需求。
redlock整体架构
在此大家需要注意,在实现RedLock时需要遵守如下5个约定:
以毫秒为单位获取当前时间;使用相同的key和具有唯一性的value(例如UUID+TID)顺序从每一个Redis节点中加锁。客户端需要设置连接、响应超时,并且超时时间应该<锁失效时间。这样可以避免Redis宕机,客户端还在等待响应结果。如果Redis没有在单位时间内响应,客户端应该快速失败请求另外的Redis;当满足>=N/2+1个节点加锁成功,且锁的使用时间<失效时间时,才算加锁成功;加锁成功后,key的真正有效时间等于有效时间减去获取锁使用时间;客户端加锁失败时,需要在所有Redis节点上进行解锁,以防止在某些Redis节点上加锁成功但客户端无响应或超时而影响其它客户端无法加锁。
透过源码看本质
这里我在jedis-distributed-lock项目中实现了RedLock,那么接下来我就简单的从源码层面为大家再巩固加深一些印象。首先,我们需要在加锁前后记录当前时间戳,用endTime-beginTime后即可以得出锁的使用时间。示例1-1:
@Override
public boolean tryLock(long time, TimeUnit unit) {
if (locks.size() < 3) {
throw new JedisLockException("More than 3 redis nodes are required");
}
long beginTime = System.currentTimeMillis();//记录开始时间
// TODO 加锁逻辑处理
long endTime = System.currentTimeMillis() - beginTime;//获取锁的使用时间
}
RedLock算法的核心实际上就是尝试顺序从所有的目标Redis节点上获取锁资源,只是我们需要在加锁后记录结果,以便于最后判断这一次的分布式加锁是否真正成功。示例1-2:
locks.stream().filter(lock -> Objects.nonNull(lock)).forEach(lock -> {
boolean result;
try {
result = time == -1L ? lock.tryLock() : lock.tryLock(lockTime, TimeUnit.MILLISECONDS);
} catch (Throwable e) {
result = false;
}
if (result) {
acquiredLocks.incrementAndGet();
}
});
当加锁逻辑结束后,接下来要做的事情就是验证子锁的成功次数,毕竟我们需要满足>=(N>>1)+1个节点加锁成功,且锁的使用时间<失效时间时,一次加锁操作才算是真正意义上的成功;反之如果是不满足任意一项条件,我们都需要尝试从所有的目标Redis节点中释放锁资源,以便于其他客户端能够在接下来顺利获取到锁资源。示例1-3:
//当出现子锁取锁失败时,N/2+1个节点成功则代表取锁成功
if (acquiredLocks.get() >= (locks.size() - failedLocksLimit())) {
//获取锁的使用时间
long endTime = System.currentTimeMillis() - beginTime;
if (remainTime != -1L) {
if ((remainTime - endTime) <= 0L) {//锁使用时间<失效时间时,锁才算获取成功,排除tryLock
unlockInner(locks);//取锁超时后释放所有锁
return false;
}
}
return true;
} else {
unlockInner(locks);//当失败次数达到阈值时,释放所有子锁
return false;
}
在此大家需要注意,相对于单点锁来说,RedLock的优势非常明显,高容错性、高可用性,但是缺点也很扎眼,那就是性能较低。这里我做了一个简单的benchmark来比较单点锁和RedLock(3 nodes)的性能比较。示例1-4:
# RedLock lock
[threadSize]:10, [taskSize]:10w, [rt]:36s, [avg]:0.00s, [tps]:2777.78/s
# RedLock trylock
[threadSize]:20, [taskSize]:100w, [rt]:63s, [avg]:0.00s, [tps]:15873.02/s
--------------------------------------------------------------------------
# lock
[threadSize]:10, [taskSize]:10w, [rt]:21s, [avg]:0.00s, [tps]:4761.90/s
# trylock
[threadSize]:20, [taskSize]:100w, [rt]:15s, [avg]:0.00s, [tps]:66666.67/s
从benchmark的对比结果来看,RedLock和单点锁的性能差异还是非常明显的。当然,在实际开发过程中,究竟如何选择,还需要结合实际的业务场景而定。