分布式协同ZooKeeper笔记

第一节 Zookeeper 简介

1.1 分布式系统定义及⾯临的问题

ZooKeeper 最为主要的使⽤场景,是作为分布式系统的分布式协同服务。我们将分布式系统定义为:分布式系统是同时跨越多个物理主机,独⽴运⾏的多个软件所组成系统。类⽐⼀下,分布式系统就是⼀群⼈⼀起⼲活。⼈多⼒量⼤,每个服务器的算⼒是有限的,但是通过分布式系统,由 n 个服务器组成起来的集群,算⼒是可以⽆限扩张的。优点显⽽易⻅,⼈多⼲活快,并且互为备份。但是缺点也很明显。我们可以想象⼀下,以⼀个⼩研发团队开发软件为例,假设我们有⼀个 5 ⼈的项⽬组,要开始⼀个系统的开发,项⽬组将⾯临如下问题:

image.png

图中列举的就是项⽬组将要⾯临到的问题,这些问题在我们⽇常⼯作中也是天天发⽣,并没感觉有多么 复杂,但是这是因为我们⼈类的⼤脑是个超级计算机,能够灵活应对这些问题,⽽且现实中信息的交换 不依赖⽹络,不会因⽹络延迟或者中断,出现信息不对等,⽽且现实中对以上问题的处理其实并不严 谨,从⽽也引发了很多问题。想⼀想,项⽬中是不是出现过沟通不畅造成任务分配有歧义?是否由于⼈ 员离职造成任务进⾏不下去,甚⾄要联系离职⼈员协助?是不是出现过任务分配不合理?类似这样的各 种问题,肯定会发⽣于你的项⽬组中。在现实世界,我们可以⼈为去协调,即使出错了,⼈⼯去补错, 加加班搞定就好。但在计算机的世界,这样做是⾏不通的,⼀切都要保证严谨,以上问题要做到尽可能 不要发⽣。因此,分布式系统必须采⽤合理的⽅式解决掉以上的问题

实际上要想解决这些问题并没有那么复杂,我们仅需要做⼀件事就可以万事⽆忧—让信息在项⽬组成员 中同步。如果能做到信息同步,那么每个⼈在⼲什么,⼤家都是清楚的,⼲到什么程度也是清晰的,⽆论谁离职也不会产⽣问题。分配的⼯作,能够及时清晰的同步给每个组员,确保每个组员收到的任务分配没有冲突。

分布式系统的协调⼯作就是通过某种⽅式,让每个节点的信息能够同步和共享。这依赖于服务进程之间 的通信。通信⽅式有两种:

通过⽹络进⾏信息共享

这就像现实中,开发 leader 在会上把任务传达下去,组员通过听 leader 命令或者看 leader 的邮件知道⾃ ⼰要⼲什么。当任务分配有变化时,leader 会单独告诉组员,或者再次召开会议。信息通过⼈与⼈之间 的直接沟通,完成传递。

通过共享存储

这就好⽐开发 leader 按照约定的时间和路径,把任务分配表放到了 svn 上,组员每天去 svn 上拉取最新的任务分配表,然后⼲活。其中 svn 就是共享存储。更好⼀点的做法是,当 svn ⽂件版本更新时,触发邮件通知,每个组员再去拉取最新的任务分配表。这样做更好,因为每次更新,组员都能第⼀时间得到消息,从⽽让⾃⼰⼿中的任务分配表永远是最新的。此种⽅式依赖于中央存储。整个过程如下图所示:

image.png

ZooKeeper 如何解决分布式系统⾯临的问题

ZooKeeper 对分布式系统的协调,使⽤的是第⼆种⽅式,共享存储。其实共享存储,分布式应⽤也需要和存储进⾏⽹络通信。

实际上,通过 ZooKeeper 实现分布式协同的原理,和项⽬组通过 SVN 同步⼯作任务的例⼦是⼀样的。 ZooKeeper 就像是 svn,存储了任务的分配、完成情况等共享信息。每个分布式应⽤的节点就是组员, 订阅这些共享信息。当主节点(组 leader),对某个从节点的分⼯息作出改变时,相关订阅的从节点 得到 zookeeper 的通知,取得⾃⼰最新的任务分配。完成⼯作后,把完成情况存储到 zookeeper。主节 点订阅了该任务的完成情况信息,所以将得到 zookeeper 的完⼯的通知。参考下图,是不是和前⾯项⽬ 组通过 svn 分配⼯作的例⼦⼀模⼀样?仅仅是把 svn 和邮件系统合⼆为⼀,以 ZooKeeper 代替

image.png

注:Slave 节点要想获取 ZooKeeper 的更新通知,需事先在关⼼的数据节点上设置观察点。⼤多数分布式系统中出现的问题,都源于信息的共享出了问题。如果各个节点间信息不能及时共享和同步,那么就会在协作过程中产⽣各种问题。ZooKeeper 解决协同问题的关键,就是在于保证分布式系统信息的⼀致性。

1.2 zookeeper 的基本概念

Zookeeper 是⼀个开源的分布式协调服务,其设计⽬标是将那些复杂的且容易出错的分布式⼀致性服务封装起来,构成⼀个⾼效可靠的原语集,并以⼀些简单的接⼝提供给⽤户使⽤。zookeeper 是⼀个典型的分布式数据⼀致性的解决⽅案,分布式应⽤程序可以基于它实现诸如数据订阅/发布、负载均衡、命名服务、集群管理、分布式锁和分布式队列等功能

基本概念

① 集群⻆⾊
通常在分布式系统中,构成⼀个集群的每⼀台机器都有⾃⼰的⻆⾊,最典型的集群就是 Master/Slave 模式(主备模式),此情况下

把所有能够处理写操作的机器称为 Master 机器,把所有通过异步复制⽅式获取最新数据,并提供读服务的机器为 Slave 机器。
⽽在 Zookeeper 中,这些概念被颠覆了。它没有沿⽤传递的 Master/Slave 概念,⽽是引⼊了 Leader、Follower、Observer 三种⻆⾊。Zookeeper 集群中的所有机器通过 Leader 选举来选定⼀台被称为 Leader 的机器,Leader 服务器为客户端提供读和写服务,除 Leader 外,其他机器包括 Follower 和 Observer

Follower 和 Observer 都能提供读服务,唯⼀的区别在于 Observer 不参与 Leader 选举过程,不参与写操作的过半写成功策略,因此 Observer 可以在不影响写性能的情况下提升集群的性能。

② 会话(session)

Session 指客户端会话,⼀个客户端连接是指客户端和服务端之间的⼀个 TCP ⻓连接,Zookeeper 对外的服务端⼝默认为 2181,客户端启动的时候,⾸先会与服务器建⽴⼀个 TCP 连接,从第⼀次连接建⽴开始,客户端会话的⽣命周期也开始了,通过这个连接,客户端能够⼼跳检测与服务器保持有效的会话,也能够向 Zookeeper 服务器发送请求并接受响应,同时还能够通过该连接接受来⾃服务器的 Watch 事件通知。

③ 数据节点(Znode)

在谈到分布式的时候,我们通常说的“节点”是指组成集群的每⼀台机器。然⽽,在 ZooKeeper 中,“节点”分为两类,第⼀类同样是指构成集群的机器,我们称之为机器节点;第⼆类则是指数据模型中的数据单元,我们称之为数据节点——ZNode。ZooKeeper 将所有数据存储在内存中,数据模型是⼀棵树 (ZNode Tree),由斜杠(/)进⾏分割的路径,就是⼀个 Znode,例如/app/path1。每个 ZNode 上都会保存⾃⼰的数据内容,同时还会保存⼀系列属性信息。

④ 版本

刚刚我们提到,Zookeeper 的每个 Znode 上都会存储数据,对于每个 ZNode,Zookeeper 都会为其维护⼀个叫作 Stat 的数据结构,Stat 记录了这个 ZNode 的三个数据版本,分别是 version(当前 ZNode 的版本)、cversion(当前 ZNode ⼦节点的版本)、aversion(当前 ZNode 的 ACL 版本)。

⑤ Watcher(事件监听器)

Wathcer(事件监听器),是 Zookeeper 中⼀个很重要的特性,Zookeeper 允许⽤户在指定节点上注册 ⼀些 Watcher,并且在⼀些特定事件触发的时候,Zookeeper 服务端会将事件通知到感兴趣的客户端,该机制是 Zookeeper 实现分布式协调服务的重要特性

⑥ ACL

Zookeeper 采⽤ ACL(Access Control Lists)策略来进⾏权限控制,其定义了如下五种权限:

· CREATE:创建⼦节点的权限。

· READ:获取节点数据和⼦节点列表的权限。

· WRITE:更新节点数据的权限。

· DELETE:删除⼦节点的权限。

· ADMIN:设置节点 ACL 的权限。

其中需要注意的是,CREATE 和 DELETE 这两种权限都是针对⼦节点的权限控制

第二节 Zookeeper 环境搭建

2.1 Zookeeper 的搭建⽅式

Zookeeper 安装⽅式有三种,单机模式和集群模式以及伪集群模式。

■ 单机模式:Zookeeper 只运⾏在⼀台服务器上,适合测试环境;

■ 集群模式:Zookeeper 运⾏于⼀个集群上,适合⽣产环境,这个计算机集群被称为⼀个“集合体”

■ 伪集群模式:就是在⼀台服务器上运⾏多个 Zookeeper 实例;

2.1.1 单机模式搭建

zookeeper 安装以 linux 环境为例:

#下载地址:https://archive.apache.org/dist/zookeeper/
#本地mac系统安装tentermlite方便上传Linux
#tentermlite连接远程
#上传安装包
rz 
#解压
tar -xvf zookeeper-3.4.14.tar
#跳转
cd zookeeper-3.4.14 
#创建data文件夹,用于存储日志快照等文件
mkdir data
#修改配置文件
cd conf
mv zoo_sample.cfg zoo.cfg
vim zoo.cfg
#修改dataDir为data目录
#dataDir=/root/software/zookeeper/zookeeper-3.4.14/data
#进入bin目录下启动服务
cd ..
cd bin
./zkServer.sh start
#查看运行状态
./zkServer.sh status
#Mode: standalone 单机模式
#关闭命令
./zkServer.sh stop

image.png

2.1.2 伪集群模式搭建

Zookeeper 不但可以在单机上运⾏单机模式 Zookeeper,⽽且可以在单机模拟集群模式 Zookeeper 的运 ⾏,也就是将不同实例运⾏在同⼀台机器,⽤端⼝进⾏区分,伪集群模式为我们体验 Zookeeper 和做⼀ 些尝试性的实验提供了很⼤的便利。⽐如,我们在测试的时候,可以先使⽤少量数据在伪集群模式下进⾏测试。当测试可⾏的时候,再将数据移植到集群模式进⾏真实的数据实验。这样不但保证了它的可⾏性,同时⼤⼤提⾼了实验的效率。这种搭建⽅式,⽐较简便,成本⽐较低,适合测试和学习

注意事项:

⼀台机器上部署了 3 个 server,也就是说单台机器及上运⾏多个 Zookeeper 实例。这种情况下,必须保 证每个配置⽂档的各个端⼝号不能冲突,除 clientPort 不同之外,dataDir 也不同。另外,还要在 dataDir 所对应的⽬录中创建 myid ⽂件来指定对应的 Zookeeper 服务器实例

■ clientPort 端⼝:

如果在 1 台机器上部署多个 server,那么每台机器都要不同的 clientPort,⽐如 server1 是 2181,server2 是 2182,server3 是 2183

■ dataDir 和 dataLogDir:

dataDir 和 dataLogDir 也需要区分下,将数据⽂件和⽇志⽂件分开存放,同时每个 server 的这两变量所 对应的路径都是不同的

■ server.X 和 myid:

server.X 这个数字就是对应,data/myid 中的数字。在 3 个 server 的 myid ⽂件中分别写⼊了 1,2,3,那 么每个 server 中的 zoo.cfg 都配 server.1 server.2,server.3 就⾏了。因为在同⼀台机器上,后⾯连着的 2 个端⼝,3 个 server 都不要⼀样,否则端⼝冲突

#下载地址:https://archive.apache.org/dist/zookeeper/
#本地mac系统安装tentermlite方便上传Linux
#tentermlite连接远程
#上传安装包
rz 
#解压
tar -xvf zookeeper-3.4.14.tar
#跳转
cd zookeeper-3.4.14 
#修改名称
mv zookeeper-3.4.14 zookeeper-01
#添加data和logs文件
cd zookeeper-01
mkdir data
cd data
mkdir logs
#修改conf 下的配置文件
cd ..
cd conf
mv zoo_sample.cfg zoo.cfg
vim zoo.cfg
#修改添加文件配置如下
dataDir=/root/software/zookeeper/zkcluster/zookeeper-01/data
dataLogDir=/root/software/zookeeper/zkcluster/zookeeper-01/data/logs
server.1=localhost:2881:3881 
server.2=localhost:2882:3882
server.3=localhost:2883:3883
#复制两份
cp zookeeper-01 zookeeper-02 
cp zookeeper-01 zookeeper-03
#分别修改zookeeper-02下zoo.cfg 和 zookeeper-03中的配置文件为
#zookeeper-02下zoo.cfg
dataDir=/root/software/zookeeper/zkcluster/zookeeper-02/data
dataLogDir=/root/software/zookeeper/zkcluster/zookeeper-02/data/logs
clientPort=2182
server.1=localhost:2881:3881 
server.2=localhost:2882:3882
server.3=localhost:2883:3883
#zookeeper-03下zoo.cfg
dataDir=/root/software/zookeeper/zkcluster/zookeeper-03/data
dataLogDir=/root/software/zookeeper/zkcluster/zookeeper-03/data/logs
clientPort=2183
server.1=localhost:2881:3881 
server.2=localhost:2882:3882
server.3=localhost:2883:3883
#配置集群
#分别在每个zookeeper文件夹下data文件下创建myid并写入值分别为1、2、3
cd ..
cd data
touch myid
vim myid 
1
#其他两个zookeeper-02和zookeeper-03服务同样操作,值分别为2和3
#分别在每个server的zoo.cfg配置集群服务器ip列表
#server.服务器ID=服务器IP地址:服务器之间通信端⼝:服务器之间投票选举端⼝
#刚才添加过之后就不用添加了
server.1=localhost:2881:3881 
server.2=localhost:2882:3882
server.3=localhost:2883:3883
#分别启动每一个服务
cd ../bin
./zkServer.sh start
#查看状态
./zkServer.sh status
# server-1 Mode: follower
# server-2 Mode: leader
# server-3 Mode: follower

image.png

问题

我的服务器 ip 开始配置的是 云服务器的 ip 出现问题后改的 localhost 才可以了,不知道原因是什么

安全组和防火墙都打开了也不行,必须 localhost 或者内网地址才可以

第三节 Zookeeper 基本使⽤

3.1 ZooKeeper 系统模型

ZooKeeper 数据模型 Znode

在 ZooKeeper 中,数据信息被保存在⼀个个数据节点上,这些节点被称为 znode。ZNode 是 Zookeeper 中最⼩数据单位,在 ZNode 下⾯⼜可以再挂 ZNode,这样⼀层层下去就形成了⼀个层次化命名空间 ZNode 树,我们称为 ZNode Tree,它采⽤了类似⽂件系统的层级树状结构进⾏管理。⻅下图 示例:

image.png

在 Zookeeper 中,每⼀个数据节点都是⼀个 ZNode,上图根⽬录下有两个节点,分别是:app1 和 app2,其中 app1 下⾯⼜有三个⼦节点,所有 ZNode 按层次化进⾏组织,形成这么⼀颗树,ZNode 的节点路径标识⽅式和 Unix ⽂件系统路径⾮常相似,都是由⼀系列使⽤斜杠(/)进⾏分割的路径表示,开发⼈员可以向这个节点写⼊数据,也可以在这个节点下⾯创建⼦节点。

ZNode 的类型

刚刚已经了解到,Zookeeper 的 znode tree 是由⼀系列数据节点组成的,那接下来,我们就对数据节点 做详细讲解

Zookeeper 节点类型可以分为三⼤类:

持久性节点(Persistent)

临时性节点(Ephemeral)

顺序性节点(Sequential)

在开发中在创建节点的时候通过组合可以⽣成以下四种节点类型:持久节点、持久顺序节点、临时节点、临时顺序节点。不同类型的节点则会有不同的⽣命周期

持久节点:是 Zookeeper 中最常⻅的⼀种节点类型,所谓持久节点,就是指节点被创建后会⼀直存在服务器,直到删除操作主动清除

持久顺序节点:就是有顺序的持久节点,节点特性和持久节点是⼀样的,只是额外特性表现在顺序上。 顺序特性实质是在创建节点的时候,会在节点名后⾯加上⼀个数字后缀,来表示其顺序

临时节点:就是会被⾃动清理掉的节点,它的⽣命周期和客户端会话绑在⼀起,客户端会话结束,节点 会被删除掉。与持久性节点不同的是,临时节点不能创建⼦节点。

临时顺序节点:就是有顺序的临时节点,和持久顺序节点相同,在其创建的时候会在名字后⾯加上数字后缀。

事务 ID

⾸先,先了解,事务是对物理和抽象的应⽤状态上的操作集合。往往在现在的概念中,狭义上的事务通 常指的是数据库事务,⼀般包含了⼀系列对数据库有序的读写操作,这些数据库事务具有所谓的 ACID 特 性,即原⼦性(Atomic)、⼀致性(Consistency)、隔离性(Isolation)和持久性(Durability)。

⽽在 ZooKeeper 中,事务是指能够改变 ZooKeeper 服务器状态的操作,我们也称之为事务操作或更新操 作,⼀般包括数据节点创建与删除、数据节点内容更新等操作。对于每⼀个事务请求,ZooKeeper 都会 为其分配⼀个全局唯⼀的事务 ID,⽤ ZXID 来表示,通常是⼀个 64 位的数字。每⼀个 ZXID 对应⼀次更新操作,从这些 ZXID 中可以间接地识别出 ZooKeeper 处理这些更新操作请求的全局顺序

ZNode 的状态信息

image.png

整个 ZNode 节点内容包括两部分:节点数据内容和节点状态信息。图中 quota 是数据内容,其他的属于状态信息。那么这些状态信息都有什么含义呢?

cZxid 就是 Create ZXID,表示节点被创建时的事务 ID。
ctime 就是 Create Time,表示节点创建时间。
mZxid 就是 Modified ZXID,表示节点最后⼀次被修改时的事务 ID。
mtime 就是 Modified Time,表示节点最后⼀次被修改的时间。
pZxid 表示该节点的⼦节点列表最后⼀次被修改时的事务 ID。只有⼦节点列表变更才会更新 pZxid,
⼦节点内容变更不会更新。
cversion 表示⼦节点的版本号。
dataVersion 表示内容版本号。
aclVersion 标识 acl 版本
ephemeralOwner 表示创建该临时节点时的会话 sessionID,如果是持久性节点那么值为 0
dataLength 表示数据⻓度。
numChildren 表示直系⼦节点数。

Watcher–数据变更通知

Zookeeper 使⽤ Watcher 机制实现分布式数据的发布/订阅功能

⼀个典型的发布/订阅模型系统定义了⼀种 ⼀对多的订阅关系,能够让多个订阅者同时监听某⼀个主题 对象,当这个主题对象⾃身状态变化时,会通知所有订阅者,使它们能够做出相应的处理。

