一文带你搞懂 Zookeeper

1,199 阅读14分钟

zookeeper 重要概念介绍

ZooKeeperApache 软件基金会的一个软件项目,它为大型分布式计算提供开源的分布式配置服务、同步服务和命名注册,是一个典型的 分布式数据一致性解决方案

目标:ZooKeeper 致力于为分布式应用提供一个高性能、高可用,且具有严格顺序访问控制能力的 分布式协调服务

  1. 高性能:将 全量数据存储在内存 中,并直接服务于客户端的所有非事务请求,尤其适用于 以读为主 的应用场景,对于事务请求操作多的不太适合。
  2. 高可用:一般以集群的方式对外提供服务,只要集群中超过一般的机器都能够正常工作,那么整个集群就能够正常对外服务。
  3. 严格的访问顺序:对于来自客户端的每个事务请求,zk 都会分配一个 全局事务id:zxid,它反映了所有事务操作的先后顺序。

zk 集群角色

  1. Leader :集群工作的核心,事务请求的唯一调度和处理者,保证事务处理的顺序性。对于有写操作的请求,需统一转发给 Leader 处理。Leader 需决定编号执行操作。
  2. Follower :处理客户端非事务请求,转发事务请求转发给 Leader,参与 Leader 选举。
  3. Observer 观察者:处理非事务请求的独立处理,对于事务请求, 同样转发给 Leader 服务器进行处理,不参与 leader 的选举,不参与 2PC 数据同步过程。

2PC 数据同步过程具体的内容可以参考 ZK 的数据同步流程章节的 消息广播 部分

zk 节点的特性

同一级节点 key 名称唯一

已经存在的节点再次创建便会提示节点已经存在。

创建节点时,必须指定节点的全路径

$ ls /runoob
$ create /runoob/child 0
$ create /runoob/child/ch01 0

临时节点在 session 关闭的时候就会被清除

自动给顺序节点进行编号

# 创建顺序节点 create -s 就是会在目录的后面添加一串序列号
$ create /aha

Created /aha0000000003

提供了节点的 watch 机制

参考下面章节的:演示 zkClient 的监听事件

delete 命令只能一层一层的删除

当使用 delete 命令删除具有子节点的节点时,会报错。

可以使用 deleteall 命令进行实现

zk 的 权限控制

zkACL(Access Control List) 即访问控制列表,权限在生产环境中还是很重要的一环。

ACL 的构成

zkacl 通过 [scheme:id:permissions] 来定义权限列表

  1. scheme:代表采用的某种权限机制,包括 world、auth、digest、ip 这几种。

    • world:默认方式,相当于全世界都能访问
    • auth:代表已经认证通过的用户
    • digest:使用用户名和密码的方式进行认证
    • ip:使用 ip 地址的方式进行认证,可以指定具体的 ip 或者 地址段
  2. id:代表允许访问的用户。

  3. permissions:权限组合字符串,由 cdrwa 组成,其中每个字母代表支持不同权限,创建权限 create(c)、删除权限 delete(d)、读权限 read(r)、写权限 write(w)、管理权限admin(a),管理员权限是允许进行 ACL 的操作,其中 d 是对子节点的操作权限,其他的都是对自身节点的操作权限。

使用 digest 模式进行权限控制

编写工具类生成创建 digest 模式用户所需要的用户名和密码

导入 maven 依赖

<!-- 创建 Zookeeper 客户端依赖,一定要和 Zookeeper Server 版本保持一致 curator依赖中其实也包含,但是这边为了版本
        对应显示的声明了一下-->
<dependency>
  <groupId>org.apache.zookeeper</groupId>
  <artifactId>zookeeper</artifactId>
  <version>3.6.3</version>
  <exclusions>
    <!--因为 zk 包使用的是 log4j 日志,和 springboot 的logback 日志冲突 -->
    <exclusion>
      <groupId>org.slf4j</groupId>
      <artifactId>slf4j-log4j12</artifactId>
    </exclusion>
  </exclusions>
</dependency>

编写工具类

package com.aha.utils;

import lombok.extern.slf4j.Slf4j;
import org.I0Itec.zkclient.ZkClient;
import org.apache.zookeeper.server.auth.DigestAuthenticationProvider;

import java.security.NoSuchAlgorithmException;

/**
 * zk 的工具类
 *
 * @author: WT
 * @date: 2021/12/1 16:36
 */
@Slf4j
public class ZkUtils {

