20201226 ZooKeeper - 拉勾教育

环境信息

  • 教学版本:3.4.14
  • 官网最新稳定版本:3.6.2
  • 笔记版本:3.6.2

ZooKeeper 简介

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

  • ZooKeeper 最为主要的使用场景,是作为分布式系统的分布式协同服务。
  • 我们将分布式系统定义为:分布式系统是同时跨越多个物理主机,独立运行的多个软件所组成系统。类比一下,分布式系统就是一群人一起干活。人多力量大,每个服务器的算力是有限的,但是通过分布式系统,由 n 个服务器组成起来的集群,算力是可以无限扩张的。
  • 优点显而易见,人多干活快,并且互为备份。但是缺点也很明显。解决办法是在节点间做好 信息同步
  • 分布式系统的协调工作就是通过某种方式,让每个节点的信息能够同步和共享。这依赖于服务进程之间的通信。通信方式有两种:
    • 通过网络进行信息共享
    • 通过共享存储

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

ZooKeeper 对分布式系统的协调,使用的是第二种方式,共享存储。即使使用共享存储,分布式应用也需要和存储进行网络通信。

img

注意:Slave 节点要想获取 ZooKeeper 的更新通知,需事先在关心的数据节点上设置观察点。

大多数分布式系统中出现的问题,都源于信息的共享出了问题。如果各个节点间信息不能及时共享和同步,那么就会在协作过程中产生各种问题。 ZooKeeper 解决协同问题的关键,就是在于保证分布式系统信息的一致性。

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 的三个数据版本,分别是 dataVersion (当前 ZNode 的版本)、 cversion (当前 ZNode 子节点的版本)、 aclVersion (当前 ZNode 的 ACL 版本)。
  • Watcher(事件监听器)
    • Watcher(事件监听器),是 ZooKeeper 中一个很重要的特性, ZooKeeper 允许用户在指定节点上注册一些 Watcher ,并且在一些特定事件触发的时候, ZooKeeper 服务端会将事件通知到感兴趣的客户端,该机制是 ZooKeeper 实现分布式协调服务的重要特性
  • ACL(Access Control Lists)
    • ZooKeeper 采用 ACL ( Access Control Lists )策略来进行权限控制,其定义了如下五种权限,其中需要注意的是, CREATE 和 DELETE 这两种权限都是针对子节点的权限控制 :
      • CREATE :创建子节点的权限。
      • READ :获取节点数据和子节点列表的权限。
      • WRITE :更新节点数据的权限。
      • DELETE :删除子节点的权限。
      • ADMIN :设置节点 ACL 的权限。

ZooKeeper 环境搭建

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

  • 单机模式: ZooKeeper 只运行在一台服务器上,适合测试环境;
  • 集群模式: ZooKeeper 运行于一个集群上,适合生产环境,这个计算机集群被称为一个“集合体”
  • 伪集群模式:就是在一台服务器上运行多个 ZooKeeper 实例;

单机模式搭建

  1. 官网下载安装文件

  2. 上传到服务器,解压

    cd /hwj/
    tar -zxvf apache-zookeeper-3.6.2-bin.tar.gz
    
  3. 创建 data 文件夹

    cd apache-zookeeper-3.6.2-bin/
    mkdir data
    
  4. 修改配置文件名称

    cd conf
    cp zoo_sample.cfg zoo.cfg
    
  5. 修改 zoo.cfg 中的 data 属性

    dataDir=/hwj/apache-zookeeper-3.6.2-bin/data
    
  6. 启动 ZooKeeper 服务

    #	zkServer.sh 用法
    ./zkServer.sh [--config <conf-dir>] {start|start-foreground|stop|version|restart|status|print-cmd}
    #	常用命令
    ##	启动
    ./zkServer.sh start
    ##	停止
    ./zkServer.sh stop
    ##	重启
    ./zkServer.sh restart
    ##	查看状态
    ./zkServer.sh status
    
    #	客户端连接
    ./zkCli.sh
    

伪集群模式搭建

集群规划:

  • 部署目录在 /hwj/zkCluster,分别为 zk01zk02zk03
  • myid 文件内容分别为 1、2、3
  • 占用端口分别为 12181、12182、12183

