redis setnx 分布式锁_分布式锁Redis方案

最近在开发中遇到一个场景,我们需要通过消息中间件拉取兄弟部门的数据,经过处理写入到本地数据库,供给用户查询,而这个服务在生产环境下有多个实例,而拉取数据仅需要一个实例拉取就够了。这种场景笔者能想到的有两种处理方案

配置文件

这种处理方式比较简单,java中有properties文件,可以在properties文件中指定一个变量fetch,我们指定某个实例拉取数据,然后将该实例下的properties文件的变量设置为true,实例在拉取数据前先判断fetch变量是否为true,只有变量为true的实例才会进行拉取数据的操作。

这种方式比较简单,不需要编写太多的代码就能实现我们想要的功能,但是如果生产环境下有实例宕机会比较麻烦,我们得手动修改其它正常实例的配置变量,并且重启实例,而且笔者也不愿意受到运维的骚扰。

分布式锁

分布式锁是这种场景下常用的处理方案,也能很好的解决上述的问题。除了避免重复工作外,分布式锁也可以避免多个进程(线程)同时处理同一个资源造成资源状态错乱的问题。分布式锁归根结底也是锁,分布式锁和并发锁具有很多相似的地方,在实现分布式锁之前,我们可以想一想并发锁具有哪些特征

互斥性

互斥性是并发锁最基本的特性,并发锁需要保证线程的互斥执行

高效性

加锁和解锁的过程必须足够高效,才能显著提高并发性能

可重入性

可重入性是指线程获取锁之后,可以再次获取同一把锁,释放锁时候也需要释放指定的加锁次数才能真正的释放锁,这种机制可以防止死锁

公平性

公平锁在加锁时候会先检查队列中是否有线程在等待,如果有的话就排队,正因为排队的过程,公平锁的性能比非公平锁的性能要差

读写锁

在java中为了进一步提高性能,也有了读写锁,读锁是共享锁,它可以由多个线程共同持有,而写锁是独占的,将锁细粒度化,是提高并发性能的有效手段

在实现分布式锁时,互斥性和高效性是必须要满足的,可重入性,公平性和读写锁功能我们可以结合业务按需实现。我们再看看java并发锁的抽象接口,在实现分布式锁时候,我们可以参照java并发锁的接口设计,其中tryLock这个方法在开发中比较常用,笔者在实现分布式锁时候也实现了该方法

1public interface Lock { 2 void lock(); 3 void lockInterruptibly() throws InterruptedException; 4 boolean tryLock(); 5 boolean tryLock(long time, TimeUnit unit) throws InterruptedException; 6 void unlock(); 7 Condition newCondition(); 8} 9

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变量即可。

源代码如下所示

1public boolean tryLock(int time, TimeUnit timeUnit) { 2 if (time <= 0 || timeUnit == null) 3 throw new RuntimeException("pramater is invalid"); 4 LockData lockData = this.lockDataThreadLocal.get(); 5 if (lockData != null) { 6 lockData.count++; 7 return true; 8 } else { 9 lockData = new LockData(1, this.generateLockData()); 10 } 11 boolean locked = this.redisLockHelper.trySetnx(source, lockData.value, time, timeUnit); 12 if (locked) { 13 lockDataThreadLocal.set(lockData); 14 lockData.future = EXECUTOR.scheduleAtFixedRate(new RenewTask(lockData), 10, 10, TimeUnit.SECONDS); 15 } 16 return locked; 17} 18

释放锁原理

释放锁时,首先判断ThreadLocal是否存在lockData变量,如果不存在直接返回,如果存在并且重入次数大于1,将count变量减一,直接返回即可。如果重入次数为1,需要删除redis中的key。为了避免线程释放不属于自己的锁,我们还需要进行value的比对。

1public void unlock() { 2 LockData lockData = this.lockDataThreadLocal.get(); 3 if (lockData == null) 4 return; 5 else if (lockData.count > 1) { 6 lockData.count--; 7 } else { 8 this.redisLockHelper.remove(this.source, lockData.value); 9 this.lockDataThreadLocal.set(null); 10 lockData.future.cancel(true); 11 } 12} 13

