最近在开发中遇到一个场景,我们需要通过消息中间件拉取兄弟部门的数据,经过处理写入到本地数据库,供给用户查询,而这个服务在生产环境下有多个实例,而拉取数据仅需要一个实例拉取就够了。这种场景笔者能想到的有两种处理方案
配置文件
这种处理方式比较简单,java中有properties文件,可以在properties文件中指定一个变量fetch,我们指定某个实例拉取数据,然后将该实例下的properties文件的变量设置为true,实例在拉取数据前先判断fetch变量是否为true,只有变量为true的实例才会进行拉取数据的操作。
这种方式比较简单,不需要编写太多的代码就能实现我们想要的功能,但是如果生产环境下有实例宕机会比较麻烦,我们得手动修改其它正常实例的配置变量,并且重启实例,而且笔者也不愿意受到运维的骚扰。
分布式锁
分布式锁是这种场景下常用的处理方案,也能很好的解决上述的问题。除了避免重复工作外,分布式锁也可以避免多个进程(线程)同时处理同一个资源造成资源状态错乱的问题。分布式锁归根结底也是锁,分布式锁和并发锁具有很多相似的地方,在实现分布式锁之前,我们可以想一想并发锁具有哪些特征
互斥性
互斥性是并发锁最基本的特性,并发锁需要保证线程的互斥执行
高效性
加锁和解锁的过程必须足够高效,才能显著提高并发性能
可重入性
可重入性是指线程获取锁之后,可以再次获取同一把锁,释放锁时候也需要释放指定的加锁次数才能真正的释放锁,这种机制可以防止死锁
公平性
公平锁在加锁时候会先检查队列中是否有线程在等待,如果有的话就排队,正因为排队的过程,公平锁的性能比非公平锁的性能要差
读写锁
在java中为了进一步提高性能,也有了读写锁,读锁是共享锁,它可以由多个线程共同持有,而写锁是独占的,将锁细粒度化,是提高并发性能的有效手段
在实现分布式锁时,互斥性和高效性是必须要满足的,可重入性,公平性和读写锁功能我们可以结合业务按需实现。我们再看看java并发锁的抽象接口,在实现分布式锁时候,我们可以参照java并发锁的接口设计,其中tryLock这个方法在开发中比较常用,笔者在实现分布式锁时候也实现了该方法
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
Redis常规方案
笔者在实现Redis分布式锁的时候,互斥性和高效性是必须满足的,但是重入性,公平性和读写锁的功能我们可以结合业务场景选择实现。互斥性可以通过setnx和lua脚本实现。但是分布式系统中,由于受到网络,时间误差,STW等因素影响,完全的互斥性也很难实现。对于高效性而言,一个程序要想实现高性能必须避开三个因素,资源竞争,IO,海量数据。在redis分布式锁方案中,我们不会涉及到并发也即资源竞争,也不会遇到海量数据,虽然分布式系统IO是无法避免的,但是我们可以尽量减少IO次数,比如可以将一些不必要的IO异步化。
获取锁原理
Redis的setnx指令是set if not exists命令的简称,redis在执行setnx命令的时候,首先检查key是否存在,若给定的key已经存在,setnx不做任何操作,如果不存在redis才会将键设置为指定的value(这里的value笔者将其设置为UUID)。Redis的setnx命令是原子性指令。为了避免进程崩溃,导致死锁,我们需要对redis key设置失效时间。Lua脚本是一串redis命令的集合,redis的lua脚本具有隔离性,redis在执行lua脚本的时候,不会执行其它的请求命令。
如何实现重入性呢?这里笔者使用了ThreadLocal,线程在首次加锁成功后,就向ThreadLocal中设置LockData变量,LockData中的count变量的意义就是重入次数。线程首次加锁成功后,在释放锁前继续加锁,只需要自增count变量即可。
源代码如下所示
public boolean tryLock(int time, TimeUnit timeUnit) {
if (time <= 0 || timeUnit == null)
throw new RuntimeException("pramater is invalid");
LockData lockData = this.lockDataThreadLocal.get();
if (lockData != null) {
lockData.count++;
return true;
} else {
lockData = new LockData(1, this.generateLockData());
}
boolean locked = this.redisLockHelper.trySetnx(source, lockData.value, time, timeUnit);
if (locked) {
lockDataThreadLocal.set(lockData);
lockData.future = EXECUTOR.scheduleAtFixedRate(new RenewTask(lockData), 10, 10, TimeUnit.SECONDS);
}
return locked;
}
释放锁原理
释放锁时,首先判断ThreadLocal是否存在lockData变量,如果不存在直接返回,如果存在并且重入次数大于1,将count变量减一,直接返回即可。如果重入次数为1,需要删除redis中的key。为了避免线程释放不属于自己的锁,我们还需要进行value的比对。
public void unlock() {
LockData lockData = this.lockDataThreadLocal.get();
if (lockData == null)
return;
else if (lockData.count > 1) {
lockData.count--;
} else {
this.redisLockHelper.remove(this.source, lockData.value);
this.lockDataThreadLocal.set(null);
lockData.future.cancel(true);
}
}
锁续约机制
为了避免死锁,上述redis分布式锁实现方案对redis的key设置了过期时间(30s),如果持有锁的进程(线程)执行时间过长,超过了redis 的key有效时间,同时其它进程(线程)在锁过期时间后重新申请获取了锁,那么在同一时间就会有两个进程同时操作相同资源,资源状态就会出现错误。为了解决这个问题,我们在获取分布式锁之后,就注册一个定时任务,每隔10s钟时间就定时将key的生命设置为30s时间。
public void run() {
RedisLock.LOGGER.info("renew lock {}", RedisLock.this.source);
boolean renewed = RedisLock.this.redisLockHelper.renewLock(RedisLock.this.source, lockData.value);
if (!renewed) {
thread.interrupt();
}
}
常规方案缺陷
在生产环境中,为了保证高可用性,redis主服务器会有备份slave服务器,备份服务器会从主服务器同步数据,但是受分布式CAP定理约束,为了保证redis的写入性能,会采用异步同步模式,在异步同步模式下,client向master写入之后就会返回,而slave会在一定时间后异步同步数据。所以slave和master的数据同步存在时间差,如果master节点在这个时间差内崩溃,slave节点提升为master,此时client2又向新master申请分布式锁,那么在当前系统中,就同时有两个进程认为自己获取了分布式锁。上述的redis分布式锁实现方案在redis服务器failover过程时出现互斥性问题。
了解过java虚拟机的同学可能知道java虚拟机Full GC存在一个STW(Stop the world)过程,在执行STW过程时,除了GC线程,所有的用户线程都停止运行。虽然上述redis分布式锁实现方案存在锁续约线程,但是锁续约线程在STW时也会停止运行,用户持有的分布式锁可能也已经失效,而用户线程却全然不知,此时若另外一进程持有该分布式锁,那么系统的状态就会出现混乱。
RedLock方案
RedLock方案和常规redis分布式方案大致相同,只是RedLock只有获取半数以上redis实例上锁时才算获取到分布式锁。RedLock算法要求分布式环境中包含N(N最好是奇数)个Redis Master节点,这些节点相互独立,无需备份,相互隔离部署在不同的机器上。5个redis master节点是比较合理的RedLock最小配置。Client只有成功设置半数以上master节点key-value的时候,client才能算获取到分布式锁。更详细申请分布式锁的过程分为如下5个步骤。
- Client首先获取本地时间
- Client向每个master实例申请锁,锁生命为t1,并且申请可能会失败,比如网络阻塞,redis实例宕机,或者当前redis master节点锁已经被占据,申请锁时候,如果失败不能一直重试,也不能失败一次就放弃,所以申请资源需要有一个快速失败时间
- Client计算获取锁消耗时间t2,如果消耗时间小于锁的存活时间,并且client获取半数以上节点资源则认为client获取了锁
- Client成功获取锁,锁的窗口时间t = t1 – t2
- 在指定时间内client获取锁失败,client需要释放已经申请的redis master节点资源
代码实现
RedLock有了成熟的开源实现,maven 坐标如下
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.3.2</version>
</dependency>
Redisson内部也是使用了lua脚本,使用的核心数据结构是hash,在申请锁的时候,redis会执行如下lua代码
// 分布式锁的key 不存在
if (redis.call('exists', KEYS[1]) == 0) then
//直接获取锁
redis.call('hset', KEYS[1], ARGV[2], 1);
//设置失效时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
// key 存在,value也匹配
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
//重入次数加一
redis.call('hincrby', KEYS[1], ARGV[2], 1);
//设置锁的有效时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
return redis.call('pttl', KEYS[1])
释放该锁的时候会执行如下lua脚本
//需要释放的分布式锁不存在,直接返回
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
//分布式锁被其它进程(线程)占用,直接返回
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;
//当前进程(线程)持有分布式锁,重入次数减一
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
//分布式锁重入次数为0,删除该key,彻底释放该锁
redis.call('del', KEYS[1]);
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
return nil
缺陷
和常规redis分布式锁方案一样,RedLock同样会受GC的影响,存在多个进程同时持有分布式锁的互斥性问题。也有大佬提出了token fetch来解决这个问题,也就是在获取分布式锁时候同时获取一个token,提交数据时候比对token,只有当前的token大于等于上一轮提交数据的token才能正常提交数据。
并且RedLock严重依赖于系统时间,如果redis实例上的时间相差太大,仍然会存在多个进程同时持有同一把分布式锁的问题。比如client1获取了A,B,C,D,E五个redis实例上的A,B,C三把锁(超过半数以上节点,算持有了分布式锁),但是C实例时间较快,提前时间到期释放锁,client2申请取得了C,D,E三把锁,也持有了分布式锁,又会出现互斥性问题。
总结
在笔者看来RedLock和常规的redis分布式锁方案相比没有优势,在性能方面,在申请RedLock时,需要同时申请多个redis实例的锁,即使同时并行申请多个redis实例资源,RedLock的性能也会受到最慢实例的影响,显而易见,RedLock的性能不如常规的redis锁。在运维方面,RedLock需要部署5个Redis实例,部署复杂。Redis常规方案和RedLock都会受到STW过程存在多个进程持有同一把锁的互斥性问题。至于RedLock因为使用了多个redis实例,RedLock的可用性比常规方案更好的说法也站不住脚,常规redis分布式锁方案只需要添加redis备份节点,常规方案的可用性也能大幅提升。
其它
上述涉及到的代码已经上传到github
lan1tian/lockgithub.com