操作步骤:

  1. 解压并修改目录名称,将服务部署在 /hwj/zkCluster/zk01 目录

  2. 创建 data 和 logs 目录,在 data 目录下创建 myid 文件

    echo "1" > myid
    
  3. 修改配置文件

    clientPort=12181
    dataDir=/hwj/zkCluster/zk01/data
    dataLogDir=/hwj/zkCluster/zk01/data/logs
    
    #	server.服务器ID=服务器IP地址:服务器之间通信端口:服务器之间投票选举端口  
    server.1=192.168.181.130:12881:13881
    server.2=192.168.181.130:12882:13882
    server.3=192.168.181.130:12883:13883
    
  4. 创建便捷脚本 start.sh,统一启停集群

    #!/usr/bin/env bash
    echo "================ zk01 ================"
    /hwj/zkCluster/zk01/bin/zkServer.sh $1
    echo "================ zk02 ================"
    /hwj/zkCluster/zk02/bin/zkServer.sh $1
    echo "================ zk03 ================"
    /hwj/zkCluster/zk03/bin/zkServer.sh $1
    

ZooKeeper 基本使用

ZooKeeper 系统模型

ZooKeeper 数据模型 ZNode

在 ZooKeeper 中,数据信息被保存在一个个数据节点上,这些节点被称为 ZNode 。 ZNode 是 ZooKeeper 中最小数据单位,在 ZNode 下面又可以再挂 ZNode ,这样一层层下去就形成了一个层次化命名空间 ZNode 树,我们称为 ZNode Tree ,它采用了类似文件系统的层级树状结构进行管理。见下图示例:

img

ZNode 的类型

ZooKeeper 节点类型可以分为三大类:

  • 持久性节点(Persistent)
  • 临时性节点(Ephemeral)
  • 顺序性节点(Sequential)

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

  • 持久节点: 是 ZooKeeper 中最常见的一种节点类型,所谓持久节点,就是指节点被创建后会一直存在服务器,直到删除操作主动清除
  • 持久顺序节点: 就是有顺序的持久节点,节点特性和持久节点是一样的,只是额外特性表现在顺序上。顺序特性实质是在创建节点的时候,会在节点名后面加上一个数字后缀,来表示其顺序。
  • 临时节点: 就是会被自动清理掉的节点,它的生命周期和客户端会话绑在一起,客户端会话结束,节点会被删除掉。与持久性节点不同的是,临时节点不能创建子节点。
  • 临时顺序节点: 就是有顺序的临时节点,和持久顺序节点相同,在其创建的时候会在名字后面加上数字后缀。

事务 ID

  • 首先,先了解,事务是对物理和抽象的应用状态上的操作集合。往往在现在的概念中,狭义上的事务通常指的是数据库事务,一般包含了一系列对数据库有序的读写操作,这些数据库事务具有所谓的 ACID 特性,即原子性( Atomic )、一致性( Consistency )、隔离性( Isolation )和持久性( Durability )。
  • 而在 ZooKeeper 中,事务是指能够改变 ZooKeeper 服务器状态的操作,我们也称之为事务操作或更新操作,一般包括数据节点创建与删除、数据节点内容更新等操作。对于每一个事务请求, ZooKeeper 都会为其分配一个全局唯一的事务 ID ,用 ZXID 来表示,通常是一个 64 位的数字。每一个 ZXID 对应一次更新操作,从这些 ZXID 中可以间接地识别出 ZooKeeper 处理这些更新操作请求的全局顺序

ZNode 的状态信息

ls2 命令在新版本已被移除,使用 ls -s 替代

[zk: localhost:12181(CONNECTED) 2] ls -s /zookeeper
[config, quota]
cZxid = 0x0
ctime = Wed Dec 31 16:00:00 PST 1969
mZxid = 0x0
mtime = Wed Dec 31 16:00:00 PST 1969
pZxid = 0x0
cversion = -2
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 0
numChildren = 2