    /**
     * 生成 digest 用户名和密码的加密串
     * @param idPassword 用户名和密码的明文
     * @return
     * @throws NoSuchAlgorithmException
     */
    public static String getDigestAuthStr (String idPassword) throws NoSuchAlgorithmException {
        return DigestAuthenticationProvider.generateDigest(idPassword);
    }

    public static void main(String[] args) throws NoSuchAlgorithmException {
        // user:svY1VPBeOtL797NihAvQMUtFLHs=
        log.info(ZkUtils.getDigestAuthStr("user:111111"));
    }

}

根据生成的加密权限串对相应的节点进行设置

# 创建 aha 节点,并设置 user:111111 用户拥有 读写和删除权限和设置acl权限
$ create /aha aha-data digest:user:svY1VPBeOtL797NihAvQMUtFLHs=:rwda

# 执行命令之后在不进行用户登录时在查看 /aha 节点
$ ls /aha

# 返回权限不足
[zk: localhost:2181(CONNECTED) 2] get /aha
Insufficient permission : /aha

# 进行用户的登陆 然后在查看相应的节点
$ addauth digest user:111111

$ get /aha

# 就可以正常的进行查看了
[zk: localhost:2181(CONNECTED) 7] get /aha
aha-data

采坑指南:

# 创建 aha 节点,并设置 user:111111 用户拥有 读写和删除权限和设置acl权限
$ create /aha aha-data digest:user:svY1VPBeOtL797NihAvQMUtFLHs=:rwda

在执行这个命令的时候,一定要给节点设置值,因为 create 命令是这个样子的:create [-s] [-e] [-c] [-t ttl] path [data] [acl]所以如果不添加 data 就想添加 acl 的话,acl 的串会被当成 data 设置到节点中,不会设置 acl

使用 auth 模式进行权限的设置

auth 模式与 digest 模式的区别为:

  1. 在使用 auth 模式设置节点的权限的时候,需要先进行用户的创建,即要先进行 addauth digest user:pwd
  2. 再给节点进行权限设置的时候只需要用户名设置进去就可以了,参考下面的实例
# 添加用户
$ addauth digest aha:111

# 添加节点并设置权限
$ create /auth auth-data auth:aha:cdwra

# 在其他客户端连接或者是本客户端重连之后想要操作 /auth 就需要先进行用户的登陆,否则就会报权限不足
$ addauth digest aha:111
# 之后就可以进行操作了

使用 ip 模式进行权限的设置

# 指定 10.211.55.4 有 cdrwa 权限
$ create /ip ip-date ip:10.211.55.4:cdrwa

# 不是 10.211.55.4 对 /ip 节点进行操作
$ get /ip

# 提示权限不够足
[zk: localhost:2181(CONNECTED) 0] ls /ip 
Insufficient permission : /ip

# 使用 10.211.55.4 对 /ip 节点进行操作
$ get /ip

# 可以正常返回

# 使用多个 ip 的方式
$ setAcl /ip ip:10.211.55.4:cdwra,ip:10.211.55.5:cdwra

# 也可以使用 ip 段的方式
$ setAcl /ip ip:10.211.55.4/24:cdwra

zk 的读写机制

zk 使用单一的主进程 Leader 来接收和处理客户端所有事务请求,注意这里是事务请求,非事务请求可以直接通过 followerobserver 进行操作。

所谓事务请求便是涉及到数据修改的请求:新增,修改,删除

所谓非事务请求便是:查询操作

当非 leader 节点接受到了事务操作,它会将请求转发给 leader 节点来处理。

zk 的数据同步流程

zk 中主要依赖 ZAB:(Zookeeper Atomic Broadcas) 原子消息广播协议来实现分布式数据的一致性。

ZAB 协议与 Paxos 类似,也是一种数据一致性的算法,他主要包括:消息广播崩溃恢复 两个部分。

消息广播

当节点收到客户端或者 observer,follower 节点转发过来的事务请求后,leader 节点会将此请求转化为 Proposal: (提议) 广播到所有的 follower 节点,注意这边不包括 observer 节点,当集群中有过半的 follower 节点给 leader 节点进行了正确的 ACK 反馈,这时候 leader 节点就会像所有的 follower 节点发送 commit 消息,将此次提议进行提交。这个过程可以被称为 2PC 事务提交。如下图所示:

注意:observer 节点只负责同步 leader 的数据,全程不参与 2PC 的过程。

ACK : Acknowledge character 确认字符

崩溃恢复

