ZooKeeper 是应用于分布式应用程序分布式协调服务。
ZooKeeper 最早起源于雅虎研究院的一个研究小组。当时,研究人员发现,在雅虎的很多大型系统基本都需要依赖一个类似的系统来进行分布式协调,但是这些系统都存在分布式单点问题,所以雅虎的开发人员就试图开发出一个通用的无单点问题的分布式协调框架,以便让开发人员将精力集中在处理业务逻辑上。
特性
我们来看看 ZooKeeper 官网上对 ZooKeeper 的介绍。
-
ZooKeeper 是简单的
-
ZooKeeper 是复制的
-
ZooKeeper 是有序的
-
ZooKeeper 是快速的
ZooKeeper 可以协调分布式应用程序,同时它自己也支持分布式部署,所以说 ZooKeeper 是可复制的;ZooKeeper 中的数据存储在内存中,访问速度非常快,所以说 ZooKeeper 是快速的;ZooKeeper 的数据模型类似于 Linux 系统的目录树结构,所以说 ZooKeeper 是简单的;对于来自客户端的每个更新请求,ZooKeeper 都会分配一个全局唯一的递增 ID,这个 ID 反映了所有事务请求的先后顺序,所以说 ZooKeeper 是有序的。
数据模型
ZooKeeper 有一个分层命名空间,很像分布式文件系统。唯一的区别是命名空间中的每个节点都可以有与其关联的数据以及子节点。这就像拥有一个文件系统,允许文件也成为目录。节点的路径始终表示为规范的、绝对的、斜杠分隔的路径。ZooKeeper 文件树中的节点称为 znode,可以通过类似于 Linux 的文件系统路径的方式访问,比如 /app1/p_1
,其中根节点是 /
。
Znode
ZooKeeper 并不是被设计为通用数据库或大型对象存储。相反,它管理协调数据。该数据可以采用配置、状态信息、集合点等形式。各种形式的协调数据的一个共同属性是它们相对较小:以千字节为单位。
znode 有四种类型:
-
无序持久节点(PERSISTENT):除非客户端主动删除节点,否则 ZooKeeper 不会自己删除持久的 znode。
-
无序临时节点(EPHEMERAL):客户端会话(连接断开)结束时,ZooKeeper 就会删除临时的 znode。
-
有序持久节点(PERSISTENT_SEQUENTIAL):除了不会主动删除节点之外,创建的节点名称添加数字后缀,顺序创建的节点的数字后缀会一次递增。
-
有序临时节点(EPHEMERAL_SEQUENTIAL):除了连接断开之后会自动删除节点之外,创建的节点名称添加数字后缀,顺序创建的节点的数字后缀会一次递增。
每个 znode 在存储数据的同时,都会维护一个叫做 Stat
的数据结构,里面存储了关于该节点的全局状态信息。如下:
状态属性 | 说明 |
---|---|
czxid | znode 创建时的事务 ID |
ctime | znode 创建时的时间 |
mzxid | znode 最后一个更新时的事务 ID |
mtime | znode 最后一次更新时的时间 |
pzxid | znode 的子节点最后一次被修改时的事务 ID |
version | znode 数据的更改次数 |
cversion | znode 子节点的更改次数 |
aversion | znode 的 ACL 的更改次数 |
ephemeralOwner | 如果 znode 是临时节点,则表示创建该 znode 的会话的 SessionID;否则该属性值为 0 |
dataLength | znode 中数据字段的长度 |
numChildren | 节点当前的子节点个数 |
ACL
ZooKeeper 使用 ACL(Access Control Lists)来控制对 znode 的访问。ACL 实现与 UNIX 文件访问权限非常相似;它使用权限位来允许/禁止针对节点的各种操作。
ZooKeeper 定义了如下五种权限:
- CREATE:允许创建子节点
- READ:允许从节点中获取数据并列出其子节点
- WRITE:允许为节点设置数据
- DELETE:允许删除子节点
- ADMIN:允许为节点设置权限
Watch
ZooKeeper 中所有读取操作 getData()、getChildren()、Exists()
都可以选择设置 Watch,ZooKeeper 对 Watch 的定义如下:Watch 事件是一次性触发,发送到设置 Watch 的客户端,当设置的 Watch 的数据发生变化时发送。
当数据发生变化时,会向客户端发送一个 Watch 事件。例如,如果客户端执行 getData("/znode1", true)
,随后 /znode1
的数据被更改或删除,则客户端将获得 /znode1
的 Watch 事件。如果 /znode1
再次更改,则不会发送任何 Watch 事件,除非客户端再次执行读取来设置新的 Watch。
可以将 Watch 分为数据 Watch 和子节点 Watch。getData()
和 exists()
设置数据 Watch,getChildren()
设置子节点 Watch。
CLI
ZooKeeper 其中一个设计目标是提供非常简单的编程接口。基于此,它只支持这些操作:
create
:在树中创建一个 nodedelete
:删除一个 nodeexists
:测试 node 是否存在get data
:从一个 node 中读取数据set data
:写数据到一个 node 中get children
:检索 node 的子节点列表sync
:等待数据传播
ZAB 协议
ZAB 协议全称 ZooKeeper Atomic Broadcast(ZooKeeper 原子广播协议)。它是为分布式协调服务 ZooKeeper 专门设计的一种支持崩溃恢复 和原子广播的协议。
ZooKeeper 使用一个单一的主进程来接受并处理客户端的所有事务请求,并采用 ZAB 的原子广播协议,将服务器数据的状态变更以事务 Proposal 的形式广播到所有的副本进程中去。
ZAB 协议的核心是定义了对于那些会改变 ZooKeeper 服务器数据状态的事务请求的处理方式,即:
所有事务请求必须由一个全局唯一的服务器来协调处理,这样的服务器被称为 Leader 服务器,而余下的服务器称为 Follower 服务器。Leader 服务器负责将一个客户端事务请求转换成一个事务 Proposal(提案),并将该 Proposal 分发给集群中所有的 Follower 服务器。
之后 Leader 服务器需要等待半数以上的 Follower 进行正确的反馈之后,Leader 服务器就会再次向 Follower 分发 Commit 消息,表示将前一个 Proposal 进行提交。
ZAB 协议包括两种基本模式:崩溃恢复和消息广播。
当整个服务框架在启动过程中,或是 Leader 服务器出现崩溃、网络中断、重启或者集群中不存在过半的服务器与 Leader 服务器保持正常通信等异常异常情况时,ZAB 就会进入崩溃恢复并选举产生新的 Leader 服务器。当选举产生了新的 Leader 服务器,同时集群中已经有过半的机器与该 Leader 服务器完成了状态同步(数据同步)之后,ZAB 协议就会退出崩溃恢复模式。
之后就进入了消息广播模式。如果此时一个同样遵循 ZAB 协议的 ZooKeeper 服务器加入到集群时,那么如果此时已经存在一个 Leader 在负责消息广播,那么该服务器就会自觉进入消息恢复模式,在完成之后加入到消息广播流程中。
ZooKeeper 设计成只允许唯一的一个 Leader 服务器来进行事务请求的处理。Leader 服务器在接收到客户端的事务请求之后,会生成对应提案并发起一轮广播;如果是其他非 Leader 服务器接收到客户端的事务请求,那么它会将这个事务请求转发给 Leader 服务器。
ZooKeeper 中消息广播的具体步骤如下:
- 客户端发起一个写操作请求,Leader 服务器将客户端的 Request 请求转化位事务 Proposal 提案,同时为每个 Proposal 分配一个全局递增唯一的 ID,即 ZXID(事务ID)。
- Leader 服务器与每个 Follower 之间都有一个队列,Leader 将消息发送到该队列。
- Follower 机器从队列中取出消息处理完毕后(写入本地事务日志中),向 Leader 服务器发送 ACK 确认。
- Leader 服务器收到半数以上的 Follower 的 ACK 后,即认为可以发送 Commit,Leader 向所有的 Follower 服务器发送 Commit 消息。
崩溃恢复
一旦 Leader 服务器出现奔溃或者由于网络原因导致 Leader 服务器失去了与过半 Follower 的联系,那么就会进入崩溃恢复模式。
在整个过程中,需要一个高效的 Leader 选举算法快速选举出一个新的 Leader 服务器,同时还要让其他服务器快速感知新产生的 Leader。崩溃恢复主要包括两部分:Leader 选举和数据恢复。
ZAB 协议崩溃恢复需要满足两个要求:
- 崩溃恢复过程需要确保已经被 Leader 提交的 Proposal 必须最终被所有的 Follower 服务器提交。
- 确保丢弃已经被 Leader 发出的但是没有被提交的 Proposal。
所以,ZAB 设计了一个选举算法:
- 在第一轮选举中每个节点都会投自己
- 之后的选举,节点通过比较节点之间的 ZXID 来判断把票投给谁,ZXID 大的获得投票,如果 ZXID 相同,则比较 myid,myid 大的获得投票。
数据同步
关于数据同步 ZooKeeper 中一共有四类:
- DIFF:直接差异化同步
- TRUNC+DIFF:先回滚再差异化同步
- TRUNC:仅回滚同步
- SNAP:全量同步
在数据同步之前,Leader 服务器会进行数据同步的初始化,首先会从 ZooKeeper 的内存数据库中提取出事务前期对应的提议缓存队列,同时会初始化三个 ZXID 的值。
- peerLastZxid:这是 Follower 的最后处理的 ZXID
- minCommittedLog:Leader 服务器的提议缓存队列中最大的 ZXID
- maxCommittedLog:Leader 服务器的提议缓存队列中最小的 ZXID
根据这三个参数,就可以确定四种同步方式,分别为:
- 直接差异化同步
场景:当 minCommittedLog < peerLastZxid < maxCommittedLog 时。
假如某个时刻 Leader 服务器的提议缓存队列对应的 ZXID 依次是:0x500000001、0x500000002、0x500000003、0x500000004、0x500000005
,而 Leader 服务器的 maxCommittedLog 为 0x500000003
,于是 Leader 服务器依次将 0x500000004
和 0x500000005
两个提议同步给 Leader 服务器即可。
- 先回滚再差异化同步
场景:假设集群有 A、B、C 三台机器,B 是 Leader 节点,此时的 Leader_epoch 为 5,B 的替换缓存队列为:0x500000001,0x500000002
。此时 Leader 正要处理 0x500000003
,并且已经将事务写入到了 Leader 本地的事务日志中,但是 B 在要发送 Commit 给其他 Follower 节点时挂了。之后集群开始重新选举,A 当选为新的 Leader,同时 Leader_epoch 变更为 6,之后 A 和 C 继续对外提供服务,又提交了 0x600000001
和 0x600000002
两个事务,此时 A 节点的提议缓存队列为:0x500000001,0x500000002,0x600000001,0x600000002
, B 节点在修复异常后重新加入集群,并开始同时数据。Leader 发现 B 中包含了自己没有的事务 0x500000003
,于是让 Follower 先回滚事务到 0x500000002
,然后在执行差异化同步。
- 仅回滚同步
场景:这里和先回滚再差异化同步类似,直接回滚就可以了。
- 全量同步
场景:peerLastZxid < minCommittedLog,当 Follower 远远落后 Leader 的数据时,直接全量同步。
使用
单机模式
我们使用 docker 来启动 zookeeper 服务,首先肯定是在 docker 中拉取 zookeeper 的镜像了。
bash
$ docker search zookeeper
NAME DESCRIPTION STARS OFFICIAL AUTOMATED
zookeeper Apache ZooKeeper is an open-source server wh... 1378 [OK]
$ docker pull zookeeper
Using default tag: latest
latest: Pulling from library/zookeeper
a2abf6c4d29d: Already exists
2bbde5250315: Pull complete
202a34e7968e: Pull complete
4e4231e30efc: Pull complete
707593b95343: Pull complete
b070e6dedb4b: Pull complete
46e5380f3905: Pull complete
8b7e330117e6: Pull complete
Digest: sha256:2c8c5c2db6db22184e197afde13e33dad849af90004c330f20b17282bcd5afd7
Status: Downloaded newer image for zookeeper:latest
docker.io/library/zookeeper:latest
docker hub 中的镜像地址:https://hub.docker.com/_/zookeeper。
从 docker hub 中的介绍可知,zookeeper 默认配置文件的路径是 /conf/zoo.cfg,默认存储数据快照的路径是 /data。所以我们在运行容器的时候挂载这两个路径。
bash
$ docker run --name zk1 -d -p 2181:2181 -v /d/IT/docker/zookeeper/server1/zoo.cfg:/conf/zoo.cfg -v /d/IT/docker/zookeeper/server1/data:/data zookeeper
运行成功后会输出 containerID,同时查看本地的 /data 目录会多出了一些文件,其中的 myid 是 zookeeper 服务器的状态数据,其中存储了一个数字,该数字在 zookeeper 集群中是唯一的。
先创建一个 network 用于容器将网络通信
bash
$ docker network create zookeeper
然后将 zk1 容器添加到 network 中
bash
$ docker network connect zookeeper zk1
我们进入 zk1 查看此时服务器的状态
bash
$ docker exec -it zk1 bash
$ zkServer.sh status
ZooKeeper JMX enabled by default
Using config: /conf/zoo.cfg
Client port found: 2181. Client address: localhost. Client SSL: false.
Mode: standalone
Mode: standalone 表示此时是单机模式。
现在我们使用客户端来连接 zookeeper 服务器并测试 zookeeper 提供的 API。
创建客户端容器,这里我使用 --network
参数提前将客户端添加到 network 中了。
bash
$ docker run -it --rm --name zkclient --network zookeeper zookeeper /bin/bash
连接 zk1 服务器
bash
$ zkCli.sh -server zk1
连上服务器之后我们用 zookeeper-cli 来验证 znode 的一些特性。完整的 zookeeper-cli 请参考 https://zookeeper.apache.org/doc/current/zookeeperCLI.html
bash
# 创建一个不存储数据的持久节点
[zk: zk1(CONNECTED) 4] create /persistent_node
Created /persistent_node
# 验证是否创建成功
[zk: zk1(CONNECTED) 6] get /persistent_node
null
# 创建一个存储数据的临时节点
[zk: zk1(CONNECTED) 11] create -e /ephemeral_node mydata
Created /ephemeral_node
# 验证是否创建成功
[zk: zk1(CONNECTED) 12] get /ephemeral_node
mydata
# 关闭连接
[zk: zk1(CONNECTED) 17] close
WatchedEvent state:Closed type:None path:null
2023-08-20 10:19:40,289 [myid:] - INFO [main:ZooKeeper@1232] - Session: 0x1000091e7010001 closed
2023-08-20 10:19:40,289 [myid:] - INFO [main-EventThread:ClientCnxn$EventThread@570] - EventThread shut down for session: 0x1000091e7010001
# 然后重新连接
[zk: zk1(CLOSED) 18] connect
2023-08-20 10:20:13,651 [myid:] - INFO [main:ZooKeeper@637] - Initiating client connection, connectString=zk1 sessionTimeout=30000 watcher=org.apache.zookeeper.ZooKeeperMain$MyWatcher@77ec78b9
...
# 再获取临时节点会发现报错,临时节点不存在,这也符合上面对临时节点的描述,连接关闭后zookeeper就会删除当前连接创建临时节点
[zk: zk1(CONNECTED) 19] get /ephemeral_node
org.apache.zookeeper.KeeperException$NoNodeException: KeeperErrorCode = NoNode for /ephemeral_node
# 我们再获取持久节点会发现依旧存在,这也符合上面对持久节点的描述
[zk: zk1(CONNECTED) 20] get /persistent_node
null
# 我们连续创建三个持久有序节点,会发现节点名称后缀的数字是步长为1单调底层的
[zk: zk1(CONNECTED) 21] create -s /persistent_sequential_node
Created /persistent_sequential_node0000000002
[zk: zk1(CONNECTED) 22] create -s /persistent_sequential_node1
Created /persistent_sequential_node10000000003
[zk: zk1(CONNECTED) 23] create -s /persistent_sequential_node2
Created /persistent_sequential_node20000000004
# 最后我们获取持久节点的元数据
[zk: zk1(CONNECTED) 21] stat /persistent_node
cZxid = 0x4
ctime = Sun Aug 20 10:10:27 UTC 2023
mZxid = 0x4
mtime = Sun Aug 20 10:10:27 UTC 2023
pZxid = 0x4
cversion = 0
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 0
numChildren = 0
zookeeper-cli 中的命令演示就到这里,除了上面列举的命令还有一些命令,但是命令总数并不多,这也符合 zookeeper 简单的开发目标。
集群模式
单机模式一般用于程序员学习,在生产上肯定是用的集群模式的,下面我们运行三个 zookeeper 服务器,以集群模式运行 zookeeper。
集群模式的 zoo.cfg 配置文件如下,三个服务器都用同样的配置即可,这里介绍以下配置项都代表什么意思。
cfg
tickTime=2000
initLimit=5
syncLimit=2
dataDir=/data
clientPort=2181
server.1=zk1:2888:3888
server.2=zk2:2888:3888
server.3=zk3:2888:3888
- tickTime:zookeeper 使用的基本时间单位,以毫秒为单位。用于管理心跳间隔和超时。
- initLimit:初始化连接的时间限制(以 tickTime 的倍数计算)。当一台服务器刚启动时,它会等待 initLimit 倍的 tickTime,然后才能接受客户端的请求。
- syncLimit:领导者服务器和从属服务器之间发送消息的时间限制(以 tickTime 的倍数计算)。
- dataDir:zookeeper 存储数据的目录
- clientPort:客户端连接 zookeeper 的目录
- server.x:每个服务器的配置,在上述示例中,
server.1
、server.2
、server.3
分别表示三个 zookeeper 服务器的配置。其中 x 代表 zookeeper 的 myid,你需要提前在 data 目录中创建,属性值被冒号分成了三段,第一段是服务器的 IP 地址,需要根据实际情况填写,第二段是集群同步数据和传递心跳包的端口号,第三段是集群选举领导者的端口号,按理说这两个端口号要设置成一样的。
因为要启动三个 docker 容器,我们使用 docker-compose 来实现,docker-compose.yml 文件如下:
yml
version: '3'
services:
zk1:
image: zookeeper
hostname: zk1
environment:
ZOO_MY_ID: 1
ZOO_SERVERS: server.1=0.0.0.0:2888:3888;2181 server.2=zk2:2888:3888;2181 server.3=zk3:2888:3888;2181
networks:
- zookeeper
zk2:
image: zookeeper
hostname: zk2
environment:
ZOO_MY_ID: 2
ZOO_SERVERS: server.1=zk1:2888:3888;2181 server.2=0.0.0.0:2888:3888;2181 server.3=zk3:2888:3888;2181
networks:
- zookeeper
zk3:
image: zookeeper
hostname: zk3
environment:
ZOO_MY_ID: 3
ZOO_SERVERS: server.1=zk1:2888:3888;2181 server.2=zk2:2888:3888;2181 server.3=0.0.0.0:2888:3888;2181
networks:
- zookeeper
networks:
zookeeper:
external: true
在文件所在目录执行 docker compose up -d
后台启动集群。
我试过以绑定数据卷的方式在宿主机中指定 zoo.cfg 文件和 myid 文件,但是集群在选举的时候报错
myid could not be determined, will not able to locate clientPort in the server configs.
,用 docker hub 上提供的环境变量的方式则可以成功,读者如果有兴趣可以研究一下是什么原因,按理说自己创建 myid 文件也是可以的。
集群搭建成功后进入一个服务器中查看当前服务器在集群中的状态,同时也可以验证集群是否搭建成功,即选举过程是否完成。
bash
# 进入容器
$ docker exec -it zookeeper-zk1-1 bash
# 查看服务器状态,可以看到当前服务器的 mode 是 follower,所以它是一个 follower 类型的服务器
root@zk1:/apache-zookeeper-3.7.0-bin# ./bin/zkServer.sh status
ZooKeeper JMX enabled by default
Using config: /conf/zoo.cfg
Client port found: 2181. Client address: localhost. Client SSL: false.
Mode: follower
使用 ZooKeeper 解决分布式问题
统一配置管理
比如我们现在有三个系统 A、B、C,它们有三份配置,分别是 ASystem.yml
、BSystem.yml
、CSystem.yml
,然后,这三份配置又非常类似,很多的配置项几乎都一样。
此时,如果我们要改变其中一份配置项的信息,很可能其他两份都要改。并且,改变了配置项的信息很可能就要重启系统。于是,我们希望把 ASystem.yml、BSystem.yml、CSystem.yml
相同的配置项抽取出来成一份公用的配置 common.yml
,并且即便 common.yml
改了,也不需要系统 A、B、C 重启。
做法:我们可以将 common.yml
这份配置放在 ZooKeeper 的 znode 节点中,系统 A、B、C 监听着这个 znode 节点有无变更,如果变更了,及时响应。
统一命名服务
统一命名服务的理解其实和域名一样,是我们为这某一部分的资源给它取一个名字,别人通过这个名字就可以拿到对应的资源。
比如说,现在我有一个域名 www.java3y.com
,但我这个域名下有多台机器:192.168.1.1、192.168.1.2、192.168.1.3、192.168.1.4
。
别人访问 www.java3y.com
即可访问到我的机器,而不是通过 IP 去 访问。
分布式锁
我们可以使用 ZooKeeper 来实现分布式锁,那是怎么实现的呢?下面来看看:
系统 A、B、C 都去访问 /locks
节点
访问的时候会创建带有顺序号的临时节点(EPHEMERAL_SEQUENTIAL
),比如,系统 A 创建了 id_000000
节点,系统 B 创建了 id_000002
节点,系统 C 创建了 id_000001
节点。
接着,拿到 /locks
节点下的所有子节点判断自己创建的是不是最小的那个节点:
- 如果是,则拿到锁。
- 释放锁:执行完操作后,把创建的节点给删掉
- 如果不是,则监听比自己要小 1 的节点变化
举个例子:
-
系统 A 拿到
/locks
节点下的所有子节点,经过比较,发现自己(id_000000
)是子节点最小的,所以得到锁。 -
系统 B 拿到
/locks
节点下的所有子节点,经过比较,发现自己(id_000002
)不是所有子节点最小的。所以监听比自己小 1 的节点(id_000001
)的状态。 -
系统 C 拿到
/locks
节点下的所有子节点,经过比较,发现自己(id_000001
)不是所有子节点最小的,所以监听比自己小 1 的节点(id_000000
)的状态。 -
... ...
-
等到系统 A 执行完操作以后,将自己创建的节点删除(
id_000000
)。通过监听,系统 C 发现id_000000
节点已经删除了,发现自己已经是最小的节点了,于是顺利拿到锁。 -
...系统 B 如上
集群状态
经过上面几个例子,我相信大家也很容易想到 ZooKeeper 是怎么 "感知" 节点的动态新增或者删除的了。
还是以我们三个系统 A、B、C 为例,在 ZooKeeper 中创建临时节点即可:
只要系统 A 挂了,那 /groupMember/A
这个节点就会删除,通过监听 groupMember
下的子节点,系统 B 和 C 就能够感知到系统 A 已经挂了。
除了能够感知节点的上下线变化,ZooKeeper 还可以实现动态选举 Master 的功能。原理很简单,如果想要实现动态选举 Master 的功能,znode 节点的类型是带顺序号的临时节点就好了。
ZooKeeper 会每次选举最小编号的作为 Master,如果 Master 挂了,自然对应的 znode 节点就会删除,然后让新的最小编号作为 Master,这样就可以实现动态选举的功能了。
参考:
https://zookeeper.apache.org/doc/current/zookeeperCLI.html
https://www.51cto.com/article/704705.html
https://segmentfault.com/a/1190000037550497
https://juejin.cn/post/6945395113132556325
http://www.jasongj.com/zookeeper/fastleaderelection/
https://dunwu.github.io/bigdata-tutorial/zookeeper/ZooKeeper原理.html
https://juejin.cn/post/6882277384112832519#comment
https://juejin.cn/post/7099251134333714440?searchId=20230821172027F859C1AF37B50EB4EC11