Zookeeper 面试题

一、ZooKeeper 基础题

1.1、Zookeeper 的典型应用场景

Zookeeper 是一个典型的发布/订阅模式的分布式数据管理与协调框架,开发人员可以使用它来进行分布式数据的发布和订阅。

通过对 Zookeeper 中丰富的数据节点进行交叉使用,配合 Watcher 事件通知机制,可以非常方便的构建一系列分布式应用,涉及到的核心功能,如:

  • 数据发布/订阅

  • 负载均衡

  • 命名服务

  • 分布式协调/通知

  • 集群管理

  • Master 选举

  • 分布式锁

  • 分布式队列

数据发布/订阅

介绍

数据发布/订阅系统,即所谓的配置中心,顾名思义就是发布者发布数据供订阅者进行数据订阅。

目的

动态获取数据(配置信息)

实现数据(配置信息)的集中式管理和数据的动态更新

设计模式

Push 模式

Pull 模式

数据(配置信息)特性

(1)数据量通常比较小

(2)数据内容在运行时会发生动态更新

(3)集群中各机器共享,配置一致

如:机器列表信息、运行时开关配置、数据库配置信息等

基于 Zookeeper 的实现方式

  • 数据存储:将数据(配置信息)存储到 Zookeeper 上的一个数据节点

  • 数据获取:应用在启动初始化节点从 Zookeeper 数据节点读取数据,并在该节点上注册一个数据变更 Watcher

  • 数据变更:当变更数据时,更新 Zookeeper 对应节点数据,Zookeeper会将数据变更通知发到各客户端,客户端接到通知后重新读取变更后的数据即可。

负载均衡

zk 的命名服务

命名服务是指通过指定的名字来获取资源或者服务的地址,利用 zk 创建一个全局的路径,这个路径就可以作为一个名字,指向集群中的集群,提供的服务的地址,或者一个远程的对象等等。

分布式通知和协调

对于系统调度来说:操作人员发送通知实际是通过控制台改变某个节点的状态,然后 zk 将这些变化发送给注册了这个节点的 watcher 的所有客户端。

对于执行情况汇报:每个工作进程都在某个目录下创建一个临时节点。并携带工作的进度数据,这样汇总的进程可以监控目录子节点的变化获得工作进度的实时的全局情况。

zk 的命名服务(文件系统)

命名服务是指通过指定的名字来获取资源或者服务的地址,利用 zk 创建一个全局的路径,即是唯一的路径,这个路径就可以作为一个名字,指向集群中的集群,提供的服务的地址,或者一个远程的对象等等。

zk 的配置管理(文件系统、通知机制)

程序分布式的部署在不同的机器上,将程序的配置信息放在 zk 的 znode 下,当有配置发生改变时,也就是 znode 发生变化时,可以通过改变 zk 中某个目录节点的内容,利用 watcher 通知给各个客户端,从而更改配置。

Zookeeper 集群管理(文件系统、通知机制)

所谓集群管理无在乎两点:是否有机器退出和加入、选举 master。

对于第一点,所有机器约定在父目录下创建临时目录节点,然后监听父目录节点

的子节点变化消息。一旦有机器挂掉,该机器与 zookeeper 的连接断开,其所创建的临时目录节点被删除,所有其他机器都收到通知:某个兄弟目录被删除,于是,所有人都知道:它上船了。

新机器加入也是类似,所有机器收到通知:新兄弟目录加入,highcount 又有了,对于第二点,我们稍微改变一下,所有机器创建临时顺序编号目录节点,每次选取编号最小的机器作为 master 就好。

Zookeeper 分布式锁(文件系统、通知机制)

有了 zookeeper 的一致性文件系统,锁的问题变得容易。锁服务可以分为两类,一个是保持独占,另一个是控制时序。

对于第一类,我们将 zookeeper 上的一个 znode 看作是一把锁,通过 createznode的方式来实现。所有客户端都去创建 /distribute_lock 节点,最终成功创建的那个客户端也即拥有了这把锁。用完删除掉自己创建的 distribute_lock 节点就释放出锁。

对于第二类, /distribute_lock 已经预先存在,所有客户端在它下面创建临时顺序编号目录节点,和选 master 一样,编号最小的获得锁,用完删除,依次方便。

Zookeeper 队列管理(文件系统、通知机制)

两种类型的队列:

(1)同步队列,当一个队列的成员都聚齐时,这个队列才可用,否则一直等待所有成员到达。

(2)队列按照 FIFO 方式进行入队和出队操作。

第一类,在约定目录下创建临时目录节点,监听节点数目是否是我们要求的数目。

第二类,和分布式锁服务中的控制时序场景基本原理一致,入列有编号,出列按编号。在特定的目录下创建 PERSISTENT_SEQUENTIAL 节点,创建成功时Watcher 通知等待的队列,队列删除序列号最小的节点用以消费。此场景下Zookeeper 的 znode 用于消息存储,znode 存储的数据就是消息队列中的消息内容,SEQUENTIAL 序列号就是消息的编号,按序取出即可。由于创建的节点是持久化的,所以不必担心队列消息的丢失问题。

1.2、什么是 ZooKeeper

ZooKeeper 是一个开放源码的分布式协调服务,它是集群的管理者,监视着集群中各个节点的状态,根据节点提交的反馈进行下一步合理操作。最终,将简单易用的接口和性能高效、功能稳定的系统提供给用户。

分布式应用程序可以基于 Zookeeper 实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能。

Zookeeper 保证了如下分布式一致性特性:

  • 顺序一致性

  • 原子性

  • 单一视图

  • 可靠性

  • 实时性(最终一致性)

客户端的读请求可以被集群中的任意一台机器处理,如果读请求在节点上注册了监听器,这个监听器也是由所连接的 zookeeper 机器来处理。对于写请求,这些请求会同时发给其他 zookeeper 机器并且达成一致后,请求才会返回成功。因此,随着 zookeeper 的集群机器增多,读请求的吞吐会提高但是写请求的吞吐会下降。