在 ZooKeeper 中,引⼊了 Watcher 机制来实现这种分布式的通知功能。ZooKeeper 允许客户端向服务端注册⼀个 Watcher 监听,当服务端的⼀些指定事件触发了这个 Watcher,那么就会向指定客户端发送⼀个事件通知来实现分布式的通知功能。

整个 Watcher 注册与通知过程如图所示。

image.png

Zookeeper 的 Watcher 机制主要包括客户端线程、客户端 WatcherManager、Zookeeper 服务器三部分。

具体⼯作流程为:客户端在向 Zookeeper 服务器注册的同时,会将 Watcher 对象存储在客户端的 WatcherManager 当中。当 Zookeeper 服务器触发 Watcher 事件后,会向客户端发送通知,客户端线程 从 WatcherManager 中取出对应的 Watcher 对象来执⾏回调逻辑。

ACL–保障数据的安全

Zookeeper 作为⼀个分布式协调框架,其内部存储了分布式系统运⾏时状态的元数据,这些元数据会直接影响基于 Zookeeper 进⾏构造的分布式系统的运⾏状态,因此,如何保障系统中数据的安全,从⽽避免因误操作所带来的数据随意变更⽽导致的数据库异常⼗分重要,在 Zookeeper 中,提供了⼀套完善的 ACL(Access Control List)权限控制机制来保障数据的安全。

我们可以从三个⽅⾯来理解 ACL 机制:权限模式(Scheme)、授权对象(ID)、权限 (Permission),通常使⽤"scheme: id : permission"来标识⼀个有效的 ACL 信息。

权限模式:Scheme

权限模式⽤来确定权限验证过程中使⽤的检验策略,有如下四种模式:

  1. IP

IP 模式就是通过 IP 地址粒度来进⾏权限控制,如"ip:192.168.0.110"表示权限控制针对该 IP 地址, 同时 IP 模式可以⽀持按照⽹段⽅式进⾏配置,如"ip:192.168.0.1/24"表示针对 192.168.0.*这个⽹段进⾏权限控制。

  1. Digest

Digest 是最常⽤的权限控制模式,要更符合我们对权限控制的认识,其使 ⽤"username:password"形式的权限标识来进⾏权限配置,便于区分不同应⽤来进⾏权限控制。当我们通过“username:password”形式配置了权限标识后,Zookeeper 会先后对其进⾏ SHA-1 加密 和 BASE64 编码。

  1. World

World 是⼀种最开放的权限控制模式,这种权限控制⽅式⼏乎没有任何作⽤,数据节点的访问权限 对所有⽤户开放,即所有⽤户都可以在不进⾏任何权限校验的情况下操作 ZooKeeper 上的数据。 另外,World 模式也可以看作是⼀种特殊的 Digest 模式,它只有⼀个权限标识,即“world: anyone”。

  1. Super

Super 模式,顾名思义就是超级⽤户的意思,也是⼀种特殊的 Digest 模式。在 Super 模式下,超级 ⽤户可以对任意 ZooKeeper 上的数据节点进⾏任何操作。

授权对象:ID

授权对象指的是权限赋予的⽤户或⼀个指定实体,例如 IP 地址或是机器等。在不同的权限模式下,授权对象是不同的,表中列出了各个权限模式和授权对象之间的对应关系。

权限模 式授权对象
IP通常是⼀个 IP 地址或 IP 段:例如:192.168.10.110 或 192.168.10.1/24
Digest⾃定义,通常是 username:BASE64(SHA-1(username:password))例如: zm:sdfndsllndlksfn7c=
Digest只有⼀个 ID :anyone
Super超级⽤户

权限

权限就是指那些通过权限检查后可以被允许执⾏的操作。在 ZooKeeper 中,所有对数据的操作权限分为 以下五⼤类:

  • CREATE(C):数据节点的创建权限,允许授权对象在该数据节点下创建⼦节点。
  • DELETE(D):⼦节点的删除权限,允许授权对象删除该数据节点的⼦节点。
  • READ(R):数据节点的读取权限,允 许授权对象访问该数据节点并读取其数据内容或⼦节点列表等。
  • WRITE(W):数据节点的更新权限,允许授权对象对该数据节点进⾏更新操作。
  • ADMIN(A):数据节点的管理权限,允许授权对象 对该数据节点进⾏ ACL 相关的设置操作。
3.2 ZooKeeper 命令⾏操作

现在已经搭建起了⼀个能够正常运⾏的 zookeeper 服务了,所以接下来,就是来借助客户端来对 zookeeper 的数据节点进⾏操作

⾸先,进⼊到 zookeeper 的 bin ⽬录之后

通过 zkClient 进⼊ zookeeper 客户端命令⾏

./zkcli.sh 连接本地的zookeeper服务器 ./zkCli.sh -server ip:port 连接指定的服务器

连接成功之后,系统会输出 Zookeeper 的相关环境及配置信息等信息。输⼊ help 之后,屏幕会输出可⽤ 的 Zookeeper 命令,如下图所示

image.png

创建节点

使⽤ create 命令,可以创建⼀个 Zookeeper 节点, 如

create [-s][-e] path data acl

其中,-s 或-e 分别指定节点特性,顺序或临时节点,若不指定,则创建持久节点;acl ⽤来进⾏权限控制。

① 创建顺序节点

使⽤ create -s /zk-test 123 命令创建 zk-test 顺序节点

执⾏完后,就在根节点下创建了⼀个叫做/zk-test 的节点,该节点内容就是 123,同时可以看到创建的 zk-test 节点后⾯添加了⼀串数字以示区别

② 创建临时节点

使⽤ create -e /zk-temp 123 命令创建 zk-temp 临时节点

临时节点在客户端会话结束后,就会⾃动删除,使⽤ quit 命令退出客户端就会清除

③ 创建永久节点

使⽤ create /zk-permanent 123 命令创建 zk-permanent 永久节点

创建具体操作如下

➜  bin ./zkCli.sh 
[zk: localhost:2181(CONNECTED) 1] create -s /zk-test 123 
Created /zk-test0000000000
[zk: localhost:2181(CONNECTED) 8] create -e /zk-temp 123t
Created /zk-temp
[zk: localhost:2181(CONNECTED) 9] ls /
[zk-test0000000000, zookeeper, zk-temp]
[zk: localhost:2181(CONNECTED) 10] create /zk-permanent 123p
Created /zk-permanent
[zk: localhost:2181(CONNECTED) 11] ls /
[zk-permanent, zk-test0000000000, zookeeper, zk-temp]

读取节点

与读取相关的命令有 ls 命令和 get 命令

ls 命令可以列出 Zookeeper 指定节点下的所有⼦节点,但只能查看指定节点下的第⼀级的所有⼦节点

ls path 其中,path 表示的是指定数据节点的节点路径

get 命令可以获取 Zookeeper 指定节点的数据内容和属性信息。

get path

ls2 命令和 get 命令类似,只不过获取到 直系子节点列表

若获取根节点下⾯的所有⼦节点,使⽤ ls / 命令即可

具体操作如下

[zk: localhost:2181(CONNECTED) 14] ls /       
[zk-permanent, zk-test0000000000, zookeeper, zk-temp]
[zk: localhost:2181(CONNECTED) 15] ls /zk-permanent   
[]
ls2 /
[zk-permanent, zk-test0000000000, zookeeper, zk-temp]
cZxid = 0x0
ctime = Thu Jan 01 08:00:00 CST 1970
mZxid = 0x0
mtime = Thu Jan 01 08:00:00 CST 1970
pZxid = 0x200000004
cversion = 2
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 0
numChildren = 4
[zk: localhost:2181(CONNECTED) 16] ls2 /zk-permanent
[]
cZxid = 0x200000004
ctime = Fri Jun 11 19:35:37 CST 2021
mZxid = 0x200000004
mtime = Fri Jun 11 19:35:37 CST 2021
pZxid = 0x200000004
cversion = 0
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 4
numChildren = 0
[zk: localhost:2181(CONNECTED) 17] get zk-permanent
Command failed: java.lang.IllegalArgumentException: Path must start with / character
[zk: localhost:2181(CONNECTED) 18] get /zk-permanent
123p
cZxid = 0x200000004
ctime = Fri Jun 11 19:35:37 CST 2021
mZxid = 0x200000004
mtime = Fri Jun 11 19:35:37 CST 2021
pZxid = 0x200000004
cversion = 0
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 4
numChildren = 0

从上⾯的输出信息中,我们可以看到

第⼀⾏是节点/zk-permanent 的数据内容,其次是创建该节点的事务 ID(cZxid)

最后⼀次更新该节点的事务 ID(mZxid),最后⼀次更新该节点的时间 (mtime)等属性信息

更新节点

使⽤ set 命令,可以更新指定节点的数据内容,⽤法如下

set path data [version]

其中,data 就是要更新的新内容,version 表示数据版本,在 zookeeper 中,节点的数据是有版本概 念的,这个参数⽤于指定本次更新操作是基于 Znode 的哪⼀个数据版本进⾏的,如将/zk-permanent 节点的数据更新为 456,可以使⽤如下命令:set /zk-permanent 456

[zk: localhost:2181(CONNECTED) 2] set /zk-permanent 456p
cZxid = 0x200000004
ctime = Fri Jun 11 19:35:37 CST 2021
mZxid = 0x200000007
mtime = Sat Jun 12 11:26:52 CST 2021
pZxid = 0x200000004
cversion = 0
dataVersion = 1
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 4
numChildren = 0
[zk: localhost:2181(CONNECTED) 3] get /zk-permanent
456p
cZxid = 0x200000004
ctime = Fri Jun 11 19:35:37 CST 2021
mZxid = 0x200000007
mtime = Sat Jun 12 11:26:52 CST 2021
pZxid = 0x200000004
cversion = 0
dataVersion = 1
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 4
numChildren = 0

现在 dataVersion 已经变为 1 了,表示进⾏了更新

删除节点

使⽤ delete 命令可以删除 Zookeeper 上的指定节点,⽤法如下

delete path [version]

其中 version 也是表示数据版本,使⽤ delete /zk-permanent 命令即可删除/zk-permanent 节点

[zk: localhost:2181(CONNECTED) 4] delete /zk-permanent
[zk: localhost:2181(CONNECTED) 5] ls /
[zk-test0000000000, zookeeper]

可以看到,已经成功删除/zk-permanent 节点。值得注意的是,若删除节点存在⼦节点,那么⽆法删除 该节点,必须先删除⼦节点,再删除⽗节点

3.3 Zookeeper 的 api 使⽤
3.3.1 简单介绍

Zookeeper 作为⼀个分布式框架,主要⽤来解决分布式⼀致性问题,它提供了简单的分布式原语,并且 对多种编程语⾔提供了 API,所以接下来重点来看下 Zookeeper 的 java 客户端 API 使⽤⽅式 Zookeeper API 共包含五个包,分别为:

(1)org.apache.zookeeper

(2)org.apache.zookeeper.data

(3)org.apache.zookeeper.server

(4)org.apache.zookeeper.server.quorum

(5)org.apache.zookeeper.server.upgrade

其中 org.apache.zookeeper,包含 Zookeeper 类,他是我们编程时最常⽤的类⽂件。这个类是 Zookeeper 客户端的主要类⽂件。如果要使⽤ Zookeeper 服务,应⽤程序⾸先必须创建⼀个 Zookeeper 实例,这时就需要使⽤此类。

⼀旦客户端和 Zookeeper 服务端建⽴起了连接,Zookeeper 系统将会给本次连接会话分配⼀个 ID 值,并且客户端将会周期性的向服务器端发送⼼跳来维持会话连接。只要连接有效,客户端就可以使⽤ Zookeeper API 来做相应处理了。

3.3.2 代码实现

导入依赖

 <dependency>
            <groupId>org.apache.zookeeper</groupId>
            <artifactId>zookeeper</artifactId>
            <version>3.4.14</version>
        </dependency>

创建会话

package com.galaxy.zookeeper.api;

import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.Watcher;
import org.apache.zookeeper.ZooKeeper;

import java.io.IOException;
import java.util.concurrent.CountDownLatch;

/**
 * 创建会话
 * cmd + option + l 格式化代码
 * @author lane
 * @date 2021年06月12日 下午12:07
 */
public class CreateSession implements Watcher {
    //countDownLatch这个类使⼀个线程等待,主要不让main⽅法结束
    private static CountDownLatch countDownLatch = new CountDownLatch(1);

    public static void main(String[] args) throws IOException, InterruptedException {


    /* 客户端可以通过创建⼀个zk实例来连接zk服务器
     new Zookeeper(connectString,sesssionTimeOut,Wather)
     connectString: 连接地址:IP:端⼝
     sesssionTimeOut:会话超时时间:单位毫秒
     Wather:监听器(当特定事件触发监听时,
     zk会通过watcher通知到客户端)*/

        ZooKeeper zooKeeper = new ZooKeeper("127.0.0.1:2181", 5000, new CreateSession());
        System.out.println(zooKeeper.getState());
        //等待下会话的连接,要异步向客户端发送会话完成才算是真正的连接
        countDownLatch.await();
        //表示会话真正建⽴
        System.out.println("=========Client Connected tozookeeper==========");

    }

    // 当前类实现了Watcher接⼝,重写了process⽅法,
    // 该⽅法负责处理来⾃Zookeeper服务端的 watcher通知,
    // 在收到服务端发送过来的SyncConnected事件之后,
    // 解除主程序在CountDownLatch上 的等待阻塞,
    // ⾄此,会话创建完毕
    @Override
    public void process(WatchedEvent watchedEvent) {
        //当连接创建了,服务端发送给客户端SyncConnected事件
        if (watchedEvent.getState() == Event.KeeperState.SyncConnected) {
            {
                countDownLatch.countDown();
            }
        }
    }
}

打印结果

CONNECTING
=Client Connected tozookeeper==

注意

ZooKeeper 客户端和服务端会话的建⽴是⼀个异步的过程,也就是说在程序中,构造⽅法会在处理完客户端初始化⼯作后⽴即返回,在⼤多数情况下,此时并没有真正建⽴好⼀个可⽤的会话,在会话的⽣命周期中处于“CONNECTING”的状态。

当该会话真正创建完毕后 ZooKeeper 服务端会向会话对应的客户端发送⼀个事件通知,以告知客户端,客户端只有在获取这个通知之后,才算真正建⽴了会话。

创建节点

package com.galaxy.zookeeper.api;

import org.apache.zookeeper.*;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.concurrent.CountDownLatch;

/**
 * 创建会话
 * cmd + option + l 格式化代码
 * @author lane
 * @date 2021年06月12日 下午12:07
 */
public class CreateNode implements Watcher {
    //countDownLatch这个类使⼀个线程等待,主要不让main⽅法结束
    private static CountDownLatch countDownLatch = new CountDownLatch(1);
    private static ZooKeeper zooKeeper;