在正常情况消息广播情况下能运行良好,但是一旦 Leader 服务器出现崩溃,或者由于网络原理导致 Leader 服务器失去了与过半 Follower 的通信,那么就会进入崩溃恢复模式,需要选举出一个新的 Leader 服务器。在这个过程中可能会出现两种数据不一致性的隐患,需要 ZAB 协议的特性进行避免。

ZAB 协议的恢复模式使用了以下策略:

  1. 选举 zxid 最大的节点作为新的 leader
  2. leader 将事务日志中尚未提交的消息进行处理

zk leader 选举

重要参数介绍

  1. 服务器 ID(myid):编号越大在选举算法中权重越大
  2. 全局事务 ID(zxid):值越大说明数据越新,权重越大
  3. 逻辑时钟 (epoch-logicalclock):同一轮投票过程中的逻辑时钟值是相同的,每投完一次值会增加,用来验证是否是同一轮投票

选举状态介绍

  1. LOOKING:竞选状态
  2. FOLLOWING:随从状态,同步 leader 状态,参与投票
  3. OBSERVING:观察状态,同步 leader 状态,不参与投票
  4. LEADING:领导者状态

选举流程介绍

选举使用的算法便是 paxos 算法。

每个节点启动的时候都是 LOOKING 观望状态,接下来就开始进行选举流程。这里选取三台机器组成的集群为例。第一台服务器 server1 启动时,无法进行 leader 选举,当第二台服务器 server2 启动时,两台机器可以相互通信,进入 leader 选举过程。

  1. 发送投票:每台 server 发出一个投票,由于是初始情况,server1server2 都将自己作为 leader 服务器进行投票,每次投票包含所推举的服务器 myidzxidepoch,这边使用 servern(myid,zxid) 表示,此时 server1 投票为 (1,0)server2 投票为(2,0),然后将各自投票发送给集群中其他机器。

  2. 检查投票:集群中的每个服务器收到投票后,首先判断该投票的有效性,如检查是否是本轮投票epoch、是否来自 LOOKING 状态的服务器。

  3. 投票对比:针对每一次投票,服务器都需要将其他服务器的投票和自己的投票进行对比,对比规则如下:

    • 优先比较 epoch
    • 检查 zxidzxid 比较大的服务器优先作为 leader
    • 如果 zxid 相同,那么就比较 myidmyid 较大的服务器作为 leader 服务器
  4. 统计投票:每次投票后,服务器统计投票信息,判断是都有过半机器接收到相同的投票信息。server1server2 都统计出集群中有两台机器接受了 (2,0) 的投票信息,此时已经选出了 server2leader 节点。

  5. 改变服务器状态。一旦确定了 leader,每个服务器响应更新自己的状态,如果是 follower,那么就变更为 FOLLOWING,如果是 Leader,变更为 LEADING。此时 server3 继续启动,直接加入变更自己为 FOLLOWING

Zookeeper 的安装

环境介绍

  1. 操作系统:Ubuntu 20.04.2 LTS
  2. JDK 1.8.0_292
  3. zookeeper-3.6.3

安装 JDK

ZookeeperJava 语言开发的所以要先按照 JDK,按照步骤可以参考:JDK 多平台安装

单节点方式安装 Zookeeper

节点规划

这边使用 master 机器进行单节点的演示,地址为 10.211.255.3

安装的目录为:/home/parallels/Downloads/zookeeper

Zookeeper 官方网站获取你想要版本的安装地址:https://zookeeper.apache.org/releases.html

注意:这边选择的时候不要选择源码包。

# 下载压缩包
$ wget https://dlcdn.apache.org/zookeeper/zookeeper-3.6.3/apache-zookeeper-3.6.3-bin.tar.gz

# 解压
$ tar -zxvf 安装包

# 我安装到了 /home/parallels/Downloads 下

$ cd conf
$ cp zoo_sample.cfg zoo.cfg

# 在 conf 目录下启动
$ ../bin/zkServer.sh start

# 查看服务端的启动状态
$ ../bin/zkServer.sh status

# 停止服务
$ ../bin/zkServer.sh stop

这样单节点 zk 的安装基本就搞定了。使用的配置文件就是默认的 conf/zoo.cfg

配置环境变量

配置环境变量就是为了能更方便的使用它的命令

# 编辑配置文件
$ vim /etc/environment

# 在 PATH 的前面添加
ZK_HOME="/home/parallels/Downloads/zookeeper/apache-zookeeper-3.6.3-bin"

# 修改 PTAH,在它的最后面添加 
:$ZK_HOME/bin

文件如下图所示:

使用 zkClient 操作 zkServer