有序性是 zookeeper 中非常重要的一个特性,所有的更新都是全局有序的,每个更新都有一个唯一的时间戳,这个时间戳称为 zxid(Zookeeper Transaction Id)。而读请求只会相对于更新有序,也就是读请求的返回结果中会带有这个zookeeper 最新的 zxid。

1.3、ZooKeeper 提供了什么?

  • 文件系统

  • 通知机制

1.4、Zookeeper 文件系统

Zookeeper 提供一个多层级的节点命名空间(节点称为 znode)。与文件系统不同的是,这些节点都可以设置关联的数据,而文件系统中只有文件节点可以存放数据而目录节点不行。

Zookeeper 为了保证高吞吐和低延迟,在内存中维护了这个树状的目录结构,这种特性使得 Zookeeper 不能用于存放大量的数据,每个节点的存放数据上限为1M。

1.5、ZAB 协议?

ZAB 协议是为分布式协调服务 Zookeeper 专门设计的一种支持崩溃恢复的原子广播协议。

ZAB 协议包括两种基本的模式:崩溃恢复和消息广播。

当整个 zookeeper 集群刚刚启动或者 Leader 服务器宕机、重启或者网络故障导致不存在过半的服务器与 Leader 服务器保持正常通信时,所有进程(服务器)进入崩溃恢复模式,首先选举产生新的 Leader 服务器,然后集群中 Follower 服务器开始与新的 Leader 服务器进行数据同步,当集群中超过半数机器与该 Leader服务器完成数据同步之后,退出恢复模式进入消息广播模式,Leader 服务器开始接收客户端的事务请求生成事物提案来进行事务请求处理。

1.6、四种类型的数据节点 Znode

PERSISTENT-持久节点

  • 除非手动删除,否则节点一直存在于 Zookeeper 上

EPHEMERAL-临时节点

  • 临时节点的生命周期与客户端会话绑定,一旦客户端会话失效(客户端与zookeeper 连接断开不一定会话失效),那么这个客户端创建的所有临时节点都会被移除。

PERSISTENT_SEQUENTIAL-持久顺序节点

  • 基本特性同持久节点,只是增加了顺序属性,节点名后边会追加一个由父节点维护的自增整型数字。

EPHEMERAL_SEQUENTIAL-临时顺序节点

  • 基本特性同临时节点,增加了顺序属性,节点名后边会追加一个由父节点维护的自增整型数字。

1.7、Zookeeper Watcher 机制 -- 数据变更通知

Zookeeper 允许客户端向服务端的某个 Znode 注册一个 Watcher 监听,当服务端的一些指定事件触发了这个 Watcher,服务端会向指定客户端发送一个事件通知来实现分布式的通知功能,然后客户端根据 Watcher 通知状态和事件类型做出业务上的改变。

工作机制:

  • 客户端注册 watcher

  • 服务端处理 watcher

  • 客户端回调 watcher

Watcher 特性总结:

  • 一次性

    • 无论是服务端还是客户端,一旦一个 Watcher 被 触 发 ,Zookeeper 都会将其从相应的存储中移除。这样的设计有效的减轻了服务端的压力,不然对于更新非常频繁的节点,服务端会不断的向客户端发送事件通知,无论对于网络还是服务端的压力都非常大。
  • 客户端串行执行

    • 客户端 Watcher 回调的过程是一个串行同步的过程。
  • 轻量

    • Watcher 通知非常简单,只会告诉客户端发生了事件,而不会说明事件的具体内容。

    • 客户端向服务端注册 Watcher 的时候,并不会把客户端真实的 Watcher 对象实体传递到服务端,仅仅是在客户端请求中使用 boolean 类型属性进行了标记。

  • watcher event 异步发送 watcher 的通知事件从 server 发送到 client 是异步的,这就存在一个问题,不同的客户端和服务器之间通过 socket 进行通信,由于网络延迟或其他因素导致客户端在不同的时刻监听到事件,由于 Zookeeper 本身提供了 ordering guarantee,即客户端监听事件后,才会感知它所监视 znode发生了变化。所以我们使用 Zookeeper 不能期望能够监控到节点每次的变化。Zookeeper 只能保证最终的一致性,而无法保证强一致性。

  • 注册 watcher getData、exists、getChildren

  • 触发 watcher create、delete、setData

  • 当一个客户端连接到一个新的服务器上时,watch 将会被以任意会话事件触发。当与一个服务器失去连接的时候,是无法接收到 watch 的。而当 client 重新连接时,如果需要的话,所有先前注册过的 watch,都会被重新注册。通常这是完全透明的。只有在一个特殊情况下,watch 可能会丢失:对于一个未创建的 znode的 exist watch,如果在客户端断开连接期间被创建了,并且随后在客户端连接上之前又删除了,这种情况下,这个 watch 事件可能会被丢失。

1.8、客户端注册 Watcher 实现

  • 调用 getData()/getChildren()/exist()三个 API,传入 Watcher 对象

  • 标记请求 request,封装 Watcher 到 WatchRegistration

  • 封装成 Packet 对象,发服务端发送 request

  • 收到服务端响应后,将 Watcher 注册到 ZKWatcherManager 中进行管理

  • 请求返回,完成注册。

1.9、服务端处理 Watcher 实现

服务端接收 Watcher 并存储

  • 接收到客户端请求,处理请求判断是否需要注册 Watcher,需要的话将数据节点的节点路径和 ServerCnxn(ServerCnxn 代表一个客户端和服务端的连接,实现了 Watcher 的 process 接口,此时可以看成一个 Watcher 对象)存储在WatcherManager 的 WatchTable 和 watch2Paths 中去。

Watcher 触发

  • 以服务端接收到 setData() 事务请求触发 NodeDataChanged 事件为例:

  • 封装 WatchedEvent

    • 将通知状态(SyncConnected)、事件类型(NodeDataChanged)以及节点路径封装成一个 WatchedEvent 对象
  • 查询 Watcher

    • 从 WatchTable 中根据节点路径查找 Watcher
  • 没找到;说明没有客户端在该数据节点上注册过 Watcher

  • 找到;提取并从 WatchTable 和 Watch2Paths 中删除对应 Watcher(从这里可以看出 Watcher 在服务端是一次性的,触发一次就失效了)