    public static void main(String[] args) throws IOException, InterruptedException, KeeperException {


    /* 客户端可以通过创建⼀个zk实例来连接zk服务器
     new Zookeeper(connectString,sesssionTimeOut,Wather)
     connectString: 连接地址:IP:端⼝
     sesssionTimeOut:会话超时时间:单位毫秒
     Wather:监听器(当特定事件触发监听时,
     zk会通过watcher通知到客户端)*/

        zooKeeper = new ZooKeeper("127.0.0.1:2181", 5000, new CreateNode());
        System.out.println(zooKeeper.getState());
        //等待下会话的连接,要异步向客户端发送会话完成才算是真正的连接
        countDownLatch.await();

    }
    // 当前类实现了Watcher接⼝,重写了process⽅法,
    // 该⽅法负责处理来⾃Zookeeper服务端的 watcher通知,
    // 在收到服务端发送过来的SyncConnected事件之后,
    // 解除主程序在CountDownLatch上 的等待阻塞,
    // ⾄此,会话创建完毕
    @Override
    public void process(WatchedEvent watchedEvent) {
        //当连接创建了,服务端发送给客户端SyncConnected事件
        if (watchedEvent.getState() == Event.KeeperState.SyncConnected) {
            {
                System.out.println("发送通知完成");
                //表示会话真正建⽴
                System.out.println("Client Connected to zookeeper");
                try {
                    //创建节点
                    createNodeSync();

                } catch (UnsupportedEncodingException e) {
                    e.printStackTrace();
                } catch (KeeperException e) {
                    e.printStackTrace();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                countDownLatch.countDown();
            }

        }
    }

    public static void createNodeSync() throws UnsupportedEncodingException, KeeperException, InterruptedException {
        /**
         *  path        :节点创建的路径
         *  data[]      :节点创建要保存的数据,是个byte类型的
         *  acl         :节点创建的权限信息(4种类型)
         *                 ANYONE_ID_UNSAFE    : 表示任何人
         *                 AUTH_IDS    :此ID仅可用于设置ACL。它将被客户机验证的ID替换。
         *                 OPEN_ACL_UNSAFE    :这是一个完全开放的ACL(常用)--> world:anyone
         *                 CREATOR_ALL_ACL  :此ACL授予创建者身份验证ID的所有权限
         *  createMode    :创建节点的类型(4种类型)
         *                  PERSISTENT:持久节点
         *				    PERSISTENT_SEQUENTIAL:持久顺序节点
         *                  EPHEMERAL:临时节点
         *                  EPHEMERAL_SEQUENTIAL:临时顺序节点
         String node = zookeeper.create(path,data,acl,createMode);
         */

        String node_persistent = zooKeeper.create("/zk_persistent2", "持久节点".getBytes("utf-8"), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
        String node_persistent_sequential = zooKeeper.create("/zk_persistent_sequential2", "持久节点".getBytes("utf-8"), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT_SEQUENTIAL);
        String node_ephemeral = zooKeeper.create("/zk_ephemeral2", "临时节点".getBytes("utf-8"), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
        System.out.println("持久节点:"+node_persistent);
        System.out.println("持久顺序节点:"+node_persistent_sequential);
        System.out.println("临时节点:"+node_ephemeral);
    }
}

打印结果

CONNECTING
发送通知完成
Client Connected to zookeeper
持久节点:/zk_persistent2
持久顺序节点:/zk_persistent_sequential20000000004
临时节点:/zk_ephemeral2

获取节点数据

package com.galaxy.zookeeper.api;

import org.apache.zookeeper.*;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.List;
import java.util.concurrent.CountDownLatch;

/**
 * 创建会话
 * cmd + option + l 格式化代码
 * @author lane
 * @date 2021年06月12日 下午12:07
 */
public class GetNodeData implements Watcher {
    //countDownLatch这个类使⼀个线程等待,主要不让main⽅法结束
    private static CountDownLatch countDownLatch = new CountDownLatch(1);
    private static ZooKeeper zooKeeper;

    public static void main(String[] args) throws IOException, InterruptedException, KeeperException {


    /* 客户端可以通过创建⼀个zk实例来连接zk服务器
     new Zookeeper(connectString,sesssionTimeOut,Wather)
     connectString: 连接地址:IP:端⼝
     sesssionTimeOut:会话超时时间:单位毫秒
     Wather:监听器(当特定事件触发监听时,
     zk会通过watcher通知到客户端)*/

        zooKeeper = new ZooKeeper("127.0.0.1:2181", 5000, new GetNodeData());
        System.out.println(zooKeeper.getState());
        //等待下会话的连接,要异步向客户端发送会话完成才算是真正的连接
        countDownLatch.await();



    }
    // 当前类实现了Watcher接⼝,重写了process⽅法,
    // 该⽅法负责处理来⾃Zookeeper服务端的 watcher通知,
    // 在收到服务端发送过来的SyncConnected事件之后,
    // 解除主程序在CountDownLatch上 的等待阻塞,
    // ⾄此,会话创建完毕
    @Override
    public void process(WatchedEvent watchedEvent) {
        //当连接创建了,服务端发送给客户端SyncConnected事件
        if (watchedEvent.getState() == Event.KeeperState.SyncConnected) {
            {
            System.out.println("Client Connected to zookeeper");
                try {
                    getNoteDate();
                    //获取子节点列表
                    getChildren();
                } catch (KeeperException e) {
                    e.printStackTrace();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
        }
         /*
            子节点列表发生改变时,服务器端会发生noteChildrenChanged事件通知
            要重新获取子节点列表,同时注意:通知是一次性的,需要反复注册监听
         */
        if (watchedEvent.getType()==Event.EventType.NodeChildrenChanged){
            System.out.println("子节点列表发生改变被监听到");
            try {
                getChildren();
            countDownLatch.countDown();
            } catch (KeeperException e) {
                e.printStackTrace();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }



    }

    private void getNoteDate() throws KeeperException, InterruptedException {

        /**
         * path    : 获取数据的路径
         * watch    : 是否开启监听
         * stat    : 节点状态信息
         *        null: 表示获取最新版本的数据
         *  zk.getData(path, watch, stat);
         */

        byte[] data = zooKeeper.getData("/zk_persistent", false, null);
        System.out.println("节点内容为:"+new String(data));

    }
    public void getChildren() throws KeeperException, InterruptedException {

        /*
            path:路径
            watch:是否要启动监听,当子节点列表发生变化,会触发监听
            zooKeeper.getChildren(path, watch);
         */
        List<String> children = zooKeeper.getChildren("/zk_persistent", true);
        System.out.println("子节点列表为:"+children);

    }

}

命令行操作

[zk: localhost:2181(CONNECTED) 1] create /zk_persistent/zk_child_1 abc
Created /zk_persistent/zk_child_1

打印结果

CONNECTING
Client Connected to zookeeper
节点内容为:持久节点
子节点列表为:[]
Client Connected to zookeeper
节点内容为:持久节点
子节点列表为:[zk_child_1]
子节点列表发生改变被监听到
子节点列表为:[zk_child_1]

注意

可以看出当发生监听事件的时候会再次调用 process 方法

监听事件只执行一次再次修改的时候需要再次注册监听

修改节点数据

package com.galaxy.zookeeper.api;

import org.apache.zookeeper.*;
import org.apache.zookeeper.data.Stat;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.concurrent.CountDownLatch;

/**
 * 创建会话
 * cmd + option + l 格式化代码
 * @author lane
 * @date 2021年06月12日 下午12:07
 */
public class UpdateNode implements Watcher {
    //countDownLatch这个类使⼀个线程等待,主要不让main⽅法结束
    private static CountDownLatch countDownLatch = new CountDownLatch(1);
    private static ZooKeeper zooKeeper;

    public static void main(String[] args) throws IOException, InterruptedException, KeeperException {


    /* 客户端可以通过创建⼀个zk实例来连接zk服务器
     new Zookeeper(connectString,sesssionTimeOut,Wather)
     connectString: 连接地址:IP:端⼝
     sesssionTimeOut:会话超时时间:单位毫秒
     Wather:监听器(当特定事件触发监听时,
     zk会通过watcher通知到客户端)*/

        zooKeeper = new ZooKeeper("127.0.0.1:2181", 5000, new UpdateNode());
        System.out.println(zooKeeper.getState());
        //等待下会话的连接,要异步向客户端发送会话完成才算是真正的连接
        countDownLatch.await();



    }
    // 当前类实现了Watcher接⼝,重写了process⽅法,
    // 该⽅法负责处理来⾃Zookeeper服务端的 watcher通知,
    // 在收到服务端发送过来的SyncConnected事件之后,
    // 解除主程序在CountDownLatch上 的等待阻塞,
    // ⾄此,会话创建完毕
    @Override
    public void process(WatchedEvent watchedEvent) {
        //当连接创建了,服务端发送给客户端SyncConnected事件
        if (watchedEvent.getState() == Event.KeeperState.SyncConnected) {
            {
                System.out.println("发送通知完成");
                //表示会话真正建⽴
                System.out.println("Client Connected to zookeeper");
                try {
                    updateNode();
                } catch (KeeperException e) {
                    e.printStackTrace();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                countDownLatch.countDown();
            }

        }
    }

    private void updateNode() throws KeeperException, InterruptedException {

         /*
            path:路径
            data:要修改的内容 byte[]
            version:为-1,表示对最新版本的数据进行修改
            zooKeeper.setData(path, data,version);
         */


        byte[] data = zooKeeper.getData("/zk_persistent", false, null);
        System.out.println("修改前的值:" + new String(data));

        //修改/lg-persistent 的数据 stat: 状态信息对象
        Stat stat = zooKeeper.setData("/zk_persistent", "持久节点修改01".getBytes(), -1);

        byte[] data2 = zooKeeper.getData("/zk_persistent", false, null);
        System.out.println("修改后的值:" + new String(data2));
    }
 
}

打印结果

CONNECTING
发送通知完成
Client Connected to zookeeper
修改前的值:持久节点
修改后的值:持久节点修改 01

删除节点

package com.galaxy.zookeeper.api;

import org.apache.zookeeper.*;
import org.apache.zookeeper.data.Stat;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.concurrent.CountDownLatch;

/**
 * 创建会话
 * cmd + option + l 格式化代码
 * @author lane
 * @date 2021年06月12日 下午12:07
 */
public class DeleteNode implements Watcher {
    //countDownLatch这个类使⼀个线程等待,主要不让main⽅法结束
    private static CountDownLatch countDownLatch = new CountDownLatch(1);
    private static ZooKeeper zooKeeper;

    public static void main(String[] args) throws IOException, InterruptedException, KeeperException {


    /* 客户端可以通过创建⼀个zk实例来连接zk服务器
     new Zookeeper(connectString,sesssionTimeOut,Wather)
     connectString: 连接地址:IP:端⼝
     sesssionTimeOut:会话超时时间:单位毫秒
     Wather:监听器(当特定事件触发监听时,
     zk会通过watcher通知到客户端)*/

        zooKeeper = new ZooKeeper("127.0.0.1:2181", 5000, new DeleteNode());
        System.out.println(zooKeeper.getState());
        //等待下会话的连接,要异步向客户端发送会话完成才算是真正的连接
        countDownLatch.await();

    }
    // 当前类实现了Watcher接⼝,重写了process⽅法,
    // 该⽅法负责处理来⾃Zookeeper服务端的 watcher通知,
    // 在收到服务端发送过来的SyncConnected事件之后,
    // 解除主程序在CountDownLatch上 的等待阻塞,
    // ⾄此,会话创建完毕
    @Override
    public void process(WatchedEvent watchedEvent) {
        //当连接创建了,服务端发送给客户端SyncConnected事件
        if (watchedEvent.getState() == Event.KeeperState.SyncConnected) {
            {
                System.out.println("发送通知完成");
                //表示会话真正建⽴
                System.out.println("Client Connected to zookeeper");
                try {
                    //删除节点
                    deleteNodeSync();

                } catch (KeeperException e) {
                    e.printStackTrace();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                countDownLatch.countDown();
            }

        }
    }

    private void deleteNodeSync() throws KeeperException, InterruptedException {

         /*
      	zooKeeper.exists(path,watch) :判断节点是否存在
      	zookeeper.delete(path,version) : 删除节点
      */
        Stat stat = zooKeeper.exists("/zk_persistent/zk_child_1", false);
        System.out.println(stat==null?"节点不存在":"节点存在");
        if (stat!=null) {
            zooKeeper.delete("/zk_persistent/zk_child_1", -1);
        }
        Stat stat2 = zooKeeper.exists("/zk_persistent/zk_child_1", false);
        System.out.println(stat2==null?"节点不存在":"节点存在");
    }

}

打印结果

CONNECTING
发送通知完成
Client Connected to zookeeper
节点存在
节点不存在

3.4 Zookeeper-开源客户端
3.4.1 ZkClient 客户端

简单介绍

ZkClient 是 Github 上⼀个开源的 zookeeper 客户端,在 Zookeeper 原⽣ API 接⼝之上进⾏了包装,是⼀个 更易⽤的 Zookeeper 客户端,同时,zkClient 在内部还实现了诸如 Session 超时重连、Watcher 反复注册 等功能

添加依赖

 <!--zookeeperClient依赖-->
        <dependency>
            <groupId>com.101tec</groupId>
            <artifactId>zkclient</artifactId>
            <version>0.2</version>
        </dependency>

创建会话

package com.galaxy.zookeeper.zkclient;

import org.I0Itec.zkclient.ZkClient;

/**
 * @author lane
 * @date 2021年06月12日 下午4:07
 */
public class CreateSession {

    public static void main(String[] args) {
        /*
            创建一个zkclient实例就可以完成连接,完成会话的创建
            serverString : 服务器连接地址
            注意:zkClient通过对zookeeperAPI内部封装,将这个异步创建会话的过程同步化了..
         */
        ZkClient zkClient = new ZkClient("127.0.0.1:2181");
        //同步的了
        System.out.println("客户端创建完成");
    }
}

注意

zkClient 通过对 zookeeperAPI 内部封装,将异步创建会话的过程同步化了…

递归创建节点

ZkClient 提供了递归创建节点的接⼝,即其帮助开发者先完成⽗节点的创建,再创建⼦节点

package com.galaxy.zookeeper.zkclient;

import org.I0Itec.zkclient.ZkClient;

/**
 * 递归创建节点
 * @author lane
 * @date 2021年06月12日 下午4:07
 */
public class CreateNode {

    public static void main(String[] args) {
        /*
            创建一个zkclient实例就可以完成连接,完成会话的创建
            serverString : 服务器连接地址
            注意:zkClient通过对zookeeperAPI内部封装,将这个异步创建会话的过程同步化了..
         */
        ZkClient zkClient = new ZkClient("127.0.0.1:2181");
        //同步的了
        System.out.println("客户端创建完成");
        //创建节点
        /*
            cereateParents : 是否要创建父节点,如果值为true,那么就会递归创建节点
         */
        zkClient.createPersistent("/zk_a1/zk_b1", true);
        System.out.println("节点创建完成"+zkClient.getChildren("/zk_a1") );

    }
}

打印效果

客户端创建完成
节点创建完成[zk_b1]

注意

cereateParents : 是否要创建父节点,如果值为 true,那么就会递归创建节点父子节点

在原⽣态接⼝中是⽆法创建成功的(⽗节点不存在), 但是通过 ZkClient 通过设置 createParents 参数为 true 可以递归的先创建⽗节点,再创建⼦节点

递归删除节点

ZkClient 提供了递归删除节点的接⼝,即其帮助开发者先删除所有⼦节点(存在),再删除⽗节点。

package com.galaxy.zookeeper.zkclient;

import org.I0Itec.zkclient.ZkClient;

/**
 * 递归删除节点
 * @author lane
 * @date 2021年06月12日 下午4:07
 */
public class DeleteNode {

    public static void main(String[] args) {
        /*
            创建一个zkclient实例就可以完成连接,完成会话的创建
            serverString : 服务器连接地址
            注意:zkClient通过对zookeeperAPI内部封装,将这个异步创建会话的过程同步化了..
         */
        ZkClient zkClient = new ZkClient("127.0.0.1:2181");
        //同步的了
        System.out.println("客户端创建完成");
        //创建节点
        /*
            cereateParents : 是否要创建父节点,如果值为true,那么就会递归创建节点
         */
        zkClient.createPersistent("/zk_a1/zk_b1/zk_c1", true);
        System.out.println("删除前"+zkClient.getChildren("/zk_a1/zk_b1"));
        zkClient.deleteRecursive("/zk_a1/zk_b1");
        System.out.println("删除后"+zkClient.getChildren("/zk_a1"));
    }
}

打印效果

客户端创建完成
删除前[zk_c1]
删除后[]

注意

zkClient.deleteRecursive("/zk_a1/zk_b1"); 是删除 zk_b1 及其所以子节点

其底层先删除其所有⼦节点,然后再删除⽗节点

获取及监听节点

package com.galaxy.zookeeper.zkclient;

import org.I0Itec.zkclient.IZkChildListener;
import org.I0Itec.zkclient.ZkClient;

import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * 获取节点
 * @author lane
 * @date 2021年06月12日 下午4:07
 */
public class GetNode {

    public static void main(String[] args) throws InterruptedException {
        /*
            创建一个zkclient实例就可以完成连接,完成会话的创建
            serverString : 服务器连接地址
            注意:zkClient通过对zookeeperAPI内部封装,将这个异步创建会话的过程同步化了..
         */
        ZkClient zkClient = new ZkClient("127.0.0.1:2181");
        //同步的了
        System.out.println("客户端创建完成");
        //创建节点
        /*
            cereateParents : 是否要创建父节点,如果值为true,那么就会递归创建节点
         */
        zkClient.createPersistent("/zk_a1/zk_b4", false);

        List<String> children = zkClient.getChildren("/zk_a1");

        System.out.println("创建完成"+children);
          /*
            客户端可以对一个不存在的节点进行子节点变更的监听
            只要该节点的子节点列表发生变化,或者该节点本身被创建或者删除,都会触发监听
         */
        zkClient.subscribeChildChanges("/zk_a4", new IZkChildListener() {
            /*
                s : parentPath
                list : 变化后子节点列表
             */
            @Override
            public void handleChildChange(String parentPath, List<String> list) throws Exception {
                System.out.println(parentPath+"的子节点发生了变化,变化后为"+list);

            }
        });

        /*
            cereateParents : 是否要创建父节点,如果值为true,那么就会递归创建节点
         */
        zkClient.createPersistent("/zk_a4");
        TimeUnit.SECONDS.sleep(1);

        zkClient.createPersistent("/zk_a4/zk_b1");
        TimeUnit.SECONDS.sleep(1);
    }

}

打印效果

客户端创建完成
创建完成[zk_b4, zk_b3, zk_b2]
/zk_a4 的子节点发生了变化,变化后为[]
/zk_a4 的子节点发生了变化,变化后为[zk_b1]

注意

  1. 客户端可以对一个不存在的节点进行子节点变更的监听
  2. 只要该节点的子节点列表发生变化,或者该节点本身被创建或者删除,都会触发监听
  3. 监听是异步的

获取数据(节点是否存在、更新、删除)

package com.galaxy.zookeeper.zkclient;

import org.I0Itec.zkclient.IZkDataListener;
import org.I0Itec.zkclient.ZkClient;

import java.util.concurrent.TimeUnit;

/**
 * 递归创建节点
 * @author lane
 * @date 2021年06月12日 下午4:07
 */
public class SummaryNode {

    public static void main(String[] args) throws InterruptedException {
        /*
            创建一个zkclient实例就可以完成连接,完成会话的创建
            serverString : 服务器连接地址
            注意:zkClient通过对zookeeperAPI内部封装,将这个异步创建会话的过程同步化了..
         */
        ZkClient zkClient = new ZkClient("127.0.0.1:2181");
        //同步的了
        System.out.println("客户端创建完成");

        //判断节点是否存在
        boolean zk_a5 = zkClient.exists("/zk_a5");
        if (!zk_a5){
            //创建节点
            zkClient.createEphemeral("/zk_a5","a5_context");
        }
        //读取节点内容
        Object readData = zkClient.readData("/zk_a5");
        System.out.println("节点内容为:"+readData);
        //注册监听
        zkClient.subscribeDataChanges("/zk_a5", new IZkDataListener() {
            /*
               当节点数据内容发生变化时,执行的回调方法
               s: path
               o: 变化后的节点内容
            */
            @Override
            public void handleDataChange(String s, Object o) throws Exception {
                System.out.println(s+" 节点变更后的内容为:"+o);
            }
            /*
                当节点被删除时,会执行的回调方法
                s : path
             */
            @Override
            public void handleDataDeleted(String s) throws Exception {
                System.out.println(s+ "被删除了");
            }
        });
        //更新节点内容
        zkClient.writeData("/zk_a5", "a5_context_change");
        System.out.println("此时更新节点完成");
        TimeUnit.SECONDS.sleep(1);
        //删除节点
        zkClient.delete("/zk_a5");
        System.out.println("此时删除节点完成");
        TimeUnit.SECONDS.sleep(1);

    }
}

运行结果

客户端创建完成
节点内容为:a5_context
此时更新节点完成
/zk_a5 节点变更后的内容为:a5_context_change
此时删除节点完成
/zk_a5 被删除了

注意

异步监听通知

3.4.2 Curator 客户端

简单介绍

curator 是 Netflix 公司开源的⼀套 Zookeeper 客户端框架,和 ZKClient ⼀样,Curator 解决了很多 Zookeeper 客户端⾮常底层的细节开发⼯作,包括连接重连,反复注册 Watcher 和 NodeExistsException 异常等,是最流⾏的 Zookeeper 客户端之⼀。从编码⻛格上来讲,它提供了基于 Fluent 的编程⻛格⽀持

Fluent 风格

image.png

添加依赖

<dependency> 
 <groupId>org.apache.curator</groupId>
 <artifactId>curator-framework</artifactId>
 <version>2.12.0</version>
</dependency>

创建会话

Curator 的创建会话⽅式与原⽣的 API 和 ZkClient 的创建⽅式区别很⼤。Curator 创建客户端是通过 CuratorFrameworkFactory ⼯⼚类来实现的。具体如下:

1.使⽤ CuratorFramework 这个⼯⼚类的两个静态⽅法来创建⼀个客户端

public static CuratorFramework newClient(String connectString, RetryPolicy retryPolicy)

public static CuratorFramework newClient(String connectString, int sessionTimeoutMs, int connectionTimeoutMs, RetryPolicy retryPolicy)

其中参数 RetryPolicy 提供重试策略的接⼝,可以让⽤户实现⾃定义的重试策略,默认提供了以下实现, 分别为 ExponentialBackoffRetry(基于 backoff 的重连策略)、RetryNTimes(重连 N 次策略)、 RetryForever(永远重试策略)、

2.通过调⽤ CuratorFramework 中的 start()⽅法来启动会话

RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000,3);
CuratorFramework client = CuratorFrameworkFactory.newClient("127.0.0.1:2181",retryPolicy); 
client.start();
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000,3); 
CuratorFramework client = CuratorFrameworkFactory.newClient("127.0.0.1:2181", 5000,1000,retryPolicy);
client.start();

其实进⼀步查看源代码可以得知,其实这两种⽅法内部实现⼀样,只是对外包装成不同的⽅法。它们的底层都是通过第三个⽅法 builder 来实现的

RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000,3);

private static CuratorFramework Client = CuratorFrameworkFactory.builder() .connectString("server1:2181,server2:2181,server3:2181") .sessionTimeoutMs(50000) .connectionTimeoutMs(30000) .retryPolicy(retryPolicy) .build(); client.start();

image.png

源码解读


    public class CuratorFrameworkFactory {

    public static CuratorFrameworkFactory.Builder builder() {
        return new CuratorFrameworkFactory.Builder();
    }

    //静态内部类
    public static class Builder {
         //set方法没有加set且返回为当前对象
         public CuratorFrameworkFactory.Builder sessionTimeoutMs(int sessionTimeoutMs) {
            this.sessionTimeoutMs = sessionTimeoutMs;
            return this;
        }
        //build方法返回当前对象
          public CuratorFramework build() {
            return new CuratorFrameworkImpl(this);
        }

         private Builder() {
            this.sessionTimeoutMs = CuratorFrameworkFactory.DEFAULT_SESSION_TIMEOUT_MS;
            this.connectionTimeoutMs = CuratorFrameworkFactory.DEFAULT_CONNECTION_TIMEOUT_MS;
            this.maxCloseWaitMs = CuratorFrameworkFactory.DEFAULT_CLOSE_WAIT_MS;
            this.threadFactory = null;
            this.authInfos = null;
            this.defaultData = CuratorFrameworkFactory.LOCAL_ADDRESS;
            this.compressionProvider = CuratorFrameworkFactory.DEFAULT_COMPRESSION_PROVIDER;
            this.zookeeperFactory = CuratorFrameworkFactory.DEFAULT_ZOOKEEPER_FACTORY;
            this.aclProvider = CuratorFrameworkFactory.DEFAULT_ACL_PROVIDER;
            this.canBeReadOnly = false;
            this.useContainerParentsIfAvailable = true;
        }
    }

    }
   }

------------------------------------------------------------------------------------------
public class CuratorFrameworkImpl implements CuratorFramework {

    public CuratorFrameworkImpl(CuratorFrameworkFactory.Builder builder) {
        ZookeeperFactory localZookeeperFactory = this.makeZookeeperFactory(builder.getZookeeperFactory());
        this.client = new CuratorZookeeperClient(localZookeeperFactory, builder.getEnsembleProvider(), builder.getSessionTimeoutMs(), builder.getConnectionTimeoutMs(), new Watcher() {
            public void process(WatchedEvent watchedEvent) {
                CuratorEvent event = new CuratorEventImpl(CuratorFrameworkImpl.this, CuratorEventType.WATCHED, watchedEvent.getState().getIntValue(), CuratorFrameworkImpl.this.unfixForNamespace(watchedEvent.getPath()), (String) null, (Object) null, (Stat) null, (byte[]) null, (List) null, watchedEvent, (List) null);
                CuratorFrameworkImpl.this.processEvent(event);
            }
        }, builder.getRetryPolicy(), builder.canBeReadOnly());

    }
  1. CuratorFrameworkFactory.builder() 会生成一个 CuratorFrameworkFactory 的静态内部类 public static class Builder 对象
  2. 调用该对象的类似 set 方法 public CuratorFrameworkFactory.Builder sessionTimeoutMs(int sessionTimeoutMs) 进行赋值并返回 this 当前 Builder 对象
  3. 重复 2 的步骤进行其他字段赋值
  4. 调用 build(); 方法返回 return new CuratorFrameworkImpl(this); 获取 CuratorFramework 的实现类对象
  5. 在 4 中的构造方法 public CuratorFrameworkImpl(CuratorFrameworkFactory.Builder builder) 获取 Builder 中的 set 的值赋予其自身所需。

代码实现创建会话

package com.galaxy.zookeeper.curator;

import org.apache.curator.CuratorZookeeperClient;
import org.apache.curator.RetryPolicy;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.ExponentialBackoffRetry;


import java.util.List;

/**
 * @author lane
 * @date 2021年06月13日 上午11:08
 */
public class CreateSession {

    public static void main(String[] args) {

        RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
        //创建客户端连接
        CuratorFramework curatorFramework = CuratorFrameworkFactory.newClient("127.0.0.1:2181", retryPolicy);
        //创建会话
        curatorFramework.start();
        System.out.println("客户端会话连接成功");

        CuratorFramework client = CuratorFrameworkFactory.newClient("127.0.0.1:2181", 5000,1000,retryPolicy);
        client.start();
        System.out.println("客户端会话2连接成功");
        //其底层方法创建会话fluent风格
        CuratorFramework build = CuratorFrameworkFactory.builder()
                .connectString("127.0.0.1:2181")
                .sessionTimeoutMs(50000)
                .connectionTimeoutMs(1000)
                .retryPolicy(retryPolicy)
                .namespace("base")// 独立的命名空间 /base
                .build();
        build.start();
        System.out.println("客户端会话3连接成功");
    }

}

实现效果

注意

session 会话含有隔离命名空间 namespace,即客户端对 Zookeeper 上数据节点的任何操作都是相 对/base ⽬录进⾏的,这有利于实现不同的 Zookeeper 的业务之间的隔离

创建节点

curator 提供了⼀系列 Fluent ⻛格的接⼝,通过使⽤ Fluent 编程⻛格的接⼝,开发⼈员可以进⾏⾃由组合来完成各种类型节点的创建。

下⾯简单介绍⼀下常⽤的⼏个节点创建场景。

(1)创建⼀个初始内容为空的节点

client.create().forPath(path);

Curator 默认创建的是持久节点,内容为空。

(2)创建⼀个包含内容的节点

client.create().forPath(path,"我是内容".getBytes());

Curator 和 ZkClient 不同的是依旧采⽤ Zookeeper 原⽣ API 的⻛格,内容使⽤ byte[]作为⽅法参数。

(3)递归创建⽗节点,并选择节点类型

client.create().creatingParentsIfNeeded().withMode(CreateMode.EPHEMERAL).forPath(path);

creatingParentsIfNeeded 这个接⼝⾮常有⽤,在使⽤ ZooKeeper 的过程中,开发⼈员经常会碰到 NoNodeException 异常,其中⼀个可能的原因就是试图对⼀个不存在的⽗节点创建⼦节点。因此,开发⼈员不得不在每次创建节点之前,都判断⼀下该⽗节点是否存在——这个处理通常⽐较麻烦。在使⽤ Curator 之后,通过调⽤ creatingParentsIfNeeded 接⼝,Curator 就能够⾃动地递归创建所有需要的⽗节点。

代码实现

package com.galaxy.zookeeper.curator;

import org.apache.curator.RetryPolicy;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.apache.zookeeper.CreateMode;

import java.io.UnsupportedEncodingException;

/**
 * @author lane
 * @date 2021年06月13日 下午12:09
 */
public class CreateNode {

    public static void main(String[] args) throws Exception {

        RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);

        //其底层方法创建会话fluent风格
        CuratorFramework client = CuratorFrameworkFactory.builder()
                .connectString("127.0.0.1:2181") //server地址
                .sessionTimeoutMs(50000)    //会话超时时间
                .connectionTimeoutMs(10000) //连接超时时间
                .retryPolicy(retryPolicy)   //重试策略
                .namespace("base")// 独立的命名空间 /base
                .build();
        client.start();
        System.out.println("客户端会话连接成功");
        String path = "/zk_a6/b1";
        //可以递归创建,如果父节点不存在则创建
        String forPath = client.create()
                .creatingParentsIfNeeded()
                .withMode(CreateMode.PERSISTENT)
                .forPath(path, "b1_context".getBytes("utf-8"));
        System.out.println("节点创建完成:"+forPath);
    }

}

打印效果

客户端会话连接成功
节点创建完成:/zk_a6/b1

命令界面

[zk: localhost:2181(CONNECTED) 1] ls /
[base, zk_a1, zookeeper]
[zk: localhost:2181(CONNECTED) 3] ls /base
[zk_a6]
[zk: localhost:2181(CONNECTED) 4] ls /base/zk_a6
[b1]
[zk: localhost:2181(CONNECTED) 5] get  /base/zk_a6

[zk: localhost:2181(CONNECTED) 6] get /base/zk_a6/b1
b1_context

子节点和父节点都成功创建了

删除节点

删除节点的⽅法也是基于 Fluent ⽅式来进⾏操作,不同类型的操作调⽤ 新增不同的⽅法调⽤即可。

(1)删除⼀个⼦节点

client.delete().forPath(path);

(2)删除节点并递归删除其⼦节点

client.delete().deletingChildrenIfNeeded().forPath(path);

(3)指定版本进⾏删除

client.delete().withVersion(1).forPath(path);

如果此版本已经不存在,则删除异常,异常信息如下。

org.apache.zookeeper.KeeperException$BadVersionException: KeeperErrorCode = BadVersion for

(4)强制保证删除⼀个节点

client.delete().guaranteed().forPath(path);

只要客户端会话有效,那么 Curator 会在后台持续进⾏删除操作,直到节点删除成功。⽐如遇到⼀些⽹络异常的情况,此 guaranteed 的强制删除就会很有效果。

代码实现

package com.galaxy.zookeeper.curator;

import org.apache.curator.RetryPolicy;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.apache.zookeeper.CreateMode;

/**
 * @author lane
 * @date 2021年06月13日 下午12:09
 */
public class DeleteNode {

    public static void main(String[] args) throws Exception {

        RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);

        //其底层方法创建会话fluent风格
        CuratorFramework client = CuratorFrameworkFactory.builder()
                .connectString("127.0.0.1:2181") //server地址
                .sessionTimeoutMs(50000)    //会话超时时间
                .connectionTimeoutMs(10000) //连接超时时间
                .retryPolicy(retryPolicy)   //重试策略
                .namespace("base")// 独立的命名空间 /base
                .build();
        client.start();
        System.out.println("客户端会话连接成功");
        String path = "/zk_a6";
        Void aVoid = client.delete().deletingChildrenIfNeeded().withVersion(-1).forPath(path);
        System.out.println("删除节点成功");
    }
}

运行效果

客户端会话连接成功
删除节点成功

终端界面

[zk: localhost:2181(CONNECTED) 7] ls /base
[]

获取数据

获取节点数据内容 API 相当简单,同时 Curator 提供了传⼊⼀个 Stat 变量的⽅式来存储服务器端返回的最新的节点状态信息

// 普通查询 
client.getData().forPath(path);
// 包含状态查询 
Stat stat = new Stat();
client.getData().storingStatIn(stat).forPath(path);

代码实现

package com.galaxy.zookeeper.curator;

import org.apache.curator.RetryPolicy;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.data.Stat;

/**
 * @author lane
 * @date 2021年06月13日 下午12:09
 */
public class GetNode {

    public static void main(String[] args) throws Exception {

        RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);

        //其底层方法创建会话fluent风格
        CuratorFramework client = CuratorFrameworkFactory.builder()
                .connectString("127.0.0.1:2181") //server地址
                .sessionTimeoutMs(50000)    //会话超时时间
                .connectionTimeoutMs(10000) //连接超时时间
                .retryPolicy(retryPolicy)   //重试策略
                .namespace("base")// 独立的命名空间 /base
                .build();
        client.start();
        System.out.println("客户端会话连接成功");
        String path = "/zk_a6/b1";
        //可以递归创建,如果父节点不存在则创建
        String forPath = client.create()
                .creatingParentsIfNeeded()
                .withMode(CreateMode.PERSISTENT)
                .forPath(path, "b1_context".getBytes("utf-8"));
        System.out.println("节点创建完成:"+forPath);
        //普通查询
        byte[] bytes = client.getData().forPath(path);
        System.out.println(path+"的节点数据是"+new String(bytes));
        //状态查询
        Stat stat = new Stat();
        byte[] bytes1 = client.getData().storingStatIn(stat).forPath(path);
        System.out.println(path+"的节点数据是"+new String(bytes1)+" 状态信息是"+stat);

    }

}

运行结果

客户端会话连接成功
节点创建完成:/zk_a6/b1
/zk_a6/b1 的节点数据是 b1_context
/zk_a6/b1 的节点数据是 b1_context 状态信息是 47,47,1623564319182,1623564319182,0,0,0,0,10,0,47

更新数据

更新数据,如果未传⼊ version 参数,那么更新当前最新版本,如果传⼊ version 则更新指定 version,如 果 version 已经变更,则抛出异常。

// 普通更新

client.setData().forPath(path,"新内容".getBytes());

// 指定版本更新

client.setData().withVersion(1).forPath(path);

版本不⼀致异常信息:

org.apache.zookeeper.KeeperException$BadVersionException: KeeperErrorCode = BadVersion for

代码实现

package com.galaxy.zookeeper.curator;

import org.apache.curator.RetryPolicy;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.data.Stat;

/**
 * @author lane
 * @date 2021年06月13日 下午12:09
 */
public class UpdateNode {

    public static void main(String[] args) throws Exception {

        RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);

        //其底层方法创建会话fluent风格
        CuratorFramework client = CuratorFrameworkFactory.builder()
                .connectString("127.0.0.1:2181") //server地址
                .sessionTimeoutMs(50000)    //会话超时时间
                .connectionTimeoutMs(10000) //连接超时时间
                .retryPolicy(retryPolicy)   //重试策略
                .namespace("base")// 独立的命名空间 /base
                .build();
        client.start();
        System.out.println("客户端会话连接成功");
        String path = "/zk_a6/b1";
        //状态查询
        Stat stat = new Stat();
        byte[] bytes1 = client.getData().storingStatIn(stat).forPath(path);
        System.out.println(path+"的节点数据是"+new String(bytes1));
        int version = stat.getVersion();
        System.out.println("当前的版本为:"+version);
        client.setData().withVersion(version).forPath(path,"b1_content3".getBytes());
        byte[] bytes2 = client.getData().storingStatIn(stat).forPath(path);
        System.out.println(path+"的节点更新后数据是"+new String(bytes2));
        //如果版本不存在则是报错
        client.setData().withVersion(version).forPath(path,"b1_content4".getBytes());

    }

}

运行效果

客户端会话连接成功
/zk_a6/b1 的节点数据是 b1_content2
当前的版本为:1
/zk_a6/b1 的节点更新后数据是 b1_content3
Exception in thread “main” org.apache.zookeeper.KeeperException$BadVersionException: KeeperErrorCode = BadVersion for /base/zk_a6/b1

当携带数据版本不⼀致时,⽆法完成更新操作,-1 指定最新版本

第四节 Zookeeper 应⽤场景

ZooKeeper 是⼀个典型的发布/订阅模式的分布式数据管理与协调框架,我们可以使⽤它来进⾏分布式数据的发布与订阅。另⼀⽅⾯,通过对 ZooKeeper 中丰富的数据节点类型进⾏交叉使⽤,配合 Watcher 事件通知机制,可以⾮常⽅便地构建⼀系列分布式应⽤中都会涉及的核⼼功能,如数据发布/订阅、命名 服务、集群管理、Master 选举、分布式锁和分布式队列等。那接下来就针对这些典型的分布式应⽤场景来做下介绍

4.1 数据发布/订阅

数据发布/订阅(Publish/Subscribe)系统,即所谓的配置中⼼,顾名思义就是发布者将数据发布到 ZooKeeper 的⼀个或⼀系列节点上,供订阅者进⾏数据订阅,进⽽达到动态获取数据的⽬的,实现配置信息的集中式管理和数据的动态更新。

发布/订阅系统⼀般有两种设计模式,分别是推(Push)模式和拉(Pull)模式。在推模式中,服务端主动将数据更新发送给所有订阅的客户端;⽽拉模式则是由客户端主动发起请求来获取最新数据,通常客户端都采⽤定时进⾏轮询拉取的⽅式

ZooKeeper 采⽤的是推拉相结合的⽅式:客户端向服务端注册⾃⼰需要关注的节点,⼀旦该节点的数据发⽣变更,那么服务端就会向相应的客户端发送 Watcher 事件通知,客户端接收到这个消息通知之后, 需要主动到服务端获取最新的数据。

如果将配置信息存放到 ZooKeeper 上进⾏集中管理,那么通常情况下,应⽤在启动的时候都会主动到 ZooKeeper 服务端上进⾏⼀次配置信息的获取,同时,在指定节点上注册⼀个 Watcher 监听,这样⼀ 来,但凡配置信息发⽣变更,服务端都会实时通知到所有订阅的客户端,从⽽达到实时获取最新配置信息的⽬的。

下⾯我们通过⼀个“配置管理”的实际案例来展示 ZooKeeper 在“数据发布/订阅”场景下的使⽤⽅式。

在我们平常的应⽤系统开发中,经常会碰到这样的需求:系统中需要使⽤⼀些通⽤的配置信息,例如机器列表信息、运⾏时的开关配置、数据库配置信息等。这些全局配置信息通常具备以下 3 个特性。