整个 ZNode 节点内容包括两部分:节点数据内容和节点状态信息。其中 [config, 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 ,那么就会向指定客户端发送一个事件通知来实现分布式的通知功能。

img

  • ZooKeeper 的 Watcher 机制主要包括 客户端线程客户端 WatcherManagerZooKeeper 服务器 三部分。
  • 具体工作流程为:客户端在向 ZooKeeper 服务器注册的同时,会将 Watcher 对象存储在客户端的 WatcherManager 当中。当 ZooKeeper 服务器触发 Watcher 事件后,会向客户端发送通知,客户端线程从 WatcherManager 中取出对应的 Watcher 对象来执行回调逻辑。

ACL —— 保障数据的安全

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

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

权限模式: Scheme
  • IP

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

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

    • World 是一种最开放的权限控制模式,这种权限控制方式几乎没有任何作用,数据节点的访问权限对所有用户开放,即所有用户都可以在不进行任何权限校验的情况下操作 ZooKeeper 上的数据。另外, World 模式也可以看作是一种特殊的 Digest 模式,它只有一个权限标识,即 world:anyone
  • 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 相关的设置操作。

ZooKeeper 命令行操作

官网参考

客户端连接服务器:

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

客户端操作:

ZooKeeper -server host:port -client-configuration properties-file cmd args
        addWatch [-m mode] path # optional mode is one of [PERSISTENT, PERSISTENT_RECURSIVE] - default is PERSISTENT_RECURSIVE
        addauth scheme auth
        close
        config [-c] [-w] [-s]
        connect host:port
        create [-s] [-e] [-c] [-t ttl] path [data] [acl]
        delete [-v version] path
        deleteall path [-b batch size]
        delquota [-n|-b] path
        get [-s] [-w] path
        getAcl [-s] path
        getAllChildrenNumber path
        getEphemerals path
        history
        listquota path
        ls [-s] [-w] [-R] path
        printwatches on|off
        quit
        reconfig [-s] [-v version] [[-file path] | [-members serverID=host:port1:port2;port3[,...]*]] | [-add serverId=host:port1:port2;port3[,...]]* [-remove serverId[,...]*]
        redo cmdno
        removewatches path [-c|-d|-a] [-l]
        set [-s] [-v version] path data
        setAcl [-s] [-v version] [-R] path acl
        setquota -n|-b val path
        stat [-w] path
        sync path
        version
  • -s :顺序
  • -e :临时
  • -c :容器
  • -w :监听
  • -v : 版本

ZooKeeper 的 API 使用

官方提供的客户端:

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

org.apache.zookeeper.ZooKeeper 是最常用的类

public class ZooKeeperTest {
    
    private static CountDownLatch countDownLatch = new CountDownLatch(1);

    private ZooKeeper zooKeeper;

    @Test
    public void testZooKeeper() throws IOException, InterruptedException {
        // 连接 ZK
        createSession();

        // 创建节点
        createNode();

        // 获取节点内容
        getNodeData();
        // 获取某个节点的子节点
        getChildrens();

        // 更新节点值
        updateNodeData();

        // 删除节点
        deleteNode("/lg-persistent");

        System.in.read();
    }

    @SneakyThrows
    public void createSession() {
        zooKeeper = new ZooKeeper("127.0.0.1:2181", 5000, new Watcher() {
            @Override
            public void process(WatchedEvent event) {
                System.out.println(event);
                countDownLatch.countDown();
            }
        });
        countDownLatch.await();
        System.out.println(zooKeeper);
    }

    /**
     * 创建节点
     */
    @SneakyThrows
    private void createNode() {

        /**
         *  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("/lg-persistent", "持久节点内容".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);

        // 临时节点
        String node_ephemeral = zooKeeper.create("/lg-ephemeral", "临时节点内容".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);

        // 持久顺序节点
        String node_persistent_sequential = zooKeeper.create("/lg-persistent_sequential", "持久顺序节点内容".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT_SEQUENTIAL);

        // 临时顺序节点
        String node_ephemeral_sequential = zooKeeper.create("/lg-ephemeral_sequential", "临时顺序节点内容".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);

        System.out.println("创建的持久节点" + node_persistent);
        System.out.println("创建的临时节点" + node_ephemeral);
        System.out.println("创建的持久顺序节点" + node_persistent_sequential);
        System.out.println("创建的临时顺序节点" + node_ephemeral_sequential);

    }

    /**
     * 获取某个节点的内容
     */
    @SneakyThrows
    private void getNodeData() {

        /**
         * path    : 获取数据的路径
         * watch    : 是否开启监听
         * stat    : 节点状态信息
         *        null: 表示获取最新版本的数据
         *  zk.getData(path, watch, stat);
         */
        byte[] data = zooKeeper.getData("/lg-persistent", false, null);
        System.out.println(new String(data));

    }

    /**
     * 获取某个节点的子节点列表方法
     */
    @SneakyThrows
    public void getChildrens() {

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

    /**
     * 更新数据节点内容
     */
    @SneakyThrows
    private void updateNodeData() {

         /*
            path:路径
            data:要修改的内容 byte[]
            version:为-1,表示对最新版本的数据进行修改
            zooKeeper.setData(path, data,version);
         */
        byte[] data = zooKeeper.getData("/lg-persistent", false, null);
        System.out.println("修改前的值:" + new String(data));

        //修改/lg-persistent 的数据 stat: 状态信息对象
        Stat stat = zooKeeper.setData("/lg-persistent", "客户端修改了节点数据".getBytes(), -1);

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

    }

    /*
        删除节点的方法
     */
    @SneakyThrows
    private void deleteNode(String path) {
        System.out.println("path == " + path);
        /*
            zooKeeper.exists(path,watch) :判断节点是否存在
            zookeeper.delete(path,version) : 删除节点
        */

        Stat stat = zooKeeper.exists(path, false);
        System.out.println(stat == null ? "该节点不存在" : "该节点存在");

        if (stat != null) {
            zooKeeper.delete(path, -1);
        }

        Stat stat2 = zooKeeper.exists(path, false);
        System.out.println(stat2 == null ? "该节点不存在" : "该节点存在");


    }
}

开源客户端 Apache Curator

Apache Curator 官网

<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-framework</artifactId>
    <version>5.1.0</version>
</dependency>
public class CuratorTest {

    private CuratorFramework client;

    @Test
    @SneakyThrows
    public void testCurator() {
        String path = "/lg-curator/c1";

        // 连接 ZK
        createSession();

        // 创建节点
        createNode(path);

        // 获取节点信息
        getNodeData(path);
        // 获取某个节点的子节点
        getChildrens("/lg-curator");

        // 更新节点值
        updateNodeData(path);

        // 删除节点
        deleteNode(path);

        System.in.read();
    }

    @SneakyThrows
    private void deleteNode(String path) {
        client.delete().deletingChildrenIfNeeded().withVersion(-1).forPath(path);

        System.out.println("删除成功,删除的节点" + path);
    }

    @SneakyThrows
    private void updateNodeData(String path) {
        // 状态信息
        Stat stat = new Stat(); //0
        client.getData().storingStatIn(stat).forPath(path);

        System.out.println("获取到的节点状态信息:" + stat);

        // 更新节点内容 //1
        int version = client.setData().withVersion(stat.getVersion()).forPath(path, "修改内容1".getBytes()).getVersion();
        System.out.println("当前的最新版本是" + version);
        byte[] bytes2 = client.getData().forPath(path);
        System.out.println("修改后的节点数据内容:" + new String(bytes2));
    }

    @SneakyThrows
    private void getChildrens(String path) {
        Stat stat = new Stat();
        List<String> childrens = client.getChildren().storingStatIn(stat).forPath(path);
        System.out.println("当前节点的状态:" + stat);
        System.out.println("当前节点的子节点:" + childrens);
    }

    @SneakyThrows
    private void getNodeData(String path) {
        // 数据内容
        byte[] bytes = client.getData().forPath(path);
        System.out.println("获取到的节点数据内容:" + new String(bytes));

        // 状态信息
        Stat stat = new Stat();
        client.getData().storingStatIn(stat).forPath(path);

        System.out.println("获取到的节点状态信息:" + stat);
    }


    @SneakyThrows
    private void createSession() {
        RetryPolicy exponentialBackoffRetry = new ExponentialBackoffRetry(1000, 3);

        client = CuratorFrameworkFactory.builder().connectString("127.0.0.1:2181").sessionTimeoutMs(50000).connectionTimeoutMs(30000).retryPolicy(exponentialBackoffRetry).namespace("base").build();
        client.start();

        System.out.println(client);
        // 获取官方提供的客户端
        System.out.println(client.getZookeeperClient().getZooKeeper());
    }

    @SneakyThrows
    private void createNode(String path) {
        String s = client.create().creatingParentsIfNeeded().withMode(CreateMode.PERSISTENT).forPath(path, "init".getBytes());
        System.out.println("节点递归创建成功,该节点路径" + s);
    }
}

ZooKeeper 应用场景

ZooKeeper 是一个典型的发布 / 订阅模式的分布式数据管理与协调框架,我们可以使用它来进行分布式数据的发布与订阅。另一方面,通过对 ZooKeeper 中丰富的数据节点类型进行交叉使用,配合 Watcher 事件通知机制,可以非常方便地构建一系列分布式应用中都会涉及的核心功能,如数据发布 / 订阅、命名服务、集群管理、 Master 选举、分布式锁和分布式队列等。

数据发布/订阅

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

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

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

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

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

  • 数据量通常比较小。
  • 数据内容在运行时会发生动态变化。
  • 集群中各机器共享,配置一致。

在集群机器规模不大、配置变更不是特别频繁的情况下,可以选择将其存储在本地配置文件或是内存变量中。

命名服务

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

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

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

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

通过调用 ZooKeeper 节点创建的 API 接口可以创建一个顺序节点,并且在 API 返回值中会返回这个节点的完整名字。利用这个特性,我们就可以借助 ZooKeeper 来生成全局唯一的 ID 了。

img

对于一个任务列表的主键,使用 ZooKeeper 生成唯一 ID 的基本步骤:

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

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

集群管理

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

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

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

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

  • 大规模升级困难
  • 统一的 Agent 无法满足多样的需求
  • 编程语言多样性

ZooKeeper 的两大特性:

  1. 客户端如果对 ZooKeeper 的数据节点注册 Watcher 监听,那么当该数据节点的内容或是其子节点列表发生变更时, ZooKeeper 服务器就会向订阅的客户端发送变更通知。
  2. 对在 ZooKeeper 上创建的临时节点,一旦客户端与服务器之间的会话失效,那么临时节点也会被自动删除

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

分布式日志收集系统

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

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

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

  • 变化的日志源机器

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

  • 变化的收集器机器

    日志收集系统自身也会有机器的变更或扩容,于是会出现新的收集器机器加入或是老的收集器机器退出的情况。

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

  1. 注册收集器机器

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

  2. 任务分发

    待所有收集器机器都创建好自己对应的节点后,系统根据收集器节点下子节点的个数,将所有日志源机器分成对应的若干组,然后将分组后的机器列表分别写到这些收集器机器创建的子节点(例如 /logs/collector/host1 )上去。这样一来,每个收集器机器都能够从自己对应的收集器节点上获取日志源机器列表,进而开始进行日志收集工作。

  3. 状态汇报

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

  4. 动态分配

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

    • 全局动态分配

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

    • 局部动态分配

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

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

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

  • 节点类型

    /logs/collector 节点下创建临时节点可以很好的判断机器是否存活,但是,临时节点无法创建子节点,所以需要选择持久节点来标识每一台收集器机器,同时在节点下分别创建 /logs/collector/[Hostname]/status 节点来表征每一个收集器机器的状态,这样,既能实现对所有机器的监控,同时机器挂掉后,依然能够将分配任务还原

  • 日志系统节点监听

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

Master 选举

Master 选举是一个在分布式系统中非常常见的应用场景。分布式最核心的特性就是能够将具有独立计算能力的系统单元部署在不同的机器上,构成一个完整的分布式系统。而与此同时,实际场景中往往也需要在这些分布在不同机器上的独立系统单元中选出一个所谓的“老大”,在计算机中,我们称之为 Master 。

在分布式系统中, Master 往往用来协调集群中其他系统单元,具有对分布式系统状态变更的决定权。例如,在一些读写分离的应用场景中,客户端的写请求往往是由 Master 来处理的;而在另一些场景中, Master 则常常负责处理一些复杂的逻辑,并将处理结果同步给集群中其他系统单元。 Master 选举可以说是 ZooKeeper 最典型的应用场景了。

利用 ZooKeeper 的强一致性,能够很好保证在分布式高并发情况下节点的创建一定能够保证全局唯一性,即 ZooKeeper 将会保证客户端无法重复创建一个已经存在的数据节点。也就是说,如果同时有多个客户端请求创建同一个节点,那么最终一定只有一个客户端请求能够创建成功。利用这个特性,就能很容易地在分布式环境中进行 Master 选举了。

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

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

分布式锁

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

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

排他锁

排他锁( Exclusive Locks ,简称 X 锁),又称为 写锁独占锁,是一种基本的锁类型。

如果事务 T1 对数据对象 O1 加上了排他锁,那么在整个加锁期间,只允许事务 T1 对 O1 进行读取和更新操作,其他任何事务都不能再对这个数据对象进行任何类型的操作——直到 T1 释放了排他锁

借助 ZooKeeper 实现排他锁:

  1. 定义锁

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

  2. 获取锁

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

  3. 释放锁

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

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

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

共享锁

共享锁( Shared Locks ,简称 S 锁),又称为 读锁,同样是一种基本的锁类型。

如果事务 T1 对数据对象 O1 加上了共享锁,那么当前事务只能对 O1 进行读取操作,其他事务也只能对这个数据对象加共享锁——直到该数据对象上的所有共享锁都被释放。

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

借助 ZooKeeper 来实现共享锁:

  1. 定义锁

    和排他锁一样,同样是通过 ZooKeeper 上的数据节点来表示一个锁,是一个类似于 /shared_lock/[Hostname]-请求类型-序号临时顺序节点,例如 /shared_lock/host1-R-0000000001

  2. 获取锁

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

    判断读写顺序:

    1. 创建完节点后,获取 /shared_lock 节点下所有子节点,并对该节点变更注册监听。
    2. 确定自己的节点序号在所有子节点中的顺序。
    3. 对于读请求:若没有比自己序号小的子节点或所有比自己序号小的子节点都是读请求,那么表明自己已经成功获取到共享锁,同时开始执行读取逻辑,若有写请求,则需要等待。对于写请求:若自己不是序号最小的子节点,那么需要等待。
    4. 接收到 Watcher 通知后,重复步骤 1
  3. 释放锁

    流程与排他锁⼀致

羊群效应

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

img

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

  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 。
    • 对于读请求:向比自己序号小的最后一个写请求节点注册 Watcher 监听。
    • 对于写请求:向比自己序号小的最后一个节点注册 Watcher 监听。
  4. 等待 Watcher 通知,继续进入步骤 2 。

注意

  • 改进后的分布式锁实现相对来说比较麻烦,如同在多线程并发编程实践中,我们会去尽量缩小锁的范围——对于分布式锁实现的改进其实也是同样的思路。
  • 在具体的实际开发过程中,我们提倡根据具体的业务场景和集群规模来选择适合自己的分布式锁实现:在集群规模不大、网络资源丰富的情况下,第一种分布式锁实现方式是简单实用的选择;而如果集群规模达到一定程度,并且希望能够精细化地控制分布式锁机制,那么就可以试试改进版的分布式锁实现

分布式队列

分布式队列可以简单分为两大类:

  • 常规的 FIFO 先入先出队列模型
  • 等待队列元素聚集后统一安排处理执行的 Barrier 模型

FIFO 先入先出

FIFO(First Input First Output,先入先出)

使用 ZooKeeper 实现 FIFO 队列,和之前提到的共享锁的实现非常类似。 FIFO 队列就类似于一个全写的共享锁模型,大体的设计思路其实非常简单:所有客户端都会到 /queue_fifo 这个节点下面创建一个临时顺序节点,例如如 /queue_fifo/host1-00000001

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

  1. 通过调用 getChildren 接口来获取 /queue_fifo 节点的所有子节点,即获取队列中所有的元素。
  2. 确定自己的节点序号在所有子节点中的顺序。
  3. 如果自己的序号不是最小,那么需要等待,同时向比自己序号小的最后一个节点注册 Watcher 监听。
  4. 接收到 Watcher 通知后,重复步骤 1

Barrier:分布式屏障

Barrier 原意是指障碍物、屏障,而在分布式系统中,特指系统之间的一个协调条件,规定了一个队列的元素必须都集聚后才能统一进行安排,否则一直等待。

这往往出现在那些大规模分布式并行计算的应用场景上:最终的合并计算需要基于很多并行计算的子结果来进行。

这其实是在 FIFO 队列的基础上进行了增强,大致的设计思想如下:开始时, /queue_barrier 节点是一个已经存在的默认节点,并且将其节点的数据内容赋值为一个数字 n 来代表 Barrier 值,例如 n = 10 表示只有当 /queue_barrier 节点下的子节点个数达到 10 后,才会打开 Barrier 。之后,所有的客户端都会到 /queue_barrie 节点下创建一个临时节点

创建完节点后,按照如下步骤执行:

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

ZooKeeper 深入进阶

ZAB 协议

  • 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 进行提交

img

ZAB 协议介绍

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

  • 进入崩溃恢复模式:
    • 当整个服务框架启动过程中,或者是 Leader 服务器出现网络中断、崩溃退出或重启等异常情况时, ZAB 协议就会进入崩溃恢复模式,同时选举产生新的 Leader 服务器。
    • 当选举产生了新的 Leader 服务器,同时集群中已经有过半的机器与该 Leader 服务器完成了状态同步之后, ZAB 协议就会退出恢复模式,其中,所谓的状态同步 就是指数据同步,用来保证集群中过半的机器能够和 Leader 服务器的数据状态保持一致
  • 进入消息广播模式:
    • 当集群中已经有过半的 Follower 服务器完成了和 Leader 服务器的状态同步,那么整个服务框架就可以进入消息广播模式, 当一台同样遵守 ZAB 协议的服务器启动后加入到集群中,如果此时集群中已经存在一个 Leader 服务器在负责进行消息广播,那么加入的服务器就会自觉地进入数据恢复模式
      • 找到 Leader 所在的服务器,并与其进行数据同步,然后一起参与到消息广播流程中去。
    • ZooKeeper 只允许唯一的一个 Leader 服务器来进行事务请求的处理, Leader 服务器在接收到客户端的事务请求后,会生成对应的事务提议并发起一轮广播协议,而如果集群中的其他机器收到客户端的事务请求后,那么这些非 Leader 服务器会首先将这个事务请求转发给 Leader 服务器。
消息广播

ZAB 协议的消息广播过程使用原子广播协议,类似于一个二阶段提交过程,针对客户端的事务请求, Leader 服务器会为其生成对应的事务 Proposal ,并将其发送给集群中其余所有的机器,然后再分别收集各自的选票,最后进行事务提交。

在 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 服务器上被提交了,并且已经得到过半 Follower 服务器的 ACK 反馈,但是在它将 Commit 消息发送给所有 Follower 机器之前, Leader 服务器挂了,如图所示

    img

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

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

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

    img

    在图所示的集群中,假设初始的 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 的联系和区别

联系

  1. 都存在一个类似于 Leader 进程的角色,由其负责协调多个 Follower 进程的运行
  2. Leader 进程都会等待超过半数的 Follower 做出正确的反馈后,才会将一个提议进行提交
  3. 在 ZAB 协议中,每个 Proposal 中都包含了一个 epoch 值,用来代表当前的 Leader 周期,在 Paxos 算法中,同样存在这样的一个标识,名字为 Ballot 。

区别

  • Paxos 算法中,新选举产生的主进程会进行两个阶段的工作,第一阶段称为读阶段,新的主进程和其他进程通信来收集主进程提出的提议,并将它们提交。第二阶段称为写阶段,当前主进程开始提出自己的提议
  • ZAB 协议在 Paxos 基础上添加了同步阶段,此时,新的 Leader 会确保 存在过半的 Follower 已经提交了之前的 Leader 周期中的所有事务 Proposal 。这一同步阶段的引入,能够有效地保证 Leader 在新的周期中提出事务 Proposal 之前,所有的进程都已经完成了对之前所有事务 Proposal 的提交
  • 总的来说, ZAB 协议和 Paxos 算法的本质区别在于,两者的设计目标不太一样, ZAB 协议主要用于构建一个高可用的分布式数据主备系统,而 Paxos 算法则用于构建一个分布式的一致性状态机系统

服务器角色

  • Leader 服务器是 ZooKeeper 集群工作的核心,其主要工作有以下两个:

    1. 事务请求的唯一调度和处理者,保证集群事务处理的顺序性
    2. 集群内部各服务器的调度者
  • Follower 服务器是 ZooKeeper 集群状态中的跟随者,其主要工作有以下三个:

    1. 处理客户端非事务性请求(读取数据),转发事务请求给 Leader 服务器
    2. 参与事务请求 Proposal 的投票
    3. 参与 Leader 选举投票
  • Observer 服务器是 ZooKeeper 集群状态中的观察者

    • Observer 是 ZooKeeper 自 3.3.0 版本开始引入的一个全新的服务器角色。从字面意思看,该服务器充当了一个观察者的角色——其观察 ZooKeeper 集群的最新状态变化并将这些状态变更同步过来。
    • Observer 服务器在工作原理上和 Follower 基本是一致的,对于非事务请求,都可以进行独立的处理,而对于事务请求,则会转发给 Leader 服务器进行处理。
    • 和 Follower 唯一的区别在于, Observer 不参与任何形式的投票,包括事务请求 Proposal 的投票和 Leader 选举投票。
    • 简单地讲, Observer 服务器只提供非事务服务,通常用于在不影响集群事务处理能力的前提下提升集群的非事务处理能力。

ZooKeeper 源码分析

参考资料

posted @ 2020-12-26 09:53  流星<。)#)))≦  阅读(145)  评论(0编辑  收藏  举报