在操作之前应该先保证 zkServer 的正常运行

基础操作

# 连接 zkServer
$ zkCli.sh -server

# 当不指定ip和端口的时候默认连接 127.0.0.1:2181,指定端口和ip示例如下
$ zkCli.sh -server 127.0.0.1:2181

# 连接成功之后 它的操作与 linux 文件系统的操作类似
$ ls /

# 创建目录并给目录赋值 默认是持久化节点
$ create /aha aha

# 查看创建目录的值
$ get /aha

# 在/aha 下创建子目录并且创建值
$ create /aha/child child

# 查看目录结构 ls /
# 下面会显示 aha 和 zookeeper 但是 是不能进入/aha 里面的 他是维护了一个虚拟的目录结构 是没有cd命令的 这边有一个 stat 命令是很重要的
$ stat /aha

# 返回以下信息
cZxid = 0x200000002                    # 创建/aha 和这个对象的事务id
ctime = Wed May 12 13:25:46 CST 2021   # 创建节点时的时间
mZxid = 0x200000002                    # 最后修改节点时的事务ID
mtime = Wed May 12 13:25:46 CST 2021   # 最后修改节点时的时间
pZxid = 0x200000003                    # 表示该节点的子节点列表最后一次修改的事务ID,添加子节点或删除子节点会影响子节点列表,
# 但是修改子节点的数据内容则不影响该ID(注意,只有子节点列表变更了才会变更pzxid,子节点内容变更不会影响pzxid)
cversion = 1                           # 子节点的版本
dataVersion = 0                        # 数据版本号,数据每次修改该版本号加1
aclVersion = 0                         # 权限版本号,权限每次修改该版本号加1
ephemeralOwner = 0x0                   # 创建该临时节点的会话的sessionID。(如果该节点是持久节点,那么这个属性值为0)
dataLength = 9                         # 该节点的数据长度
numChildren = 1                        # 该节点拥有子节点的数量(只统计直接子节点的数量)

# 创建临时节点  create -e 
$ create -e /aha/temp temp-value

# 创建顺序节点 create -s 就是会在目录的后面添加一串序列号
$ create /aha/aha

Created /aha/aha0000000003

# 创建临时顺序节点的话就是
$ create -e -s 

# 修改节点的值 用 set 命令
$ set path data [-v version]
 - version: 是可选项,版本号,可以作为乐观锁,只有设置的时候,version字段填写正确才能正确的设置值

# 当 version 不正确的时候是设置不成功的
$ set /LOCK/aha uuu -v 3

# 同步命令 后面加的是目录
sync /aha

可以在客户端中直接使用 help 查看命令的使用方法

临时节点(目录)下面是不能有子节点的

搭建 zk 集群

节点规划

这边使用 master01,node01,node02 三台机器进行集群的搭建演示,地址分别为 10.211.255.3,10.211.55.4,10.211.55.5

安装的目录为:/home/parallels/Downloads/zookeeper

第一步仍然是安装 JDK 以及安装包的下载,zk 环境变量的配置,参考单节点章节,不在赘述。

这边设置一下主机的名称,这样不容易在操作多台主机的时候分不清哪一台是哪一台:

$ hostnamectl set-hostname master01
$ hostnamectl set-hostname node01
$ hostnamectl set-hostname node02

设置完成之后,重启机器,重新连接,就可以看到:

FAQ:为了防止脑裂的问题,zk 集群节点的个数要求是 2n + 1

修改 zoo.cfg 的配置

# 在 conf 下,拷贝出一个 zoo.cfg
$ cp zoo_sample.cfg zoo.cfg

zoo.cfg 文件的简单说明:

dataDir=/tmp/zookeeperzk 数据保存的位置,默认在 tmp 下,这个文件夹是存放临时数据的位置,建议修改;

zk 三个端口的说明:

  1. 2181 : 对 client 端提供服务
  2. 2888 : 集群内机器通信使用
  3. 3888 : 选举 leader 使用

tickTime :心跳间隔(毫秒),超过2倍 tickTime 将会被认为会话超时

initLimit=10 : 初始化连接时最长的时间(心跳间隔次数),超过该值会认为连接超时

syncLimit=5: zk 集群中leaderfollower 同步消息的超时时间(心跳间隔次数)

clientPort: 服务器监听的端口号,用于客户端连接服务器

修改 zoo.cfg 文件:

# 1. 在三台机器的 zoo.cfg 文件末尾添加