调用 process 方法来触发 Watcher

  • 这里 process 主要就是通过 ServerCnxn 对应的 TCP 连接发送 Watcher 事件通知。

1.10、客户端回调 Watcher

客户端 SendThread 线程接收事件通知,交由 EventThread 线程回调 Watcher。

客户端的 Watcher 机制同样是一次性的,一旦被触发后,该 Watcher 就失效了。

1.11、ACL 权限控制机制

UGO(User/Group/Others)

目前在 Linux/Unix 文件系统中使用,也是使用最广泛的权限控制方式。是一种粗粒度的文件系统权限控制模式。

ACL(Access Control List)访问控制列表

包括三个方面:

权限模式(Scheme)

  • IP:从 IP 地址粒度进行权限控制

  • Digest:最常用,用类似于 username:password 的权限标识来进行权限配置,便于区分不同应用来进行权限控制

  • World:最开放的权限控制方式,是一种特殊的 digest 模式,只有一个权限标识"world:anyone"

  • Super:超级用户

授权对象

  • 授权对象指的是权限赋予的用户或一个指定实体,例如 IP 地址或是机器灯。

权限 Permission

  • CREATE:数据节点创建权限,允许授权对象在该 Znode 下创建子节点

  • DELETE:子节点删除权限,允许授权对象删除该数据节点的子节点

  • READ:数据节点的读取权限,允许授权对象访问该数据节点并读取其数据内容或子节点列表等

  • WRITE:数据节点更新权限,允许授权对象对该数据节点进行更新操作

  • ADMIN:数据节点管理权限,允许授权对象对该数据节点进行 ACL 相关设置操作

1.12、 Chroot 特性

3.2.0 版本后,添加了 Chroot 特性,该特性允许每个客户端为自己设置一个命名空间。如果一个客户端设置了 Chroot,那么该客户端对服务器的任何操作,都将会被限制在其自己的命名空间下。

通过设置 Chroot,能够将一个客户端应用于 Zookeeper 服务端的一颗子树相对应,在那些多个应用公用一个 Zookeeper 集群的场景下,对实现不同应用间的相互隔离非常有帮助。

1.13、会话管理

分桶策略:将类似的会话放在同一区块中进行管理,以便于 Zookeeper 对会话进行不同区块的隔离处理以及同一区块的统一处理。

分配原则:每个会话的"下次超时时间点"(ExpirationTime)

计算公式:

ExpirationTime_ = currentTime + sessionTimeout

ExpirationTime = (ExpirationTime_ / ExpirationInrerval + 1) *

ExpirationInterval , ExpirationInterval 是指 Zookeeper 会话超时检查时间间隔,默认 tickTime

1.14、Zookeeper 对节点的 watch 监听通知是永久的吗?为什么不是永久的?

不是。官方声明:一个 Watch 事件是一个一次性的触发器,当被设置了 Watch的数据发生了改变的时候,则服务器将这个改变发送给设置了 Watch 的客户端,以便通知它们。

为什么不是永久的,举个例子,如果服务端变动频繁,而监听的客户端很多情况下,每次变动都要通知到所有的客户端,给网络和服务器造成很大压力。

一般是客户端执行 getData("/节点 A",true),如果节点 A 发生了变更或删除,客户端会得到它的 watch 事件,但是在之后节点 A 又发生了变更,而客户端又没有设置 watch 事件,就不再给客户端发送。

在实际应用中,很多情况下,我们的客户端不需要知道服务端的每一次变动,我只要最新的数据即可。

1.15、Zookeeper 的 java 客户端都有哪些?

java 客户端:zk 自带的 zkclient 及 Apache 开源的 Curator。

1.16、chubby 是什么,和 zookeeper 比你怎么看?

chubby 是 google 的,完全实现 paxos 算法,不开源。zookeeper 是 chubby的开源实现,使用 zab 协议,paxos 算法的变种。

1.17、说几个 zookeeper 常用的命令。

常用命令:ls get set create delete 等。

二、Zookeeper 提升

2.1、服务器角色

Leader

  • 事务请求的唯一调度和处理者,保证集群事务处理的顺序性

  • 集群内部各服务的调度者

Follower

  • 处理客户端的非事务请求,转发事务请求给 Leader 服务器

  • 参与事务请求 Proposal 的投票

  • 参与 Leader 选举投票

Observer

  • 3.0 版本以后引入的一个服务器角色,在不影响集群事务处理能力的基础上提升集群的非事务处理能力

  • 处理客户端的非事务请求,转发事务请求给 Leader 服务器

  • 不参与任何形式的投票

2.2、Zookeeper 下 Server 工作状态

服务器具有四种状态,分别是 LOOKING、FOLLOWING、LEADING、OBSERVING。

  • LOOKING:寻 找 Leader 状态。当服务器处于该状态时,它会认为当前集群中没有 Leader,因此需要进入 Leader 选举状态。

  • FOLLOWING:跟随者状态。表明当前服务器角色是 Follower。

  • LEADING:领导者状态。表明当前服务器角色是 Leader。

  • OBSERVING:观察者状态。表明当前服务器角色是 Observer。

2.3、数据同步

整个集群完成 Leader 选举之后,Learner(Follower 和 Observer 的统称)会向Leader 服务器进行注册。当 Learner 服务器想像Leader 服务器完成注册后,进入数据同步环节。

数据同步流程:(均以消息传递的方式进行)

  • Learner 向 Learder 注册

  • 数据同步

  • 同步确认

Zookeeper 的数据同步通常分为四类:

  • 直接差异化同步(DIFF 同步)

  • 先回滚再差异化同步(TRUNC+DIFF 同步)

  • 仅回滚同步(TRUNC 同步)

  • 全量同步(SNAP 同步)

在进行数据同步前,Leader 服务器会完成数据同步初始化:

peerLastZxid:

· 从 learner 服务器注册时发送的 ACKEPOCH 消息中提取 lastZxid(该Learner 服务器最后处理的 ZXID)

minCommittedLog:

· Leader 服务器 Proposal 缓存队列 committedLog 中最小 ZXIDmaxCommittedLog:

· Leader 服务器 Proposal 缓存队列 committedLog 中最大 ZXID直接差异化同步(DIFF 同步)

· 场景:peerLastZxid 介于 minCommittedLog 和 maxCommittedLog之间先回滚再差异化同步(TRUNC+DIFF 同步)

· 场景:当新的 Leader 服务器发现某个 Learner 服务器包含了一条自己没有的事务记录,那么就需要让该 Learner 服务器进行事务回滚--回滚到 Leader服务器上存在的,同时也是最接近于 peerLastZxid 的 ZXID仅回滚同步(TRUNC 同步)

· 场景:peerLastZxid 大于 maxCommittedLog

全量同步(SNAP 同步)

· 场景一:peerLastZxid 小于 minCommittedLog

· 场景二:Leader 服务器上没有 Proposal 缓存队列且 peerLastZxid 不等于 lastProcessZxid

2.4、zookeeper 是如何保证事务的顺序一致性的?

zookeeper 采用了全局递增的事务 Id 来标识,所有的 proposal(提议)都在被提出的时候加上了 zxid,zxid 实际上是一个 64 位的数字,高 32 位是 epoch( 时期; 纪元; 世; 新时代)用来标识 leader 周期,如果有新的 leader 产生出来,epoch会自增,低 32 位用来递增计数。当新产生 proposal 的时候,会依据数据库的两阶段过程,首先会向其他的 server 发出事务执行请求,如果超过半数的机器都能执行并且能够成功,那么就会开始执行。

2.5、分布式集群中为什么会有 Master?

在分布式环境中,有些业务逻辑只需要集群中的某一台机器进行执行,其他的机器可以共享这个结果,这样可以大大减少重复计算,提高性能,于是就需要进行leader 选举。

2.6、zk 节点宕机如何处理?

Zookeeper 本身也是集群,推荐配置不少于 3 个服务器。Zookeeper 自身也要保证当一个节点宕机时,其他节点会继续提供服务。

如果是一个 Follower 宕机,还有 2 台服务器提供访问,因为 Zookeeper 上的数据是有多个副本的,数据并不会丢失;

如果是一个 Leader 宕机,Zookeeper 会选举出新的 Leader。

ZK 集群的机制是只要超过半数的节点正常,集群就能正常提供服务。只有在 ZK节点挂得太多,只剩一半或不到一半节点能工作,集群才失效。

所以

3 个节点的 cluster 可以挂掉 1 个节点(leader 可以得到 2 票>1.5)

2 个节点的 cluster 就不能挂掉任何 1 个节点了(leader 可以得到 1 票<=1)

2.7、zookeeper 负载均衡和 nginx 负载均衡区别

zk 的负载均衡是可以调控,nginx 只是能调权重,其他需要可控的都需要自己写插件;但是 nginx 的吞吐量比 zk 大很多,应该说按业务选择用哪种方式。

2.8、Zookeeper 有哪几种几种部署模式?

部署模式:单机模式、伪集群模式、集群模式。

2.9、集群最少要几台机器,集群规则是怎样的?

集群规则为 2N+1 台,N>0,即 3 台。

2.10、集群支持动态添加机器吗?

其实就是水平扩容了,Zookeeper 在这方面不太好。两种方式:

全部重启:关闭所有 Zookeeper 服务,修改配置之后启动。不影响之前客户端的会话。

逐个重启:在过半存活即可用的原则下,一台机器重启不影响整个集群对外提供服务。这是比较常用的方式。

3.5 版本开始支持动态扩容。

2.11、ZAB 和 Paxos 算法的联系与区别?

相同点:

(1)两者都存在一个类似于 Leader 进程的角色,由其负责协调多个 Follower 进程的运行

(2)Leader 进程都会等待超过半数的 Follower 做出正确的反馈后,才会将一个提案进行提交

(3)ZAB 协议中,每个 Proposal 中都包含一个 epoch 值来代表当前的 Leader周期,Paxos 中名字为 Ballot

不同点:

ZAB 用来构建高可用的分布式数据主备系统(Zookeeper),Paxos 是用来构建分布式一致性状态机系统

三、分布式锁原理与实战

3.1、公平锁和可重入锁的原理

最经典的分布式锁是可重入的公平锁。什么是可重入的公平锁呢?直接讲解的概念和原理,会比较抽象难懂,还是从具体的实例入手吧!这里用一个简单的故事来类比,估计就简单多了。

故事发生在一个没有自来水的古代,在一个村子有一口井,水质非常的好,村民们都抢着取井里的水。井就那么一口,村里的人很多,村民为争抢取水打架斗殴,甚至头破血流。

问题总是要解决,于是村长绞尽脑汁,最终想出了一个凭号取水的方案。井边安排一个看井人,维护取水的秩序。取水秩序很简单:

(1)取水之前,先取号;

(2)号排在前面的,就可以先取水;

(3)先到的排在前面,那些后到的,一个一个挨着,在井边排成一队。

取水示意图,如图所示。

这种排队取水模型,就是一种锁的模型。排在最前面的号,拥有取水权,就是一种典型的独占锁。另外,先到先得,号排在前面的人先取到水,取水之后就轮到下一个号取水,挺公平的,说明它是一种公平锁。

什么是可重入锁呢?

假定,取水时以家庭为单位,家庭的某人拿到号,其他的家庭成员过来打水,这时候不用再取号,如图所示。

图中,排在1号的家庭,老公取号,假设其老婆来了,直接排第一个,正所谓妻凭夫贵。再看上图的2号,父亲正在打水,假设其儿子和女儿也到井边了,直接排第二个,所谓子凭父贵。总之,如果取水时以家庭为单位,则同一个家庭,可以直接复用排号,不用从后面排起重新取号。