  • 数据量通常⽐较⼩。
  • 数据内容在运⾏时会发⽣动态变化。集群中各机器共享,配置⼀致。
  • 集群中各机器共享,配置⼀致。

对于这类配置信息,⼀般的做法通常可以选择将其存储在本地配置⽂件或是内存变量中。⽆论采⽤哪种 ⽅式,其实都可以简单地实现配置管理,在集群机器规模不⼤、配置变更不是特别频繁的情况下,⽆论刚刚提到的哪种⽅式,都能够⾮常⽅便地解决配置管理的问题。但是,⼀旦机器规模变⼤,且配置信息 变更越来越频繁后,我们发现依靠现有的这两种⽅式解决配置管理就变得越来越困难了。我们既希望能够快速地做到全局配置信息的变更,同时希望变更成本⾜够⼩,因此我们必须寻求⼀种更为分布式化的解决⽅案

接下来我们就以⼀个“数据库切换”的应⽤场景展开,看看如何使⽤ ZooKeeper 来实现配置管理:

配置存储

在进⾏配置管理之前,⾸先我们需要将初始化配置信息存储到 Zookeeper 上去,⼀般情况下,我们可以 在 Zookeeper 上选取⼀个数据节点⽤于配置信息的存储,例如:/app1/database_config

image.png

配置管理的 zookeeper 节点示意图

我们将需要管理的配置信息写⼊到该数据节点中去,例如:

#数据库配置信息
#DBCP 
dbcp.driverClassName=com.mysql.jdbc.Driver 
dbcp.dbJDBCUrl=jdbc:mysql://127.0.0.1:3306/test
dbcp.username=zm 
dbcp.password=1234 
dbcp.maxActive=30 dbcp.maxIdle=10

配置获取

集群中每台机器在启动初始化阶段,⾸先会从上⾯提到的 ZooKeeper 配置节点上读取数据库信息,同时,客户端还需要在该配置节点上注册⼀个数据变更的 Watcher 监听,⼀旦发⽣节点数据变更,所有订阅的客户端都能够获取到数据变更通知。

配置变更

在系统运⾏过程中,可能会出现需要进⾏数据库切换的情况,这个时候就需要进⾏配置变更。借助 ZooKeeper,我们只需要对 ZooKeeper 上配置节点的内容进⾏更新,ZooKeeper 就能够帮我们将数据变更的通知发送到各个客户端,每个客户端在接收到这个变更通知后,就可以重新进⾏最新数据的获取。

4.2 命名服务

命名服务(Name Service)也是分布式系统中⽐较常⻅的⼀类场景,是分布式系统最基本的公共服务之 ⼀。在分布式系统中,被命名的实体通常可以是集群中的机器、提供的服务地址或远程对象等——这些我们都可以统称它们为名字(Name),其中较为常⻅的就是⼀些分布式服务框架(如 RPC、RMI)中 的服务地址列表,通过使⽤命名服务,客户端应⽤能够根据指定名字来获取资源的实体、服务地址和提供者的信息等。

ZooKeeper 提供的命名服务功能能够帮助应⽤系统通过⼀个资源引⽤的⽅式来实现对资源的定位与使⽤。另外,⼴义上命名服务的资源定位都不是真正意义的实体资源——在分布式环境中,上层应⽤仅仅需要⼀个全局唯⼀的名字,类似于数据库中的唯⼀主键。

所以接下来。我们来看看如何使⽤ ZooKeeper 来实现⼀套分布式全局唯⼀ ID 的分配机制

所谓 ID,就是⼀个能够唯⼀标识某个对象的标识符。在我们熟悉的关系型数据库中,各个表都需要⼀个 主键来唯⼀标识每条数据库记录,这个主键就是这样的唯⼀ ID。在过去的单库单表型系统中,通常可以 使⽤数据库字段⾃带的 auto_increment 属性来⾃动为每条数据库记录⽣成⼀个唯⼀的 ID,数据库会保证 ⽣成的这个 ID 在全局唯⼀。但是随着数据库数据规模的不断增⼤,分库分表随之出现,⽽ auto_increment 属性仅能针对单⼀表中的记录⾃动⽣成 ID,因此在这种情况下,就⽆法再依靠数据库的 auto_increment 属性来唯⼀标识⼀条记录了。于是,我们必须寻求⼀种能够在分布式环境下⽣成全局唯 ⼀ ID 的⽅法。

⼀说起全局唯⼀ ID,相信⼤家都会联想到 UUID。没错,UUID 是通⽤唯⼀识别码(Universally Unique Identifier)的简称,是⼀种在分布式系统中⼴泛使⽤的⽤于唯⼀标识元素的标准。确实,UUID 是⼀个⾮常不错的全局唯⼀ ID ⽣成⽅式,能够⾮常简便地保证分布式环境中的唯⼀性。⼀个标准的 UUID 是⼀个包含 32 位字符和 4 个短线的字符串,例如“e70f1357-f260-46ff-a32d-53a086c57ade”。 UUID 的优势⾃然不必多说,我们重点来看看它的缺陷。

⻓度过⻓

UUID 最⼤的问题就在于⽣成的字符串过⻓。显然,和数据库中的 INT 类型相⽐,存储⼀个 UUID 需要花费更多的空间。

含义不明

上⾯我们已经看到⼀个典型的 UUID 是类似于“e70f1357-f260-46ff-a32d-53a086c57ade”的⼀个字符串。根据这个字符串,开发⼈员从字⾯上基本看不出任何其表达的含义,这将会⼤⼤影响问题排查和开发调试的效率。

所以接下来,我们结合⼀个分布式任务调度系统来看看如何使⽤ ZooKeepe 来实现这类全局唯⼀ ID 的⽣成。 之前我们已经提到,通过调⽤ ZooKeeper 节点创建的 API 接⼝可以创建⼀个顺序节点,并且在 API 返回值中会返回这个节点的完整名字。利⽤这个特性,我们就可以借助 ZooKeeper 来⽣成全局唯⼀的 ID 了,如下图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AJ7qTrRx-1623757845734)(https://scenery-1303094101.cos.ap-shanghai.myqcloud.com/uPic/20210615/image-20210613145442-o0hu25c.png “全局唯⼀ID⽣成的ZooKeeper节点示意图”)]

说明,对于⼀个任务列表的主键,使⽤ ZooKeeper ⽣成唯⼀ ID 的基本步骤:

  1. 所有客户端都会根据⾃⼰的任务类型,在指定类型的任务下⾯通过调⽤ create()接⼝来创建⼀个 顺序节点,例如创建“job-”节点。
  2. 节点创建完毕后,create()接⼝会返回⼀个完整的节点名,例如“job-0000000003”。
  3. 客户端拿到这个返回值后,拼接上 type 类型,例如“type2-job-0000000003”,这就可以作为⼀个全局唯⼀的 ID 了。

在 ZooKeeper 中,每⼀个数据节点都能够维护⼀份⼦节点的顺序序列,当客户端对其创建⼀个顺序⼦节点的时候 ZooKeeper 会⾃动以后缀的形式在其⼦节点上添加⼀个序号,在这个场景中就是利⽤了 ZooKeeper 的这个特性

4.3 集群管理

随着分布式系统规模的⽇益扩⼤,集群中的机器规模也随之变⼤,那如何更好地进⾏集群管理也显得越 来越重要了。所谓集群管理,包括集群监控与集群控制两⼤块,前者侧重对集群运⾏时状态的收集,后者则是对集群进⾏操作与控制。

在⽇常开发和运维过程中,我们经常会有类似于如下的需求:

  • 如何快速的统计出当前⽣产环境下⼀共有多少台机器
  • 如何快速的获取到机器上下线的情况
  • 如何实时监控集群中每台主机的运⾏时状态

在传统的基于 Agent 的分布式集群管理体系中,都是通过在集群中的每台机器上部署⼀个 Agent,由这 个 Agent 负责主动向指定的⼀个监控中⼼系统(监控中⼼系统负责将所有数据进⾏集中处理,形成⼀系列报表,并负责实时报警,以下简称“监控中⼼”)汇报⾃⼰所在机器的状态。在集群规模适中的场景下,这确实是⼀种在⽣产实践中⼴泛使⽤的解决⽅案,能够快速有效地实现分布式环境集群监控,但是 ⼀旦系统的业务场景增多,集群规模变⼤之后,该解决⽅案的弊端也就显现出来了。

⼤规模升级困难

以客户端形式存在的 Agent,在⼤规模使⽤后,⼀旦遇上需要⼤规模升级的情况,就⾮常麻烦,在升级成本和升级进度的控制上⾯临巨⼤的挑战。

统⼀的 Agent ⽆法满⾜多样的需求

对于机器的 CPU 使⽤率、负载(Load)、内存使⽤率、⽹络吞吐以及磁盘容量等机器基本的物理状态, 使⽤统⼀的 Agent 来进⾏监控或许都可以满⾜。但是,如果需要深⼊应⽤内部,对⼀些业务状态进⾏监控,例如,在⼀个分布式消息中间件中,希望监控到每个消费者对消息的消费状态;或者在⼀个分布式 任务调度系统中,需要对每个机器上任务的执⾏情况进⾏监控。很显然,对于这些业务耦合紧密的监控需求,不适合由⼀个统⼀的 Agent 来提供。

编程语⾔多样性

随着越来越多编程语⾔的出现,各种异构系统层出不穷。如果使⽤传统的 Agent ⽅式,那么需要提供各种语⾔的 Agent 客户端。另⼀⽅⾯,“监控中⼼”在对异构系统的数据进⾏整合上⾯临巨⼤挑战。

Zookeeper 的两⼤特性:

1.客户端如果对 Zookeeper 的数据节点注册 Watcher 监听,那么当该数据节点的内容或是其⼦节点 列表发⽣变更时,Zookeeper 服务器就会向订阅的客户端发送变更通知。

2.对在 Zookeeper 上创建的临时节点,⼀旦客户端与服务器之间的会话失效,那么临时节点也会被⾃动删除

利⽤其两⼤特性,可以实现集群机器存活监控系统,若监控系统在/clusterServers 节点上注册⼀个 Watcher 监听,那么但凡进⾏动态添加机器的操作,就会在/clusterServers 节点下创建⼀个临时节点:/clusterServers/[Hostname],这样,监控系统就能够实时监测机器的变动情况。

下⾯通过分布式⽇志收集系统这个典型应⽤来学习 Zookeeper 如何实现集群管理。

分布式⽇志收集系统

分布式⽇志收集系统的核⼼⼯作就是收集分布在不同机器上的系统⽇志,在这⾥我们重点来看分布式⽇志系统(以下简称“⽇志系统”)的收集器模块。

在⼀个典型的⽇志系统的架构设计中,整个⽇志系统会把所有需要收集的⽇志机器(我们以“⽇志源机 器”代表此类机器)分为多个组别,每个组别对应⼀个收集器,这个收集器其实就是⼀个后台机器(我们 以“收集器机器”代表此类机器),⽤于收集⽇志

对于⼤规模的分布式⽇志收集系统场景,通常需要解决两个问题:

  • 变化的⽇志源机器

在⽣产环境中,伴随着机器的变动,每个应⽤的机器⼏乎每天都是在变化的(机器硬件问题、扩容、机房迁移或是⽹络问题等都会导致⼀个应⽤的机器变化),也就是说每个组别中的⽇志源机器通常是在不断变化的

  • 变化的收集器机器

⽇志收集系统⾃身也会有机器的变更或扩容,于是会出现新的收集器机器加⼊或是⽼的收集器机器退出的 情况。

⽆论是⽇志源机器还是收集器机器的变更,最终都可以归结为如何快速、合理、动态地为每个收集器分配对应的⽇志源机器。这也成为了整个⽇志系统正确稳定运转的前提,也是⽇志收集过程中最⼤的技术挑战之⼀,在这种情况下,我们就可以引⼊ zookeeper 了,下⾯我们就来看 ZooKeeper 在这个场景中的使⽤。

使⽤ Zookeeper 的场景步骤如下

① 注册收集器机器

使⽤ ZooKeeper 来进⾏⽇志系统收集器的注册,典型做法是在 ZooKeeper 上创建⼀个节点作为收集器的根节点,例如/logs/collector(下⽂我们以“收集器节点”代表该数据节点),每个收集器机器在启动的时候,都会在收集器节点下创建⾃⼰的节点,例如/logs/collector/[Hostname]

image.png

② 任务分发

待所有收集器机器都创建好⾃⼰对应的节点后,系统根据收集器节点下⼦节点的个数,将所有⽇志源机器分成对应的若⼲组,然后将分组后的机器列表分别写到这些收集器机器创建的⼦节点(例 如/logs/collector/host1)上去。这样⼀来,每个收集器机器都能够从⾃⼰对应的收集器节点上获取⽇志源机器列表,进⽽开始进⾏⽇志收集⼯作。

③ 状态汇报

完成收集器机器的注册以及任务分发后,我们还要考虑到这些机器随时都有挂掉的可能。因此,针对这个问题,我们需要有⼀个收集器的状态汇报机制:每个收集器机器在创建完⾃⼰的专属节点后,还需要在对应的⼦节点上创建⼀个状态⼦节点,例如/logs/collector/host1/status,每个收集器机器都需要定期向该节点写⼊⾃⼰的状态信息。我们可以把这种策略看作是⼀种⼼跳检测机制,通常收集器机器都会在这个节点中写⼊⽇志收集进度信息。⽇志系统根据该状态⼦节点的最后更新时间来判断对应的收集器机器是否存活。

④ 动态分配

如果收集器机器挂掉或是扩容了,就需要动态地进⾏收集任务的分配。在运⾏过程中,⽇志系统始终关注着/logs/collector 这个节点下所有⼦节点的变更,⼀旦检测到有收集器机器停⽌汇报或是有新的收集器机器加⼊,就要开始进⾏任务的重新分配。⽆论是针对收集器机器停⽌汇报还是新机器加⼊的情况, ⽇志系统都需要将之前分配给该收集器的所有任务进⾏转移。为了解决这个问题,通常有两种做法:

  • 全局动态分配

这是⼀种简单粗暴的做法,在出现收集器机器挂掉或是新机器加⼊的时候,⽇志系统需要根据新的收集器机器列表,⽴即对所有的⽇志源机器重新进⾏⼀次分组,然后将其分配给剩下的收集器机器。

  • 局部动态分配

全局动态分配⽅式虽然策略简单,但是存在⼀个问题:⼀个或部分收集器机器的变更,就会导致全局动态任务的分算配,影响⾯⽐较⼤,因此⻛险也就⽐较⼤。所谓局部动态分配,顾名思义就是在⼩范围内进⾏任务的动态分配。在这种策略中,每个收集器机器在汇报⾃⼰⽇志收集状态的同时,也会把⾃⼰的负载汇报上去。请注意,这⾥提到的负载并不仅仅只是简单地指机器 CPU 负载(Load),⽽是⼀个对当前收集器任务执⾏的综合评估,这个评估算法和 ZooKeeper 本身并没有太⼤的关系,这⾥不再赘述。

在这种策略中,如果⼀个收集器机器挂了,那么⽇志系统就会把之前分配给这个机器的任务重新分配到 那些负载较低的机器上去。同样,如果有新的收集器机器加⼊,会从那些负载⾼的机器上转移部分任务给这个新加⼊的机器。

上述步骤已经完整的说明了整个⽇志收集系统的⼯作流程,其中有两点注意事项:

** ① 节点类型**

在/logs/collector 节点下创建临时节点可以很好的判断机器是否存活,但是,若机器挂了,其节点会被删除,记录在节点上的⽇志源机器列表也被清除,所以需要选择持久节点来标识每⼀台机器,同时在节点下分别创建 /logs/collector/[Hostname]/status 节点来表征每⼀个收集器机器的状态,这样,既能实现对所有机器的监控,同时机器挂掉后,依然能够将分配任务还原。

② ⽇志系统节点监听

若采⽤ Watcher 机制,那么通知的消息量的⽹络开销⾮常⼤,需要采⽤⽇志系统主动轮询收集器节点的策略,这样可以节省⽹络流量,但是存在⼀定的延时。

4.4 Master 选举

Master 选举是⼀个在分布式系统中⾮常常⻅的应⽤场景。分布式最核⼼的特性就是能够将具有独⽴计算能⼒的系统单元部署在不同的机器上,构成⼀个完整的分布式系统。⽽与此同时,实际场景中往往也需要在这些分布在不同机器上的独⽴系统单元中选出⼀个所谓的“⽼⼤”,在计算机中,我们称之为 Master。

在分布式系统中,Master 往往⽤来协调集群中其他系统单元,具有对分布式系统状态变更的决定权。例如,在⼀些读写分离的应⽤场景中,客户端的写请求往往是由 Master 来处理的;⽽在另⼀些场景中, Master 则常常负责处理⼀些复杂的逻辑,并将处理结果同步给集群中其他系统单元。Master 选举可以 说是 ZooKeeper 最典型的应⽤场景了,接下来,我们就结合“⼀种海量数据处理与共享模型”这个具体例 ⼦来看看 ZooKeeper 在集群 Master 选举中的应⽤场景。

在分布式环境中,经常会碰到这样的应⽤场景:集群中的所有系统单元需要对前端业务提供数据,⽐如 ⼀个商品 ID,或者是⼀个⽹站轮播⼴告的⼴告 ID(通常出现在⼀些⼴告投放系统中)等,⽽这些商品 ID 或是⼴告 ID 往往需要从⼀系列的海量数据处理中计算得到——这通常是⼀个⾮常耗费 I/O 和 CPU 资源 的过程。鉴于该计算过程的复杂性,如果让集群中的所有机器都执⾏这个计算逻辑的话,那么将耗费⾮常多的资源。⼀种⽐较好的⽅法就是只让集群中的部分,甚⾄只让其中的⼀台机器去处理数据计算,⼀旦计算出数据结果,就可以共享给整个集群中的其他所有客户端机器,这样可以⼤⼤减少重复劳动,提升性能。 这⾥我们以⼀个简单的⼴告投放系统后台场景为例来讲解这个模型。

image.png

整个系统⼤体上可以分成客户端集群、分布式缓存系统、海量数据处理总线和 ZooKeeper 四个部分

⾸先我们来看整个系统的运⾏机制。图中的 Client 集群每天定时会通过 ZooKeeper 来实现 Master 选举。 选举产⽣ Master 客户端之后,这个 Master 就会负责进⾏⼀系列的海量数据处理,最终计算得到⼀个数据结果,并将其放置在⼀个内存/数据库中。同时,Master 还需要通知集群中其他所有的客户端从这个内存/数据库中共享计算结果。

接下去,我们将重点来看 Master 选举的过程,⾸先来明确下 Master 选举的需求:在集群的所有机器中选举出⼀台机器作为 Master。针对这个需求,通常情况下,我们可以选择常⻅的关系型数据库中的主键特性来实现:集群中的所有机器都向数据库中插⼊⼀条相同主键 ID 的记录,数据库会帮助我们⾃动 进⾏主键冲突检查,也就是说,所有进⾏插⼊操作的客户端机器中,只有⼀台机器能够成功——那么, 我们就认为向数据库中成功插⼊数据的客户端机器成为 Master。

借助数据库的这种⽅案确实可⾏,依靠关系型数据库的主键特性能够很好地保证在集群中选举出唯⼀的 ⼀个 Master。但是我们需要考虑的另⼀个问题是,如果当前选举出的 Master 挂了,那么该如何处理? 谁来告诉我 Master 挂了呢?显然,关系型数据库没法通知我们这个事件。那么,如果使⽤ ZooKeeper 是否可以做到这⼀点呢? 那在之前,我们介绍了 ZooKeeper 创建节点的 API 接⼝,其中⼀个重要特性便 是:利⽤ ZooKeeper 的强⼀致性,能够很好保证在分布式⾼并发情况下节点的创建⼀定能够保证全局唯⼀性,即 ZooKeeper 将会保证客户端⽆法重复创建⼀个已经存在的数据节点。也就是说,如果同时有多 个客户端请求创建同⼀个节点,那么最终⼀定只有⼀个客户端请求能够创建成功。利⽤这个特性,就能 很容易地在分布式环境中进⾏ Master 选举了。

image.png

在这个系统中,⾸先会在 ZooKeeper 上创建⼀个⽇期节点,例如“2020-11-11

客户端集群每天都会定时往 ZooKeeper 上创建⼀个临时节点,例如/master_election/2020-1111/binding。在这个过程中,只有⼀个客户端能够成功创建这个节点,那么这个客户端所在的机器就成为了 Master。同时,其他没有在 ZooKeeper 上成功创建节点的客户端,都会在节 点/master_election/2020-11-11 上注册⼀个⼦节点变更的 Watcher,⽤于监控当前的 Master 机器是 否存活,⼀旦发现当前的 Master 挂了,那么其余的客户端将会重新进⾏ Master 选举。

从上⾯的讲解中,我们可以看到,如果仅仅只是想实现 Master 选举的话,那么其实只需要有⼀个能够保证数据唯⼀性的组件即可,例如关系型数据库的主键模型就是⾮常不错的选择。但是,如果希望能够快速地进⾏集群 Master 动态选举,那么就可以基于 ZooKeeper 来实现

4.5 分布式锁

分布式锁是控制分布式系统之间同步访问共享资源的⼀种⽅式。如果不同的系统或是同⼀个系统的不同 主机之间共享了⼀个或⼀组资源,那么访问这些资源的时候,往往需要通过⼀些互斥⼿段来防⽌彼此之间的⼲扰,以保证⼀致性,在这种情况下,就需要使⽤分布式锁了。

在平时的实际项⽬开发中,我们往往很少会去在意分布式锁,⽽是依赖于关系型数据库固有的排他性来实现不同进程之间的互斥。这确实是⼀种⾮常简便且被⼴泛使⽤的分布式锁实现⽅式。然⽽有⼀个不争的事实是,⽬前绝⼤多数⼤型分布式系统的性能瓶颈都集中在数据库操作上。因此,如果上层业务再给 数据库添加⼀些额外的锁,例如⾏锁、表锁甚⾄是繁重的事务处理,那么就会让数据库更加不堪重负

下⾯我们来看看使⽤ ZooKeeper 如何实现分布式锁,这⾥主要讲解排他锁和共享锁两类分布式锁。

4.5.1 排他锁

排他锁(Exclusive Locks,简称 X 锁),⼜称为写锁或独占锁,是⼀种基本的锁类型。如果事务 T1 对 数据对象 O1 加上了排他锁,那么在整个加锁期间,只允许事务 T1 对 O1 进⾏读取和更新操作,其他任 何事务都不能再对这个数据对象进⾏任何类型的操作——直到 T1 释放了排他锁

从上⾯讲解的排他锁的基本概念中,我们可以看到,排他锁的核⼼是如何保证当前有且仅有⼀个事务获得锁,并且锁被释放后,所有正在等待获取锁的事务都能够被通知到。

下⾯我们就来看看如何借助 ZooKeeper 实现排他锁:

① 定义锁

在通常的 Java 开发编程中,有两种常⻅的⽅式可以⽤来定义锁,分别是 synchronized 机制和 JDK5 提供的 ReentrantLock。然⽽,在 ZooKeeper 中,没有类似于这样的 API 可以直接使⽤,⽽是通过 ZooKeeper 上的数据节点来表示⼀个锁,例如/exclusive_lock/lock 节点就可以被定义为⼀个锁,如图:

image.png

② 获取锁

在需要获取排他锁时,所有的客户端都会试图通过调⽤ create()接⼝,在/exclusive_lock 节点下创建 临时⼦节点/exclusive_lock/lock。在前⾯,我们也介绍了,ZooKeeper 会保证在所有的客户端中,最终只有⼀个客户端能够创建成功,那么就可以认为该客户端获取了锁。同时,所有没有获取到锁的客户 端就需要到/exclusive_lock 节点上注册⼀个⼦节点变更的 Watcher 监听,以便实时监听到 lock 节点的变更情况

③ 释放锁

在“定义锁”部分,我们已经提到,/exclusive_lock/lock 是⼀个临时节点,因此在以下两种情况下,都有可能释放锁。

  • 当前获取锁的客户端机器发⽣宕机,那么 ZooKeeper 上的这个临时节点就会被移除。
  • 正常执⾏完业务逻辑后,客户端就会主动将⾃⼰创建的临时节点删除。

⽆论在什么情况下移除了 lock 节 点,ZooKeeper 都会通知所有在/exclusive_lock 节点上注册了⼦节点变更 Watcher 监听的客户端。这些 客户端在接收到通知后,再次重新发起分布式锁获取,即重复“获取锁”过程。整个排他锁的获取和释放流程,如下图:

image.png

4.5.2 共享锁

共享锁(Shared Locks,简称 S 锁),⼜称为读锁,同样是⼀种基本的锁类型。 如果事务 T1 对数据对象 O1 加上了共享锁,那么当前事务只能对 O1 进⾏读取操作,其他事务也只能对这 个数据对象加共享锁——直到该数据对象上的所有共享锁都被释放。

共享锁和排他锁最根本的区别在于,加上排他锁后,数据对象只对⼀个事务可⻅,⽽加上共享锁后,数据对所有事务都可⻅。

下⾯我们就来看看如何借助 ZooKeeper 来实现共享锁。

① 定义锁

和排他锁⼀样,同样是通过 ZooKeeper 上的数据节点来表示⼀个锁,是⼀个类似于 “/shared_lock/[Hostname]-请求类型-序号”的临时顺序节点,例如/shared_lock/host1-R0000000001,那么,这个节点就代表了⼀个共享锁,如图所示:

image.png

② 获取锁

在需要获取共享锁时,所有客户端都会到/shared_lock 这个节点下⾯创建⼀个临时顺序节点,如果当前是读请求,那么就创建例如/shared_lock/host1-R-0000000001 的节点;如果是写请求,那么就创建例 如/shared_lock/host2-W-0000000002 的节点。

判断读写顺序

通过 Zookeeper 来确定分布式读写顺序,⼤致分为四步

  1. 创建完节点后,获取/shared_lock 节点下所有⼦节点,并对该节点变更注册监听。

2. 确定⾃⼰的节点序号在所有⼦节点中的顺序。

3. 对于读请求:若没有⽐⾃⼰序号⼩的⼦节点或所有⽐⾃⼰序号⼩的⼦节点都是读请求,那么表明⾃⼰已经成功获取到共享锁,同时开始执⾏读取逻辑,若有写请求,则需要等待。对于写请求:若⾃⼰不是序号最⼩的⼦节点,那么需要等待。

4. 接收到 Watcher 通知后,重复步骤 1

③ 释放锁

其释放锁的流程与独占锁⼀致。

4.5.3 ⽺群效应

上⾯讲解的这个共享锁实现,⼤体上能够满⾜⼀般的分布式集群竞争锁的需求,并且性能都还可以—这⾥说的⼀般场景是指集群规模不是特别⼤,⼀般是在 10 台机器以内。但是如果机器规模扩⼤之后,会有什么问题呢?我们着重来看上⾯“判断读写顺序”过程的步骤 3,结合下⾯的图,看看实际运⾏中的情况

image.png

针对如上图所示的情况进⾏分析

  1. host1 ⾸先进⾏读操作,完成后将节点/shared_lock/host1-R-00000001 删除。
  2. 余下 4 台机器均收到这个节点移除的通知,然后重新从/shared_lock 节点上获取⼀份新的⼦节点列表。
  3. 每台机器判断⾃⼰的读写顺序,其中 host2 检测到⾃⼰序号最⼩,于是进⾏写操作,余下的机器则继续等待。
  4. 继续…

可以看到,host1 客户端在移除⾃⼰的共享锁后,Zookeeper 发送了⼦节点更变 Watcher 通知给所有机器,然⽽除了给 host2 产⽣影响外,对其他机器没有任何作⽤。⼤量的 Watcher 通知和⼦节点列表获取两个操作会重复运⾏,这样不仅会对 zookeeper 服务器造成巨⼤的性能影响影响和⽹络开销,更为严重的是,如果同⼀时间有多个节点对应的客户端完成事务或是事务中断引起节点消失,ZooKeeper 服务器就会在短时间内向其余客户端发送⼤量的事件通知,这就是所谓的⽺群效应

上⾯这个 ZooKeeper 分布式共享锁实现中出现⽺群效应的根源在于,没有找准客户端真正的关注点。我们再来回顾⼀下上⾯的分布式锁竞争过程,它的核⼼逻辑在于:判断⾃⼰是否是所有⼦节点中序号最⼩ 的。于是,很容易可以联想到,每个节点对应的客户端只需要关注⽐⾃⼰序号⼩的那个相关节点的变更情况就可以了——⽽不需要关注全局的⼦列表变更情况。

可以有如下改动来避免⽺群效应。

改进后的分布式锁实现:

⾸先,我们需要肯定的⼀点是,上⾯提到的共享锁实现,从整体思路上来说完全正确。这⾥主要的改动在于:每个锁竞争者,只需要关注/shared_lock 节点下序号⽐⾃⼰⼩的那个节点是否存在即可,具体实 现如下。

  1. 客户端调⽤ create 接⼝常⻅类似于/shared_lock/[Hostname]-请求类型-序号的临时顺序节点。
  2. 客户端调⽤ getChildren 接⼝获取所有已经创建的⼦节点列表(不注册任何 Watcher)。
  3. 如果⽆法获取共享锁,就调⽤ exist 接⼝来对⽐⾃⼰⼩的节点注册 Watcher。
    1. 对于读请求:向⽐⾃ 序号⼩的最后⼀个写请求节点注册 Watcher 监听。
    2. 对于写请求:向⽐⾃⼰序号⼩的最后⼀个节点注册 Watcher 监听。
  4. 等待 Watcher 通知,继续进⼊步骤 2。

此⽅案改动主要在于:每个锁竞争者,只需要关注/shared_lock 节点下序号⽐⾃⼰⼩的那个节点是否存在即可。

image.png

注意 相信很多同学都会觉得改进后的分布式锁实现相对来说⽐较麻烦。确实如此,如同在多线程并发编 程实践中,我们会去尽量缩⼩锁的范围——对于分布式锁实现的改进其实也是同样的思路。那么对于开发⼈员来说,是否必须按照改进后的思路来设计实现⾃⼰的分布式锁呢?答案是否定的。

在具体的实际开发过程中,我们提倡根据具体的业务场景和集群规模来选择适合⾃⼰的分布式锁实现:在集群规模不⼤、⽹络资源丰富的情况下,第⼀种分布式锁实现⽅式是简单实⽤的选择;⽽如果集群规模达到⼀定程度,并且希望能够精细化地控制分布式锁机制,那么就可以试试改进版的分布式锁实现。

4.6 分布式队列

分布式队列可以简单分为两⼤类:⼀种是常规的 FIFO 先⼊先出队列模型,还有⼀种是等待队列元素聚集后统⼀安排处理执⾏的 Barrier 模型。

① FIFO 先⼊先出

FIFO(First Input First Output,先⼊先出), FIFO 队列是⼀种⾮常典型且应⽤⼴泛的按序执⾏的队列

模型:先进⼊队列的请求操作先完成后,才会开始处理后⾯的请求。 使⽤ ZooKeeper 实现 FIFO 队列,和之前提到的共享锁的实现⾮常类似。FIFO 队列就类似于⼀个全写的共享锁模型,⼤体的设计思路其实⾮常简单:所有客户端都会到/queue_fifo 这个节点下⾯创建⼀个临时顺序节点,例如如/queue_fifo/host1-00000001。

image.png

创建完节点后,根据如下 4 个步骤来确定执⾏顺序。

  1. 通过调⽤ getChildren 接⼝来获取/queue_fifo 节点的所有⼦节点,即获取队列中所有的元素。
  2. 确定⾃⼰的节点序号在所有⼦节点中的顺序。
  3. 如果⾃⼰的序号不是最⼩,那么需要等待,同时向⽐⾃⼰序号⼩的最后⼀个节点注册 Watcher 监听。
  4. 接收到 Watcher 通知后,重复步骤 1。

image.png

② Barrier:分布式屏障

Barrier 原意是指障碍物、屏障,⽽在分布式系统中,特指系统之间的⼀个协调条件,规定了⼀个队列的元素必须都集聚后才能统⼀进⾏安排,否则⼀直等待。这往往出现在那些⼤规模分布式并⾏计算的应⽤场景上:最终的合并计算需要基于很多并⾏计算的⼦结果来进⾏。这些队列其实是在 FIFO 队列的基础 上进⾏了增强,⼤致的设计思想如下:开始时,/queue_barrier 节点是⼀个已经存在的默认节点,并且将其节点的数据内容赋值为⼀个数字 n 来代表 Barrier 值,例如 n=10 表示只有当/queue_barrier 节点下的 ⼦节点个数达到 10 后,才会打开 Barrier。之后,所有的客户端都会到/queue_barrie 节点下创建⼀个临时节点,例如/queue_barrier/host1,如图所示。

image.png

创建完节点后,按照如下步骤执⾏。

  1. 通过调⽤ getData 接⼝获取/queue_barrier 节点的数据内容:10。
  2. 通过调⽤ getChildren 接⼝获取/queue_barrier 节点下的所有⼦节点,同时注册对⼦节点变更的 Watcher 监听。
  3. 统计⼦节点的个数。
  4. 如果⼦节点个数还不⾜ 10 个,那么需要等待。
  5. 接受到 Wacher 通知后,重复步骤 2

image.png

第五节 Zookeeper 深⼊进阶

5.1 ZAB 协议

ZAB 概念

在深⼊了解 zookeeper 之前,很多同学可能会认为 zookeeper 就是 paxos 算法的⼀个实现,但事实上, zookeeper 并没有完全采⽤ paxos 算法,⽽是使⽤了⼀种称为 Zookeeper Atomic Broadcast(ZAB, Zookeeper 原⼦消息⼴播协议)的协议作为其数据⼀致性的核⼼算法。

ZAB 协议并不像 Paxos 算法那样是⼀种通⽤的分布式⼀致性算法,它是⼀种特别为 zookeeper 专⻔设计 的⼀种⽀持崩溃恢复的原⼦⼴播协议

在 zookeeper 中,主要就是依赖 ZAB 协议来实现分布式数据的⼀致性,基于该协议,Zookeeper 实现了 ⼀种主备模式的系统架构来保持集群中各副本之间的数据的⼀致性,表现形式就是使⽤⼀个单⼀的主进 程来接收并处理客户端的所有事务请求,并采⽤ ZAB 的原⼦⼴播协议,将服务器数据的状态变更以事务 Proposal 的形式⼴播到所有的副本进程中,ZAB 协议的主备模型架构保证了同⼀时刻集群中只能够有⼀ 个主进程来⼴播服务器的状态变更,因此能够很好地处理客户端⼤量的并发请求。但是,也要考虑到主进程在任何时候都有可能出现崩溃退出或重启现象,因此 ZAB 协议还需要做到当前主进程当出现上述异常情况的时候,依旧能正常⼯作。

ZAB 核⼼

ZAB 协议的核⼼是定义了对于那些会改变 Zookeeper 服务器数据状态的事务请求的处理⽅式

所有事务请求必须由⼀个全局唯⼀的服务器来协调处理,这样的服务器被称为 Leader 服务器,余下的服务器则称为 Follower 服务器,Leader 服务器负责将⼀个客户端事务请求转化成⼀个事务 Proposal(提 议),并将该 Proposal 分发给集群中所有的 Follower 服务器,之后 Leader 服务器需要等待所有 Follower 服务器的反馈,⼀旦超过半数的 Follower 服务器进⾏了正确的反馈后,那么 Leader 就会再次向 所有的 Follower 服务器分发 Commit 消息,要求其将前⼀个 Proposal 进⾏提交

image.png

ZAB 协议介绍

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

进⼊崩溃恢复模式:

当整个服务框架启动过程中,或者是 Leader 服务器出现⽹络中断、崩溃退出或重启等异常情况时,ZAB 协议就会进⼊崩溃恢复模式,同时选举产⽣新的 Leader 服务器。当选举产⽣了新的 Leader 服务器,同时集群中已经有过半的机器与该 Leader 服务器完成了状态同步之后,ZAB 协议就会退出恢复模式,其中,所谓的状态同步就是指数据同步,⽤来保证集群中过半的机器能够和 Leader 服务器的数据状态保持⼀致

进⼊消息⼴播模式:

当集群中已经有过半的 Follower 服务器完成了和 Leader 服务器的状态同步,那么整个服务框架就可以进 ⼊消息⼴播模式,当⼀台同样遵守 ZAB 协议的服务器启动后加⼊到集群中,如果此时集群中已经存在⼀个 Leader 服务器在负责进⾏消息⼴播,那么加⼊的服务器就会⾃觉地进⼊数据恢复模式:找到 Leader 所在的服务器,并与其进⾏数据同步,然后⼀起参与到消息⼴播流程中去。Zookeeper 只允许唯⼀的⼀个 Leader 服务器来进⾏事务请求的处理,Leader 服务器在接收到客户端的事务请求后,会⽣成对应的事务提议并发起⼀轮⼴播协议,⽽如果集群中的其他机器收到客户端的事务请求后,那么这些⾮ Leader 服务器会⾸先将这个事务请求转发给 Leader 服务器。

接下来我们就重点讲解⼀下 ZAB 协议的消息⼴播过程和崩溃恢复过程

① 消息⼴播

ZAB 协议的消息⼴播过程使⽤原⼦⼴播协议,类似于⼀个⼆阶段提交过程,针对客户端的事务请求, Leader 服务器会为其⽣成对应的事务 Proposal,并将其发送给集群中其余所有的机器,然后再分别收集各⾃的选票,最后进⾏事务提交。

image.png

在 ZAB 的⼆阶段提交过程中,移除了中断逻辑,所有的 Follower 服务器要么正常反馈 Leader 提出的事务 Proposal,要么就抛弃 Leader 服务器,同时,ZAB 协议将⼆阶段提交中的中断逻辑移除意味着我们可以 在过半的 Follower 服务器已经反馈 Ack 之后就开始提交事务 Proposal 了,⽽不需要等待集群中所有的 Follower 服务器都反馈响应,但是,在这种简化的⼆阶段提交模型下,⽆法处理因 Leader 服务器崩溃退出⽽带来的数据不⼀致问题,因此 ZAB 采⽤了崩溃恢复模式来解决此问题,另外,整个消息⼴播协议是 基于具有 FIFO 特性的 TCP 协议来进⾏⽹络通信的,因此能够很容易保证消息⼴播过程中消息接受与发送 的顺序性。

在整个消息⼴播过程中,Leader 服务器会为每个事务请求⽣成对应的 Proposal 来进⾏⼴播,并且在⼴播 事务 Proposal 之前,Leader 服务器会⾸先为这个事务 Proposal 分配⼀个全局单调递增的唯⼀ ID,称之 为事务 ID(ZXID),由于 ZAB 协议需要保证每个消息严格的因果关系,因此必须将每个事务 Proposal 按 照其 ZXID 的先后顺序来进⾏排序和处理。

具体的过程:在消息⼴播过程中,Leader 服务器会为每⼀个 Follower 服务器都各⾃分配⼀个单独的队列,然后将需要⼴播的事务 Proposal 依次放⼊这些队列中去,并且根据 FIFO 策略进⾏消息发送。每⼀ 个 Follower 服务器在接收到这个事务 Proposal 之后,都会⾸先将其以事务⽇志的形式写⼊到本地磁盘中 去,并且在成功写⼊后反馈给 Leader 服务器⼀个 Ack 响应。当 Leader 服务器接收到超过半数 Follower 的 Ack 响应后,就会⼴播⼀个 Commit 消息给所有的 Follower 服务器以通知其进⾏事务提交,同时 Leader ⾃身也会完成对事务的提交,⽽每⼀个 Follower 服务器在接收到 Commit 消息后,也会完成对事务的提交。

② 崩溃恢复

ZAB 协议的这个基于原⼦⼴播协议的消息⼴播过程,在正常情况下运⾏⾮常良好,但是⼀旦在 Leader 服务器出现崩溃,或者由于⽹络原因导致 Leader 服务器失去了与过半 Follower 的联系,那么就会进⼊崩溃恢复模式。在 ZAB 协议中,为了保证程序的正确运⾏,整个恢复过程结束后需要选举出⼀个新的 Leader 服务器,因此,ZAB 协议需要⼀个⾼效且可靠的 Leader 选举算法,从⽽保证能够快速地选举出新的 Leader,同时,Leader 选举算法不仅仅需要让 Leader ⾃身知道已经被选举为 Leader,同时还需要让集群中的所有其他机器也能够快速地感知到选举产⽣出来的新 Leader 服务器。

基本特性

根据上⾯的内容,我们了解到,ZAB 协议规定了如果⼀个事务 Proposal 在⼀台机器上被处理成功,那么 应该在所有的机器上都被处理成功,哪怕机器出现故障崩溃。接下来我们看看在崩溃恢复过程中,可能会出现的两个数据不⼀致性的隐患及针对这些情况 ZAB 协议所需要保证的特性。

ZAB 协议需要确保那些已经在 Leader 服务器上提交的事务最终被所有服务器都提交

假设⼀个事务在 Leader 服务器上被提交了,并且已经得到过半 Folower 服务器的 Ack 反馈,但是在它将 Commit 消息发送给所有 Follower 机器之前,Leader 服务器挂了,如图所示

image.png

图中的消息 C2 就是⼀个典型的例⼦:在集群正常运⾏过程中的某⼀个时刻,Server1 是 Leader 服务器,其先后⼴播了消息 P1、P2、C1、P3 和 C2,其中,当 Leader 服务器将消息 C2(C2 是 Commit Of Proposal2 的缩写,即提交事务 Proposal2)发出后就⽴即崩溃退出了。针对这种情况,ZAB 协议就需要确保事务 Proposal2 最终能够在所有的服务器上都被提交成功,否则将出现不⼀致。

ZAB 协议需要确保丢弃那些只在 Leader 服务器上被提出的事务

如果在崩溃恢复过程中出现⼀个需要被丢弃的提案,那么在崩溃恢复结束后需要跳过该事务 Proposal, 如图所示。

image.png

在图所示的集群中,假设初始的 Leader 服务器 Server1 在提出了⼀个事务 Proposal3 之后就崩溃退出 了,从⽽导致集群中的其他服务器都没有收到这个事务 Proposal3。于是,当 Server1 恢复过来再次加⼊到集群中的时候,ZAB 协议需要确保丢弃 Proposal3 这个事务。

结合上⾯提到的这两个崩溃恢复过程中需要处理的特殊情况,就决定了 ZAB 协议必须设计这样⼀个 Leader 选举算法:能够确保提交已经被 Leader 提交的事务 Proposal,同时丢弃已经被跳过的事务 Proposal。针对这个要求,如果让 Leader 选举算法能够保证新选举出来的 Leader 服务器拥有集群中所有机器最⾼编号(即 ZXID 最⼤)的事务 Proposal,那么就可以保证这个新选举出来的 Leader ⼀定具有所有已经提交的提案。更为重要的是,如果让具有最⾼编号事务 Proposal 的机器来成为 Leader,就可 以省去 Leader 服务器检查 Proposal 的提交和丢弃⼯作的这⼀步操作了。

数据同步

完成 Leader 选举之后,在正式开始⼯作(即接收客户端的事务请求,然后提出新的提案)之前, Leader 服务器会⾸先确认事务⽇志中的所有 Proposal 是否都已经被集群中过半的机器提交了,即是否完成数据同步。下⾯我们就来看看 ZAB 协议的数据同步过程。

所有正常运⾏的服务器,要么成为 Leader,要么成为 Follower 并和 Leader 保持同步。Leader 服务器需要确保所有的 Follower 服务器能够接收到每⼀条事务 Proposal,并且能够正确地将所有已经提交了的事务 Proposal 应⽤到内存数据库中去。具体的,Leader 服务器会为每⼀个 Follower 服务器都准备⼀个队列,并将那些没有被各 Follower 服务器同步的事务以 Proposal 消息的形式逐个发送给 Follower 服务器, 并在每⼀个 Proposal 消息后⾯紧接着再发送⼀个 Commit 消息,以表示该事务已经被提交。等到 Follower 服务器将所有其尚未同步的事务 Proposal 都从 Leader 服务器上同步过来并成功应⽤到本地数据库中后,Leader 服务器就会将该 Follower 服务器加⼊到真正的可⽤ Follower 列表中,并开始之后的其他流程。

运⾏时状态分析

在 ZAB 协议的设计中,每个进程都有可能处于如下三种状态之⼀

  • LOOKING:Leader 选举阶段。
  • FOLLOWING:Follower 服务器和 Leader 服务器保持同步状态。
  • LEADING:Leader 服务器作为主进程领导状态。

所有进程初始状态都是 LOOKING 状态,此时不存在 Leader,接下来,进程会试图选举出⼀个新的 Leader,之后,如果进程发现已经选举出新的 Leader 了,那么它就会切换到 FOLLOWING 状态,并开始和 Leader 保持同步,处于 FOLLOWING 状态的进程称为 Follower,LEADING 状态的进程称为 Leader,当 Leader 崩溃或放弃领导地位时,其余的 Follower 进程就会转换到 LOOKING 状态开始新⼀轮的 Leader 选 举。

⼀个 Follower 只能和⼀个 Leader 保持同步,Leader 进程和所有的 Follower 进程之间都通过⼼跳检测机制来感知彼此的情况。若 Leader 能够在超时时间内正常收到⼼跳检测,那么 Follower 就会⼀直与该 Leader 保持连接,⽽如果在指定时间内 Leader ⽆法从过半的 Follower 进程那⾥接收到⼼跳检测,或者 TCP 连接断开,那么 Leader 会放弃当前周期的领导,并转换到 LOOKING 状态,其他的 Follower 也会选择 放弃这个 Leader,同时转换到 LOOKING 状态,之后会进⾏新⼀轮的 Leader 选举

ZAB 与 Paxos 的联系和区别

联系:

① 都存在⼀个类似于 Leader 进程的⻆⾊,由其负责协调多个 Follower 进程的运⾏。

② Leader 进程都会等待超过半数的 Follower 做出正确的反馈后,才会将⼀个提议进⾏提交。

③ 在 ZAB 协议中,每个 Proposal 中都包含了⼀个 epoch 值,⽤来代表当前的 Leader 周期,在 Paxos 算法中,同样存在这样的⼀个标识,名字为 Ballot。

区别:

Paxos 算法中,新选举产⽣的主进程会进⾏两个阶段的⼯作,第⼀阶段称为读阶段,新的主进程和其他进程通信来收集主进程提出的提议,并将它们提交。第⼆阶段称为写阶段,当前主进程开始提出⾃⼰的提议。

ZAB 协议在 Paxos 基础上添加了同步阶段,此时,新的 Leader 会确保存在过半的 Follower 已经提交了之前的 Leader 周期中的所有事务 Proposal。这⼀同步阶段的引⼊,能够有效地保证 Leader 在新的周期中提出事务 Proposal 之前,所有的进程都已经完成了对之前所有事务 Proposal 的提交。

总的来说,ZAB 协议和 Paxos 算法的本质区别在于,两者的设计⽬标不太⼀样,ZAB 协议主要⽤于构建⼀个⾼可⽤的分布式数据主备系统,⽽ Paxos 算法则⽤于构建⼀个分布式的⼀致性状态机系统

5.2 服务器⻆⾊
5.2.1 Leader

Leader 服务器是 Zookeeper 集群⼯作的核⼼,其主要⼯作有以下两个:

(1) 事务请求的唯⼀调度和处理者,保证集群事务处理的顺序性。

(2) 集群内部各服务器的调度者。

1.请求处理链

使⽤责任链来处理每个客户端的请求是 Zookeeper 的特⾊,Leader 服务器的请求处理链如下:

image.png

可以看到,从 prepRequestProcessor 到 FinalRequestProcessor 前后⼀共 7 个请求处理器组成了 leader 服务器的请求处理链

(1) PrepRequestProcessor。请求预处理器,也是 leader 服务器中的第⼀个请求处理器。在 Zookeeper 中,那些会改变服务器状态的请求称为事务请求(创建节点、更新数据、删除节点、创建会话等), PrepRequestProcessor 能够识别出当前客户端请求是否是事务请求。对于事务请求, PrepRequestProcessor 处理器会对其进⾏⼀系列预处理,如创建请求事务头、事务体、会话检查、ACL 检查和版本检查等。

(2) ProposalRequestProcessor。事务投票处理器。也是 Leader 服务器事务处理流程的发起者,对于⾮事务性请求,ProposalRequestProcessor 会直接将请求转发到 CommitProcessor 处理器,不再做任何处理,⽽对于事务性请求,处理将请求转发到 CommitProcessor 外,还会根据请求类型创建对应的 Proposal 提议,并发送给所有的 Follower 服务器来发起⼀次集群内的事务投票。同时, ProposalRequestProcessor 还会将事务请求交付给 SyncRequestProcessor 进⾏事务⽇志的记录。

(3) SyncRequestProcessor。事务⽇志记录处理器。⽤来将事务请求记录到事务⽇志⽂件中,同时会触发 Zookeeper 进⾏数据快照。

(4) AckRequestProcessor。负责在 SyncRequestProcessor 完成事务⽇志记录后,向 Proposal 的投票收集器发送 ACK 反馈,以通知投票收集器当前服务器已经完成了对该 Proposal 的事务⽇志记录。

(5) CommitProcessor。事务提交处理器。对于⾮事务请求,该处理器会直接将其交付给下⼀级处理器处理;对于事务请求,其会等待集群内 针对 Proposal 的投票直到该 Proposal 可被提交,利⽤ CommitProcessor,每个服务器都可以很好地控制对事务请求的顺序处理。

(6) ToBeCommitProcessor。该处理器有⼀个 toBeApplied 队列,⽤来存储那些已经被 CommitProcessor 处理过的可被提交的 Proposal。其会将这些请求交付给 FinalRequestProcessor 处理器处理,待其处理完后,再将其从 toBeApplied 队列中移除。

(7) FinalRequestProcessor。⽤来进⾏客户端请求返回之前的操作,包括创建客户端请求的响应, 针对事务请求,该处理器还会负责将事务应⽤到内存数据库中。

5.2.2 Follower

Follower 服务器是 Zookeeper 集群状态中的跟随者,其主要⼯作有以下三个:

(1) 处理客户端⾮事务性请求(读取数据),转发事务请求给 Leader 服务器。

(2) 参与事务请求 Proposal 的投票。

(3) 参与 Leader 选举投票。

和 leader ⼀样,Follower 也采⽤了责任链模式组装的请求处理链来处理每⼀个客户端请求,由于不需要对事务请求的投票处理,因此 Follower 的请求处理链会相对简单,其处理链如下

image.png

和 Leader 服务器的请求处理链最⼤的不同点在于,Follower 服务器的第⼀个处理器换成了 FollowerRequestProcessor 处理器,同时由于不需要处理事务请求的投票,因此也没有了 ProposalRequestProcessor 处理器。

(1) FollowerRequestProcessor

其⽤作识别当前请求是否是事务请求,若是,那么 Follower 就会将该请求转发给 Leader 服务器, Leader 服务器在接收到这个事务请求后,就会将其提交到请求处理链,按照正常事务请求进⾏处理。

(2) SendAckRequestProcessor

其承担了事务⽇志记录反馈的⻆⾊,在完成事务⽇志记录后,会向 Leader 服务器发送 ACK 消息以表明⾃身完成了事务⽇志的记录⼯作

5.2.3 Observer

Observer 是 ZooKeeper ⾃ 3.3.0 版本开始引⼊的⼀个全新的服务器⻆⾊。从字⾯意思看,该服务器充当 了⼀个观察者的⻆⾊——其观察 ZooKeeper 集群的最新状态变化并将这些状态变更同步过来。 Observer 服务器在⼯作原理上和 Follower 基本是⼀致的,对于⾮事务请求,都可以进⾏独⽴的处理,⽽对于事务请求,则会转发给 Leader 服务器进⾏处理。和 Follower 唯⼀的区别在于,Observer 不参与任 何形式的投票,包括事务请求 Proposal 的投票和 Leader 选举投票。简单地讲,Observer 服务器只提供⾮事务服务,通常⽤于在不影响集群事务处理能⼒的前提下提升集群的⾮事务处理能⼒。

另外,Observer 的请求处理链路和 Follower 服务器也⾮常相近,其处理链如下

image.png

另外需要注意的⼀点是,虽然在图中可以看到,Observer 服务器在初始化阶段会将 SyncRequestProcessor 处理器也组装上去,但是在实际运⾏过程中,Leader 服务器不会将事务请求的 投票发送给 Observer 服务器。

5.3 服务器启动
5.3.1 服务端整体架构图

image.png

Zookeeper 服务器的启动,⼤致可以分为以下五个步骤

1. 配置⽂件解析

2. 初始化数据管理器

3. 初始化⽹络 I/O 管理器

4. 数据恢复

5. 对外服务

5.3.2 单机版服务器启动

单机版服务器的启动其流程图如下

image.png

上图的过程可以分为预启动和初始化过程。

预启动

  1. 统⼀由 QuorumPeerMain 作为启动类。⽆论单机或集群,在 zkServer.cmd 和 zkServer.sh 中都配置了 QuorumPeerMain 作为启动⼊⼝类。
  2. 解析配置⽂件 zoo.cfg。zoo.cfg 配置运⾏时的基本参数,如 tickTime、dataDir、 clientPort 等参数。
  3. 创建并启动历史⽂件清理器 DatadirCleanupManager。对事务⽇志和快照数据⽂件进⾏定 时清理。
  4. 判断当前是集群模式还是单机模式启动。若是单机模式,则委托给 ZooKeeperServerMain 进 ⾏启动。
  5. 再次进⾏配置⽂件 zoo.cfg 的解析。
  6. 创建服务器实例 ZooKeeperServer。Zookeeper 服务器⾸先会进⾏服务器实例的创建,然后 对该服务器实例进⾏初始化,包括连接器、内存数据库、请求处理器等组件的初始化。

初始化

1.创建服务器统计器 ServerStats。ServerStats 是 Zookeeper 服务器运⾏时的统计器。

2. 创建 Zookeeper 数据管理器 FileTxnSnapLog。FileTxnSnapLog 是 Zookeeper 上层服务器和底层数据存储之间的对接层,提供了⼀系列操作数据⽂件的接⼝,如事务⽇志⽂件和快照数据⽂件。 Zookeeper 根据 zoo.cfg ⽂件中解析出的快照数据⽬录 dataDir 和事务⽇志⽬录 dataLogDir 来创建 FileTxnSnapLog。

3. 设置服务器 tickTime 和会话超时时间限制。

4. 创建 ServerCnxnFactory。通过配置系统属性 zookeper.serverCnxnFactory 来指定使 ⽤ Zookeeper ⾃⼰实现的 NIO 还是使⽤ Netty 框架作为 Zookeeper 服务端⽹络连接⼯⼚。

5. 初始化 ServerCnxnFactory。Zookeeper 会初始化 Thread 作为 ServerCnxnFactory 的主线程,然后再初始化 NIO 服务器。

6. 启动 ServerCnxnFactory 主线程。进⼊ Thread 的 run ⽅法,此时服务端还不能处理客户端请求。

7. 恢复本地数据。启动时,需要从本地快照数据⽂件和事务⽇志⽂件进⾏数据恢复。

8. 创建并启动会话管理器。Zookeeper 会创建会话管理器 SessionTracker 进⾏会话管理。

9. 初始化 Zookeeper 的请求处理链。Zookeeper 请求处理⽅式为责任链模式的实现。会有多个请求处理器依次处理⼀个客户端请求,在服务器启动时,会将这些请求处理器串联成⼀个请求处理链。

10. 注册 JMX 服务。Zookeeper 会将服务器运⾏时的⼀些信息以 JMX 的⽅式暴露给外部。

11. 注册 Zookeeper 服务器实例。将 Zookeeper 服务器实例注册给 ServerCnxnFactory,之后 Zookeeper 就可以对外提供服务。

⾄此,单机版的 Zookeeper 服务器启动完毕。

5.3.3 集群服务器启动

单机和集群服务器的启动在很多地⽅是⼀致的,其流程图如下:

image.png

上图的过程可以分为预启动、初始化、Leader 选举、Leader 与 Follower 启动期交互、Leader 与

Follower 启动等过程

预启动

  1. 统⼀由 QuorumPeerMain 作为启动类。
  2. 解析配置⽂件 zoo.cfg。
  3. 创建并启动历史⽂件清理器 DatadirCleanupFactory。
  4. 判断当前是集群模式还是单机模式的启动。在集群模式中,在 zoo.cfg ⽂件中配置了多个服务器 地址,可以选择集群启动。

初始化

  1. 创建 ServerCnxnFactory。
  2. 初始化 ServerCnxnFactory。
  3. 创建 Zookeeper 数据管理器 FileTxnSnapLog。
  4. 创建 QuorumPeer 实例。Quorum 是集群模式下特有的对象,是 Zookeeper 服务器实例 (ZooKeeperServer)的托管者,QuorumPeer 代表了集群中的⼀台机器,在运⾏期间, QuorumPeer 会不断检测当前服务器实例的运⾏状态,同时根据情况发起 Leader 选举。
  5. 创建内存数据库 ZKDatabase。ZKDatabase 负责管理 ZooKeeper 的所有会话记录以及 DataTree 和事务⽇志的存储。
  6. 初始化 QuorumPeer。将核⼼组件如 FileTxnSnapLog、ServerCnxnFactory、ZKDatabase 注册到 QuorumPeer 中,同时配置 QuorumPeer 的参数,如服务器列表地址、Leader 选举算法和会话 超时时间限制等。
  7. 恢复本地数据。
  8. 启动 ServerCnxnFactory 主线程

Leader 选举

  1. 初始化 Leader 选举。

集群模式特有,Zookeeper ⾸先会根据⾃身的服务器 ID(SID)、最新的 ZXID(lastLoggedZxid)和当前的服务器 epoch(currentEpoch)来⽣成⼀个初始化投票,在初始化过程中,每个服务器都会给⾃⼰投票。然后,根据 zoo.cfg 的配置,创建相应 Leader 选举算法 实现,Zookeeper 提供了三种默认算法(LeaderElection、AuthFastLeaderElection、 FastLeaderElection),可通过 zoo.cfg 中的 electionAlg 属性来指定,但现只⽀持 FastLeaderElection 选举算法。在初始化阶段,Zookeeper 会创建 Leader 选举所需的⽹络 I/O 层 QuorumCnxManager,同时启动对 Leader 选举端⼝的监听,等待集群中其他服务器创建连接。

  1. 注册 JMX 服务。
  2. 检测当前服务器状态

运⾏期间,QuorumPeer 会不断检测当前服务器状态。在正常情况下,Zookeeper 服务器的状态 在 LOOKING、LEADING、FOLLOWING/OBSERVING 之间进⾏切换。在启动阶段,QuorumPeer 的初始状态是 LOOKING,因此开始进⾏ Leader 选举。

  1. Leader 选举

ZooKeeper 的 Leader 选举过程,简单地讲,就是⼀个集群中所有的机器相互之间进⾏⼀系列投 票,选举产⽣最合适的机器成为 Leader,同时其余机器成为 Follower 或是 Observer 的集群机器⻆⾊初始化过程。关于 Leader 选举算法,简⽽⾔之,就是集群中哪个机器处理的数据越新(通常我们根据每个服务器处理过的最⼤ ZXID 来⽐较确定其数据是否更新),其越有可能成为 Leader。当然,如果集群中的所有机器处理的 ZXID ⼀致的话,那么 SID 最⼤的服务器成为 Leader,其余机器称为 Follower 和 Observer

Leader 和 Follower 启动期交互过程

到这⾥为⽌,ZooKeeper 已经完成了 Leader 选举,并且集群中每个服务器都已经确定了⾃⼰的⻆⾊—通常情况下就分为 Leader 和 Follower 两种⻆⾊。下⾯我们来对 Leader 和 Follower 在启动期间的交互进⾏介绍,其⼤致交互流程如图所示。

image.png

  1. 创建 Leader 服务器和 Follower 服务器。完成 Leader 选举后,每个服务器会根据⾃⼰服务器的⻆⾊创建相应的服务器实例,并进⼊各⾃⻆⾊的主流程。
  2. Leader 服务器启动 Follower 接收器 LearnerCnxAcceptor。运⾏期间,Leader 服务器需要和所有其余的服务器(统称为 Learner)保持连接以确集群的机器存活情况,LearnerCnxAcceptor 负责接 收所有⾮ Leader 服务器的连接请求。
  3. Learner 服务器开始和 Leader 建⽴连接。所有 Learner 会找到 Leader 服务器,并与其建⽴连接。
  4. Leader 服务器创建 LearnerHandler。Leader 接收到来⾃其他机器连接创建请求后,会创建⼀个 LearnerHandler 实例,每个 LearnerHandler 实例都对应⼀个 Leader 与 Learner 服务器之间的连接,其负责 Leader 和 Learner 服务器之间⼏乎所有的消息通信和数据同步。
  5. 向 Leader 注册。Learner 完成和 Leader 的连接后,会向 Leader 进⾏注册,即将 Learner 服务器的基 本信息(LearnerInfo),包括 SID 和 ZXID,发送给 Leader 服务器。
  6. Leader 解析 Learner 信息,计算新的 epoch。Leader 接收到 Learner 服务器基本信息后,会解析出 该 Learner 的 SID 和 ZXID,然后根据 ZXID 解析出对应的 epoch_of_learner,并和当前 Leader 服务器 的 epoch_of_leader 进⾏⽐较,如果该 Learner 的 epoch_of_learner 更⼤,则更新 Leader 的 epoch_of_leader = epoch_of_learner + 1。然后 LearnHandler 进⾏等待,直到过半 Learner 已经 向 Leader 进⾏了注册,同时更新了 epoch_of_leader 后,Leader 就可以确定当前集群的 epoch 了。
  7. 发送 Leader 状态。计算出新的 epoch 后,Leader 会将该信息以⼀个 LEADERINFO 消息的形式发送给 Learner,并等待 Learner 的响应。
  8. Learner 发送 ACK 消息。Learner 接收到 LEADERINFO 后,会解析出 epoch 和 ZXID,然后向 Leader 反馈⼀个 ACKEPOCH 响应。
  9. 数据同步。Leader 收到 Learner 的 ACKEPOCH 后,即可进⾏数据同步。
  10. 启动 Leader 和 Learner 服务器。当有过半 Learner 已经完成了数据同步,那么 Leader 和 Learner 服 务器实例就可以启动了

Leader 和 Follower 启动

  1. 创建启动会话管理器。
  2. 初始化 Zookeeper 请求处理链,集群模式的每个处理器也会在启动阶段串联请求处理链。
  3. 注册 JMX 服务。

⾄此,集群版的 Zookeeper 服务器启动完毕

5.4 leader 选举

Leader 选举概述 Leader 选举是 zookeeper 最重要的技术之⼀,也是保证分布式数据⼀致性的关键所在。

当 Zookeeper 集群中的⼀台服务器出现以下两种情况之⼀时,需要进⼊ Leader 选举。

(1) 服务器初始化启动。

(2) 服务器运⾏期间⽆法和 Leader 保持连接。 下⾯就两种情况进⾏分析讲解。

5.4.1 服务器启动时期的 Leader 选举

若进⾏ Leader 选举,则⾄少需要两台机器,这⾥选取 3 台机器组成的服务器集群为例。在集群初始化阶段,当有⼀台服务器 Server1 启动时,其单独⽆法进⾏和完成 Leader 选举,当第⼆台服务器 Server2 启动 时,此时两台机器可以相互通信,每台机器都试图找到 Leader,于是进⼊ Leader 选举过程。选举过程 如下

(1) 每个 Server 发出⼀个投票

由于是初始情况,Server1(假设 myid 为 1)和 Server2 假设 myid 为 2)都会将⾃⼰作为 Leader 服务器来 进⾏投票,每次投票会包含所推举的服务器的 myid 和 ZXID,使⽤(myid, ZXID)来表示,此时 Server1 的 投票为(1, 0),Server2 的投票为(2, 0),然后各⾃将这个投票发给集群中其他机器

