zookeeper 创建临时顺序节点_分布式锁Zookeeper方案

1. Redis分布式锁回顾

为了避免持有分布式锁的进程(线程)崩溃造成死锁的情况,上篇文章谈到的两种redis实现方案都为redis的key设置了定时失效。但是这种定时失效又会因为网路IO,GC影响造成在同一时间多个进程持有同一把锁的互斥性情况。这篇文章介绍一种新的分布式锁实现方案,也就是Zookeeper分布式锁方案,Zookeeper分布式锁的正确性远高于Redis分布式锁。

2. Zookeeper

ZooKeeper是一个开源的分布式协调服务,简单的说,zookeeper(zookeeper集群)作为分布式文件系统,它的作用是向外提供统一的一致性的视图。分布式系统由于受到CAP定理的约束,只能在一致性和可用性间做取舍,Zookeeper追求的是一致性。著名的RPC中间件Dubbo使用Zookeeper作为服务注册中心,Kafka使用Zookeeper维护meta数据。笔者在这篇文章中主要介绍使用Zookeeper实现分布式锁。在介绍Zookeeper分布式锁前,笔者先介绍Zookeeper三大关键特性,ZNode节点,Session心跳,Watcher机制。

2.1 ZNode

ZNode是Zookeeper的数据节点,在zookeeper中,ZNode节点有持久节点和临时节点两种节点。持久ZNode节点一旦被创建,除非执行了删除命令,否则持久节点就一直保留。客户端创建临时节点,一旦客户端断开和Zookeeper的连接,临时节点就会被删除。在Zookeeper分布式锁中,Zookeeper使用ZNode抽象分布式锁。

1创建持久节点命令 2create /per 123 3创建持久顺序节点 4create –s /per 123 5创建临时节点 6create –e /tmp 123 7创建临时顺序节点 8create –e –s /tmp 123 9

2.2 会话(Session)

客户端和zookeeper服务器通信后会建立一个会话,同时客户端和服务端会保持一条长连接,客户端需向服务端发送心跳告诉服务端自己仍然存活,如果超过sessionTimeout时间客户端没有发送心跳包,那么zookeeper服务端会认为客户端会话失效,同时会移除客户端创建的全部临时节点。

2.3 Watch机制

Watcher机制是Zookeeper提供的一种监听机制。客户端向zookeeper注册监听自己感兴趣的节点,一旦该节点状态发生了变化,Zookeeper就将节点变化的事件通过长连接告诉客户端。

3. 获取锁原理

上文提到,Zookeeper使用临时ZNode节点抽象分布式锁。进程在尝试获取zookeeper分布式锁的时候,如果当前锁空闲则直接获取锁,否则进行排队,同时监听ZNode节点。zookeeper因为采用了长连接心跳机制,一旦zookeeper监测到持有锁的线程崩溃,zookeeper就会释放锁,同时watch机制会通知其它等待相同锁的进程(线程),其它线程就能再次申请锁了。

假设我们现在需要某个服务拉取数据,但是这个服务有四个实例,我们仅需要其中一个实例拉取数据。笔者使用/fetch节点抽象分布式锁。在获取锁时,需要进行如下的步骤

  1. 检测/fetch锁节点是否存在,如果不存在,创建持久节点。
  2. 在/fetch节点下创建临时顺序节点,临时序号是Zookeeper生成的自增序号
  3. 获取/fetch节点下全部临时节点,判断自己创建的临时节点是否是所有顺序节点中序号最小的一个,如果是,那么判定当前线程持有分布式锁,如果不是,那么需要对节点/fetch进行监听。

Zookeeper实现互斥性的原理是通过临时节点的最小序号实现的。如果当前进程持有的zookeeper节点不是最小序号,那么会进行排队的过程。所以Zookeeper分布式锁自然是公平锁实现。

那么Zookeeper分布式锁时如何实现可重入性?和Redis分布式锁实现一样,Zookeeper分布式锁也是通过lockData变量实现的。lockData中有一个lockCount变量,表示加锁次数,在获取锁后再次加锁只需要将lockData的变量加一即可