以上这个故事模型中,取号一次,可以用来多次取水,其原理为可重入锁的模型。在重入锁模型中,一把独占锁,可以被多次锁定,这就叫做可重入锁。

3.2、ZooKeeper分布式锁的原理

理解了经典的公平可重入锁的原理后,再来看在分布式场景下的公平可重入锁的原理。通过前面的分析,基本可以判定:ZooKeeper

的临时顺序节点,天生就有一副实现分布式锁的胚子。为什么呢?

ZooKeeper的每一个节点,都是一个天然的顺序发号器。

在每一个节点下面创建临时顺序节点(EPHEMERAL_SEQUENTIAL)类型,新的子节点后面,会加上一个次序编号,而这个生成的次序编号,是上一个生成的次序编号加一。

例如,有一个用于发号的节点"/test/lock"为父亲节点,可以在这个父节点下面创建相同前缀的临时顺序子节点,假定相同的前缀为"/test/lock/seq-"。第一个创建的子节点基本上应该为/test/lock/seq-0000000000,下一个节点则为/test/lock/seq-0000000001,依次类推,如果所示。

ZooKeeper节点的递增有序性,可以确保锁的公平

一个ZooKeeper分布式锁,首先需要创建一个父节点,尽量是持久节点(PERSISTENT类型),然后每个要获得锁的线程,都在这个节点下创建个临时顺序节点。由于ZK节点,是按照创建的次序,依次递增的。

为了确保公平,可以简单的规定:编号最小的那个节点,表示获得了锁。所以,每个线程在尝试占用锁之前,首先判断自己是排号是不是当前最小,如果是,则获取锁。

ZooKeeper的节点监听机制,可以保障占有锁的传递有序而且高效

每个线程抢占锁之前,先尝试创建自己的ZNode。同样,释放锁的时候,就需要删除创建的Znode。创建成功后,如果不是排号最小的节点,就处于等待通知的状态。等谁的通知呢?不需要其他人,只需要等前一个Znode的通知就可以了。前一个Znode删除的时候,会触发Znode事件,当前节点能监听到删除事件,就是轮到了自己占有锁的时候。第一个通知第二个、第二个通知第三个,击鼓传花似的依次向后。

ZooKeeper的节点监听机制,能够非常完美地实现这种击鼓传花似的信息传递。具体的方法是:每一个等通知的Znode节点,只需要监听(linsten)或者监视(watch)排号在自己前面那个,而且紧挨在自己前面的那个节点,就能收到其删除事件了。只要上一个节点被删除了,就进行再一次判断,看看自己是不是序号最小的那个节点,如果是,自己就获得锁。

另外,ZooKeeper的内部优越的机制,能保证由于网络异常或者其他原因,集群中占用锁的客户端失联时,锁能够被有效释放。一旦占用Znode锁的客户端与ZooKeeper集群服务器失去联系,这个临时Znode也将自动删除。排在它后面的那个节点,也能收到删除事件,从而获得锁。正是由于这个原因,在创建取号节点的时候,尽量创建临时znode节点

ZooKeeper的节点监听机制,能避免羊群效应

ZooKeeper这种首尾相接,后面监听前面的方式,可以避免羊群效应。所谓羊群效应就是一个节点挂掉,所有节点都去监听,然后做出反应,这样会给服务器带来巨大压力,所以有了临时顺序节点,当一个节点挂掉,只有它后面的那一个节点才做出反应。

3.3、分布式锁的抢占过程

接下来我们一起来看看,多客户端获取及释放zk分布式锁的整个流程及背后的原理。

首先大家看看下面的图,如果现在有两个客户端一起要争抢zk上的一把分布式锁,会是个什么场景?

参见上图。zk里有一把锁,这个锁就是zk上的一个节点。然后呢,两个客户端都要来获取这个锁,具体是怎么来获取呢?

咱们就假设客户端A抢先一步,对zk发起了加分布式锁的请求,这个加锁请求是用到了zk中的一个特殊的概念,叫做"临时顺序节点"。

简单来说,就是直接在"my_lock"这个锁节点下,创建一个顺序节点,这个顺序节点有zk内部自行维护的一个节点序号。

3.3.1、客户端A发起一个加锁请求

比如说,第一个客户端来搞一个顺序节点,zk内部会给起个名字叫做:xxx-000001。然后第二个客户端来搞一个顺序节点,zk可能会起个名字叫做:xxx-000002。大家注意一下,最后一个数字都是依次递增的,从1开始逐次递增。zk会维护这个顺序。

所以这个时候,假如说客户端A先发起请求,就会搞出来一个顺序节点,大家看下面的图,Curator框架大概会弄成如下的样子:

大家看,客户端A发起一个加锁请求,先会在你要加锁的node下搞一个临时顺序节点,这一大坨长长的名字都是Curator框架自己生成出来的。

然后,那个最后一个数字是"1"。大家注意一下,因为客户端A是第一个发起请求的,所以给他搞出来的顺序节点的序号是"1"。

接着客户端A创建完一个顺序节点。还没完,他会查一下"my_lock"这个锁节点下的所有子节点,并且这些子节点是按照序号排序的,这个时候他大概会拿到这么一个集合:

接着客户端A会走一个关键性的判断,就是说:唉!兄弟,这个集合里,我创建的那个顺序节点,是不是排在第一个啊?

如果是的话,那我就可以加锁了啊!因为明明我就是第一个来创建顺序节点的人,所以我就是第一个尝试加分布式锁的人啊!

bingo!加锁成功!大家看下面的图,再来直观的感受一下整个过程。

3.3.2、客户端B过来排队

接着假如说,客户端A都加完锁了,客户端B过来想要加锁了,这个时候他会干一样的事儿:先是在"my_lock"这个锁节点下创建一个临时顺序节点,此时名字会变成类似于:

大家看看下面的图:

客户端B因为是第二个来创建顺序节点的,所以zk内部会维护序号为"2"。

接着客户端B会走加锁判断逻辑,查询"my_lock"锁节点下的所有子节点,按序号顺序排列,此时他看到的类似于:

同时检查自己创建的顺序节点,是不是集合中的第一个?

明显不是啊,此时第一个是客户端A创建的那个顺序节点,序号为"01"的那个。所以加锁失败!

3.3.3、客户端B开启监听客户端A

加锁失败了以后,客户端B就会通过ZK的API对他的顺序节点的上一个顺序节点加一个监听器。zk天然就可以实现对某个节点的监听。

客户端B的顺序节点是:

他的上一个顺序节点,不就是下面这个吗?

即客户端A创建的那个顺序节点!

所以,客户端B会对:

这个节点加一个监听器,监听这个节点是否被删除等变化!大家看下面的图。

接着,客户端A加锁之后,可能处理了一些代码逻辑,然后就会释放锁。那么,释放锁是个什么过程呢?

其实很简单,就是把自己在zk里创建的那个顺序节点,也就是:

这个节点给删除。

删除了那个节点之后,zk会负责通知监听这个节点的监听器,也就是客户端B之前加的那个监听器,说:兄弟,你监听的那个节点被删除了,有人释放了锁。

此时客户端B的监听器感知到了上一个顺序节点被删除,也就是排在他之前的某个客户端释放了锁。

3.3.4、客户端B抢锁成功

此时,就会通知客户端B重新尝试去获取锁,也就是获取"my_lock"节点下的子节点集合,此时为:

集合里此时只有客户端B创建的唯一的一个顺序节点了!

然后呢,客户端B判断自己居然是集合中的第一个顺序节点,bingo!可以加锁了!直接完成加锁,运行后续的业务代码即可,运行完了之后再次释放锁。

3.4、分布式锁的基本实现

接下来就是基于ZooKeeper,实现一下分布式锁。首先,定义了一个锁的接口Lock,很简单,仅仅两个抽象方法:一个加锁方法,一个解锁方法。Lock接口的代码如下:

java 复制代码
package com.crazymakercircle.zk.distributedLock;

/**
 * create by 尼恩 @ 疯狂创客圈
 **/
public interface Lock {
    /**
     * 加锁方法
     *
     * @return 是否成功加锁
     */
    boolean lock() throws Exception;

    /**
     * 解锁方法
     *
     * @return 是否成功解锁
     */
    boolean unlock();
}

使用ZooKeeper实现分布式锁的算法,有以下几个要点:

  • 一把分布式锁通常使用一个Znode节点表示;如果锁对应的Znode节点不存在,首先创建Znode节点。这里假设为"/test/lock",代表了一把需要创建的分布式锁。

  • 抢占锁的所有客户端,使用锁的Znode节点的子节点列表来表示;如果某个客户端需要占用锁,则在"/test/lock"下创建一个临时有序的子节点。

    • 这里,所有临时有序子节点,尽量共用一个有意义的子节点前缀。

    • 比如,如果子节点的前缀为"/test/lock/seq-",则第一次抢锁对应的子节点为"/test/lock/seq-000000000",第二次抢锁对应的子节点为"/test/lock/seq-000000001",以此类推。

    • 再比如,如果子节点前缀为"/test/lock/",则第一次抢锁对应的子节点为"/test/lock/000000000",第二次抢锁对应的子节点为"/test/lock/000000001",以此类推,也非常直观。

  • 如果判定客户端是否占有锁呢?

    • 很简单,客户端创建子节点后,需要进行判断:自己创建的子节点,是否为当前子节点列表中序号最小的子节点。如果是,则认为加锁成功;如果不是,则监听前一个Znode子节点变更消息,等待前一个节点释放锁。
  • 一旦队列中的后面的节点,获得前一个子节点变更通知,则开始进行判断,判断自己是否为当前子节点列表中序号最小的子节点,如果是,则认为加锁成功;如果不是,则持续监听,一直到获得锁。

  • 获取锁后,开始处理业务流程。完成业务流程后,删除自己的对应的子节点,完成释放锁的工作,以方面后继节点能捕获到节点变更通知,获得分布式锁。

3.5、加锁的实现

Lock接口中加锁的方法是lock()。lock()方法的大致流程是:首先尝试着去加锁,如果加锁失败就去等待,然后再重复。

3.5.1、lock()方法的实现代码

lock()方法加锁的实现代码,大致如下:

java 复制代码
package com.crazymakercircle.zk.distributedLock;

import com.crazymakercircle.zk.ZKclient;
import lombok.extern.slf4j.Slf4j;
import org.apache.curator.framework.CuratorFramework;
import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.Watcher;

import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * create by 尼恩 @ 疯狂创客圈
 **/
@Slf4j
public class ZkLock implements Lock {
    //ZkLock的节点链接
    private static final String ZK_PATH = "/test/lock";
    private static final String LOCK_PREFIX = ZK_PATH + "/";
    private static final long WAIT_TIME = 1000;
    //Zk客户端
    CuratorFramework client = null;

    private String locked_short_path = null;
    private String locked_path = null;
    private String prior_path = null;
    final AtomicInteger lockCount = new AtomicInteger(0);
    private Thread thread;

    public ZkLock() {
        ZKclient.instance.init();
        synchronized (ZKclient.instance) {
            if (!ZKclient.instance.isNodeExist(ZK_PATH)) {
                ZKclient.instance.createNode(ZK_PATH, null);
            }
        }
        client = ZKclient.instance.getClient();
    }

