排他锁
排他锁(Exclusive Locks,简称 X 锁),又称为写锁或独占锁,是一种基本的锁类型。如果事务 对数据对象
, 加上了排他锁,那么在整个加锁期间,只允许事务
对
进行读取和更新操作,其他任何事务都不能再对这个数据对象进行任何类型的操作——直到
释放了排他锁。
从上面讲解的排他锁的基本概念中,我们可以看到,排他锁的核心是如何保证当前有且仅有一个事务获得锁,并且锁被释放后,所有正在等待获取锁的事务都能够被通知到。
下面我们就来看看如何借助 ZooKeeper 实现排他锁。
定义锁
在通常的 Java 开发编程中,有两种常见的方式可以用来定义锁, 分别是 synchronized 机制和 JDK5 提供的 ReentrantLock。然而,在 ZooKeeper 中,没有类似于这样的 API 可以直接使用,而是通过 ZooKeeper 上的数据节点来表示一个锁,例如/exclusive_lock/lock节点就可以被定义为一个锁,如图 6-14 所示。
获取锁
在需要获取排他锁时,所有的客户端都会试图通过调用 create()接口,在/exclusive_lock 节点下创建临时子节点/exclusive_lock/lock。,ZooKeeper 会保证在所有的客户端中,最终只有一个客户端能够创建成功,那么就可以认为该客户端获取了锁。同时,所有没有获取到锁的客户端就需要到/exclusive_lock 节点上注册一个子节点变更的 Watcher 监听,以便实时监听到 lock 节点的变更情况。
释放锁
在“定义锁”部分,我们已经提到,/exclusive_lock/lock 是一个临时节点,因此在以下两种情况下,都有可能释放锁。
- 当前获取锁的客户端机器发生宕机,那么 ZooKeeper 上的这个临时节点就会被移除。
- 正常执行完业务逻辑后,客户端就会主动将自己创建的临时节点删除。
无论在什么情况下移除了 lock 节点,ZooKeeper 都会通知所有在/exclusive_lock 节点上注册了子节点变更 Watcher 监听的客户端。这些客户端在接收到通知后,再次重新发起分布式锁获取, 即重复“获取锁”过程。整个排他锁的获取和释放流程。
共享锁
共享锁(Shared Locks,简称S 锁),又称为读锁,同样是一种基本的锁类型。如果事务对数据对象
加上了共享锁,那么当前事务只能对
, 进行读取操作,其他事务也只能对这个数据对象加共享锁——直到该数据对象上的所有共享锁都被释放。
共享锁和排他锁最根本的区别在于,加上排他锁后,数据对象只对一个事务可见,而加上共享锁后,数据对所有事务都可见。下面我们就来看看如何借助 ZooKeeper 来实现共享锁。
定义锁
和排他锁一样,同样是通过 ZooKeeper 上的数据节点来表示一个锁,是一个类似于* /shared_lock/[Hostname]- 请求类型-序号”的临时顺序节点,例如/shared_lock/192.168.0.1-R-0000000001,那么,这个节点就代表了一个共享锁,如图 6-16 所示。
获取锁
在需要获取共享锁时,所有客户端都会到/shared_lock 这个节点下面创建一个临时顺序节点
- 如果当前是读请求,那么就创建例如/shared_lock/192.168.0.1-R-0000000001 的节点;
- 如果是写请求,那么就创建例如/shared_lock/192.168.0.1-W-0000000001 的节点。
判断读写顺序
根据共享锁的定义,不同的事务都可以同时对同一个数据对象进行读取操作,而更新操作必须在当前没有任何事务进行读写操作的情况下进行。基于这个原则,我们来看看如何通过 ZooKeeper 的节点来确定分布式读写顺序,大致可以分为如下4个步骤。
- 创建完节点后,获取/shared_lock 节点下的所有子节点,并对该节点注册子节点变更的 Watcher 监听。
- 确定自己的节点序号在所有子节点中的顺序
-
- 对于读请求:
- 如果没有比自己序号小的子节点,或是所有比自己序号小的子节点都是读请求,那么表明自己已经成功获取到了共享锁,同时开始执行读取逻辑。
- 如果比自己序号小的子节点中有写请求,那么就需要进入等待。
- 对于写请求:
- 如果自己不是序号最小的子节点,那么就需要进入等待。
- 对于读请求:
- 接收到 Watcher 通知后,重复步骤1。
释放锁
释放锁的逻辑和排他锁是一致的,这里不再赘述。整个共享锁的获取和释放流程,可以用图 6-17 来表示。
羊群效应
上面讲解的这个共享锁实现,大体上能够满足一般的分布式集群竞争锁的需求,并且性能都还可以——这里说的一般场景是指集群规模不是特别大,一般是在 10 台机器以内。
但是如果机器规模扩大之后,会有什么问题呢?我们着重来看上面“判断读写顺序”过程的步骤 3,结合图 6-18 给出的实例,看看实际运行中的情况。
针对图 6-18 中的实际情况,我们看看会发生什么事情。
- 192.168.0.1 这台机器首先进行读操作,完成读操作后将节点/192.168.0.1-R-0000000001 删除。
- 余下的4台机器均收到了这个节点被移除的通知,然后重新从/shared_lock 节点上获取一份新的子节点列表。
- 每个机器判断自己的读写顺序。其中 192.168.0.2 这台机器检测到自己已经是序号最小的机器了,于是开始进行写操作,而余下的其他机器发现没有轮到自己进行读取或更新操作,于是继续等待。
- 继续……
上面这个过程就是共享锁在实际运行中最主要的步骤了,我们着重看下上面步骤3中提到的:“而余下的其他机器发现没有轮到自己进行读取或更新操作,于是继续等待。”很明显,我们看到,192.168.0.1 这个客户端在移除自己的共享锁后,ZooKeeper 发送了子节点变更 Watcher 通知给所有机器,然而这个通知除了给 192.168.0.2 这台机器产生实际影响外,对于余下的其他所有机器都没有任何作用。
相信读者也已经意识到了,在这整个分布式锁的竞争过程中,大量的“Watcher 通知”和“子节点列表获取”两个操作重复运行,并且绝大多数的运行结果都是判断出自己并非是序号最小的节点,从而继续等待下一次通知——这个看起来显然不怎么科学。客户端无端地接收到过多和自己并不相关的事件通知,如果在集群规模比较大的情况下,不仅会对 ZooKeeper 服务器造成巨大的性能影响和网络冲击,更为严重的是,如果同一时间有多个节点对应的客户端完成事务或是事务中断引起节点消失,ZooKeeper 服务器就会在短时间内向其余客户端发送大量的事件通知——这就是所谓的羊群效应。上面这个 ZooKeeper 分布式共享锁实现中出现羊群效应的根源在于, 没有找准客户端真正的关注点。我们再来回顾一下上面的分布式锁竞争过程,它的核心逻辑在于:判断自己是否是所有子节点中序号最小的。于是,很容易可以联想到,每个节点对应的客户端只需要关注比自己序号小的那个相关节点的变更情况就可以了——而不需要关注全局的子列表变更情况。
改进后的分布式锁实现
现在我们来看看如何改进上面的分布式锁实现。首先,我们需要肯定的一点是,上面提到的共享锁实现,从整体思路上来说完全正确。这里主要的改动在于:每个锁竞争者,只需要关注/shared_lock节点下序号比自己小的那个节点是否存在即可,具体实现如下。
- 客户端调用 create()方法创建一个类似于“/shared_lock/[Hostname]-请求类型-序号”的临时顺序节点。
- 客户端调用 getChildren()接口来获取所有已经创建的子节点列表,注意,这里不注册任何 Watcher。
- 如果无法获取共享锁, 那么就调用 exist()来对比自己小的那个节点注册 Watcher。注意,这里“比自己小的节点”只是一个笼统的说法,具体对于读请求和写请求不一样。
-
- 读请求:向比自己序号小的最后一个写请求节点注册 Watcher 监听。
- 写请求:向比自己序号小的最后一个节点注册 Watcher 监听。
- 等待 Watcher 通知,继续进入步骤 2。
改进后的分布式锁流程如图 6-19 所示。
看到这里,相信很多读者都会觉得改进后的分布式锁实现相对来说比较麻烦。确实如此,如同在多线程并发编程实践中,我们会去尽量缩小锁的范围——对于分布式锁实现的改进其实也是同样的思路。那么对于开发人员来说,是否必须按照改进后的思路来设计实现自己的分布式锁呢?答案是否定的。在具体的实际开发过程中,我们提倡根据具体的业务场景和集群规模来选择适合自己的分布式锁实现:在集群规模不大、网络资源丰富的情况下,第一种分布式锁实现方式是简单实用的选择;而如果集群规模达到一定程度,并且希望能够精细化地控制分布式锁机制,那么不妨试试改进版的分布式锁实现。