锁续约机制

为了避免死锁,上述redis分布式锁实现方案对redis的key设置了过期时间(30s),如果持有锁的进程(线程)执行时间过长,超过了redis 的key有效时间,同时其它进程(线程)在锁过期时间后重新申请获取了锁,那么在同一时间就会有两个进程同时操作相同资源,资源状态就会出现错误。为了解决这个问题,我们在获取分布式锁之后,就注册一个定时任务,每隔10s钟时间就定时将key的生命设置为30s时间。

1public void run() { 2 RedisLock.LOGGER.info("renew lock {}", RedisLock.this.source); 3 boolean renewed = RedisLock.this.redisLockHelper.renewLock(RedisLock.this.source, lockData.value); 4 if (!renewed) { 5 thread.interrupt(); 6 } 7} 8

常规方案缺陷

在生产环境中,为了保证高可用性,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个步骤。

  1. Client首先获取本地时间
  2. Client向每个master实例申请锁,锁生命为t1,并且申请可能会失败,比如网络阻塞,redis实例宕机,或者当前redis master节点锁已经被占据,申请锁时候,如果失败不能一直重试,也不能失败一次就放弃,所以申请资源需要有一个快速失败时间
  3. Client计算获取锁消耗时间t2,如果消耗时间小于锁的存活时间,并且client获取半数以上节点资源则认为client获取了锁
  4. Client成功获取锁,锁的窗口时间t = t1 – t2
  5. 在指定时间内client获取锁失败,client需要释放已经申请的redis master节点资源

代码实现

RedLock有了成熟的开源实现,maven 坐标如下

1<dependency> 2 <groupId>org.redisson</groupId> 3 <artifactId>redisson</artifactId> 4 <version>3.3.2</version> 5</dependency> 6

Redisson内部也是使用了lua脚本,使用的核心数据结构是hash,在申请锁的时候,redis会执行如下lua代码

1// 分布式锁的key 不存在 2if (redis.call('exists', KEYS[1]) == 0) then 3 //直接获取锁 4redis.call('hset', KEYS[1], ARGV[2], 1); 5//设置失效时间 6 redis.call('pexpire', KEYS[1], ARGV[1]); 7 return nil; 8end; 9// key 存在,value也匹配 10if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then 11 //重入次数加一 12redis.call('hincrby', KEYS[1], ARGV[2], 1); 13//设置锁的有效时间 14 redis.call('pexpire', KEYS[1], ARGV[1]); 15 return nil; 16end; 17return redis.call('pttl', KEYS[1]) 18 19

释放该锁的时候会执行如下lua脚本

1//需要释放的分布式锁不存在,直接返回 2if (redis.call('exists', KEYS[1]) == 0) then 3 redis.call('publish', KEYS[2], ARGV[1]); 4 return 1; 5end; 6//分布式锁被其它进程(线程)占用,直接返回 7if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then 8 return nil; 9end; 10//当前进程(线程)持有分布式锁,重入次数减一 11local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); 12if (counter > 0) then 13 redis.call('pexpire', KEYS[1], ARGV[2]); 14 return 0; 15else 16 //分布式锁重入次数为0,删除该key,彻底释放该锁 17 redis.call('del', KEYS[1]); 18 redis.call('publish', KEYS[2], ARGV[1]); 19 return 1; 20end; 21return nil 22

缺陷

和常规redis分布式锁方案一样,RedLock同样会受GC的影响,存在多个进程同时持有分布式锁的互斥性问题。也有大佬提出了token fetch来解决这个问题,也就是在获取分布式锁时候同时获取一个token,提交数据时候比对token,只有当前的token大于等于上一轮提交数据的token才能正常提交数据。

38e9ac289731e8c35f3cd03ac3cd77e9.png

并且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/lock​github.com

4e88f2b98bc8927365c544f2e0fe73c8.png

代码交流 2021