server.1=10.211.55.3:2888:3888
server.2=10.211.55.4:2888:3888
server.3=10.211.55.5:2888:3888

# 2. 修改 zoo.cfg 的 dataDir

dataDir=/home/parallels/Downloads/zookeeper/apache-zookeeper-3.6.3-bin/data

拓展阅读:如果这边需要 observer 的话,可以添加这一行配置server.4=10.211.55.6:2888:3888:observer

添加 myid

dataDir 配置的文件夹中添加 myid 文件,文件的内容便是 zoo.cfgserver.nn。例如:10.211.55.3 中就应该填写 1

依次操作其他几台节点。

启动集群

# 1. 启动集群的时候三台机器依次执行 - 注意这边是三台机器都要手动启动的
$ zkServer.sh start 

# 2. 查看集群的状态,在节点上执行
$ zkServer.sh status

# follower 节点显示如下内容

root@master01:/home/parallels/Downloads/zookeeper/apache-zookeeper-3.6.3-bin/conf# zkServer.sh status
ZooKeeper JMX enabled by default
Using config: /home/parallels/Downloads/zookeeper/apache-zookeeper-3.6.3-bin/bin/../conf/zoo.cfg
Client port found: 2181. Client address: localhost. Client SSL: false.
Mode: follower

# leader 节点显示如下内容

root@node01:/home/parallels/Downloads/zookeeper/apache-zookeeper-3.6.3-bin/conf# zkServer.sh status
ZooKeeper JMX enabled by default
Using config: /home/parallels/Downloads/zookeeper/apache-zookeeper-3.6.3-bin/bin/../conf/zoo.cfg
Client port found: 2181. Client address: localhost. Client SSL: false.
Mode: leader

踩坑说明:

当你 telnet 一个端口不通的时候,有一下两种情况:

  1. 防火墙的问题: 遇到这种情况可以关闭防火墙或者增加防火墙的端口

  2. 你访问的那台机器压根没有监听那个端口: 排查这种问题应该在被访问的机器上执行如下吗命令:netstat -lntup 可以结合 grep 命令使用

使用客户端测试集群

# 注意这边连接的时候要使用 2181 端口
$ zkCli.sh -server 10.211.55.4:2181

zk 图形界面工具

https://github.com/zzhang5/zooinspector

SpringBoot 集成 zk

使用 zkClient 操作 zk

添加 maven 依赖

<!-- 创建 Zookeeper 客户端依赖,一定要和 Zookeeper Server 版本保持一致 curator依赖中其实也包含,但是这边为了版本
        对应显示的声明了一下-->
<dependency>
  <groupId>org.apache.zookeeper</groupId>
  <artifactId>zookeeper</artifactId>
  <version>3.6.3</version>
  <exclusions>
    <!--因为 zk 包使用的是 log4j 日志,和 springboot 的logback 日志冲突 -->
    <exclusion>
      <groupId>org.slf4j</groupId>
      <artifactId>slf4j-log4j12</artifactId>
    </exclusion>
  </exclusions>
</dependency>

<!-- https://mvnrepository.com/artifact/com.101tec/zkclient -->
<dependency>
  <groupId>com.101tec</groupId>
  <artifactId>zkclient</artifactId>
  <version>0.11</version>
</dependency>

编写配置

zookeeper:
  # zookeeper Server 地址,如果有多个使用逗号分隔。如 ip1:port1,ip2:port2,ip3:port3
  address: 10.211.55.3:2181,10.211.55.4:2181,10.211.55.5:2181
  retryCount: 5               # 重试次数
  initElapsedTimeMs: 1000     # 初始重试间隔时间
  maxElapsedTimeMs: 5000      # 最大重试间隔时间
  sessionTimeoutMs: 30000     # Session 超时时间
  connectionTimeoutMs: 10000  # 连接超时时间

编写配置类

package com.aha.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

/**
 * 连接 zookeeper 配置类
 *
 * @author: WT
 * @date: 2021/11/22 18:14
 */
@Data
@Configuration
@ConfigurationProperties(prefix = "zookeeper")
public class ZkClientProperties {

    /** 重试次数 */
    private int retryCount;

    /** 初始重试间隔时间 */
    private int initElapsedTimeMs;

    /** 最大重试间隔时间 */
    private int maxElapsedTimeMs;

    /**连接地址 */
    private String address;

    /**Session过期时间 */
    private int sessionTimeoutMs;

    /**连接超时时间 */
    private int connectionTimeoutMs;

}

注册 zkClient

package com.aha.client;

