分布式锁
概述
分布式锁指的是,所有服务中的所有线程都去获取同一把锁,但只有一个线程可以成功的获得锁,其他没有获得锁的线程必须全部等待,直到持有锁的线程释放锁。
分布式锁是可以跨越多个实例,多个进程的锁
分布式锁具备的条件:
互斥性:任意时刻,只能有一个客户端持有锁
锁超时释放:持有锁超时,可以释放,防止死锁
可重入性:一个线程获取了锁之后,可以再次对其请求加锁
高可用、高性能:加锁和解锁开销要尽可能低,同时保证高可用
安全性:锁只能被持有该锁的服务(或应用)释放。
容错性:在持有锁的服务崩溃时,锁仍能得到释放,避免死锁。
分布式锁实现方案
分布式锁都是通过第三方组件来实现的,目前比较流行的分布式锁的解决方案有:
数据库,通过数据库可以实现分布式锁,但是在高并发的情况下对数据库压力较大,所以很少使用。
Redis,借助Redis也可以实现分布式锁,而且Redis的Java客户端种类很多,使用的方法也不尽相同。
Zookeeper,Zookeeper也可以实现分布式锁,同样Zookeeper也存在多个Java客户端,使用方法也不相同
Redis实现分布式锁
SETNX
基本方案:Redis提供了setXX指令来实现分布式锁
设置分布式锁后,能保证并发安全,但上述代码还存在问题,如果执行过程中出现异常,程序就直接抛出异常退出,导致锁没有释放造成最终死锁的问题。(即使将锁放在finally中释放,但是假如是执行到中途系统宕机,锁还是没有被成功的释放掉,依然会出现死锁现象)
设置超时时间
但是,即使设置了超时时间后,还存在问题。
假设有多个线程,假设设置锁的过期时间10s,线程1上锁后执行业务逻辑的时长超过十秒,锁到期释放锁,线程2就可以获得锁执行,此时线程1执行完删除锁,删除的就是线程2持有的锁,线程3又可以获取锁,线程2执行完删除锁,删除的是线程3的锁,如此往后,这样就会出问题。
让线程只删除自己的锁
解决办法就是让线程只能删除自己的锁,即给每个线程上的锁添加唯一标识(这里UUID实现,基本不会出现重复),删除锁时判断这个标识:
但上述红框中由于判定和释放锁不是原子的,极端情况下,可能判定可以释放锁,在执行删除锁操作前刚好时间到了,其他线程获取锁执行,前者线程删除锁删除的依然是别的线程的锁,所以要让删除锁具有原子性,可以利用redis事务或lua脚本实现原子操作判断+删除
Redis的单条命令操作是原子性的,但是多条命令操作并不是原子性的,因此Lua脚本实现的就是令Redis的多条命令也实现原子操作
redis事务不是原子操作的,详情请看 Redis的事务
但是,可以利用Redis的事务和watch实现的乐观锁 来监视锁的状态
尽管这样,还是会有问题,锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁的释放,但其实此时业务还在执行中,还是应该将业务执行结束之后再释放锁。
上面用到了lua脚本来操作redis,这里额外说几点使用lua脚本的注意点:
避免复杂运算:Redis是单线程的,如果Lua脚本中包含耗时的循环或复杂计算,会阻塞整个Redis服务器,影响其他请求。脚本应专注于数据操作,而非复杂逻辑。
注意集群环境:在Redis Cluster模式下,脚本中所有操作的Key必须位于同一个哈希槽(hash slot),否则会报错。可以通过使用“哈希标签”(例如 {user123}:order)来确保多个Key被路由到同一节点。
脚本缓存与调试:可以使用 SCRIPT LOAD命令将脚本预加载到Redis中,然后通过返回的SHA1摘要值来执行,效率更高。在正式部署前,建议先在Redis-CLI中测试脚本的正确性
续时
因此可以设定,任务不完成,锁就不释放。
可以维护一个定时线程池 ScheduledExecutorService,每隔 2s 去扫描加入队列中的 Task,判断失效时间是否快到了,如果快到了,则给锁续上时间。
那如何判断是否快到失效时间了呢?可以用以下公式:【失效时间】<= 【当前时间】+【失效间隔(三分之一超时)】
Redisson
使用Redis + lua方式可能存在的问题
不可重入性。同一个线程无法多次获取同一把锁
不可重试。获取锁只尝试一次就返回false,没有重试机制
超时释放。锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁的释放,存在安全隐患
主从一致性。如果Redis是主从集群,主从同步存在延迟,当主机宕机时,从成为了主,但可能存在从此时还未完成同步,因此从上就没有锁标识,此时会出现线程安全问题。
RLock是Redisson分布式锁的最核心接口,继承了concurrent包的Lock接口和自己的RLockAsync接口,RLockAsync的返回值都是RFuture,是Redisson执行异步实现的核心逻辑,也是Netty发挥的主要阵地。
RLock如何加锁解锁,实现可重入性?
从RLock进入,找到RedissonLock类,找到tryLock 方法再继续找到tryAcquireOnceAsync 方法,这是加锁的主要代码(版本不一此处实现有差别,和最新3.15.x有一定出入,但是核心逻辑依然未变。此处以3.13.6为例)
此处出现leaseTime时间判断的2个分支,实际上就是加锁时是否设置过期时间,未设置过期时间(-1)时则会有watchDog 的锁续约 (下文),一个注册了加锁事件的续约任务。我们先来看有过期时间tryLockInnerAsync 部分
evalWriteAsync方法是eval命令执行lua的入口
eval命令执行Lua脚本的地方,此处将Lua脚本展开
总共3个参数完成了一段逻辑:
- 判断该锁是否已经有对应hash表存在,
没有对应的hash表:则set该hash表中一个entry的key为锁名称,value为1,之后设置该hash表失效时间为leaseTime
存在对应的hash表:则将该lockName的value执行+1操作,也就是计算进入次数,再设置失效时间leaseTime
- 最后返回这把锁的ttl剩余时间
再看看RLock如何解锁?
看unlock方法,同样查找方法名,一路到unlockInnerAsync
将lua脚本展开
该Lua KEYS有2个Arrays.asList(getName(), getChannelName())
ARGV变量有三个LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId)
具体执行步骤如下:
如果该锁不存在则返回nil;
如果该锁存在则将其线程的hash key计数器-1,
计数器counter>0,重置下失效时间,返回0;否则,删除该锁,发布解锁消息unlockMessage,返回1;
加锁解锁流程总结如下:

总的来说就是通过Hash类型来存储锁的次数:

RLock的锁重试问题
需要分析的是锁重试的,所以,在使用lock.tryLock()方法的时候,不能用无参的。
在调用tryAcquire方法后,返回了一个Long的ttl
继续跟着代码进去查看,最后会发现,调用tryLockInnerAsync方法。这个方法就是获取锁的Lua脚本的。
这个lua脚本上面提到了。就是 判断,如果获取到锁,返回一个nil.也就是null。如果没有获取到,就调用 pttl,name。其实就是获取当前name锁的剩余有效期。
获取到ttl。如果返回null说获取锁成功,直接返回true.如果返回的不是null,说明需要进行重试操作了。主要是根据时间进行判断的。经过一系列判断后,do,while是真正执行重试相关逻辑的。如下:
主要是do while机制进行锁重试的,while会检查时间是否还充足会继续循环。当然这个循环不是直接while(true)的盲等机制,而是利用信号量和订阅的方式实现的,会等别人释放锁,再进行尝试,这种方式对cpu友好
Redisson的超时续约
跟随tryLock代码,在RedissonLock类中的tryAcquireOnceAsync方法中,会看到如下代码:
在使用trylock的时候,如果设置了锁过期时间,就不会执行续命相关逻辑了。
其中默认的watchdogTimeout时间是30秒。
看门狗机制:在获取锁成功以后,开启一个定时任务,每隔一段时间就会去重置锁的超时时间,以确保锁是在程序执行完unlock手动释放的,不会发生因为业务阻塞,key超时而自动释放的情况。
到期续约方法:
查看renewExpirationAsync方法源码,其调用了Lua脚本执行续命操作的。
pexpire重置锁的有效期。
总体逻辑如下:
开启一个任务,10秒钟后执行
开始的这个任务中重置有效期。假设设置的是默认30秒,则重置为30秒
更新后又重复步骤1、2
那么什么时候取消这个续约的任务呢?在释放锁unlock时
multilock解决主从一致性问题
Redis分布式锁会有个缺陷,就是在Redis哨兵模式下:
客户端1对某个master节点写入了redisson锁,此时会异步复制给对应的slave节点。但是这个过程中一旦发生master节点宕机,主备切换,slave节点从变为了master节点。
客户端2来尝试加锁的时候,在新的master节点上也能加锁,此时就会导致多个客户端对同一个分布式锁完成了加锁。
系统在业务语义上一定会出现问题,导致各种脏数据的产生。 这个缺陷导致在哨兵模式或者主从模式下,如果master实例宕机的时候,可能导致多个客户端同时完成加锁。
因此redisson提出来了MutiLock锁,使用这把锁就不使用主从了,每个节点的地位都是一样的, 这把锁加锁的逻辑需要写入到每一个主丛节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获得锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。

使用multilock()方法。必须在所有的节点都获取锁成功,才算成功。 缺点是运维成本高,实现复杂。
总结Redisson
Redisson分布式锁解决前三个问题原理

总结Redisson分布式锁原理:
可重入:利用hash结构记录线程id和重入次数
可重试:利用信号量和PubSub功能来实现等待、唤醒,获取锁失败的重试机制
超时续约:利用watchDog,开启一个定时任务,每隔一段时间(releaseTime/3),重置超时时间。
使用multilock: 多个独立的redis节点,必须在所有节点都获取重入锁,才算获取成功;
redLock
不管是redLock,还是redissonLock,两者底层都是通过相同的lua脚本来加锁、释放锁的,所以,两者只是外部形态的不同,底层是一样的。redLock是继承了redissonMultiLock,大部分的逻辑,都是在redissonMultiLock中去实现的,所以源码部分,大部分都是RedissonMultiLock
原理
redLock的使用,需要有奇数台独立部署的Redis节点
在加锁的时候,会分别去N台节点上加锁,如果半数以上的节点加锁成功,就认为当前线程加锁成功