    @Override
    public boolean lock() {
//可重入,确保同一线程,可以重复加锁

        synchronized (this) {
            if (lockCount.get() == 0) {
                thread = Thread.currentThread();
                lockCount.incrementAndGet();
            } else {
                if (!thread.equals(Thread.currentThread())) {
                    return false;
                }
                lockCount.incrementAndGet();
                return true;
            }
        }

        try {
            boolean locked = false;
//首先尝试着去加锁
            locked = tryLock();

            if (locked) {
                return true;
            }
            //如果加锁失败就去等待
            while (!locked) {

                await();

                //获取等待的子节点列表

                List<String> waiters = getWaiters();
//判断,是否加锁成功
                if (checkLocked(waiters)) {
                    locked = true;
                }
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            unlock();
        }

        return false;
    }



//...省略其他的方法

}

3.5.2、tryLock()尝试加锁

尝试加锁的tryLock方法是关键,做了两件重要的事情:

(1)创建临时顺序节点,并且保存自己的节点路径

(2)判断是否是第一个,如果是第一个,则加锁成功。如果不是,就找到前一个Znode节点,并且保存其路径到prior_path。

尝试加锁的tryLock方法,其实现代码如下:

java 复制代码
   /**
     * 尝试加锁
     * @return 是否加锁成功
     * @throws Exception 异常
     */
    private boolean tryLock() throws Exception {
        //创建临时Znode
        locked_path = ZKclient.instance
                .createEphemeralSeqNode(LOCK_PREFIX);
        //然后获取所有节点
        List<String> waiters = getWaiters();

        if (null == locked_path) {
            throw new Exception("zk error");
        }
        //取得加锁的排队编号
        locked_short_path = getShortPath(locked_path);

        //获取等待的子节点列表,判断自己是否第一个
        if (checkLocked(waiters)) {
            return true;
        }

        // 判断自己排第几个
        int index = Collections.binarySearch(waiters, locked_short_path);
        if (index < 0) { // 网络抖动,获取到的子节点列表里可能已经没有自己了
            throw new Exception("节点没有找到: " + locked_short_path);
        }

        //如果自己没有获得锁,则要监听前一个节点
        prior_path = ZK_PATH + "/" + waiters.get(index - 1);

        return false;
    }

    private String getShortPath(String locked_path) {

        int index = locked_path.lastIndexOf(ZK_PATH + "/");
        if (index >= 0) {
            index += ZK_PATH.length() + 1;
            return index <= locked_path.length() ? locked_path.substring(index) : "";
        }
        return null;
    }

创建临时顺序节点后,其完整路径存放在locked_path成员中;另外还截取了一个后缀路径,放在locked_short_path成员中,后缀路径是一个短路径,只有完整路径的最后一层。

为什么要单独保存短路径呢?

因为,在获取的远程子节点列表中的其他路径返回结果时,返回的都是短路径,都只有最后一层路径。所以为了方便后续进行比较,也把自己的短路径保存下来。

创建了自己的临时节点后,调用checkLocked方法,判断是否是锁定成功。如果锁定成功,则返回true;如果自己没有获得锁,则要监听前一个节点,此时需要找出前一个节点的路径,并保存在prior_path成员中,供后面的await()等待方法去监听使用。

3.5.3、checkLocked()检查是否持有锁

在checkLocked()方法中,判断是否可以持有锁。判断规则很简单:当前创建的节点,是否在上一步获取到的子节点列表的第一个位置:

(1)如果是,说明可以持有锁,返回true,表示加锁成功;

(2)如果不是,说明有其他线程早已先持有了锁,返回false。

checkLocked()方法的代码如下:

java 复制代码
   private boolean checkLocked(List<String> waiters) {

        //节点按照编号,升序排列
        Collections.sort(waiters);

        // 如果是第一个,代表自己已经获得了锁
        if (locked_short_path.equals(waiters.get(0))) {
            log.info("成功的获取分布式锁,节点为{}", locked_short_path);
            return true;
        }
        return false;
    }

checkLocked方法比较简单,将参与排队的所有子节点列表,从小到大根据节点名称进行排序。排序主要依靠节点的编号,也就是后Znode路径的10位数字,因为前缀都是一样的。排序之后,做判断,如果自己的locked_short_path编号位置排在第一个,如果是,则代表自己已经获得了锁。如果不是,则会返回false。

如果checkLocked()为false,外层的调用方法,一般来说会执行await()等待方法,执行夺锁失败以后的等待逻辑。

3.5.4、await()监听前一个节点释放锁

await()也很简单,就是监听前一个ZNode节点(prior_path成员)的删除事件,代码如下:

java 复制代码
private void await() throws Exception {

    if (null == prior_path) {
        throw new Exception("prior_path error");
    }

    final CountDownLatch latch = new CountDownLatch(1);


    //订阅比自己次小顺序节点的删除事件
    Watcher w = new Watcher() {
        @Override
        public void process(WatchedEvent watchedEvent) {
            System.out.println("监听到的变化 watchedEvent = " + watchedEvent);
            log.info("[WatchedEvent]节点删除");

            latch.countDown();
        }
    };

    client.getData().usingWatcher(w).forPath(prior_path);
/*
    //订阅比自己次小顺序节点的删除事件
    TreeCache treeCache = new TreeCache(client, prior_path);
    TreeCacheListener l = new TreeCacheListener() {
        @Override
        public void childEvent(CuratorFramework client,
                               TreeCacheEvent event) throws Exception {
            ChildData data = event.getData();
            if (data != null) {
                switch (event.getType()) {
                    case NODE_REMOVED:
                        log.debug("[TreeCache]节点删除, path={}, data={}",
                                data.getPath(), data.getData());

                        latch.countDown();
                        break;
                    default:
                        break;
                }
            }
        }
    };

    treeCache.getListenable().addListener(l);
    treeCache.start();*/
    latch.await(WAIT_TIME, TimeUnit.SECONDS);
}

首先添加一个Watcher监听,而监听的节点,正是前面所保存在prior_path成员的前一个节点的路径。这里,仅仅去监听自己前一个节点的变动,而不是其他节点的变动,提升效率。完成监听之后,调用latch.await(),线程进入等待状态,一直到线程被监听回调代码中的latch.countDown() 所唤醒,或者等待超时。

上面的代码中,监听前一个节点的删除,可以使用两种监听方式:

(1)Watcher 订阅;

(2)TreeCache 订阅。

两种方式的效果,都差不多。但是这里的删除事件,只需要监听一次即可,不需要反复监听,所以使用的是Watcher一次性订阅。而TreeCache 订阅的代码在源码工程中已经被注释,仅仅供大家参考。

一旦前一个节点prior_path节点被删除,那么就将线程从等待状态唤醒,重新一轮的锁的争夺,直到获取锁,并且完成业务处理。

至此,分布式Lock加锁的算法,还差一点就介绍完成。这一点,就是实现锁的可重入。

3.5.5、可重入的实现代码

什么是可重入呢?只需要保障同一个线程进入加锁的代码,可以重复加锁成功即可。

修改前面的lock方法,在前面加上可重入的判断逻辑。代码如下:

java 复制代码
@Override
public boolean lock() {
	//可重入的判断
	synchronized (this) {
		if (lockCount.get() == 0) {
			thread = Thread.currentThread();
			lockCount.incrementAndGet();
		} else {
			if (!thread.equals(Thread.currentThread())) {
				return false;
			}
			lockCount.incrementAndGet();
			return true;
		}
	}
	//....
}

为了变成可重入,在代码中增加了一个加锁的计数器lockCount

,计算重复加锁的次数。如果是同一个线程加锁,只需要增加次数,直接返回,表示加锁成功。

至此,lock()方法已经介绍完成,接下来,就是去释放锁

3.6、释放锁的实现

Lock接口中的unLock()方法,表示释放锁,释放锁主要有两个工作:

(1)减少重入锁的计数,如果最终的值不是0,直接返回,表示成功的释放了一次;

(2)如果计数器为0,移除Watchers监听器,并且删除创建的Znode临时节点。

unLock()方法的代码如下:

java 复制代码
    /**
     * 释放锁
     *
     * @return 是否成功释放锁
     */
    @Override
    public boolean unlock() {
//只有加锁的线程,能够解锁
        if (!thread.equals(Thread.currentThread())) {
            return false;
        }
//减少可重入的计数
        int newLockCount = lockCount.decrementAndGet();
//计数不能小于0
        if (newLockCount < 0) {
            throw new IllegalMonitorStateException("Lock count has gone negative for lock: " + locked_path);
        }
//如果计数不为0,直接返回
        if (newLockCount != 0) {
            return true;
        }
        //删除临时节点
        try {
            if (ZKclient.instance.isNodeExist(locked_path)) {
                client.delete().forPath(locked_path);
            }
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }

        return true;
    }

这里,为了尽量保证线程安全,可重入计数器的类型,使用的不是int类型,而是Java并发包中的原子类型------AtomicInteger。

3.7、分布式锁的使用

写一个用例,测试一下ZLock的使用,代码如下:

java 复制代码
   @Test
    public void testLock() throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            FutureTaskScheduler.add(() -> {
                //创建锁
                ZkLock lock = new ZkLock();
                lock.lock();
//每条线程,执行10次累加
                for (int j = 0; j < 10; j++) {
//公共的资源变量累加
                    count++;
                }
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                log.info("count = " + count);
                //释放锁
                lock.unlock();

            });
        }

        Thread.sleep(Integer.MAX_VALUE);
    }

