为了不浪费您宝贵的时间,特在此加注:本文主要是记录Redisson和curator分布式锁实现原理,仅作个人记录用于日后回顾,不保证可读性。
当然,如若您有分布式锁相关问题,欢迎与我讨论。一起在讨论中成长是再好不过的事情了~
Redis Distributed Lock
Lock WatchDog
redisson在客户端里实现了一个watchdog,主要监控只有一把锁的客户端是否还存活着,如果存活着,那么watchDog会每10s延长一次个锁的过期时间,默认延长30s。
leaseTime
客户端获取到锁以后,执行时间特别长的话,可以指定这个参数,比如说1分钟,如果超过了1分钟,redisson就会自动释放锁。
Reentrant Lock
Lock Data Structure
Map Key = anyLock :{d14413ef-810a-4963-87c1-d8b4bbd9dcd0:1}
anyLock是所有客户端知道的KEY
37195ec5-0ac6-4784-a92a-702688e7de36:1,前半部分是UUID生成的,代表当前客户端,不同的客户端UUID不同,后半部分代表当前客户端的线程ID
加锁
Redisson客户端的线程尝试加锁,第一次加锁成功,后续再尝试加锁,加锁次数+1。
如果其他线程来加锁or其他客户端的线程来加锁,会进入while(true)
循环阻塞等待,直到获取到锁。
lock.tryLock(100,TimeUnit.SECONDS);
客户端会尝试加锁,最多等待100s,如果100s内加锁失败就会返回false,如果加锁成功,返回true。没有指定leaseTime,所以后台的watch dog会每10s刷新一次锁的过期时间30s,直至客户端调用unlock方法释放锁。
lock.tryLock(100,10,TimeUnit.SECONDS);
客户端会尝试加锁,最多等待100s,如果100s内加锁失败就会返回false,如果加锁成功,返回true。leaseTime=10,因此reids里的锁key10s后自动过期,即自动释放锁。
释放锁
手动释放锁:Redisson客户端线程尝试释放锁,会把UUID+线程ID对应的加锁次数递减1,如果加锁次数还大于0,此时会刷新锁的过期时间为30ms。如果加锁次数为0,即删除锁key,锁成功释放。
宕机自动释放锁:如果持有锁的客户端宕机,后台负责刷新锁过期时间的watch dog就会销毁,redis锁key自动过期,锁成功释放。
Fair Lock
加锁
第一次加锁没什么特别的,就是直接对anyLock的Key进行加锁。当第二个线程/客户端来进行加锁的时候,会入队等待,并在计算timeout写入有序集合,后面进来线程按顺序排队。
当有线程来尝试加锁,会进入while(true)
死循环,该循环主要是用于把队列中,可能因为网络延迟or宕机超时的客户端给剔除掉,避免占用队列,当重新过来加锁的时候,重新进入队列排队加锁。
释放锁
把客户端的加锁次数减1,如果加锁次数大于0,会刷新锁的过期时间为30s,如果加锁次数为0,删除锁key,锁成功释放,队头的客户端or线程出队,对anyLock进行加锁,队列中的其他客户端or线程继续排队等待。
Multi Lock
加锁
在锁定多个资源的时候使用,可以将多个RLock
对象关联为一个联锁,联锁默认的最大等待时间为locks.size() * 1500
,比如说有三个RLock,默认的最大等待时间为4500ms,获取时进入while(true)
死循环,调用各个锁的tryLock()
方法,当第一个锁和第二个锁已经获取到,第三个锁在获取时,已经超过了4500ms,加锁失败,这时候会释放掉前两个锁,继续执行死循环重新开始获取锁。
释放锁
依次调用所有的锁的释放的逻辑,lua脚本,同步等待所有的锁释放完毕,才会返回。
Red Lock
RedLock算法思想,不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁,必须在大多数(n/2 + 1)redis节点上都成功创建锁,才能算整体RedLock加锁成功,避免说仅仅在一个redis实例上加锁。
为了取到锁,客户端应该执行以下操作:
- 获取当前Unix时间,以毫秒为单位。
- 依次尝试从N个实例,使用相同的key和随机值获取锁。在步骤2,当向Redis设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个Redis实例。
- 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
- 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
- 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功)。
漏洞
5个master实例,客户端A尝试加锁,仅仅成功的在3个master实例加了锁,成功了;此时不幸的是此时3个master中的1个master突然宕机了,锁key还没同步到他的slave实例上去,此时salve切换为新的master
5个master,其中一个是新切换过来的master,其实只有2个master是有客户端A加锁的一个痕迹的,另外3个master是没有这个锁key的
然后的不幸的是,此时客户端B来加锁,他其实很有可能可以成功的在3个master上成功加锁,达到了一个大多数的数字,完成了加锁,还是会发生说多个客户端同时重复加锁,所以说也是不是完全靠谱的
ReadWriteLock
Lock Data Structure
多客户端加读锁时的数据结构
{
mode:read,
UUID_01:threadID_01:2,
UUID_02:threadID_02:1,
}
{anyLock}:UUID_01:threadID_01:rwlock_timeout:1: 1 → timeout key,每次加锁都会增加一个
{anyLock}:UUID_01:threadID_01:rwlock_timeout:2: 1
{anyLock}:UUID_02:threadID_02:rwlock_timeout:1: 1
写锁
{
mode: write,
UUID:threadID:write : 2
}
加锁
加读锁的时候mode为read,加写锁的时候mode为write,其他逻辑不变。
读锁不互斥且可重入,也就是说读锁可以被多个客户端or线程持有。
写锁与写锁是互斥的,当有人加了写锁,其他客户端再来加写锁的时候lua脚本啥也不会干,相当于加写锁失败;
读锁与写锁是互斥的,当有人加了读锁,其他客户端再来加写锁的时候lua脚本啥也不会干,相当于加写锁失败;当有人加了写锁,其他客户端再来加读锁的时候lua脚本啥也不会干,相当于加读锁失败。
以上都是不同客户端的逻辑。
同一个客户端,先加读锁再加写锁,可以加;先加写锁,再加读锁不可以加。
// has unlocked read-locks redis.call('hset', KEYS[1], 'mode', 'read'); "
先读锁后写锁的情况,如果写锁释放完了,还剩读锁,会把mode转换成read。
如果没指定leaseTime,会开启watch dog,读锁可以多次加,在进行刷新读锁的时候,会对每一次加锁的key都进行pexpire。
释放锁
释放锁首先会递减加锁次数,接下来删除timeout key,当加锁的hash结构里只有mode:xxxx的时候会删掉anyLock,锁释放成功。
Semaphore
允许多个客户端获取一把锁,任何一个线程释放锁之后,其他等待的线程就可以尝试进来获取一下这个锁。
一开始会设置semaphore的值N
加锁
第一次加锁会判断semaphore的值是不是大于0,如果大于0,允许加锁,加锁不断递减semaphore,此时semaphore被递减为0,其他客户端再来加锁,不允许加锁,且进入while(true)
死循环,直至有人释放锁,他才能成功加锁。
释放锁
每次加锁的客户端释放锁,semaphore值累加1,此时其他处于死循环中的客户端就能成功获取到锁。
CountDownLatch
客户端A一开始会设置countDown的值,要求必须达到countDown数目的客户端来执行countDown()
方法,CountDownLatch才会继续往下走,否则客户端A就陷入死循环,直至指定数量的客户端执行了countDown,才会执行后续代码。
Zookeeper Distributed Lock
Shared Lock
不可重入,基于Semaphore实现,最多允许一个客户端来获取。
ourIndex < maxLeases(1)
ourIndex表示自己创建的子目录在/lock/lock_01目录下的位置,如果是0加锁成功,其他人再来加锁就是1,2....N,阻塞等待,直到第一个锁成功释放,才可以获取到锁。
Shared Reentrant Lock
在获取锁的时候,客户端一上来就会创建临时顺序节点,然后获取到当前path下的所有子节点,把子节点从小到大进行排序,然后判断自己是不是子节点中的第一个节点,如果是,获取锁成功,如果不是,会监听上一个节点是否释放锁, 并且当前线程会陷入等待,直到其他线程释放掉锁后来通知监听器,监听器执行notifyAll()
方法,唤醒后继续执行上述逻辑,直到获取锁成功。
上面的加锁逻辑说明zk的可重入锁其实是一个公平锁
Semaphore
首先设置Semaphore最多有N个线程可以获取到锁,在获取锁的时候,客户端一上来会创建一把Reentrant lock,保证顺序加锁,然后会在我们指定的目录下创建lease目录的子目录,获取lease目录下的所有子节点,判断是否≤我们设置的值,如果是,获取锁成功,并且释放Reentrant lock,让其他线程可以获取Semaphore,如果大于了我们设置的值,线程会陷入wait()
阻塞等待,当其他线程释放掉Semaphore锁的时候,会通知lease子目录的监听器,该监听器会调用notifyAll()
方法,其他线程继续执行上述逻辑,直到子目录≤我们设置的值,即获取Semaphore锁成功。
Shared Reentrant Read Write Lock
读锁+读锁
子节点:/locks/lock_01/_c_6331fd97-7a64-4f15-bcc0-96d7140614e8-__READ__000000003
子节点列表:[_c_6331fd97-7a64-4f15-bcc0-96d7140614e8-__READ__0000000003]
一上来首先判断是不是写锁,不是写锁,就表明是读锁,再根据名称判断自己是不是位于子节点列表中的第一位,此时是第一位,ourIndex=0
然后判断ourIndex < Ingeter.MAX_VALUE(2147483647)
;如果小于,加锁成功,如果不小于,加锁失败。
2147483647这么大的值,几乎不可能有客户端加满,因此我们可以认为读锁是可以重复加的。
读锁+写锁
读锁:_c_765d60e1-16dd-451d-83bf-fb2b553590f0-__READ__0000000004
写锁:/locks/lock_01/_c_ffca5c70-c2c8-4571-8cd5-a07b806e7852-__WRIT__0000000005
子节点列表:["_c_765d60e1-16dd-451d-83bf-fb2b553590f0-__READ__0000000004","_c_ffca5c70-c2c8-4571-8cd5-a07b806e7852-__WRIT__0000000005"]
先加读锁肯定是可以成功的,再来看加写锁的逻辑,获取写锁在子节点列表中的位置ourIndex = 1,写锁的maxLease是1
此时会判断ourIndex(1) < maxLease (1)
,条件不成立,因此加写锁失败。
失败以后会对上一个子节点加一个watcher监听器,当上一个子节点释放就会通知写锁,此时写锁是子节点列表中的第一位,也就是ourIndex=0,此时条件成立,加写锁成功
如果有人先加了读锁,此时其他客户端再来加写锁,无法加锁成功。
写锁+读锁
写锁:/locks/lock_01/_c_8d243e8f-88bc-4075-b0f3-23d42a56230b-__WRIT__0000000011
读锁:/locks/lock_01/_c_64e71ce4-20cf-4692-bdd1-169bcb94b242-__READ__0000000012
子节点列表:["_c_8d243e8f-88bc-4075-b0f3-23d42a56230b-__WRIT__0000000011","_c_64e71ce4-20cf-4692-bdd1-169bcb94b242-__READ__0000000012"]
先加写锁肯定会成功,如果是线程来加读锁此时会判断writeMutex.isOwnedByCurrentThread()
,我是不是当前加写锁的线程,如果我是当前加写锁的线程,此时再来加读锁直接成功。
如果是其他客户端来加读锁,遍历子节点列表,然后看下第一个节点是不是加的写锁,如果是把firstWriteIndex设置为0,index++,再次循环判断第二个子节点的名字是不是以我的节点名字开头(我就是过来加读锁的节点名),如果是ourIndex设置为1。
此时会判断ourIndex(1) < firstWriteIndex(0)
,条件不成立,因此加读锁失败。
下面就是失败后的逻辑了,不用太关心。
失败以后会对上一个子节点加一个watcher监听器,当上一个子节点释放就会通知读锁,此时读锁是子节点列表中的第一位,也就是ourIndex=0,因为没进写锁的判断逻辑,firstWriteIndex还是Ingeter.MAX_VALUE(2147483647)
,ourIndex<firstWriteIndex
,条件成立,加读锁成功。
同一个线程,先加写锁,再加读锁可以成功(这点和redis一样)。
不同客户端,先加写锁,再加读锁会失败。
写锁+写锁
重复加写锁没什么可说的,第二次来加写锁,该节点处于子节点列表中的第二位,因此ourIndex=1,ourIndex(1) < maxLease (1)
,条件不成立,因此加写锁失败。
重复加写锁就会失败。
Multi Shared Lock
底层原理就是依次遍历获取每一个锁,阻塞直到获取到每个锁为止,然后返回true表示获取锁成功。
如果获取的过程中有报错,依次释放获取到的锁,然后返回false表示获取锁失败。