本文主要有以下内容:
- Zookeeper 简介与集群搭建
- Zookeeper的数据模型
- Zookeeper客户端指令以及JavaAPI
- 如何使用zookeeper实现注册中心
- 如何使用zookeeper实现分布式锁
Zookeeper简介与集群搭建
zookeeper:是 Apache 软件基金会的一个软件项目,它为大型分布式计算提供开源的分布式配置服务、同步服务和命名注册。它是一个开源的分布式应用程序协调服务, 作为 Google Chubby 的一个开源实现, 是 Hadoop 和 Hbase 的重要组件。 ZooKeeper 的目标是封装好复杂易出错的关键服务, 暴露简单易用、高效、稳定的接口给用户, 提供 java 和 C 接口。
本机系统是 macOS,使用docker-compose
安装集群,docker-compose.yml
文件如下:
version: '3.8'
services:
zookeeper-node1:
image: zookeeper:3.5.5
platform: linux/amd64
container_name: zookeeper-node1
hostname: zookeeper-node1
ports:
- "2181:2181" # 客户端端口
- "2888:2888" # 集群节点间通信端口
- "3888:3888" # 集群选举端口
environment:
ZOO_MY_ID: 1
ZOO_SERVERS: "server.1=zookeeper-node1:2888:3888;2181 server.2=zookeeper-node2:2888:3888;2181 server.3=zookeeper-node3:2888:3888;2181"
volumes:
- ./zookeeper-node1/data:/data
- ./zookeeper-node1/datalog:/datalog
networks:
- zookeeper-net
zookeeper-node2:
image: zookeeper:3.5.5
platform: linux/amd64
container_name: zookeeper-node2
hostname: zookeeper-node2
ports:
- "2182:2181"
- "2889:2888"
- "3889:3888"
environment:
ZOO_MY_ID: 2
ZOO_SERVERS: "server.1=zookeeper-node1:2888:3888;2181 server.2=zookeeper-node2:2888:3888;2181 server.3=zookeeper-node3:2888:3888;2181"
volumes:
- ./zookeeper-node2/data:/data
- ./zookeeper-node2/datalog:/datalog
networks:
- zookeeper-net
zookeeper-node3:
image: zookeeper:3.5.5
platform: linux/amd64
container_name: zookeeper-node3
hostname: zookeeper-node3
ports:
- "2183:2181"
- "2890:2888"
- "3890:3888"
environment:
ZOO_MY_ID: 3
ZOO_SERVERS: "server.1=zookeeper-node1:2888:3888;2181 server.2=zookeeper-node2:2888:3888;2181 server.3=zookeeper-node3:2888:3888;2181"
volumes:
- ./zookeeper-node3/data:/data
- ./zookeeper-node3/datalog:/datalog
networks:
- zookeeper-net
networks:
zookeeper-net:
driver: bridge
参数说明:
环境变量:
ZOO_MY_ID
: 每个节点的唯一 ID(必须与ZOO_SERVERS
中的server.x
对应)。ZOO_SERVERS
: 定义所有集群节点(格式:server.<zoo_my_id>=<host>:<peer_port>:<election_port>;<client_port>
)。端口映射:
2181-2183
: 客户端连接端口(映射到宿主机的不同端口)。2888-2890
: 节点间数据同步端口。3888-3890
: 集群选举端口。持久化存储:
- 数据目录 (
/data
) 和事务日志目录 (/datalog
) 映射到宿主机,避免容器重启后数据丢失
# 创建持久化目录(避免权限问题)如果无法启动:请给对应的目录写权限,chmod 777 direction
mkdir -p zookeeper-node{1,2,3}/data
mkdir -p zookeeper-node{1,2,3}/datalog
# 启动集群
docker-compose up -d
# 查看容器状态
docker-compose ps
安装完成并成功启动之后,可以在/apache-zookeeper-3.5.5-bin/bin
目录下看到内置的一些脚本文件
- zkServer.sh | zkServer.cmd 服务端启动命令
# 启动 ZooKeeper 服务(前台模式)
zkServer.sh start
# 后台启动
zkServer.sh start-foreground
# 停止
zkServer.sh stop
# 查看状态
zkServer.sh status
# 重启
zkServer.sh restart
- zkCli.sh | zkCli.cmd 客户端启动命令
# 连接本地默认端口(2181)
zkCli.sh
# 连接指定服务器和端口
zkCli.sh -server 127.0.0.1:2181
# 连接集群中的其他节点
zkCli.sh -server zookeeper-node1:2181,zookeeper-node2:2182
- zkEnv:设置 ZooKeeper 运行所需的环境变量(如 Java 路径、日志配置)。
- zhCleanup:清理 ZooKeeper 的旧事务日志(
log.*
)和快照文件(snapshot.*
),释放磁盘空间。
# zkCleanup.sh -n <保留的快照数量> [-d <数据目录>]
# 清理数据目录,保留最近的 3 个快照
zkCleanup.sh -n 3
# 指定自定义数据目录(需与 zoo.cfg 中的 dataDir 一致)
zkCleanup.sh -n 5 -d /usr/local/var/zookeeper/data
Zookeeper的数据模型
在学习一个东西的时候,除了搞懂这个东西是干什么的之外,我最想搞明白这个软件或者语言也罢、我能够操作的最小单元是什么。例如mysql中的数据页、vue里面的vue
文件【component】。同样的对于zookeeper而言,我们能接触到最小的数据单元就是一个个node
。每一个node可以用一个特定的路径
去描述它,如同Linux中的文件的路径一样,/
是一个root
节点、以后创建的节点都是其直接子节点或者间接子节点。每一个node还可以存储自己的数据。
- 路径:相当于是节点的权限定名称
- 数据:节点的私有财产
zookeeper把节点分为了四种:持久化节点、持久化序号节点、临时节点、临时序号节点,临时类节点在会话结束之后就会被删除。
每一个节点有如下的属性去描述:
cZxid = 0x0 # 创建节点时的事务id
ctime = Thu Jan 01 00:00:00 UTC 1970 # 创建时间
mZxid = 0x0 # 最后修改节点时的事务ID
mtime = Thu Jan 01 00:00:00 UTC 1970 # 最后修改节点时的时间
pZxid = 0x0 # 表示该节点的子节点列表最后一次修改的事务ID,添加子节点或删除子节点就会影响子节点列表,但是修改子节点的数据内容则不影响该ID(注意,只有子节点列表变更了才会变更pzxid,子节点内容变更不会影响pzxid)
cversion = -2 #子节点版本号,子节点每次修改版本号加1
dataVersion = 0 # 数据版本号,数据每次修改该版本号加1
aclVersion = 0 # 权限版本号,权限每次修改该版本号加1
ephemeralOwner = 0x0 #创建该临时节点的会话的sessionID。(如果该节点是持久节点,那么这个属性值为0)
dataLength = 0 # 数据长度
numChildren = 2 # 该节点拥有子节点的数量(只统计直接子节点的数量)
上述属性可以通过ls -s path
查看。
Zookeeper基本操作指令
在成功安装Zookeeper之后,通过docker-desktop
可以进入容器内、或者也可以通过docker exec -it 容器名称 /bin/bash
进入到容器内部如docker exec -it zookeeper-node1 /bin/bash
。进入之后就可以通过zkCli.sh
登陆本地控制台,(类似于:redis-cli命令)。由于我本地装有docker-desktop,可以直接进如容器内。对于节点而言:无外乎就只有创建、删除、查看、修改节点这几项操作。
- 创建节点
create [-s] [-e] path data acl
- [-s] [-e] :-s 和 -e 都是可选的,-s 代表顺序节点, -e 代表临时节点,注意其中 -s 和 -e 可以同时使用的,并且临时节点不能再创建子节点。
- path:指定要创建节点的路径,比如 /runoob。
- data:要在此节点存储的数据。
- acl:访问权限相关,默认是 world,相当于全世界都能访问。
- 修改当前节点的数据:set 命令用于修改节点存储的数据。
set path data [version]
- path:节点路径。
- data:需要存储的数据。
- [version] :可选项,版本号(可用作乐观锁)
-
查看节点信息:get / ls -s
# 查看节点数据 get path # 查看节点详细信息 ls -s path
- 删除节点
#delete 命令用于删除某节点。不能包含子节点 否则删除失败
delete path
# 删除节点 子节点一起删除
deleteall path
上面的命令都是对节点的一些基本操作、是不是很简单?
JavaAPI操作Zookeeper
新建一个maven项目在项目中引入如下依赖信息:
<dependencies>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-framework</artifactId>
<version>4.0.0</version>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>4.0.0</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.11</version>
</dependency>
</dependencies>
Curator
:Curator 是 Netflix公司开源的一套 Zookeeper 客户端框架。原生 Zookeeper 的API都会清楚其复杂度。Curator 在其基础上进行封装、实现一些开发细节,包括接连重连、反复注册 Watcher 和NodeExistsException 等。目前已经作为 Apache 的顶级项目出现,是最流行的 Zookeeper 客户端之一。
连接
// 集群的链接地址
private static final String zkServerPath = "192.168.1.3:2181,192.168.1.3:2182,192.168.1.3:2183";
private CuratorFramework client;
@Test
public void connect() {
try {
client = CuratorFrameworkFactory.
builder().connectString(zkServerPath).
sessionTimeoutMs(4000).retryPolicy(new
ExponentialBackoffRetry(1000, 3)).
namespace("").build();
client.start();
byte[] bytes = client.getData().forPath("/");
System.out.println(new String(bytes));
client.close();
} catch (Exception e) {
e.printStackTrace();
}
}
- zkServerPath: 集群的链接地址
- sessionTimeoutMs: 会话超时时间
- retryPolicy:重试次数
- nameSpace:指定操作的命名空间、如果指定之后、后续所有的操作都在此命名空间下,如指定
super
,则后续的操作都在/super
下
新增节点
首先查看一下当前的节点信息:ls /
修改代码如下:
{
private static final String zkServerPath = "192.168.1.3:2181,192.168.1.3:2182,192.168.1.3:2183";
private CuratorFramework client;
@Before
public void connect() {
try {
client = CuratorFrameworkFactory.
builder().connectString(zkServerPath).
sessionTimeoutMs(4000).retryPolicy(new
ExponentialBackoffRetry(1000, 3)).
namespace("super").build();
client.start();
} catch (Exception e) {
e.printStackTrace();
}
}
@Test
public void create() {
try {
client.create().forPath("/man", "superman".getBytes());
List<String> strings = client.getChildren().forPath("/");
for (String string : strings) {
System.out.println(string);
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@After
public void close() {
if (client != null) {
client.close();
}
}
}
在这里我们制定了命名空间,但是zookeeper中并没有此节点,运行程序之后,可以看到为我们创建了/super
的节点
上面创建的方式只能一个节点创建、如果父节点不存在则会失败
@Test
public void testCreateParentNodeIfNeeded() {
try {
client.create().creatingParentsIfNeeded().forPath("/node1/p1", "superman".getBytes());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
修改 / 删除
修改操作:
@Test
public void updade() {
try {
Stat stat = client.setData().forPath("/node1/p1", "hello zookeeper".getBytes());
System.out.println(stat);
System.out.println(new String(client.getData().forPath("/node1/p1")));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
Stat 是一个JavaBean对象,其属性如下:
private long czxid;
private long mzxid;
private long ctime;
private long mtime;
private int version;
private int cversion;
private int aversion;
private long ephemeralOwner;
private int dataLength;
private int numChildren;
private long pzxid;
就是对ls -s
命名的返回值的JavaBean
@Test
public void delete() {
try {
// 对应delete命令
client.delete().forPath("/node1/p1");
// 对应deleteall
client.delete().deletingChildrenIfNeeded().forPath("/node1");
} catch (Exception e) {
throw new RuntimeException(e);
}
}
查询
对于一个node而言、自身可以拥有数据和当前节点的信息以及子节点的信息。
// 某一个节点的数据
@Test
public void getData(){
try {
System.out.println(new String(client.getData().forPath("/node1/p1")));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// 获取某一个节点的状态信息 ls -s /super/node1/p1
@Test
public void getNodeStat(){
try {
Stat stat = client.checkExists().forPath("/node1/p1");
System.out.println(stat);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// ls /super/node1
@Test
public void getChildren(){
try {
List<String> strings = client.getChildren().forPath("/node1");
for (String string : strings) {
System.out.println(string);
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
Zookeeper监听机制
Zookeeper本身提供了对节点的监控机制,当节点发生变化之后、对这个节点进行监控的客户端就会收到通知,有如下监控方式
- 对于某一个节点数据的监听,当这个节点发生变化后,客户端会收到通知
- 对于某一个节点的子节点的监控,当前节点发生变化,不会收到通知
- 监控当前节点及其所有子节点,
在Curator
中提供了如下的三个类进行节点的监控:
- NodeCache: 监听某一个特定的节点:如
/super/p1
: 当这个节点的数据发生变化之后可以被通知- PathChildrenCache:监听某一个节点的所有直接子节点,节点个数和子节点数据发生变化之后被通知,
- TreeCache:监控当前节点及其所有子节点(直接和间接子节点)节点数据和子节点的增删改查,
监听器的编码很简单,主要有以下四步:
- 创建对应的缓存对象
- 注册监听器回调
- 服务器触发对应的时间
- 客户端回调被触发
@Test
public void testNodeCache() throws Exception {
NodeCache nodeCache = new NodeCache(client, "/node1/p1", false);
nodeCache.getListenable().addListener(() -> {
System.out.println("节点数据被修改了");
System.out.println(new String(nodeCache.getCurrentData().getData()));
});
nodeCache.start();
while (true) {
}
}
@Test
public void testPathChildrenCache() throws Exception {
PathChildrenCache pathChildrenCache = new PathChildrenCache(client, "/node1", true);
pathChildrenCache.getListenable().addListener(
(client, event) -> {
System.out.println("事件类型:" + event.getType());
System.out.println(event.getData());
}
);
pathChildrenCache.start();
while (true) {
}
}
@Test
public void testTreeCache() throws Exception {
TreeCache treeCache = new TreeCache(client, "/");
treeCache.getListenable().addListener(
(client, event) -> {
System.out.println("事件类型:" + event.getType());
if (event.getType() == TreeCacheEvent.Type.INITIALIZED) {
System.out.println("初始化完成");
}
System.out.println(event.getData());
}
);
treeCache.start();
while (true){
}
}
Zookeeper应用场景
Zookeeper如何实现分布式锁?
- 客户端在同一个节点下创建临时的序列号节点,并获取当前节点下的所有临时节点
- 如果自己创建的节点是序号最小的节点则,自己获取到了分布式锁
- 当自己创建的节点不是最小的节点时,找到比自己小的节点注册监听器,监听删除事件,
- 当获取锁的进程执行完逻辑后,删除自己创建的临时节点,服务端会触发监控了当前节点的回调事件
- 客户端继续比对,如果是最小则拿到了锁,反之则没有。
zookeeper如何做注册中心?
- 创建服务的持久化节点如:UserService,
- 服务提供者启动时,在UserService下创建临时节点保存自己的ip地址和端口号
- 服务消费者启动时,监听UserService节点,获取到临时的节点信息,并注册监听器监听节点信息
- 将节点信息保存到本地,就可以通过rpc调用服务提供方的信息
Zookeeper集群的选主过程
在上文所述的过程中,虽然我们搭建了zookeeper集群,但是我们并没有介绍相关信息,首先介绍一下集群中的角色介绍。
- Leader:整个 Zookeeper 集群工作机制中的核心,过选举产生的集群领导者,提供读写服务;一个 Zookeeper 集群中同一时间只能有一个实际工作的 Leader,它用来维护各个 Follow 与 Observer 之间的心跳;Leader 是事务请求的唯一调度和处理者,Follow 接收到事务请求会将请求转发给 Leader 处理。
- Follower:Follower 只提供读服务,即只处理非事务请求,它接收到事务请求会转发给 Leader 服务器;它参与 Leader 的选举
- Observer:功能和 Follow 基本一致,提供读服务,即只处理非事务请求,唯一的差别是不参与任何投票的选举
事务请求:增删改操作 非事务请求:查询操作
可以通过zkServer.sh status
查看在集群中承担的角色
zkServer.sh status
# output
ZooKeeper JMX enabled by default
Using config: /conf/zoo.cfg
Client port found: 2181. Client address: localhost.
Mode: follower
集群中各个服务器的状态:
- LOOKING:寻找Leader状态。处于该状态的服务器会认为集群中没有Leader,需要进行Leader选举;
- FOLLOWING:跟随着状态,说明当前服务器角色为Follower;
- LEADING:领导者状态,表明当前服务器角色为Leader;
- OBSERVING:观察者状态,表明当前服务器角色为Observer。
Zookeeper选主的时机有如下几种情况:
- 集群刚启动时
- leader挂掉时
- leader没挂、但是集群中的过半的Follower死掉
集群刚启动时的选主过程
以三个节点的集群为例:
当节点启动时、会优先给自己投票、因此当一个节点启动时,会给自己一票、此时票数没有过半。无法选举成功
# zkServer.sh status ZooKeeper JMX enabled by default Using config: /conf/zoo.cfg Client port found: 2181. Client address: localhost. Error contacting service. It is probably not running.
通过
zkServer.sh status
可以看到如上的输出信息。
当第二个节点启动时、会优先给自己投票,两个节点相互通信,每次投票会包含所推举的服务器的myid和ZXID,使用(myid,ZXID)来表示,此时节点的投票为(1, 0),节点2的投票为(2, 0),然后各自将这个投票发给集群中的其他机器;(myid在配置文件myid中,我们在docker-compose.yml中已指定 zxid 通过
stat /
可以查看 )。接受来自各个服务器的投票。集群的每个服务器收到投票后,首先判断该投票是否有效,如检查是否是本轮投票、是否来自LOOKING 状态的服务器;
处理投票。针对每一个投票,服务器都需要将别人的投票和自己的投票进行PK,PK的规则如下:
- 优先检查ZXID。ZXID比较大的服务器优先作为Leader;
- 如果ZXID相同,那么就比较myid。myid较大的服务器作为Leader服务器。
- 对于节点1而言,它的投票是(1, 0),接收Server2的投票为(2, 0),首先会比较两者的ZXID,均为0,再比较myid,此时节点2的myid最大,于是更新自己的投票为(2, 0),然后重新投票
- 节点2不更新票直接重新投票。
每次投票后,服务器都会统计投票信息,判断是否已经过半机器接收到相同的投票信息,一旦确定了Leader,每个服务器都会更新自己的状态,如果是 Follower,那么就变更为FOLLOWING,如果是Leader,就变更为LEADING
上述过程可以通过依次启动node1、node2、node3
验证
运行过程中的Leader选举
在运行过程中,如果leader挂掉之后,整个服务将进入不可用状态,直到重新选举成功。
集群中的非观察者节点变更状态为 Looking,然后进行投票,优先投票给自己,然后向其他服务器进行通信。流程与启动时类似。
Leader 没挂,但是一半的Follower已经挂掉
在 ZooKeeper 集群中,若 Leader 发现无法与过半(Quorum)的 Follower 保持通信,将触发集群的自我保护机制,导致 集群进入不可用状态 ZooKeeper 要求任何写操作或 Leader 选举必须得到集群中过半节点的确认。例如:3 节点集群需至少2个节点存活。
整个集群的状态变更过程如下:
-
Leader 持续与 Follower 保持心跳,确保其领导权的合法性。
-
写操作阻塞:
- 新写请求无法获得过半确认,客户端收到
WriteRequestRejected
错误。 - 已提交的写操作仍可被读取(最终一致性)。
- 新写请求无法获得过半确认,客户端收到
-
Leader 自废:
- Leader 检测到无法维持过半连接后,主动放弃 Leader 角色,进入
LOOKING
状态。在 【syncLimit * tickTime
】时间间隔内,如果统计的Follower节点小于集群的一半,则转换为 Looking 状态。
- Leader 检测到无法维持过半连接后,主动放弃 Leader 角色,进入
-
选举失败:
- 剩余节点数不足过半,无法选举出新 Leader,集群进入 无主状态
-
服务降级:
- 读请求:仍可由剩余节点处理(若客户端直接连接这些节点)。
- 写请求:全部拒绝,客户端需重试或等待恢复。