(2) 接受来⾃各个服务器的投票

集群的每个服务器收到投票后,⾸先判断该投票的有效性,如检查是否是本轮投票、是否来⾃ LOOKING 状态的服务器。

(3) 处理投票

针对每⼀个投票,服务器都需要将别⼈的投票和⾃⼰的投票进⾏ PK,PK 规则如下

  • 优先检查 ZXID。ZXID ⽐较⼤的服务器优先作为 Leader。
  • 如果 ZXID 相同,那么就⽐较 myid。myid 较⼤的服务器作为 Leader 服务器。

现在我们来看 Server1 和 Server2 实际是如何进⾏投票处理的。对于 Server1 来说,它⾃⼰的投票是 (1,0),⽽接收到的投票为(2,0)。⾸先会对⽐两者的 ZXID,因为都是 0,所以⽆法决定谁是 Leader。接下来会对⽐两者的 myid,很显然,Server1 发现接收到的投票中的 myid 是 2,⼤于⾃⼰,于是就会更新⾃⼰的投票为(2,0),然后重新将投票发出去。⽽对于 Server2 来说,不需要更新⾃⼰的投票

(4) 统计投票

每次投票后,服务器都会统计所有投票,判断是否已经有过半的机器接收到相同的投票信息。对于 Server1 和 Server2 服务器来说,都统计出集群中已经有两台机器接受了(2,0)这个投票信息。这⾥我 们需要对“过半”的概念做⼀个简单的介绍。所谓“过半”就是指⼤于集群机器数量的⼀半,即⼤于或等于 (n/2+1)。对于这⾥由 3 台机器构成的集群,⼤于等于 2 台即为达到“过半”要求。

