一、前言
分布式锁业界用的比较多的是redis和zookeeper作为锁的介质实现的。redis有比较成熟的开箱即用的分布式锁工具如Redisson,而zookeeper也有Curator实现了分布式锁并可以开箱即用,但是遗憾的是这两者都没实现读写、公平锁与非公平锁。根本原因可能是没找到一套可实现读写、公平与非公平的元语设计分布式锁,笔者在这里尝试用zookeeper提供的基本操作和网上一些对于zookeeper实现锁的思路推导下这个读写、公平与非公平锁的元语。
二、分布式锁元语设计
什么是分布式锁元语?我们可以理解为我们设计一套分布式锁方案其实是在做一道菜,做菜需要各种原始食材如鸡肉、葱、姜、酱油等,在这些食材的基础上我们可以做不同的菜,而分布式锁的加读锁、加写锁、释放锁等操作就是我们要做的菜,分布式锁就是加读锁、写锁、释放锁等的集合的一桌菜。
那zookeeper为我们提供了什么食材?
1.zookeeper根据paxos算法为我们提供了一个强一致性的基础环境。不了解的同学可以异步到一致性算法了解下强一致性的原理。
2.从用户的角度看zookeeper,其实zookeeper是一个树状结构的文件系统,这个文件系统的粒度是节点,而节点的性质有永久性的节点和临时性的节点,如果某个客户端对zookeeper创建了临时节点,则如果客户端与zookeeper断开了连接zookeeper会自动删除刚刚客户端创建的节点。
3.zookeeper为我们提供了创建顺序节点的操作。如果客户端要创建的是顺序节点 名称是“node”,zookeeper会为这个名称加上一个后缀代表顺序,如果“node00001”。
4.zookeeper为我们提供了原子性的更新数据操作。zookeeper的每个节点是有一个版本的,如果客户端想要更新某个节点的数据,客户端需要先获取这个节点原来的版本,然后客户端更新的时候需要带上这个版本,zookeeper发现如果这个客户端想要更新这个节点的数据,会对比客户端带上来的这个版本跟目前在zookeeper上的版本是否一致,如果不一致代表已经有其他的客户端更改过这个节点。这个跟JDK中的CAS是类似的。
5.监听机制。客户端可以监听zookeeper的变化事件,如节点删除事件,节点内容变化事件。
我们利用这些食材可以做什么?
1.读锁监听写锁
如上图1.1所示,lock_path是zookeeper的一个节点的名称,这个节点名称代表一个资源(锁)的名称,而下面四个节点代表四个客户端都想要获取锁。
看看客户端节点的命名,如第一个节点A的命名是 w_identifyId_num,实际上这个命名代表的含义是 “读写锁类型_客户端唯一标识_代表客户端的节点在lock_path下面的顺序”,而上图假设的是A,B,C,D是升序的,读写锁类型分别是w、r、r、r。
可以看出来因为A节点是写锁,并且处于lock_path下面的第一个子节点,因此此时A节点是占有锁的,B、C、D是读锁,且因为写锁A正在持有锁,所以B、C、D不能占有锁,只能等待,等待的方式是向A节点注册一个监听A节点是否删除的watch,当A节点删除后,B、C、D会立马收到通知,而因为B、C、D都是读锁,因此这三个节点同时获得锁。根据上面的逻辑,我们完成了读锁监听写锁的这道菜。
2.写锁监听写锁
如图1.2,节点的含义跟上面读锁监听写锁的含义是一样的,因为写锁之间不能同时获得锁,因此需要每个写锁都监听代表本客户端的节点的前一个节点的删除事件,当前面的节点删除后证明前一个写锁释放,本客户端即可获取锁,并做一系列业务操作,做完后释放锁。根据这个逻辑即可实现写锁监听写锁这道菜。
3.写锁监听读锁
有了上面两个例子,我猜读者们觉得写锁监听读锁是一个很简单的操作。如果第一个节点是A读锁,此时A占有锁,第二个节点B是写锁,那是不是B节点注册一个监听A删除的事件等待A锁删除就可以了?我们来考虑下面这种情况
如上图1.3,读锁A正在持有锁,写锁B在A后面监听着A的删除事件,此时有一个读锁C节点也要想获取锁,按照我们对读写锁的理解,此时读锁正在占有锁,C也是读锁,理应C也可以获取锁,那现在我们假设C也获取锁并且C在自己的客户端做自己的业务,此时A释放了锁也就是删除了A节点,之后B收到了通知并获取了锁并在自己的客户端做自己的业务,此时会出现一个问题,就是B(写锁)、C(读锁)同时在持有锁。
有没有解决的办法呢?当然有,很简单的一个办法就是C客户端发现此时读锁A在持有锁,C不会立刻占有锁,而是继续判断本客户端的节点与队首之间是否有写锁在等待,如果有则不获取锁,而是注册监听一个C节点前面最近的一个写锁节点B的删除事件。简单来说就是读锁只会在队首到本客户端节点之间都是读节点,才会第一时间获取锁,否则会监听距离本客户端前面距离最近的写锁节点。这个我称它为公平锁。
但是这种方式有个缺点就是上面说的,按照我们对读写锁的理解如果某个读锁C想获得锁,而当时刚好又是读锁A占有锁,则C应该可以立马占有锁的,而上面这种方式确实不可以这样,那有没有方法实现这种逻辑呢?有的,不过需要调整下上面说的方案。
还是刚刚这种情况,不过我们变了下读锁获取锁的规则,之前是读锁判断自己符合规则(如判断自己是否是队头或者判断此刻是否有读锁持有锁)后直接占有锁,然后在客户端做自己的业务逻辑,其他节点是无法感知当时有多少个读锁在持有锁的。现在我们改的规则是创建lock_path的时候需要指定一个0作为内容,我称它为read_count,代表某个时刻锁的读锁持有个数,读锁判断自己符合获取锁的规则后,需要原子性的对read_count 加1,而写锁获取锁的时候也需要增加一个条件,当删除事件触发写锁判断自己是队头的时候,需要获取read_count的值,并判断read_count是否为0,如果大于0则说明此时还有其他读锁在持有锁,写锁需要继续监听read_count的数据变化事件,每次read_count变化写锁都需要判断read_count是否等于0,如果等于0则写锁占有锁。
可能读者看了上面这段话有点懵,没关系,我们看看上图1.4,用上面的图来举例子。A是第一个要获取锁的客户端,因此需要先创建一个lock_path代表锁资源,赋值lock_path的值read_count为0,然后客户端A(读锁)在lock_path下面创建一个前缀为r_的临时循序子节点,然后客户端A判断自己是队头后,需要对read_count进行原子性加1操作,也就是read_count赋值为1,然后才可以占有锁。此时客户端B(写锁)在lock_path下也添加了个临时顺序子节点,然后B判断自己不是队头后,监听B前面的节点A的删除事件,此时客户端C(读锁)也要获取锁,然后判断自己是否是队头(如果是则获取锁),如果不是再判断下read_count的值是否大于0,如果大于0则说明此时是读锁持有锁,则C对read_count进行原子性的加1,成功后获取锁,此时客户端A完成了自己的业务释放了锁删除了对应的节点,触发了客户端B,B判断了自己是队头后也并没有立刻获取锁,而是先去lock_path下获取read_count的值,判断是否等于0,如果read_count大于0则说明还有其他客户端在持有锁,需要继续监听read_count的变化事件,直到read_count值等于0,B才可以占有锁,这样就解决了B(写)、C(读)同时占有锁的问题。这个我成它为非公平锁,因为当A获占有锁的时候,如果后面持续的有读锁加入,写锁B是有可能一直得不到锁的。
需要注意的是C对read_count加1是有可能失败的。zookeeper的node节点更新数据是需要带上版本的,而zookeeper对某个节点每做一次数据更新,都会在节点的版本version加1的。如C获取了lock_path的版本是2,并且判断read_count是1,是大于0的,因此C想要对read_count进行原子性加1,也就是在1的基础上加1赋值成2,但是因为客户端C要对zookeeper操作中间是有网络的过程的,是有延时的,如果在这过程之间,A释放了锁对read_count减1赋值为0,zookeeper对lock_path的version进行了加一操作变成3,然后触发了客户端B,B获取read_count,发现read_count等于0,于是B占有了锁,此时C的加1操作经过一定的网络延迟到达了zookeeper服务,要更新read_count的值为2,但是zookeeper发现C带上来的version是2,而现在的lock_path节点的version是3,会向客户端C返回一个异常,C获取锁失败,因此这个时候C会监听B的删除事件。其中C用version去更新read_count就是上面说的原子性加1。
至此我们完成了“写锁监听读锁”这道菜的设计,口味有公平锁和非公平锁,读者可以看菜吃饭。
4.重入锁
重入锁的在分布式锁的概念就是某个客户端可以反复的对同一个资源进行加锁,操作很简单,就是客户端在自己的客户端里面做一个reentranLockCount标识就可以了。
5.锁释放
锁的释放这里需要区分读锁和写锁,写锁的释放可以先删掉本客户端对应的节点,然后尝试去删lock_path,如果lock_path下面还有其他节点证明还有其他客户端在等待所,zookeeper是会返回一个删除异常的。而读锁的释放需要先删掉自己客户端对应的节点,然后对lock_path的read_count值原子性的减1操作,然后再尝试去删除lock_path。
三、实现分布式锁
上面针对分布式锁的食材和制作的方法论都已经准备好了,下面需要真正动手了。在这里笔者已经实现了一下,先放一个UML图
如果看过JDK关于锁的实现的同学来说,这个uml图一定比较熟悉,这个是就是模仿里面的ReentrantReadWriteLock写的。核心的类是AbstractZkSynchronizer,里面的主要方法是封装了一些zookeeper的操作,而AbstractSync类继承AbstractZkSynchronizer,这个类主要的功能是将一些lock、tryLock等动作转化为对zookeeper的操作,FairSync和NonFairSync都是继承AbstractSync,分别是公平锁与非公平锁的实现。而WriteLock和ReadLock都实现了ZkLock的接口,同样是lock方法它们会根据自己是读锁还是写锁去调AbstractSync的readLock(int) 和 acquire(int)方法。
笔者在这里会贴出主要的实现公平与非公平的写锁和读锁的代码和流程图,感兴趣的小伙伴可以到我的github看详细的代码,连接会在最后贴出来。
1.公平与非公平写锁lock()实现
上图2.2是写锁的实现,公平读锁和非公平读锁都是这个流程,因为我们定义的公平与非公平取决于读锁加锁的流程。下面再贴一下这个写锁对应的主要代码
acquire(int)是写锁lock()的委托实现。可以看出来客户端一进来这个方法会首先根据isOwnerLock()判断下本客户端是否已经持有过锁,如果已经持有锁就会通过addReenTranLock(i)对锁计数器进行加1。如果客户端还没持有锁就会进入下一个逻辑,首先会用existsLockPath()判断是否已经存在lock_path,如果不存在就自己创建一个这样的节点,然后通过addChildren(String)方法在lock_path下添加一个代表本客户端的临时顺序子节点,并返回创建的节点的名称,之后进入一个while循环,直到客户端持有锁后才退出。而while循环的逻辑是先通过getChildrenList()获取lock_path下所有的子节点,然后判断自己是否第一个节点,如果是需要再判断read_count是否为0,如果是就可以获得锁并跳出while循环,如果read_count大于0则通过watchReadCount()监听lock_path的read_count变化事件,有变化事件后就进入下一次循环,如果不是第一个节点就需要通过watchPreviousNode(String)监听自己的客户端对应的节点的前一个节点的节点删除事件,直到有删除事件发生就进入下一次循环判断。
2.非公平读锁lock()实现
上图2.4是实现非公平读锁的流程图。笔者认为这个流程图的逻辑是比较清晰的,但是有个地方可能会有疑惑,就是监听离本客户端代表的节点的最近的一个写节点会有两种结果,一种是监听成功,一种是监听失败,但是无论失败还是成功最终的结果都是会重新进入“获取锁路径下所有的子节点并由小到大排序”的这个步骤,其实很容易理解,如果监听成功,并且成功等待到写节点的删除事件,证明本客户端是有机会获取锁的所以会重复进入刚说的步骤,而监听不成功是有可能你监听前这个写节点在监听之前就已经被删除了,所以还是会进入刚说的那个逻辑。
上图2.5是非公读锁的主要代码。代码结构跟上面写锁的主要方法acquire(int)是类似的,这里主要介绍attemptNonFairLock(String)
attemptNonFairLock(String)是尝试用非公平的方式获取读锁的实现,其中依赖startupAddReadLockCount() 和 waitProcessAddReadLockCount()方法,startupAddReadLockCount() 是当读锁判断自己是队头的时候要先对read_count进行原子性加1操作, waitProcessAddReadLockCount()是当读锁节判断目前read_count大于0的时候需要对read_count进行原子性加1操作,当在方法外头判断read_count大于0的时候进去waitProcessAddReadLockCount()方法的时候read_count是有可能被其他客户端操作过的,因此在waitProcessAddReadLockCount()需要在判断一次read_count的值,如果等于0,则返回加1操作失败。
2.公平读锁lock()实现
图2.7、图2.8分别是公平读锁的流程图和代码。可以看出公平与非公平的流程是非常类似的,不同点是公平读锁判断到如果read_count大于0的时候不会去立马尝试对read_count进行原子性加1,而是会再判断本客户端节点与首节点之间是否存在写锁,如果存在写锁就算read_count大于0也不获取锁,而是选择监听距离本客户端前面最近的写节点进行监听。
3.非公平读锁tryLock(long,TimeUnit)
图2.9与图2.10分别是非公平读锁的tryLock(long, TimeUnit)的流程图和代码实现,其中tryReadLock(int, long,TimeUnit)是tryLock(long, TimeUnit)的委托实现。超时的机制主要依赖在while循环的条件里判断是否超时,我们再看看watchPreviousNode(String, long, TimeUnit)方法
watchPreviousNode(String, long, TimeUnit)的功能是监控前面写节点的删除事件,在阻塞式获取锁的情况下,是会一直阻塞在监听删除事件这件事上的,但是这里我们通过CountDownLatch的await(long, TimeUnit)有限时间等待这个节点删除时间,如果超时了就会继续往下执行,然后在外层的while循环判断已经超时就会获取锁失败。
公平锁和写锁的tryLock(long,TimeUnit) 和tryLock()跟上面的非公平读锁tryLock(long,TimeUnit)的思路是类似的,感兴趣的小伙伴可以看下我在的github项目上的实现,这里就不贴流程图和代码了。
四、最后的话
以上是笔者对于zookeeper实现支持读、写、公平、非公平的分布式锁一些个人理解。如有逻辑错误、笔误、表述不清等或者读者有不理解的地方请在评论区或者通过邮箱[email protected]与我交流。
笔者用zookeeper实现的分布式锁对应的github项目地址:https://github.com/mirrormirrormirror/zkLock