import com.aha.config.ZkClientProperties;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 生成 zk 客户端
 *
 * @author: WT
 * @date: 2021/11/22 18:18
 */
@Configuration
public class ZookeeperClient {

    @Bean
    private static ZkClient zkClient (ZkClientProperties zookeeperProperties) {
        // 这边如果需要扩展参数的话,可以看 ZkClient 的构造函数
        return new ZkClient(new ZkConnection(zookeeperProperties.getAddress()), zookeeperProperties.getConnectionTimeoutMs());
    }

}

演示 zkClient 的监听事件

编写 service 演示类

package com.aha.lock.service;


import lombok.extern.slf4j.Slf4j;
import org.I0Itec.zkclient.IZkDataListener;
import org.I0Itec.zkclient.ZkClient;
import org.springframework.stereotype.Service;

/**
 * 测试 zkClient 的监听事件
 *
 * @author: WT
 * @date: 2021/11/23 14:53
 */
@Slf4j
@Service
public class ZkWatcher {

    private final ZkClient zkClient;
    private static final String NODE_PATH = "/path";
    private static final String LOG_SEPARATOR = "++++++++++++++++++++++++++++++++++++++++++++";

    public ZkWatcher (ZkClient zkClient) {
        this.zkClient = zkClient;
    }

    public void testSubscribeChildChanges () throws InterruptedException {

        /*
         * 匿名内部类重写是 IZkChildListener
         * 监听指定节点和指定节点下子节点的变化
         * parentPath:我们指定要监听的节点
         * currentChildren:指定要监听的节点下的所有子节点
         * 当指定节点和指定节点下子节点发生 增删 操作时会被监听到,更新节点子节点和父节点的内容是不会被监听的, 递归删除删除几次会触发几次
         * 这边可以看到日志 Delivering event #1 done
         */
        zkClient.subscribeChildChanges(NODE_PATH, (parentPath, currentChildren) -> {
            log.info("当前节点为: {}, 当前节点下的子节点为:{}", parentPath, currentChildren);
            log.info(LOG_SEPARATOR);
        });

        operateNode(zkClient);

    }

    public void testSubscribeDataChanges () throws InterruptedException {

        zkClient.subscribeDataChanges(NODE_PATH, new IZkDataListener() {

            /**
             * 监听节点的 删除操作
             * @param path 节点路径
             * @throws Exception 异常
             */
            @Override
            public void handleDataDeleted(String path) throws Exception {
                log.info("删除的节点为: {}", path);
                log.info(LOG_SEPARATOR);
            }

            /**
             * 可以监听节点的 创建 更新 操作
             * @param path 节点路径
             * @param data 变更的内容
             * @throws Exception 异常
             */
            @Override
            public void handleDataChange(String path, Object data) throws Exception {
                log.info("变更的节点为:{}, 变更内容为: {}",  path ,  data);
                log.info(LOG_SEPARATOR);
            }
        });

        operateNode(zkClient);

    }

    public static void operateNode (ZkClient zkClient) throws InterruptedException {

        // 递归删除
        zkClient.deleteRecursive(NODE_PATH);

        // 节点已经存在的话是会抛异常的 : ZkNodeExistsException
        zkClient.createPersistent(NODE_PATH);
        Thread.sleep(1000);

        //  SubscribeChildChanges 不会监听父节点的 update 操作; SubscribeDataChanges 会监听节点的 update 操作
        zkClient.writeData(NODE_PATH,"父节点发生变化");
        Thread.sleep(1000);

        zkClient.createPersistent(NODE_PATH + "/" + "c1", "c1内容");
        Thread.sleep(1000);

        zkClient.createPersistent(NODE_PATH + "/" + "c2", "c2内容");
        Thread.sleep(1000);

        //不会监听子节点的 update 操作
        zkClient.writeData(NODE_PATH + "/" + "c1","c1新内容");
        Thread.sleep(1000);

        zkClient.delete(NODE_PATH + "/c2");
        Thread.sleep(1000);

        // 使用此方法删除的节点如果不为空的话会抛异常 ZkException
        // zkClient.delete(NODE_PATH);

        // 递归删除: 这里发生了两次,一次是先删除/super/c1触发一次,然后再删除/super再触发一次
        zkClient.deleteRecursive(NODE_PATH);

        // 这个延时是必须的,因为如果你的 main 方法直接停止的话,监听程序也就关闭了,监听是异步的,后面的操作可能还没监听到线程就停止了这个不合理的
        Thread.sleep(10000);

        log.info("延时结束");

    }


}