那么,当 Server1 和 Server2 都收到相同的投票信息(2,0)的时候,即认为已经选出了 Leader。

(5) 改变服务器状态

⼀旦确定了 Leader,每个服务器就会更新⾃⼰的状态:如果是 Follower,那么就变更为 FOLLOWING,如果是 Leader,那么就变更为 LEADING。

5.4.2 服务器运⾏时期的 Leader 选举

在 ZooKeeper 集群正常运⾏过程中,⼀旦选出⼀个 Leader,那么所有服务器的集群⻆⾊⼀般不会再发⽣ 变化——也就是说,Leader 服务器将⼀直作为集群的 Leader,即使集群中有⾮ Leader 机器挂了或是有 新机器加⼊集群也不会影响 Leader。但是⼀旦 Leader 所在的机器挂了,那么整个集群将暂时⽆法对外 服务,⽽是进⼊新⼀轮的 Leader 选举。服务器运⾏期间的 Leader 选举和启动时期的 Leader 选举基本过程是⼀致的。

我们还是假设当前正在运⾏的 ZooKeeper 机器由 3 台机器组成,分别是 Server1、Server2 和 Server3,当前的 Leader 是 Server2。假设在某⼀个瞬间,Leader 挂了,这个时候便开始了 Leader 选 举。