如下截图显示了两个信息

  1. /fetch节点下排队等待的进程
  2. 当前锁持有者的详细信息

c208fd793e9f55cd2003c8cc46ce2081.png

笔者在实现分布式锁时,没有从头至尾实现上述全部逻辑,开源项目Curator已经对Zookeeper分布式锁有了成熟稳定实现,在使用上也很简单

1Curator加锁的源代码如下所示 2private boolean internalLock(long time, TimeUnit unit) throws Exception 3{ 4 Thread currentThread = Thread.currentThread(); 5 LockData lockData = threadData.get(currentThread); 6 if ( lockData != null ) 7 { 8 // re-entering 9 lockData.lockCount.incrementAndGet(); 10 return true; 11 } 12 String lockPath = internals.attemptLock(time, unit, getLockNodeBytes()); 13 if ( lockPath != null ) 14 { 15 LockData newLockData = new LockData(currentThread, lockPath); 16 threadData.put(currentThread, newLockData); 17 return true; 18 } 19 return false; 20} 21

4. 释放锁原理

因为Zookeeper分布式锁支持重入性,在重入次数大于1时,unlock的时候Curator都不会向Zookeeper发出删除节点的请求,在重入次数为0时,curator才会真正发出删除请求,Zookeeper删除节点时,也会触发watcher机制,将事件通知给感兴趣的客户端。

1Public void release() throws Exception{ 2 Thread currentThread = Thread.currentThread(); 3 LockData lockData = threadData.get(currentThread); 4 if ( lockData == null ) 5 { 6 throw new IllegalMonitorStateException("You do not own the lock: " + basePath); 7 } 8 int newLockCount = lockData.lockCount.decrementAndGet(); 9 if ( newLockCount > 0 ) 10 { 11 return; 12 } 13 if ( newLockCount < 0 ) 14 { 15 throw new IllegalMonitorStateException("Lock count has gone negative for lock: " + basePath); 16 } 17 try 18 { 19 internals.releaseLock(lockData.lockPath); 20 } 21 finally 22 { 23 threadData.remove(currentThread); 24 } 25} 26 27

5. 读写锁

Zookeeper分布式锁支持读写锁,在进程(线程)创建的Zookeeper临时节点上会标注该进程需要获取锁的类型,读锁创建的临时节点会包含__READ__,而写锁创建的临时节点会包含__WRIT__。对于读锁来说,需要监听比自己序号小的最后一个写请求,而写锁和普通锁一样,都只需要监听比自己序号小的最后一个节点。下图是申请读写锁的全部进程(线程)

5888fef02467ffe972e9ad8625bf3058.png

5.1 锁升级降级

读写锁存在锁升级和降级的问题。

锁升级:从读锁变成写锁

锁降级:从写锁变成读锁

在介绍ZooKeeper读写锁升降级前,我想先介绍下JDK自带的读写锁ReentrantReadWriteLock。Jdk的ReentrantReadWriteLock支持锁降级,而不支持锁升级。细想一下也很好理解,读锁是共享的,例如当前Thread1获取了读锁,此时Thread2,Thread3也申请了读锁,因为读锁是共享的,所以Thread2和Thread3也获取了读锁,如果此时Thread1请求锁升级变成了写锁,那么当前锁的获取情况如下,Thread1获取了写锁,Thread2,Thread3获取了读锁,而写锁是互斥的,这就矛盾了,所以ReentrantReadWrite不支持锁升级也就很好理解了。ReentrantReadWriteLock支持锁降级,在降级成读锁后,如果没有显示使用unlock释放写锁,那么当前线程仍然持有写锁。

我们再回到Zookeeper分布式读写锁,和JDK的ReentrantReadWriteLock一样,Zookeeper分布式读写锁也仅支持锁降级,Zookeeper分布式读写锁和ReentrantReadWriteLock一样,如果进行锁升级会造成死锁的问题。

5.2 饥饿问题

熟悉并发编程的同学可能知道,读写锁存在写锁饿死的情况,简单的说就是读进程获取了读锁,此时的写进程在等待,但是同时又有源源不断的读请求,因为读锁共享,那么写进程永远也拿不到写锁,写锁最终被饿死。Java的读写锁是通过AQS实现的,读锁以共享的方式获取锁,成功获取锁后会向后传播,后续的读线程也能获取到读锁。为了解决写锁饿死的问题,可以指定ReentrantReadWriteLock为公平锁,那么读线程在获取读锁向后传播过程中遇到排队的写请求就会停止。

那么Zookeeper分布式读写锁是如何处理写锁饿死的情况的呢?上文提到,Zookeeper是根据排队序号大小决定取锁的,先排队的写锁的序号肯定大于后排队的读锁的序号,Zookeeper的读锁向后传播过程中在遇到排队的写锁时就会停止,所以Zookeeper分布式读写锁不会存在写饥饿的问题。

6. 缺陷

6.1 性能问题

Zookeeper作为分布式协调系统,不适合作为频繁的读写存储系统。而且我们通过增加zookeeper服务器来提高集群的读写能力也是有上限的,因为Zookeeper集群规模越大,Zookeeper数据需要同步到更多的服务器。同时Zookeeper分布式锁每一次都要申请锁和释放锁,都要动态创建删除临时节点,所以Zookeeper不能维护大量的分布式锁,也不能维护大量的客户端心跳长连接。在分布式定理中,Zookeeper追求的是CP,也就是Zookeeper保证集群向外提供统一的视图,但是Zookeeper牺牲了可用性,在极端情况下,Zookeeper可能会丢弃一些请求。并且Zookeeper集群在进行Leader选举的时候整个集群也是不可用的,集群选举时间长达30 ~ 120s。

6.2 惊群效应

在获取锁的时候有一个细节,客户端在获取锁失败的情况下,会监听/fetch节点。这会存在性能问题,如果在/fetch节点下排队等待的有1000个进程,那么锁持有者释放锁(删除/fetch节点下的临时节点)时,zookeeper会通知监听/fetch的1000个进程。然后这1000个进程会读取zookeeper下/fetch节点的全部临时节点,然后判断自己是否为最小的临时节点。但是这1000个进程中只有一个最小序号的进程会持有分布式锁,也就是说999个进程做的都是无用功。这些无用功会对zookeeper造成较大压力的读负载。Zookeeper作为分布式协调系统,本身不适合频繁的读写。

为了解决惊群效应,需要对Zookeeper分布式锁监听逻辑进行优化,实际上,排队进程真正感兴趣的是比自己临时节点序号小的节点,我们只需要监听序号比自己小的节点。对于Zookeeper分布式锁支持的读写锁来说,有点区别,对于读锁来说,需要监听比自己小的最后一个写请求,而写锁和普通锁一样,都只需要监听比自己序号小的最后一个节点。

7. 总结

同redis分布式锁相比,Zookeeper分布式锁的正确性更好,也就是不会存在两个进程(线程)同时持有同一把分布式锁的可能性。但是和redis动辄每秒二十万写入性能相比,Zookeeper的性能比较差,而且集群也很难实现扩展。在生产环境下,究竟使用Redis分布式锁还是Zookeeper分布式锁,还是需要结合具体业务来定。比如,笔者在分布式锁实现之redis方案一文中举得拉数据的例子,笔者的服务实例并不多,只有四个(Zookeeper负载不大),同时笔者在拉取兄弟部门的数据后,经过处理后写入本地数据库,也没有进行幂等性处理但是却又要求数据精确性,所以笔者在生产环境中使用了Zookeeper分布式锁。

8. 其它

涉及到的相关源代码如下 lan1tian/lock​github.com

ddc41fc8fb4258d1b884caec3ebfc36a.png

关于Redis分布式锁文章如下 蓝天:分布式锁Redis方案​zhuanlan.zhihu.com

代码交流 2021