编写测试 controller

package com.aha.lock.controller;

import com.aha.lock.service.InterprocessMutexLock;
import com.aha.lock.service.ZkWatcher;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author: WT
 * @date: 2021/11/23 14:13
 */
@RestController
public class TestController {

    private final InterprocessMutexLock interprocessMutexLock;
    private final ZkWatcher zkWatcher;


    public TestController (
            InterprocessMutexLock interprocessMutexLock,
            ZkWatcher zkWatcher
    ) {
        this.interprocessMutexLock = interprocessMutexLock;
        this.zkWatcher = zkWatcher;
    }

    /**
     * 测试 zkClient 的 SubscribeDataChanges 方法
     */
    @GetMapping("/zk-client/subscribe/data")
    public void testZkClientSubscribeDataChanges () throws InterruptedException {
        zkWatcher.testSubscribeDataChanges();
    }

    /**
     * 测试 zkClient 的 SubscribeChildChanges 方法
     */
    @GetMapping("/zk-client/subscribe/child")
    public void testZkClientSubscribeChildChanges () throws InterruptedException {
        zkWatcher.testSubscribeChildChanges();
    }
    
}

使用 Curator 操作 zk

添加 maven 依赖

<!-- 创建 Zookeeper 客户端依赖,一定要和 Zookeeper Server 版本保持一致 curator依赖中其实也包含,但是这边为了版本
        对应显示的声明了一下-->
<dependency>
  <groupId>org.apache.zookeeper</groupId>
  <artifactId>zookeeper</artifactId>
  <version>3.6.3</version>
  <exclusions>
    <!--因为 zk 包使用的是 log4j 日志,和 springboot 的logback 日志冲突 -->
    <exclusion>
      <groupId>org.slf4j</groupId>
      <artifactId>slf4j-log4j12</artifactId>
    </exclusion>
  </exclusions>
</dependency>

<!-- https://mvnrepository.com/artifact/org.apache.curator/curator-recipes
        他包含了分布式锁的实现-->
<dependency>
  <groupId>org.apache.curator</groupId>
  <artifactId>curator-recipes</artifactId>
  <version>5.1.0</version>
</dependency>

编写配置

zookeeper:
  # zookeeper Server 地址,如果有多个使用逗号分隔。如 ip1:port1,ip2:port2,ip3:port3
  address: 10.211.55.3:2181,10.211.55.4:2181,10.211.55.5:2181
  retryCount: 5               # 重试次数
  initElapsedTimeMs: 1000     # 初始重试间隔时间
  maxElapsedTimeMs: 5000      # 最大重试间隔时间
  sessionTimeoutMs: 30000     # Session 超时时间
  connectionTimeoutMs: 10000  # 连接超时时间

编写配置类

package com.aha.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

/**
 * 连接 zookeeper 配置类
 *
 * @author: WT
 * @date: 2021/11/22 18:14
 */
@Data
@Configuration
@ConfigurationProperties(prefix = "zookeeper")
public class ZkClientProperties {

    /** 重试次数 */
    private int retryCount;

    /** 初始重试间隔时间 */
    private int initElapsedTimeMs;

    /** 最大重试间隔时间 */
    private int maxElapsedTimeMs;

    /**连接地址 */
    private String address;

    /**Session过期时间 */
    private int sessionTimeoutMs;

    /**连接超时时间 */
    private int connectionTimeoutMs;

}

注册 CuratorFramework

package com.aha.client;

import com.aha.config.ZkClientProperties;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 生成 zk 客户端
 *
 * @author: WT
 * @date: 2021/11/22 18:18
 */
@Configuration
public class ZookeeperClient {

    /**
     * initMethod = "start"
     * curatorFramework 创建对象之后,调用 curatorFramework 实例的 start 方法
     */
//    @Bean(initMethod = "start")
//    public CuratorFramework curatorFramework(ZkClientProperties zookeeperProperties) {
//        return CuratorFrameworkFactory.newClient(
//                zookeeperProperties.getAddress(),
//                zookeeperProperties.getSessionTimeoutMs(),
//                zookeeperProperties.getConnectionTimeoutMs(),
//                new RetryNTimes(zookeeperProperties.getRetryCount(), zookeeperProperties.getInitElapsedTimeMs())
//        );
//    }