(1) 变更状态

Leader 挂后,余下的⾮ Observer 服务器都会将⾃⼰的服务器状态变更为 LOOKING,然后开始进⼊ Leader 选举过程。

(2) 每个 Server 会发出⼀个投票

在运⾏期间,每个服务器上的 ZXID 可能不同,此时假定 Server1 的 ZXID 为 123,Server3 的 ZXID 为 122; 在第⼀轮投票中,Server1 和 Server3 都会投⾃⼰,产⽣投票(1, 123),(3, 122),然后各⾃将投票发送给集群中所有机器。

(3) 接收来⾃各个服务器的投票,与启动时过程相同

(4) 处理投票。与启动时过程相同,此时,Server1 将会成为 Leader

(5) 统计投票。与启动时过程相同

(6) 改变服务器的状态。与启动时过程相同

第六节 Zookeeper 源码分析

6.1 源码环境搭建

zk 源码下载地址:https://github.com/apache/zookeeper/tree/release-3.5.4

注意:因为 zk 是由 ant 来构建的,所以需要使⽤ ant 命令来转换成⼯程,然后导⼊ idea

将准备好的 zookeeper-release-3.5.4 导⼊ idea 中

6.1.1 启动服务端

运⾏主类 org.apache.zookeeper.server.QuorumPeerMain 将 zoo.cfg 的完整路径配置在 Program arguments。如下

