浅析redis与zookeeper构建分布式锁的异同
进程请求分布式锁时一般包含三个阶段:1. 进程请求获取锁;2. 获取到锁的进程持有锁并执行业务逻辑;3. 获取到锁的进程释放锁;下文会按照这个三个阶段进行分析。
单机Redis
获取锁
从一开始的请求进程通过SETNX命令获取锁;
127.0.0.1:6379> SETNX redis_locks 1
(integer) 1
-> 因为存在进程通过SETNX命令获取到锁后,执行业务逻辑期间挂掉,未能释放锁,导致死锁的场景,引入了超时机制用于打破死锁形成的条件之一(获取到锁的进程一直持有锁),使得锁即使在获取锁的进程崩溃后仍可以通过超时机制得到释放;
127.0.0.1:6379> SETNX redis_locks 1
(integer) 1
127.0.0.1:6379> EXPIRE redis_locks 60
(integer) 1
-> 引入超时机制后,获取锁存在两条命令,SETNX+EXPIRE,前者用于加锁,后者用于设置锁的过期时间,即加锁过程不再具有原子性;因此亦存在进程通过SETNX获取到锁后还未执行EXPIRE便挂掉的场景,同样会导致死锁;因此Redis在2.6.12版本后扩展了SET命令的参数,使得通过一条命令SET Key Value EX 10 NX即可实现SETNX+EXPIRE的效果,保证了获取锁的原子性。
127.0.0.1:6379> SET redis_locks 1 EX 60 NX
OK
释放锁
从一开始的获取到锁的进程执行完业务逻辑后调用DEL命令释放锁;
-> 引入超时机制后使得锁的释放多了一个渠道;如果获取到锁的进程执行业务逻辑的过程中因为GC等原因造成进程暂停,并且因为进程暂停导致锁触发超时机制使得锁被释放,另一个进程获取锁成功,而当前进程重新运行时并不知道自身的锁已经被释放,会继续执行业务逻辑并且释放锁,而这个锁是被另一个进程持有的;即一个客户端释放了其他客户端持有的锁,而要解决这个问题显然要给锁加上一个持有者的唯一标识,如UUID,当进程准备释放锁时,首先检查锁的标识确认该锁是否属于自身,只有锁属于自身时才会进行释放;
// uuid可以通过 UUID.randomUUID().toString()获取
127.0.0.1:6379> SET redis_locks $uuid EX 60 NX
OK
-> 引入唯一标识后,锁的释放需要检查锁标识、释放锁两个步骤,显然两个步骤并不是原子的;在极端情况下,仍然会存在检查锁标识时该锁尚且属于自身,而检查完后锁就因为超时被释放了,此时另一个进程获取到了锁,从而导致当前进程仍然存在释放其他进程锁的可能性;因此也需要将这两个步骤变为原子的,一般是通过Lua脚本来实现;
--- 原子脚本中包含两个步骤:1)判断当前锁是否是自己的 2)锁是自己的进行释放
if redis.call("GET", KEYS[1]) == ARGV[1]
then
return redis.call("DEL", KEYS[1])
else
return 0
end
优化:自动续期
上述流程虽然已经解决了进程持有锁并进行业务逻辑时,锁已经因为过期而自动释放这个场景下当前进程释放其他进程锁的问题,而且当前进程也可以将业务逻辑继续运行完成;但如果当前业务逻辑存在先后因果关系时,如Read And Modify, Check Then Act等,可能会导致数据一致性的问题;
举个例子:
该业务逻辑用于对数据库某值进行加一,则先获取锁的进程读取数据库当前值为1,然后便因为GC陷入进程暂停导致锁超时;
此时另一个进程获取到了锁,并从数据库读取当前值为1,并进行+1后写入,此时数据库值为2;
接着第一个进程从GC中醒来,继续执行业务逻辑,对之前读到的值1进行+1后写入,此时数据库值仍为2;而这造成了更新丢失的问题;
因此最好提供一种机制可以对锁进行续期的机制,即客户端开启一个守护线程,如果当前锁即将过期,但是业务逻辑仍未完成,则该线程会自动对锁进行续期;
在Java生态环境中,Redisson通过看门狗机制实现了自动续期的功能,我们只需要进行引用即可;并且Redisson的SDK中实现了很多功能,如可重入锁、乐观锁、公平锁、读写锁以及下面集群版会提到的RedLock。
集群redis
一般生产环境通过主从+哨兵机制构建redis集群,并通过写主读从的机制对外提供服务,对于写服务只要主库写入成功便返回客户端,并通过RDB+AOF的机制进行异步的主从状态同步;
那么在写主读从策略下的redis集群中,一个进程通过SET x x EX x NX命令写主redis并成功获取到锁,并且该命令尚未通过AOF进行网络同步,如果此时主redis崩溃,哨兵会进行主备切换,而显然从库中一定是没有这个锁对应的键-值对的,因此如果此时其他进程尝试获取锁便可能会获取成功,而这会造成该锁机制不再满足互斥性;
上述问题的关键在于因为主从消息同步存在一定的滞后性,因此redis的作者提出了 RedLock 的机制用于解决上述的问题,RedLock的使用存在一个前提:不部署从库和哨兵机制,但主库要部署多个,官方推荐为5个(奇数); 如下图所示:
获取锁
如果一个进程想要在redis集群中获取到锁,那么必须使得该进程获取到锁这件事在redis集群实例间达成共识,而达成共识一般通过Quorum机制,即少数服从多数;
因此在redLock算法中,一个进程需要依次向5个实例发送SET lock uuid EX 60 NX请求,并且记录响应结果,如果有3(5/2 + 1,半数+1)以上实例返回加锁成功,那么该进程则成功获取到锁;获取锁失败则需要向集群中所有redis实例发起释放锁的请求(通过Lua脚本释放锁);
上述为不考虑网络延迟、进程暂停、时钟漂移这三个会导致数据一致性问题的方案,接着基于网络的部分同步模型来对算法做进一步的安全性探讨;
1. 考虑超出上界的网络延迟
进程请求锁后同步等待redis服务端的响应结果,如果此时因为网络延迟超出上界的缘故,导致请求进程收到redis实例返回的加锁成功的响应时,当前锁已经超过EX规定的时间并自动过期;而该进程对此并不知情仍然进行下一步的业务逻辑并在之后释放锁,而这会造成与redis单机版类似的问题-当前进程释放了其他进程的锁;因此redLock在当前进程尝试获取锁时会先获取当前时间戳T1,等客户端收到来自redis服务端的响应时,再次获取当前时间戳T2,并判断T2-T1 > EX Time,不等式成立时当前客户端才会认为自己加锁成功,否则加锁失败;
2. 考虑超出上界的进程暂停
如果进程已经获取到锁后发生较长时间的GC亦会如单机版redis一样,导致当前客户端释放其他客户端的锁,解决方案类似,通过使用Lua脚本进行锁的释放;
3. 考虑超出上界的时钟漂移
如果请求锁的客户端获取到60s的锁后进行业务逻辑的处理,而此时redis集群中一些实例在同步NTP时间时,发生了大的跳跃,造成一些实例上的锁提前过期了,这可能会导致同时有两个客户端持有集群的redis锁;举个例子:
客户端A在第一次加锁时获取了redis集群实例[1,2,3]的成功响应,而[4,5]被其他客户端加锁,但按照半数以上的原则,只有客户端A获取到了锁;
此时实例3发生了时钟跳跃导致实例3上的锁提前过期,而此时另一个客户端B请求加锁时获取到了[3,4,5]三个实例的成功响应,导致客户端B也获取到了锁;
针对这个问题,redis的作者表示需要通过定期的运维保证集群中的机器不会出现大幅度的跳跃;
4. RedLock获取锁的过程
综上所述,RedLock 获取锁的过程如下:
请求进程记录下当前时间戳T1;
请求进程依次请求redis实例获取锁,并且每个请求都会设置超时时间(该超时时间远小于锁的有效时间),如果请求进程收到响应或超过超时时间则继续向下一个redis实例申请加锁;
如果请求进程获得了半数以上的redis集群实例响应,则获取当前时间戳T2,判断T2-T1 > EX Time,如果不成立则获取锁失败;
释放锁
释放锁不仅需要通过Lua脚本进行释放,而且考虑到加锁期间存在一些redis实例中已经添加锁成功,但是响应超时了,而这对于当前锁的持有者是不知情的,因此持有锁的进程需要向集群中所有的redis实例发送请求释放锁;
zookeeper实现分布式锁的优势
因为zk基于全序广播算法ZAB的缘故,zk对于每个进程发起的获取锁的请求,都会分配一个全局唯一递增的ZXID,即ZXID越小,请求越早到达zk;并且因为zk对于每个请求的处理都会通过执行ZAB算法在集群各个节点间达成共识,所以ZXID最小的请求会获取到锁。因此zk不需要依赖额外的RedLock机制来实现分布式共识;这也是zk实现分布式锁的一个优势。
获取锁:获取锁即为在zk中创建一个临时节点,例如/exclusive_lock/lock;创建临时节点成功的进程则获取到锁,创建失败的进程则加锁失败;我们可以通过开源的zk客户端,如ZkClient、Curator的create()方法进行节点的创建;
释放锁:因为临时节点的特性,释放锁存在两种情况:1. 获取锁的进程删除临时节点便释放了所持有的锁;2. 获取锁的进程挂了,与zk断连后该临时节点会自动删除,即自动释放锁;而这是通过zk获取锁的第二个优势-没有锁过期带来的烦恼;回忆一下:redis引入超时过期机制是为了解决获取锁节点宕机的问题,并且因为这个超时过期带来了很多的问题场景。
并且可以直接通过zk的顺序节点和Watcher机制实现读写锁、乐观锁;
Watcher机制:通过Watcher机制,客户端可以向如下的/read_write_lock目录节点注册子节点变更的Watcher监听,这样当该目录下子节点发生增减时,zk会将该事件通知所有注册的客户端;
顺序节点:在顺序节点目录下的子节点,zk会为节点维护创建的先后顺序,并在节点名称后缀中增加节点创建的次序值:
通过上述两个机制,我们可以实现读写锁:
首先需要定义一个机制用于区分读写请求,可以在写入节点值时增加Read or Write进行区分;因为顺序节点后缀大小标识了请求的先后性,如上图所示:表明zk先后收到了两个获取Read锁、一个获取Wrtie锁的请求;
接着对于一个进程获取读锁的请求,如果此时/read_write_lock目录下没有包含Write的节点,则直接创建节点并返回获取成功;如果此时存在获取读锁的节点,则获取失败,不过仍然会在目录下创建节点,但需要在该写锁节点上注册Watcher监听,当该写锁节点删除后,原请求进程可以尝试重新获取读锁;
对于一个获取写锁的请求,如果此时/read_write_lock目录下Write节点已经是存活的后缀最小的节点,则获取写锁成功;如果在该请求前仍然存在其他节点,则获取写锁失败,需要在该目录下后缀不大于该写请求的节点上注册Watcher通知,这样当该节点释放后,则请求进程可以再次尝试获取写锁。
zookeeper实现分布式锁的一些问题
当然通过zk实现分布式锁仍然存在很多问题,我们同样按照网络延迟、进程暂停的角度进行分析;
进程1创建临时节点/exclusive_lock/lock成功,拿到了锁
进程1因为机器长时间GC而暂停
进程1无法给 Zookeeper 发送心跳,Zookeeper将临时节点删除
进程2创建临时节点/exclusive_lock/lock 成功,拿到了锁
进程1机器GC结束后恢复,它仍然认为自己持有锁(产生冲突)
因此无论是通过zk还是redis还是其他锁服务,都会存在类似的问题,即获取到锁的服务在持有锁期间发生进程暂停导致锁释放后,另一个进程获取倒锁,导致两个客户端都会认为自己持有锁。
可以使用类似Redisson的续约机制,通过一个守护线程进行zk临时节点的维护;但是zk不会存在释放了别人锁的情况,所以不需要通过类似Lua的机制来释放锁;
fencing token算法
上述问题的关键在于进程在唤醒后,仍然以为自己持有锁并进行共享资源的操作;因为操作系统中进程的切换或崩溃后恢复,只会在原有的执行序列位置继续执行,自然不可能自发的在唤醒后重新检查自己是否仍然持有锁;
因此fencing算法提出了让共享资源具有拒绝持有过期锁的进程发起的请求的能力:
fencing算法通过给锁加上一个序列,即每次请求进程成功获取锁时,锁服务都会返回一个递增的token;
接着请求进程拿着这个token去操作共享资源;
共享资源缓存token,并拒绝token值较小的客户端请求。
在该算法下,如先获取锁的进程1得到的token = 1;接着该进程GC后进程2获取到锁得到的token = 2,接着进程2使用值为2的token请求共享资源,共享资源对该token值进行缓存;最后进程1苏醒,使用值为1的token请求共享资源,共享资源察觉到1<2(current cached),则拒绝当前请求;
通过该算法,可以解决上述的持有锁后进程暂停带来的影响;不过如果持有过期锁的进程操作共享资源并没有先后因果关系时,可以无需考虑使用该算法,该算法存在一定的代价。
总结
一般都是使用分布式锁用作互斥,上述文章中列举了NPC场景下的一些问题,并都给出了相应的解决方案;具体使用时可以考虑场景本身对于数据绝对正确的敏感度,决定是否要使用代价更大的机制来进行保证。当然RedLock还是不推荐使用,代价太大,还是建议使用主从+哨兵的机制进行redis集群的搭建。