    @Bean(initMethod = "start")
    private static CuratorFramework getZkClient(ZkClientProperties zookeeperProperties) {
        ExponentialBackoffRetry retryPolicy = new ExponentialBackoffRetry(1000, zookeeperProperties.getRetryCount(), 5000);
        return CuratorFrameworkFactory.builder()
                .connectString(zookeeperProperties.getAddress())
                .sessionTimeoutMs(zookeeperProperties.getSessionTimeoutMs())
                .connectionTimeoutMs(zookeeperProperties.getConnectionTimeoutMs())
                .retryPolicy(retryPolicy)
                .build();
    }

}

演示 curator 的提供的分布式可重入排它锁

编写 service 演示类

package com.aha.lock.service;

import lombok.extern.slf4j.Slf4j;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.apache.zookeeper.data.Stat;
import org.springframework.stereotype.Service;

/**
 *  测试 curator 提供的 分布式可重入排它锁
 *
 * @author: WT
 * @date: 2021/11/22 17:41
 */
@Slf4j
@Service
public class InterprocessMutexLock {

    private final CuratorFramework curatorFramework;
    private int inventory = 40;

    public InterprocessMutexLock (CuratorFramework curatorFramework) {
        this.curatorFramework = curatorFramework;
    }

    public void test(String lockPath)  {

        InterProcessMutex lock = new InterProcessMutex(curatorFramework, lockPath);
        //模拟 50 个线程抢锁
        for (int i = 0; i < 50; i++) {
            new Thread(new TestThread(i, lock)).start();
        }

    }

    class TestThread implements Runnable {

        private final Integer threadFlag;
        private final InterProcessMutex lock;

        public TestThread(Integer threadFlag, InterProcessMutex lock) {
            this.threadFlag = threadFlag;
            this.lock = lock;
        }

        @Override
        public void run() {

            try {

                lock.acquire();
                log.info("第 {} 个线程获取到了锁, 库存还剩余:{}", threadFlag, inventory);
                if (inventory > 0) {
                    inventory --;
                    log.info("库存还剩余:{}", inventory);
                } else {
                    log.info("已经没有库存了 inventory: {}, 线程 {} 没有买到", inventory, threadFlag);
                }

            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                try {
                    lock.release();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }

}

编写测试 controller

package com.aha.lock.controller;

import com.aha.lock.service.InterprocessMutexLock;
import com.aha.lock.service.ZkWatcher;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author: WT
 * @date: 2021/11/23 14:13
 */
@RestController
public class TestController {

    private final InterprocessMutexLock interprocessMutexLock;
    private final ZkWatcher zkWatcher;


    public TestController (
            InterprocessMutexLock interprocessMutexLock,
            ZkWatcher zkWatcher
    ) {
        this.interprocessMutexLock = interprocessMutexLock;
        this.zkWatcher = zkWatcher;
    }

    /**
     * 测试 curator 提供的 分布式可重入排它锁
     */
    @GetMapping("/lock/mutex")
    public void testMutexLock () {
        interprocessMutexLock.test("/lock/mutex");
    }
    
}

关于代码的示例可以参考:https://github.com/WT-AHA/JAVA-TREASURE.git 下的 lock 下的 distributed-lock-zookeeper 工程

FAQ

zk 的不足

  1. zk 的性能有限,tps 大概只能达到一万多,因为只有 leader 节点可以处理事务请求。
  2. zk 选取主节点的时候是没有办法对外服务的,而且这个过程也相对较慢。
  3. zk 的权限控制相对较简单。
  4. zk 进行读取的时候可能读取的是旧数据。因为 zk 的过半原则,当有一半以上的机器数据同步完成就认为是数据同步完成了,你读取的那个节点可能就是没有同步的那个节点。

zk 是 cp 还是 ap

关于 zkcp 还是 ap 从不同的角度分析应该是不一致的:

  1. zkleader 选举期间,会暂停对外提供服务( zk 依赖 leader 来保证数据一致性),所以丢失了可用性,保证了一致性,即 cp。但这边的数据一致性指的又是应该是属于最终一致性不是强一致性,当过半的节点数据同步完成,集群就认为数据同步完成了。
  2. 一个读请求过来,为了保证可用性,不用阻塞到所有的 follower 同步完成,就可以提供数据服务,这样看又是 ap

所以 zk 是使用 最终一致性的顺序一致性过半机制 平衡了 cap 理论,但是如果是选择题的话的应该选择 cp 好一点。

扩展阅读:在分析 cap 的时候,p 一般是必须要满足的,要不就有悖于分布式的原则,所以一般是在 c 和 a 之间进行取舍。

常见组件 cap 分类:

  1. eureka 属于 ap
  2. etcd 属于 cp
  3. consul 属于 cp