image.png

在 VM options 配置,即指定到 conf ⽬录下的 log4j.properties:

,将 zoo.cfg 的完整路径配置在 Program

-Dlog4j.configuration=file:/Users/ericsun/Documents/zookeeper-release3.5.4/conf/log4j.properties

运⾏输出⽇志如下

image.png

可以得知单机版启动成功,单机版服务端地址为 127.0.0.1:2182。

注意

我自己尝试的时候,发现 jar 包错误,路径找不到 .ant 下的这些 jar ,如果自己启动的话要配置下 ant 才行

6.1.2 运⾏客户端

通过运⾏ ZooKeeperServerMain 得到的⽇志,可以得知 ZooKeeper 服务端已经启动,服务的地址 为 127.0.0.1:2182 。启动客户端来进⾏连接测试。

客户端的启动类为 org.apache.zookeeper.ZooKeeperMain 进⾏如下配置:

image.png

即客户端连接 127.0.0.1:2182,获取节点/lg 的信息。

6.2 单机模式服务端启动
6.2.1 执⾏过程概述

单机模式的 ZK 服务端逻辑写在 ZooKeeperServerMain 类中,由⾥⾯的 main 函数启动,整个过程如下:

image.png

单机模式的委托启动类为:ZooKeeperServerMain

6.2.2 服务端启动过程

看下 ZooKeeperServerMain ⾥⾯的 main 函数代码:

image.png

image.png

⼩结

zk 单机模式启动主要流程:

1、注册 jmx

2、解析 ServerConfig 配置对象

3、根据配置对象,运⾏单机 zk 服务

4、创建管理事务⽇志和快照 FileTxnSnapLog 对象,zookeeperServer 对象,并设置 zkServer 的统计对象

5、设置 zk 服务钩⼦,原理是通过设置 CountDownLatch,调⽤ ZooKeeperServerShutdownHandler 的 handle ⽅法,可以将触发 shutdownLatch.await ⽅法继续执⾏,即调⽤ shutdown 关闭单机服务

6、基于 jetty 创建 zk 的 admin 服务

7、创建连接对象 cnxnFactory 和 secureCnxnFactory(安全连接才创建该对象),⽤于处理客户端的请求

8、创建定时清除容器节点管理器,⽤于处理容器节点下不存在⼦节点的清理容器节点⼯作等

可以看到关键点在于解析配置跟启动两个⽅法,先来看下解析配置逻辑,对应上⾯的 configure ⽅法:

image.png

image.png

image.png

6.3 源码分析之 Leader 选举

分析 Zookeeper 中⼀个核⼼的模块,Leader 选举。

image.png

AuthFastLeaderElection,LeaderElection 其在 3.4.0 之后的版本中已经不建议使⽤。

Election 源码分析

public interface Election { public Vote lookForLeader() throws InterruptedException; public void shutdown(); }

说明:

选举的⽗接⼝为 Election,其定义了 lookForLeader 和 shutdown 两个⽅法,lookForLeader 表示寻找 Leader,shutdown 则表示关闭,如关闭服务端之间的连接。

默认的选举策略 FastLeaderElection

public class FastLeaderElection implements Election {

说明:FastLeaderElection 实现了 Election 接⼝,重写了接⼝中定义的 lookForLeader ⽅法和 shutdown ⽅法

在源码分析之前,我们⾸先介绍⼏个概念:

  • 外部投票:特指其他服务器发来的投票。
  • 内部投票:服务器⾃身当前的投票。
  • 选举轮次:ZooKeeper 服务器 Leader 选举的轮次,即 logical clock(逻辑时钟)。
  • PK:指对内部投票和外部投票进⾏⼀个对⽐来确定是否需要变更内部投票。
  • 选票管理 sendqueue:选票发送队列,⽤于保存待发送的选票。
  • recvqueue:选票接收队列,⽤于保存接收到的外部投票。

image.png

lookForLeader 函数

当 ZooKeeper 服务器检测到当前服务器状态变成 LOOKING 时,就会触发 Leader 选举,即调⽤ lookForLeader ⽅法来进⾏ Leader 选举。

image.png

image.png

之后每台服务器会不断地从 recvqueue 队列中获取外部选票。如果服务器发现⽆法获取到任何外部投票,就⽴即确认⾃⼰是否和集群中其他服务器保持着有效的连接,如果没有连接,则⻢上建⽴连接,如果已经建⽴了连接,则再次发送⾃⼰当前的内部投票,其流程如下

image.png

在发送完初始化选票之后,接着开始处理外部投票。在处理外部投票时,会根据选举轮次来进⾏不同的处理。

  • 外部投票的选举轮次⼤于内部投票
    若服务器⾃身的选举轮次落后于该外部投票对应服务器的选举轮次,那么就会⽴即更新⾃⼰的选举轮次(logicalclock),并且清空所有已经收到的投票,然后使⽤初始化的投票来进⾏ PK 以确定是否变更内部投票。最终再将内部投票发送出去。
  • 外部投票的选举轮次⼩于内部投票。
    若服务器接收的外选票的选举轮次落后于⾃身的选举轮次,那么 Zookeeper 就会直接忽略该外部投票,不做任何处理。
  • 外部投票的选举轮次等于内部投票。
    此时可以开始进⾏选票 PK,如果消息中的选票更优,则 需要更新本服务器内部选票,再发送给其他服务器。

之后再对选票进⾏归档操作,⽆论是否变更了投票,都会将刚刚收到的那份外部投票放⼊选票集合 recvset 中进⾏归档,其中 recvset ⽤于记录当前服务器在本轮次的 Leader 选举中收到的所有外部投票, 然后开始统计投票,统计投票是为了统计集群中是否已经有过半的服务器认可了当前的内部投票,如果 确定已经有过半服务器认可了该投票,然后再进⾏最后⼀次确认,判断是否⼜有更优的选票产⽣,若⽆,则终⽌投票,然后最终的选票,其流程如下

image.png

image.png

1.⾃增选举轮次。 在 FastLeaderElection 实现中,有⼀个 logicalclock 属性,⽤于标识当前 Leader 的 选举轮次,ZooKeeper 规定了所有有效的投票都必须在同⼀轮次中。ZooKeeper 在开始新⼀轮的投票时,会⾸先对 logicalclock 进⾏⾃增操作。

2.初始化选票。 在开始进⾏新⼀轮的投票之前,每个服务器都会⾸先初始化⾃⼰的选票。初始化选票也就是对 Vote 属性的初始化。在初始化阶段,每台服务器都会将⾃⼰推举为 Leader。

3.发送初始化选票。 在完成选票的初始化后,服务器就会发起第⼀次投票。ZooKeeper 会将刚刚初始化好的选票放⼊ sendqueue 队列中,由发送器 WorkerSender 负责

4.接收外部投票。 每台服务器都会不断地从 recvqueue 队列中获取外部投票。如果服务器发现⽆法获取到任何的外部投票,那么就会⽴即确认⾃⼰是否和集群中其他服务器保持着有效连接。如果发现没有建⽴连接,那么就会⻢上建⽴连接。如果已经建⽴了连接,那么就再次发送⾃⼰当前的内部投票。

5.判断选举轮次。 当发送完初始化选票之后,接下来就要开始处理外部投票了。在处理外部投票的时候,会根据选举轮次来进⾏不同的处理。

  • 外部投票的选举轮次⼤于内部投票。如果服务器发现⾃⼰的 选举轮次已经落后于该外部投票对应服务器的选举轮次,那么就会⽴即更新⾃⼰的选举轮次 (logicalclock),并且清空所有已经收到的投票,然后使⽤初始化的投票来进⾏ PK 以确定是否变更内部投票(关于 P K 的逻辑会在步骤 6 中统⼀讲解),最终再将内部投票发送出去。
  • 外部投票的选举轮次⼩于内部投票。 如果接收到的选票的选举轮次落后于服务器⾃身的,那么 ZooKeeper 就会直接忽略该外部投票,不做任何处理,并返回步骤 4。
  • 外部投票的选举轮次和内部投票⼀致。 这也是绝⼤多数投票的场景,如外部投票的选举轮次和内部投票⼀致的话,那么就开始进⾏选票 PK。 总的来说,只有在同⼀个选举轮次的投票才是有效的投票。

6.选票 PK。 在步骤 5 中提到,在收到来⾃其他服务器有效的外部投票后,就要进⾏选票 PK 了——也就是 FastLeaderElection.totalOrderPredicate ⽅法的核⼼逻辑。选票 PK 的⽬的是为了确定当前服务器是否需要变更投票,主要从选举轮次、ZXID 和 SID 三个因素来考虑,具体条件如下:在选票 PK 的时候依次 判断,符合任意⼀个条件就需要进⾏投票变更。

  • 如果外部投票中被推举的 Leader 服务器的选举轮次⼤于内部投票,那么就需要进⾏投票变更。
  • 如果选举轮次⼀致的话,那么就对⽐两者的 ZXID。如果外部投票的 ZXID ⼤于内部投票,那么就需要进⾏投票变更。
  • 如果两者的 ZXID ⼀致,那么就对⽐两者的 SID。如果外部投票的 SID ⼤于内部投票,那么就需要进⾏投票变更。

7.变更投票。 通过选票 PK 后,如 果确定了外部投票优于内部投票(所谓的“优于”,是指外部投票所推举的服务器更适合成为 Leader), 那么就进⾏投票变更——使⽤外部投票的选票信息来覆盖内部投票。变更完成后,再次将这个变更后的内部投票发送出去。

8.选票归档。 ⽆论是否进⾏了投票变更,都会将刚刚收到的那份外部投票放⼊“选票集合”recvset 中进⾏归档。recvset ⽤于记录当前服务器在本轮次的 Leader 选举中收到的所有外部投票——按照服务器对应的 SID 来区分,例如,{(1,vote1),(2,vote2),…}。

9.统计投票。 完成了选票归档之后,就可以开始统计投票了。统计投票的过程就是为了统计集群中是否已经有过半的服务器认可了当前的内部投 票。如果确定已经有过半的服务器认可了该内部投票,则终⽌投票。否则返回步骤 4。

10.更新服务器状 态。 统计投票后,如果已经确定可以终⽌投票,那么就开始更新服务器状态。服务器会⾸先判断当前被过半服务器认可的投票所对应的 Leader 服务器是否是⾃⼰,如果是⾃⼰的话,那么就会将⾃⼰的服务器状态更新为 LEADING。如果⾃⼰不是被选举产⽣的 Leader 的话,那么就会根据具体情况来确定⾃⼰是 FOLLOWING 或是 OBSERVING。

以上 10 个步骤,就是 FastLeaderElection 选举算法的核⼼步骤,其中步骤 4~9 会经过⼏轮循环,直到 Leader 选举产⽣。另外还有⼀个细节需要注意,就是在完成步骤 9 之后,如果统计投票发现已经有过半的服务器认可了当前的选票,这个时候,ZooKeeper 并不会⽴即进⼊步骤 10 来更新服务器状态,⽽是会等待⼀段时间(默认是 200 毫秒)来确定是否有新的更优的投票

6.4 源码分析之集群模式服务端

image.png

集群模式下启动所有的 ZK 节点启动⼊⼝都是 QuorumPeerMain 类的 main ⽅法。 main ⽅法加载配置⽂ 件以后,最终会调⽤到 QuorumPeer 的 start ⽅法,来看下:

image.png

我们已经知道了当⼀个节点启动时需要先发起选举寻找 Leader 节点,然后再根据 Leader 节点的事务信 息进⾏同步,最后开始对外提供服务,这⾥我们先来看下初始化选举的逻辑,即上⾯的 startLeaderElection ⽅法:

image.png

接下来,回到 QuorumPeer 类中 start ⽅法的最后⼀⾏ super.start(),QuorumPeer 本身也是⼀个线程类,⼀起来看下它的 run ⽅法:

image.png

节点初始化的状态为 LOOKING,因此启动时直接会调⽤ lookForLeader ⽅法发起 Leader 选举,⼀起看 下:

image.png

image.png

image.png

经过上⾯的发起投票,统计投票信息最终每个节点都会确认⾃⼰的身份,节点根据类型的不同会执⾏以下逻辑:

  1. 如果是 Leader 节点,⾸先会想其他节点发送⼀条 NEWLEADER 信息,确认⾃⼰的身份,等到各个节点的 ACK 消息以后开始正式对外提供服务,同时开启新的监听器,处理新节点加⼊的逻辑。
  2. 如果是 Follower 节点,⾸先向 Leader 节点发送⼀条 FOLLOWERINFO 信息,告诉 Leader 节点⾃⼰已处理的事务的最⼤ Zxid,然后 Leader 节点会根据⾃⼰的最⼤ Zxid 与 Follower 节点进⾏同步,如果 Follower 节点落后的不多则会收到 Leader 的 DIFF 信息通过内存同步,如果 Follower 节点落后的很 多则会收到 SNAP 通过快照同步,如果 Follower 节点的 Zxid ⼤于 Leader 节点则会收到 TRUNC 信息忽略多余的事务。
  3. 如果是 Observer 节点,则与 Follower 节点相同

结语

本文是在子慕老师的课程笔记基础,便于自己学习记忆理解整理而成,GitHub 代码地址 lane-zookeeper-3-2

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值