分布式锁
分布式锁是控制分布式系统之间同步访问共享资源的⼀种⽅式。如果不同的系统或是同⼀个系统的不同主机之间共享了⼀个或⼀组资源,那么访问这些资源的时候,往往需要通过⼀些互斥⼿段来防⽌彼此之间的⼲扰,以保证⼀致性。
来看看使⽤ZooKeeper如何实现分布式锁,这⾥主要介绍
排他锁
和
共享锁
两类分布式锁。
排他锁
排他锁(Exclusive Locks,简称 X 锁),⼜称为写锁或独占锁,核⼼是如何保证当前有且仅有⼀个事务获得锁,并且锁被释放后,所有正在等待获取锁的事务都能够被通知到。例如:如果事务 T1对数据对象 O1加上了排他锁,那么在整个加锁期间,只允许事务 T1对 O1进⾏读取和更新操作,其他任何事务都不能再对这个数据对象进⾏任何类型的操作——直到T1释放了排他锁。
下面面介绍如何借助ZooKeeper实现排他锁:
① 定义锁
在通常的开发编程中,有两种常⻅的⽅式可以⽤来定义锁,分别是synchronized机制和JDK5提供的ReentrantLock。然⽽,在ZooKeeper中,没有类似于这样的API可以直接使⽤,⽽是通过 ZooKeeper上的数据节点来表示⼀个锁,例如
/exclusive_lock/lock
节点就可以被定义为⼀个锁,如图:
② 获取锁
在需要获取排他锁时,所有的客户端都会试图通过调⽤
create()
接⼝,在
/exclusive_lock
节点下创建临时⼦节点
/exclusive_lock/lock
。ZooKeeper 会保证在所有的客户端中,最终只有⼀个客户端能够创建成功,那么就可以认为该客户端获取了锁。同时,所有没有获取到锁的客户端就需要到
/exclusive_lock
节点上注册⼀个⼦节点变更的Watcher监听,以便实时监听到lock节点的变更情况
③释放锁
在
定义锁
部分,因为/exclusive_lock/lock 是⼀个临时节点,所以释放锁有两种情况:
①当前获取锁的客户端机器发⽣宕机,临时节点消失
②正常执⾏完业务逻辑后,客户端就会主动将⾃⼰创建的临时节点删除
。
⽆论在什么情况下移除了lock节点,ZooKeeper都会通知所有在
/exclusive_lock
节点上注册了⼦节点变更Watcher监听的客户端。这些客户端在接收到通知后,再次重新发起分布式锁获取,即重复
获取锁
过程。整个排他锁的获取和释放流程,如下图:
共享锁
共享锁(Shared Locks,简称S锁),⼜称为读锁。例如:如果事务T1对数据对象O1加上了共享锁,那么当前事务只能对O1进⾏读取操作,其他事务也只能对这个数据对象加共享锁——直到该数据对象上的所有共享锁都被释放。共享锁和排他锁最根本的区别在于,加上排他锁后,数据对象只对⼀个事务可⻅,⽽加上共享锁后,数据对所有事务都可⻅。
下面介绍如何借助ZooKeeper来实现共享锁:
① 定义锁
和排他锁⼀样,同样是通过 ZooKeeper 上的数据节点来表示⼀个锁,是⼀个类似于
/shared_lock/[Hostname]-请求类型-序号
的临时顺序节点,例如
/shared_lock/host1-R-0000000001
,那么,这个节点就代表了⼀个共享锁,如图所示:
② 获取锁
在需要获取共享锁时,所有客户端都会到
/shared_lock
这个节点下⾯创建⼀个临时顺序节点,如果当前是读请求,那么就创建例如
/shared_lock/host1-R-0000000001
的节点;如果是写请求,那么就创建例如
/shared_lock/host2-W-0000000002
的节点。
判断读写顺序
通过Zookeeper来确定分布式读写顺序,⼤致分为四步
1. 创建完节点后,获取/shared_lock节点下所有⼦节点,并对该节点变更注册监听。
2. 确定⾃⼰的节点序号在所有⼦节点中的顺序。
3. 对于读请求:若没有⽐⾃⼰序号⼩的⼦节点或所有⽐⾃⼰序号⼩的⼦节点都是读请求,那么表明⾃⼰已经成功获取到共享锁,同时开始执⾏读取逻辑,若有写请求,则需要等待。对于写请求:若⾃⼰不是序号最⼩的⼦节点,那么需要等待。
4. 接收到Watcher通知后,重复步骤1
③ 释放锁
其释放锁的流程与独占锁⼀致。
弊端:⽺群效应
上⾯讲解的这个共享锁实现,⼤体上能够满⾜⼀般的分布式集群竞争锁的需求,并且性能都还可以;这⾥说的⼀般场景是指集群规模不是特别⼤,⼀般是在10台机器以内。但是如果机器规模扩⼤之后,会有什么问题呢?着重来看上⾯“判断读写顺序”过程的步骤3,结合下⾯的图,看看实际运⾏中的情况
针对如上图所示的情况进⾏分析
-
host1⾸先进⾏读操作,完成后将节点
/shared_lock/host1-R-00000001
删除。 -
余下4台机器均收到这个节点移除的通知,然后重新从
/shared_lock
节点上获取⼀份新的⼦节点列表。 - 每台机器判断⾃⼰的读写顺序,其中host2检测到⾃⼰序号最⼩,于是进⾏写操作,余下的机器则继续等待。
- 继续…
可以看到,host1客户端在移除⾃⼰的共享锁后,Zookeeper发送了⼦节点更变Watcher通知给
所有机器
,然⽽除了给host2产⽣影响外,对其他机器没有任何作⽤。⼤量的Watcher通知和⼦节点列表获取两个操作会重复运⾏,这样不仅会对zookeeper服务器造成巨⼤的性能影响影响和⽹络开销,更为严重的是,如果同⼀时间有多个节点对应的客户端完成事务或是事务中断引起节点消失,ZooKeeper服务器就会在短时间内向其余客户端发送⼤量的事件通知,这就是所谓的
⽺群效应
。
上⾯这个ZooKeeper分布式共享锁实现中出现⽺群效应的根源在于,没有找准客户端真正的关注点。我们再来回顾⼀下上⾯的分布式锁竞争过程,它的核⼼逻辑在于:判断⾃⼰是否是所有⼦节点中序号最⼩的。于是,很容易可以联想到,每个节点对应的客户端只需要关注⽐⾃⼰序号⼩的那个相关节点的变更情况就可以了——⽽不需要关注全局的⼦列表变更情况。
可以有如下改动来避免⽺群效应。
改进后的分布式锁实现
⾸先,我们需要肯定的⼀点是,上⾯提到的共享锁实现,从整体思路上来说完全正确。这⾥主要的改动在于:每个锁竞争者,只需要关注
/shared_lock
节点下序号⽐⾃⼰⼩的那个节点是否存在即可,具体实现如下。
-
客户端调⽤
create()
接⼝常⻅类似于
/shared_lock/[Hostname]-请求类型-序号
的临时顺序节点。 -
客户端调⽤
getChildren()
接⼝获取所有已经创建的⼦节点列表(不注册任何Watcher)。 -
如果⽆法获取共享锁,就调⽤
exist()
接⼝来对⽐⾃⼰⼩的节点注册Watcher。对于读请求:向⽐⾃⼰序号⼩的最后⼀个写请求节点注册Watcher监听。对于写请求:向⽐⾃⼰序号⼩的最后⼀个节点注册Watcher监听。 - 等待Watcher通知,继续进⼊步骤2。
此⽅案改动主要在于:每个锁竞争者,只需要关注
/shared_lock
节点下序号⽐⾃⼰⼩的那个节点是否存在即可。
注意
相信很多同学都会觉得改进后的分布式锁实现相对来说⽐较麻烦。确实如此,如同在多线程并发编程实践中,我们会去尽量缩⼩锁的范围——对于分布式锁实现的改进其实也是同样的思路。那么对于开发⼈员来说,是否必须按照改进后的思路来设计实现⾃⼰的分布式锁呢?答案是否定的。在具体的实际开发过程中,我们提倡根据具体的业务场景和集群规模来选择适合⾃⼰的分布式锁实现:在集群规模不⼤、⽹络资源丰富的情况下,第⼀种分布式锁实现⽅式是简单实⽤的选择;⽽如果集群规模达到⼀定程度,并且希望能够精细化地控制分布式锁机制,那么就可以试试改进版的分布式锁实现。