【中间件_Zookeeper】Zookeeper那些事

15 阅读15分钟

本文主要有以下内容:

  • 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

参数说明:

  1. 环境变量

    • ZOO_MY_ID: 每个节点的唯一 ID(必须与 ZOO_SERVERS 中的 server.x 对应)。
    • ZOO_SERVERS: 定义所有集群节点(格式:server.<zoo_my_id>=<host>:<peer_port>:<election_port>;<client_port>)。
  2. 端口映射

    • 2181-2183: 客户端连接端口(映射到宿主机的不同端口)。
    • 2888-2890: 节点间数据同步端口。
    • 3888-3890: 集群选举端口。
  3. 持久化存储

    • 数据目录 (/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目录下看到内置的一些脚本文件

SCR-20250519-pwdf.png

  1. zkServer.sh | zkServer.cmd 服务端启动命令
# 启动 ZooKeeper 服务(前台模式)
zkServer.sh start
# 后台启动
zkServer.sh start-foreground
# 停止
zkServer.sh stop
# 查看状态
zkServer.sh status 
# 重启
zkServer.sh restart 
  1. zkCli.sh | zkCli.cmd 客户端启动命令
# 连接本地默认端口(2181)
zkCli.sh
# 连接指定服务器和端口
zkCli.sh -server 127.0.0.1:2181
# 连接集群中的其他节点
zkCli.sh -server zookeeper-node1:2181,zookeeper-node2:2182
  1. zkEnv:设置 ZooKeeper 运行所需的环境变量(如 Java 路径、日志配置)。
  2. 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还可以存储自己的数据。

  • 路径:相当于是节点的权限定名称
  • 数据:节点的私有财产

node.png

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,相当于全世界都能访问。

zookeeper_create_node.png

  • 修改当前节点的数据:set 命令用于修改节点存储的数据。
set path data [version]
  • path:节点路径。
  • data:需要存储的数据。
  • [version] :可选项,版本号(可用作乐观锁)

zookeeper_update_node.png

  • 查看节点信息:get / ls -s

    # 查看节点数据
    get path 
    # 查看节点详细信息
    ls -s path
    

zookeeper_select.png

  • 删除节点
#delete 命令用于删除某节点。不能包含子节点 否则删除失败
delete path
# 删除节点 子节点一起删除
deleteall path

zookeeper_delete.png

上面的命令都是对节点的一些基本操作、是不是很简单?

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 /

zookeeper_create_ls_java.png

修改代码如下:

{
    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的节点

zookeeper_create_ls_success_java.png

上面创建的方式只能一个节点创建、如果父节点不存在则会失败

 @Test
  public void testCreateParentNodeIfNeeded() {
      try {
          client.create().creatingParentsIfNeeded().forPath("/node1/p1", "superman".getBytes());
      } catch (Exception e) {
          throw new RuntimeException(e);
      }
  }

zookeeper_create_ls_nest_success_java.png

修改 / 删除

修改操作:

  @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如何实现分布式锁?

  1. 客户端在同一个节点下创建临时的序列号节点,并获取当前节点下的所有临时节点
  2. 如果自己创建的节点是序号最小的节点则,自己获取到了分布式锁
  3. 当自己创建的节点不是最小的节点时,找到比自己小的节点注册监听器,监听删除事件,
  4. 当获取锁的进程执行完逻辑后,删除自己创建的临时节点,服务端会触发监控了当前节点的回调事件
  5. 客户端继续比对,如果是最小则拿到了锁,反之则没有。

zookeeper_lock.png

zookeeper如何做注册中心?

  1. 创建服务的持久化节点如:UserService,
  2. 服务提供者启动时,在UserService下创建临时节点保存自己的ip地址和端口号
  3. 服务消费者启动时,监听UserService节点,获取到临时的节点信息,并注册监听器监听节点信息
  4. 将节点信息保存到本地,就可以通过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的规则如下:

    1. 优先检查ZXID。ZXID比较大的服务器优先作为Leader;
    2. 如果ZXID相同,那么就比较myid。myid较大的服务器作为Leader服务器。
    3. 对于节点1而言,它的投票是(1, 0),接收Server2的投票为(2, 0),首先会比较两者的ZXID,均为0,再比较myid,此时节点2的myid最大,于是更新自己的投票为(2, 0),然后重新投票
    4. 节点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,集群进入 无主状态
  • 服务降级

    • 读请求:仍可由剩余节点处理(若客户端直接连接这些节点)。
    • 写请求:全部拒绝,客户端需重试或等待恢复。