以上代码是10个并发任务,每个任务累加10次,执行以上用例,会发现结果会是预期的和100,如果不使用锁,结果可能就不是100,因为上面的count是一个普通的变量,不是线程安全的。

原理上一个Zlock实例代表一把锁,并需要占用一个Znode永久节点,如果需要很多分布式锁,则也需要很多的不同的Znode节点。

3.8、ZooKeeper分布式锁的优点和缺点

总结一下ZooKeeper分布式锁:

(1)优点:ZooKeeper分布式锁(如InterProcessMutex),能有效的解决分布式问题,不可重入问题,使用起来也较为简单。

(2)缺点:ZooKeeper实现的分布式锁,性能并不太高。为啥呢?

因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。大家知道,ZK中创建和删除节点只能通过Leader服务器来执行,然后Leader服务器还需要将数据同不到所有的Follower机器上,这样频繁的网络通信,性能的短板是非常突出的。

总之,在高性能,高并发的场景下,不建议使用ZooKeeper的分布式锁。而由于ZooKeeper的高可用特性,所以在并发量不是太高的场景,推荐使用ZooKeeper的分布式锁。

在目前分布式锁实现方案中,比较成熟、主流的方案有两种:

(1)基于Redis的分布式锁

(2)基于ZooKeeper的分布式锁

两种锁,分别适用的场景为:

(1)基于ZooKeeper的分布式锁,适用于高可靠(高可用)而并发量不是太大的场景;

(2)基于Redis的分布式锁,适用于并发量很大、性能要求很高的、而可靠性问题可以通过其他方案去弥补的场景。

总之,这里没有谁好谁坏的问题,而是谁更合适的问题。

相关推荐
李洋-蛟龙腾飞公司1 小时前
HarmonyOS Next 应用元服务开发-分布式数据对象迁移数据文件资产迁移
分布式·华为·harmonyos
技术路上的苦行僧3 小时前
分布式专题(10)之ShardingSphere分库分表实战指南
分布式·shardingsphere·分库分表
GitCode官方4 小时前
GitCode 光引计划投稿 | GoIoT:开源分布式物联网开发平台
分布式·开源·gitcode
小扳6 小时前
微服务篇-深入了解 MinIO 文件服务器(你还在使用阿里云 0SS 对象存储图片服务?教你使用 MinIO 文件服务器:实现从部署到具体使用)
java·服务器·分布式·微服务·云原生·架构
zquwei15 小时前
SpringCloudGateway+Nacos注册与转发Netty+WebSocket
java·网络·分布式·后端·websocket·网络协议·spring
aherhuo16 小时前
kubevirt网络
linux·云原生·容器·kubernetes
catoop17 小时前
K8s 无头服务(Headless Service)
云原生·容器·kubernetes
小峰编程17 小时前
独一无二,万字详谈——Linux之文件管理
linux·运维·服务器·云原生·云计算·ai原生
小马爱打代码18 小时前
云原生服务网格Istio实战
云原生
道一云黑板报19 小时前
Flink集群批作业实践:七析BI批作业执行
大数据·分布式·数据分析·flink·kubernetes