分布式协调组件之Zookeeper

Zookeeper

 Apache Zookeeper 是一个开源的分布式协调组件,最早由雅虎公司基于Google Chubby 开源实现,在Hadoop、Kafka、Hbase 等技术中充当核心组件。

 Zookeeper 本身使用Java语言开发,Zookeeper 的设计目标是将那些复杂且容易出错的分布式一致性服务封装起来,构成一个高效可靠的原语集,并以一系列简单易用的接口提供给用户使用。

image-20221211155001272

注:Zookeeper并不是用来存储大量数据的数据库服务,它主要在分布式系统中起协调作用。

功能特性

ZooKeeper 具有以下特性:

  • 顺序一致性:所有客户端看到的服务端数据模型都是一致的;从一个客户端发起的事务请求,最终都会严格按照其发起顺序被应用到 ZooKeeper 中。
  • 原子性:所有事务请求的处理结果在整个集群中所有机器上的应用情况是一致的,即整个集群要么都成功应用了某个事务,要么都没有应用。
  • 单一视图:无论客户端连接的是哪个 Zookeeper 服务器,其看到的服务端数据模型都是一致的。
  • 高性能:ZooKeeper 将数据全量存储在内存中,所以其性能很高。需要注意的是:由于 ZooKeeper 的所有更新和删除都是基于事务的,因此 ZooKeeper 在读多写少的应用场景中有性能表现较好,如果写操作频繁,性能会大大下降。
  • 高可用:ZooKeeper 的高可用是基于副本机制实现的,此外 ZooKeeper 支持故障恢复。

主要应用

Zookeeper 的主要应用:

  • 统一配置管理(数据发布、订阅)
  • 统一命名服务
  • 集群管理
  • Master 选举
  • 分布式锁
  • 分布式队列

下面会单独详细分别讲解。

使用案例

应用程序 说明
Neo4j 在 Neo4j (图数据库)高可用性组件中使用 ZooKeeper 来进行写主选举、读从协调等。
Spring Cloud Zookeeper 为Spring Boot 应用程序提供 Apache Zookeeper 集成。
Apache Druid Druid(实时分析数据库) 使用 ZooKeeper来管理当前集群状态
Apache Dubbo RPC框架
Apache Kafka Kafka是一个分布式发布/订阅消息系统
Apache Solr
Apache Hadoop
Apache HBase
Apache Spark

环境搭建

集群搭建

一、在conf/zoo.cfg 文件中,配置数据目录位置

dataDir=../data

二、在指定的数据目录下新建 myid 文件,内容为serverID,如0、1、2,该ID在一个集群中是全局唯一的。

三、配置集群的所有节点服务器的信息:(按 server.id = ip:port:port 格式进行配置)

server.1=192.168.3.33:2888:3888
server.2=192.168.3.35:2888:3888
server.3=192.168.3.37:2888:3888

zookeeper有三个端口及作用(可修改)

  • 2181:对客户端端提供服务
  • 2888:集群内机器通讯使用(Leader监听此端口)
  • 3888:选举leader使用

四、使用脚本依次启动所有服务器。

五、启动客户端进行连接:

zkCli.cmd
zkCli.cmd -server 127.0.0.1:2181
zkCli.cmd -server 127.0.0.1:2182
zkCli.cmd -server 127.0.0.1:2183

注意:

 ZooKeeper 集群中的服务器数量要部署成奇数,这是因为相同容错服务器个数的条件下,奇数最能节省资源。比如,最大容错为2的情况下,对应的ZooKeeper服务数,奇数为5,而偶数为6,也就是6个ZooKeeper服务的情况下最多能宕掉2个服务,所以从节约资源的角度看,没必要部署6个ZooKeeper服务节点,奇数个是最佳方案。

单机搭建

单机环境不需要在数据目录下新建 myid 文件,直接使用脚本启动即可。

发版历史

版本 时间
release 3.8.0 2022
release 3.7.0 2021
release 3.6.0 2020
release 3.5.5 2019
release 3.4.0 2011
release 3.3.0 2010
release 3.1.0 2009
release 3.0.0 2008

本文基于3.7.1 版本进行讲解

数据模型 Znode

 ZooKeeper 使用类似于文件系统树形结构的数据模型,其中根节点为 /。树中的节点被称为 znode,每个节点上都会保存自己的数据和节点信息,并且有一个与之相关联的 ACL进行权限控制。ZooKeeper 的设计目标是实现协调服务,而不是真的作为一个文件存储,因此 znode 存储数据的大小被限制在 1MB 以内

zk_tree

节点类型

 如果以 znode 持久性来划分,znode 可分为持久性 znode 和临时 znode。持久性 znode 不会因为 ZooKeeper 集群重启而消失,而临时 znode 则与创建该 znode 的 ZooKeeper 会话绑定,一旦会话结束,该节点会被自动删除。

 Znode 还有一个序列化的特性,如果创建的时候指定的话,该 Znode 的名字后面会自动追加一个递增的序列号。序列号对于此节点的父节点来说是唯一的,这样便会记录每个子节点创建的先后顺序。因此组合之后,Znode 有四种节点类型:

  • PERSISTENT:持久节点
  • EPHEMERAL:临时节点
  • PERSISTENT_SEQUENTIAL:持久顺序节点
  • EPHEMERAL_SEQUENTIAL:临时顺序节点

注:临时节点和顺序节点可以用来实现非公平分布式锁和公平分布式锁,具体内容下面会详细讲解。

在Zookeeper3.5版本之后有新增三种节点类型:

  • CONTAINER:容器节点
  • PERSISTENT_WITH_TTL:持久TTL节点
  • PERSISTENT_SEQUENTIAL_WITH_TTL:持久顺序TTL节点

容器节点:

​ 容器节点是 3.5 以后新增的节点类型,只要在调用 create 方法时指定 CreateMode 为 CONTAINER 即可创建容器的节点类型,容器节点的表现形式和持久节点是一样的,但是区别是 ZK 服务端启动后,会有一个单独的线程去扫描所有的容器节点,当发现容器节点的子节点数量为 0 时,会自动删除该节点,除此之外和持久节点没有区别,官方注释给出的使用场景是可以用在 leader 或者锁的场景中。

持久 TTL、持久顺序 TTL节点:

​ TTL 是 time to live 的缩写,指存活时间,简单来说就是当该节点下面没有子节点的话,超过了 TTL 指定时间后就会被自动删除,特性跟上面的容器节点很像,只是容器节点没有超时时间而已,但是 TTL 启用是需要额外的配置, zookeeper.extendedTypesEnabled 需要配置成 true,否则的话创建 TTL 时会收到 Unimplemented 的报错。

状态属性

使用 ls -sstat 命令查看Znode 的状态属性:

[zk: localhost:2181(CONNECTED) 10] ls -s /
[test0000000000, test0000000001, test0000000002, zookeeper]
cZxid = 0x0
ctime = Thu Jan 01 08:00:00 CST 1970
mZxid = 0x0
mtime = Thu Jan 01 08:00:00 CST 1970
pZxid = 0x8
cversion = 2
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 0
numChildren = 4

各属性说明:

属性 说明
cZxid 创建节点时的事务ID
ctime 创建节点时的时间
mZxid 最后修改节点时的事务ID
mtime 最后修改节点时的时间
pZxid 表示该节点的子节点列表最后一次修改的事务ID,添加子节点或删除子节点就会影响子节点列表,但是修改子节点的数据内容则不影响该ID(注意,只有子节点列表变更了才会变更pzxid,子节点内容变更不会影响pzxid)
cversion 子节点版本号,子节点每次修改版本号加1
dataversion 数据版本号,数据每次修改该版本号加1
aclversion 权限版本号,权限每次修改该版本号加1
ephemeralOwner 创建该临时节点的会话的sessionID(如果该节点是持久节点,那么这个属性值为0)
dataLength 该节点的数据长度
numChildren 该节点拥有子节点的数量(只统计直接子节点的数量)

监听器 Watcher

 ZooKeeper 支持客户端监听 znode 的能力,一旦 znode 节点发生变化(节点数据变化、子节点增减变化),ZooKeeper 服务器会通知客户端。

常见的监听场景有以下两种:

  • 监听Znode节点的数据变化
  • 监听子节点的增减变化

 一个Watch事件是一次性的触发器,当被设置了Watch的数据发生了改变的时候,则服务器将这个改变发送给设置了Watch的客户端,以便通知它们。

 为什么不是永久的,举个例子,如果服务端变动频繁,而监听的客户端很多情况下,每次变动都要通知到所有的客户端,给网络和服务器造成很大压力。

客户端

命令行交互

ls 命令

ls 命令用于查看某个路径下目录列表。

# 查看 /runoob 节点
ls /runoob
# ls2 命令用于查看某个路径下目录列表,它比 ls 命令列出更多的详细信息。
ls2 /runoob

get 命令

get 命令用于获取节点数据和状态信息。

格式:

get path [watch]
  • path:代表路径。
  • **[watch]**:对节点进行事件监听。

注:监听范围:当节点本身及子节点数量发生变化、当前节点的数据发生修改;

终端一:

get /runoob watch

在终端二对此节点进行修改:

set /runoob 1

此时在终端一自动显示 NodeDataChanged 事件;

set 命令

set 命令用于修改节点存储的数据。

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

示例:

# 直接赋值
set /runoob 0
# 校验版本号赋值(乐观锁)
set /runoob 0 1

create 命令

create 命令用于创建节点并赋值。

create [-s] [-e] path data acl
[zk: localhost:2181(CONNECTED) 6] create -s -e /test 2
Created /test0000000000
[zk: localhost:2181(CONNECTED) 7] create -s -e /test 2
Created /test0000000001
[zk: localhost:2181(CONNECTED) 8] create -se /test 2
Created /test0000000002
[zk: localhost:2181(CONNECTED) 9] ls /
[test0000000000, test0000000001, test0000000002, zookeeper]

创建的节点既是有序,又是临时节点。session 关闭,临时节点清除

注意:不能一次性建立父节点和子节点,比如没有 /v1 这个节点,直接创建 /v1/vv1 是不允许的。必须先创建 /v1,再创建/v1/vv1。

[zk: 127.0.0.1:2183(CONNECTED) 4] create /v1/vv1 0
Node does not exist: /v1/vv1

delete 命令

delete 命令用于删除某节点。

delete path [version]

注意:要删除的节点必须没有子节点存在,否则无法删除。

[zk: 127.0.0.1:2183(CONNECTED) 8] delete /v1
Node not empty: /v1

官方Java客户端

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

注:

 zookeeper 的java客户端,选择的版本号应该与服务器的版本相同,避免引发不可预期的问题 ;

 zookeeper 官方提供的Java客户端,API偏低级,需要自己处理连接丢失, 重试等细节,开发人员需要额外做一些工作,实际生产中不推荐使用,成本高,在实际的工作中建议使用Apache Curator。

Curator 客户端

 Apache Curator是 Netflix 公司开源的一套zookeeper客户端,目前是Apache的顶级项目 。与Zookeeper提供的原生客户端相比,Curator的抽象层次更高,简化了Zookeeper客户端的开发量。Curator解决了很多zookeeper客户端非常底层的细节开发工作,包括连接重试、反复注册 wathcer 和 NodeExistsException 异常等。

 Curator 是一个组件包,由一系列的模块构成,对于开发者而言,常用的是 curator-frameworkcurator-recipes ,我们可以使用Curator 很轻松的实现以下功能:

  • 客户端重试策略
  • 监听器反复注册
  • 命名空间隔离
  • 分布式锁

下面是示例代码:

public CuratorFramework getCuratorClient(String namespace) {
       CuratorFramework curator = CuratorFrameworkFactory.builder()
               .connectString("127.0.0.1:2181")            //Zookeeper服务器地址,多个用逗号隔开
               .sessionTimeoutMs(60000)                    //会话超时时间:默认60秒
               .connectionTimeoutMs(60000)            		//连接超时时间:默认15秒
               .retryPolicy(new RetryForever(3000))   		//设置重试策略
               .namespace(namespace)       				//命名空间、隔离业务
               .build();
       curator.start();						//建立一个新会话
       return curator;
   }

public void test(String path,byte[] data) throws Exception {

       CuratorFramework curatorClient = getCuratorClient("test-curator");
       //创建节点
       curatorClient.create()
               .creatingParentsIfNeeded()      		// 如果父节点不存在则自动创建
               .withMode(CreateMode.PERSISTENT)     	//设置节点类型,默认为持久化节点
               .forPath(path, data);
      	//普通查询
      	CuratorFramework curatorClient = getCuratorClient(true);
      	byte[] bytes = curatorClient.getData().forPath(path);
   	//包含状态的查询
   	Stat stat = new Stat();
       curatorClient.getData().storingStatIn(stat).forPath(path);
   
       // 普通更新
       curatorClient.setData().forPath(path,data);
     	// 指定版本更新
      	curatorClient.setData().withVersion(1).forPath(path);
   
        // 普通删除
       curatorClient.delete().forPath(path);
   	// 指定版本删除
       curatorClient.delete().withVersion(1).forPath(path);
   	// 递归删除子节点
	curatorClient.delete().deletingChildrenIfNeeded().forPath(path);
   }

实现分布式锁:

public void testLock(){
	// 创建分布式锁:可重入排他锁
    InterProcessLock lock1 = new InterProcessMutex(curatorClient, path);
    lock1.acquire();
    lock1.acquire();// 测试锁重入
    lock1.release();
    lock1.release();
    
    // 创建分布式锁:排他锁
    InterProcessLock lock2 = new InterProcessSemaphoreMutex(curatorClient, path);
    lock2.acquire();
    lock2.release();
    
    // 创建分布式锁:读写锁
    InterProcessReadWriteLock readWriteLock = new InterProcessReadWriteLock(curatorClient, path);
   	InterProcessReadWriteLock.ReadLock readLock = readWriteLock.readLock();
    readLock.acquire();
    readLock.release();
}

注:

 Curator客户端操作Zookeeper的更多代码就不在本文一一讲解了,相关源码和示例我已经上传的GitHub,请自行获取。

可视化工具

ZooInspector:下载地址

权限控制 ACL

ZooKeeper 采用 ACL(Access Control Lists)访问控制列表来进行权限控制,保障数据安全性。

每个 znode 创建时都会带有一个 ACL 列表,用于决定谁可以对它进行何种操作。

ACL 构成

zookeeper 的 acl 通过 [scheme:id :permissions] 来构成权限列表。

  • scheme:权限方案,包括 world、auth、digest、ip、super 等几种。
  • id:授权的对象。
  • permissions:权限组合字符串,由 cdrwa 组成,其中每个字母代表支持不同权限, 创建权限 create(c)、删除权限 delete(d)、读权限 read(r)、写权限 write(w)、管理权限admin(a)。

特性如下:

  • ZooKeeper的权限控制是基于每个znode节点的,需要对每个节点设置权限;
  • 子节点不会继承父节点的权限,客户端无权访问某节点,但可能可以访问它的子节点;
  • 每个znode支持设置多种权限;

权限方案 scheme

权限方案scheme 用于设置身份认证方式

认证方式 描述
world 默认方式,相当于全世界都能访问
auth 代表已经认证通过的用户(cli中可以通过addauth digest user:pwd 来添加当前上下文中的授权用户)
digest 即用户名:密码这种方式认证,这也是业务系统中最常用的
ip 使用客户端的主机IP作为ACL ID

授权对象ID

授权对象ID是指,权限赋予的用户或者一个实体,权限方案不同,ID的描述也不同。

权限方案 授权对象
IP 通常是一个IP地址,如“192.168.0.110”或 “192.168.0.1/24”
digest 自定义,通常是“username:BASE64(SHA-1(username:password))”
world 只有一个ID,“anyone”
super 与digest一致

操作权限 permission

ZK的节点有5种操作权限:CREATE、READ、WRITE、DELETE、ADMIN增、删、改、查、管理 权限。这5种权限简写为crwda;

permissions:

  • CREATE:允许创建子节点
  • DELETE:允许删除子节点(仅直接子节点)
  • WRITE:允许为节点设置数据
  • READ:允许从节点获取数据并列出其子节点
  • ADMIN:允许为节点设置权限

ACL 命令行

ACL相关命令:

  • getAcl 命令:获取某个节点的 acl 权限信息。
  • setAcl 命令:设置某个节点的 acl 权限信息。
  • addauth 命令:输入认证授权信息,注册时输入明文密码,加密形式保存。

Zab 协议

 Zookeeper 是通过 Zab 协议来保证分布式数据的一致性。ZAB 协议即 Zookeeper原子广播协议(Zookeeper Automic Broadcast),它参照Paxos算法实现,但ZAB 协议并不像Paxos 算法那样是一个通用的分布式一致性算法,它是一个特别为Zookeeper 设计的支持崩溃恢复的原子广播协议。ZAB 协议的核心分为两个部分:消息广播、崩溃恢复

 使用 ZooKeeper 服务时,客户端只需要与 ZooKeeper 集群的任一节点建立连接即可,客户端的所有读写请求都会由该节点来负责处理。如果是读请求,节点将会使用自身保存的数据直接返回结果;写请求则会被转发给 Leader 节点进行处理。

 ZooKeeper 提供了同步写入和异步写入的接口,这样的 API 使得客户端可以以更高的吞吐量完成数据变更,而对于来自同一个客户端的并发请求,ZooKeeper 也提供了 FIFO 的顺序保证。

消息广播

 ZooKeeper 通过副本机制来实现高可用。

 在zookeeper集群中,数据副本的传递策略就是采用消息广播模式。zookeeper中数据副本的同步方式与二段提交(2PC)相似,但是却又不同。二段提交要求协调者必须等到所有的参与者全部反馈ACK确认消息后,再发送commit消息。要求所有的参与者要么全部成功,要么全部失败。而Zab协议中 Leader 等待 Follower 的ACK反馈消息是指“只要半数以上的Follower成功反馈即可,不需要收到全部Follower反馈”。

消息广播具体步骤:

1)客户端发起一个写操作请求。接收到写请求的服务器如果是Leader则直接处理,如果是Follower,则转发给Leader处理;

2)Leader 服务器将客户端的请求转化为事务提议 Proposal,同时为每个 Proposal 分配一个全局的事务ID,即zxid。

3)Leader 服务器为每个 Follower 服务器分配一个单独的队列,然后将需要广播的 Proposal 依次放到队列中,并且根据 FIFO 策略进行消息发送。

4)Follower 接收到 Proposal 后,会首先将其以事务日志的方式写入本地磁盘中,写入成功后向 Leader 反馈一个 Ack 响应消息。

5)Leader 接收到超过半数以上 Follower 的 Ack 响应消息后,即认为消息发送成功,可以发送 commit 消息。

6)Leader 向所有 Follower 广播 commit 消息,同时自身也会完成事务提交。Follower 接收到 commit 消息后,会将上一条事务提交。

image-20221121233255291

 整个消息广播是基于 FIFO 消息队列的TCP协议进行收发消息,因此确保了消息广播过程中消息接受和发送的顺序性,同时做到了异步解耦,提高通信性能。但这种简化的二阶段提交模型,是无法处理Leader 服务器崩溃退出而带来的数据不一致问题的,因此ZAB协议中添加了另一个模式,即采用崩溃恢复模式来解决这个问题

崩溃恢复

 正常情况下,基于消息广播运行是良好的,但一旦Leader服务器崩溃退出或因为网络原因Leader 不能与过半数量的Follower 服务器通信,那么就会进入崩溃恢复模式。

  • Follower 节点宕机 - ZooKeeper 集群中的每个节点都会单独在内存中维护自身的状态,并且各节点之间都保持着通讯,只要集群中有半数机器能够正常工作,那么整个集群就可以正常提供服务。
  • Leader 节点宕机 - 如果 Leader 节点挂了,系统就不能正常工作了。此时需要通过 ZAB 协议的Leader 选举机制来进行故障恢复。

什么情况下zab协议会进入崩溃恢复模式?

1、当服务器启动时

2、当leader 服务器出现网络中断,崩溃或者重启的情况

3、当集群中已经不存在过半的服务器与Leader服务器保持正常通信。

zab协议进入崩溃恢复模式会做什么?

1、当leader出现问题,zab协议进入崩溃恢复模式,并且选举出新的leader。当新的leader选举出来以后,如果集群中已经有过半机器完成了leader服务器的数据同步,退出崩溃恢复,进入消息广播模式。

2、当新的机器加入到集群中的时候,如果已经存在leader服务器,那么新加入的服务器就会自觉进入崩溃恢复模式,找到leader进行数据同步。

ZAB协议特性:

  • ZAB协议需要确保丢弃那些只在Leader服务器上被提出的事务(其他服务器均没有收到该事务Proposal);
  • ZAB协议需要确保那些已经在Leader服务器上提交的事务最终被所有服务器执行。

两种特殊场景下(Leader 宕机)的数据不一致问题

问题一:

 Leader 接收客户端请求形成一个事务提案后,还没来得及发送就宕机,Follower 都没有接收到此提案。因此经过崩溃恢复模式重新选了 leader 后,这条消息是无感知的。 此时,之前挂了的 leader 重新启动并注册成了 follower,他保留了被跳过消息的 proposal,与整个系统的数据是不一致的。

解决方案:

 丢弃旧Leader保留的未发送的事务提案。Zab 通过巧妙的设计 zxid 来实现这一目的。一个 zxid 是64位,高 32 是纪元(epoch)编号,每经过一次 leader 选举产生一个新的 leader,新 leader 会将 epoch 号 +1。低 32 位是消息计数器,每接收到一条消息这个值 +1,新 leader 选举后这个值重置为 0。

 这样设计的好处是旧的 leader 挂了后重启,它不会被选举为 leader,因为此时它的 zxid 肯定小于当前的新 leader。当旧的 leader 作为 follower 接入新的 leader 后,新的 leader 会让它将所有的拥有旧的 epoch 号的未被 COMMIT 的 proposal 清除。

问题二:

 假设一个事务在Leader 上被提出,并且已经收到超半数ACK 反馈信息后,但是在Leader向所有 Follower 广播 COMMIT 消息过程中(发送给所有合法数量的Follower之前),Leader 宕机,此时可能有部分Follower 并没有接收到 COMMIT 消息,不会提交事务,从而造成数据丢失的不一致性。

解决方案:

 选举拥有最大 ZXID 事务ID的节点作为新 Leader,最大事务ID意味着该节点拥有集群中最新的数据。新Leader 将自己未被提交事务提案进行检查,如果该事务提案被集群中超半数的节点ACK确认过,则进行 COMMIT,否则丢弃。然后将自己有但是 Follower 没有的 proposal 发送给 Follower,再将这些 proposal 的 COMMIT 命令广播给 Follower节点,确保所有的 Follower 处理了所有的消息。

注:关于问题二的场景,如果Leader没有COMMIT,或者说没有超过半数的服务器做出ACK确认应答,那么该消息还是应该被丢弃。

Leader选举

ZooKeeper 集群采用一主多从的模式,主从节点通过数据同步保证数据一致。

术语:

  • 纪元编号(epoch):同一轮投票过程中要求纪元编号是相同的,每选举一次新Leader,值会自增1;
  • 事务ID(zxid):值越大说明数据越新,权重越大;
  • 服务器ID(myid):在一个集群中全局唯一,编号越大在选举算法中权重越大;(只有当事务ID相同时才比较服务器ID)

服务器三种状态(两种角色):

  • looking:服务器处于寻找leader的状态;
  • leading:服务器作为leader时的状态;
  • following:服务器作为follower跟随者时的状态;

服务器启动时的 leader 选举:

刚开始都是looking状态,随着选举的进行会状态转换到 leading 或 following

image-20221128232813964

选举过程:(假设有3台服务器)

1、各自推选自己:ZooKeeper 集群刚启动时,所有服务器的 logicClock 都为 1,zxid 都为 0。各服务器初始化后,先把第一票投给自己并将它存入自己的票箱,同时广播给其他服务器。此时各自的票箱中只有自己投给自己的一票。

2、更新选票:其他服务器接收到广播后开始更新选票操作,以 Server1 为例流程如下:

(1)Server1 收到 Server2 和 Server3 的广播选票后,由于 logicClock 和 zxid 都相等,此时就比较 myid;

(2)Server1 收到的两张选票中 Server3 的 myid 最大,此时 Server1 判断应该遵从 Server3 的投票决定,将自己的票改投给 Server3。接下来 Server1 先清空自己的票箱(票箱中有第一步中投给自己的选票),然后将自己的新投票(1->3)和接收到的 Server3 的(3->3)投票一起存入自己的票箱,再把自己的新投票决定(1->3)广播出去,此时 Server1 的票箱中有两票:(1->3),(3->3);

(3)同理,Server2 收到 Server3 的选票后也将自己的选票更新为(2->3)并存入票箱然后广播。此时 Server2 票箱内的选票为(2->3),(3->3);

(4)Server3 根据上述规则,无须更新选票,自身的票箱内选票仍为(3->3);

(5)Server1 与 Server2 重新投给 Server3 的选票广播出去后,由于三个服务器最新选票都相同,最后三者的票箱内都包含三张投给服务器 3 的选票。

3、根据选票确定角色:根据上述选票,三个服务器一致认为此时 Server3 应该是 Leader。因此 Server1 和 Server2 都进入 FOLLOWING 状态,而 Server3 进入 LEADING 状态。之后 Leader 发起并维护与 Follower 间的心跳。

当选举结束后,准Leader的状态是Leading,其他参与投票的服务器状态为Following。

数据同步

 完成Leader 选举之后,在正式接收客户端请求之前,Leader 服务器还需要确认事务日志中的所有提案是否都被半数以上的Follower 同步了,只有过半机器完成数据同步后,才会退出崩溃恢复模式,进入原子广播模式,从而正常对外提供服务。

正常情况下的同步:

 Leader 将没有被Follower 服务器同步的事务以提案消息的形式发送到事先准备好的队列中,进行有序发送,并在每一个Proposal 消息后面紧跟着再发送一个Commit 消息,表示该事务已提交,等到Follower服务器将所有数据同步完成后,Leader才将改Follower加入到真正可用的Follower 列表中。

特殊的同步:

 旧Leader宕机时,自己已经生成事务提案,但没有来的及发送就宕机,针对这种场景,旧Leader重启之后,因为纪元编号epcho小于当前epcho,所有一定不会成为Leader,而且作为Follower 与新Leader 通信后,Leader 会根据自己服务器上最新的Proposal 和Follower 的Proposal 进行对比,对比结果是Leader要求Follower 进行一个回退操作,回退到与Leader服务器保持同步的位置。

小结:

 ZAB 协议的崩溃恢复机制简单来说,就是基于过半选举机制产生新的 Leader,之后其他机器将从新的 Leader 上同步状态,当有过半机器完成状态同步后,就退出选举 Leader 模式,进入原子广播模式。

高级进阶

集群

集群角色

Zookeeper 集群是一个基于主从复制的高可用集群,每个服务器承担如下三种角色中的一种。

  • Leader:它负责发起并维护与各 Follwer 及 Observer 间的心跳。所有的写操作必须要通过 Leader 完成再由 Leader 将写操作广播给其它服务器。一个 Zookeeper 集群同一时间只会有一个实际工作的 Leader。
  • Follower:它会响应 Leader 的心跳。Follower 可直接处理并返回客户端的读请求,同时会将写请求转发给 Leader 处理,并且负责在 Leader 处理写请求时对请求进行投票。一个 Zookeeper 集群可能同时存在多个 Follower。
  • Observer:角色与 Follower 类似,但不参与投票。

服务器状态

服务器节点有四种状态:

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

Observer 服务器

 最早的 ZooKeeper 架构中是不存在 Observer 服务器的,在集群服务运行过程中,只有 Leader 和 Follow 服务器。为什么后来增加观察者服务器呢?

 在 ZooKeeper 集群服务运行的过程中,Observer 服务器与 Follow 服务器具有一个相同的功能,那就是负责处理来自客户端的诸如查询数据节点等非事务性的会话请求操作。但与 Follow 服务器不同的是,Observer 不参与 Leader 服务器的选举工作,不参与事务请求 Proposal 的投票。

 Observer 只会从网络中接收 INFORM 类型的信息包。INFORM 信息的内部只包含已经被 Cmmit 操作过的投票信息,因为 Observer 服务器只接收已经被提交处理的 Proposal 请求,不会接收未被提交的会话请求。这样就隔离了 Observer 参与投票操作,进而使 Observer 只负责查询等相关非事务性操作,保证扩展多个 Observer 服务器时不会对 ZooKeeper 集群写入操作的性能产生影响。

image-20221122223314714

 在实际部署的时候,因为 Observer 不参与 Leader 节点等操作,并不会像 Follow 服务器那样频繁的与 Leader 服务器进行通信。因此,可以将 Observer 服务器部署在不同的网络区间中,这样也不会影响整个 ZooKeeper 集群的性能,也就是所谓的跨域部署。

 如果是observer,需要在cfg配置文件中显式配置,observer不参与投票只接受投票结果,由于不参与投票,就没有写数据时多余的网络消耗,它的引入可以提高zookeeper集群服务的能力。

# 告诉zookeeper这个节点是observer
peerType=observer

脑裂

 分布式集群中一般都要处理脑裂问题,什么是脑裂?就是同时出现了多个主节点,一个集群被分割为多个小集群,从而导致数据不一致。下面看看造成脑裂的常见场景:

场景一:

 为了提高容灾能力,一个集群的服务器被部署在两个机房内,机房A有3台,机房B有4台,总共7台,此时如果两个机房之间的通信出了问题,那么机房A和B之间就不能通信,A和B分别选出新Leader,从而被分割为两个小集群。

场景二:

 Leader 因为网络卡顿或者JVM GC停顿时间过长,导致与其他Follower暂时失去联系,此时其他Follower开始选举新Leader,选举完之后(超半数Follower认可新Leader),旧Leader 恢复正常,此时一个集群中就出现了两个Leader。

下面看看Zookeeper 中是怎么应对脑裂场景的:

针对场景一的解决方案:

 Zookeeper 要求获得超半数投票的服务器才能成为Leader,根据此条件,两个机房不可能同时产生Leader,在总数为奇数的情况下,比如机房A为3台,B为4台,此时位于机房A的Leader宕机后,机房A是无法满足超半数投票的要求的,也就无法选出Leader,机房B则可以选出新Leader;如果位于机房B的Leader宕机,此时两个机房均不能选出新Leader,只有当旧Leader重启后,机房B才能选出新Leader;

 在总数为偶数的情况下,比如机房A为4台,B为6台,当机房A的Leader宕机后,机房A是不可能再选举出Leader的,此时机房B会选出新Leader;再比如机房A为5台,机房B为5台,此时无论在哪个机房中宕机Leader,两个机房都不能选出Leader,整个集群不可用,所以这种部署方式是不可取的。

 上面分析了很多场景,可以看出,基于超半数投票的规则,多个机房下无论怎么部署,都不可能产生两个Leader。

针对场景二的解决方案:

 Zookeeper 的每个服务器节点都会存储当前最近的一条事务的事务ID,也就是zxid,zxid的前32位表示的是Leader的任期版本号epcho,每选举出一次新Leader,epcho 值就会自增一;场景二的旧Leader 恢复正常后,此时超半数的服务器的epcho 号已经大它,当它与新Leader通信后,新Leader会根据epcho判断它是上一个任期的Leader,会修改它的服务器状态并且同步数据。

冗余通信

 另外还有一种解决脑裂的方式,就是冗余通信,集群中采用多种通信方式, 防止一种通信方式失效导致集群中的节点无法通信。

动态扩容

zookeeper的特性:集群中只要有过半的机器是正常工作的,那么整个集群对外就是可用的。

zookeeper 集群是支持动态扩容的,假设集群中有3台服务器,要扩容为5台,部署步骤:

 先部署新机器,再重启老机器。在重启的过程中,需要保证一台机器重启完成后,再进行下一台机器的重启。这样就整个集群中每个时刻只有一台机器不能正常工作,而集群中有过半的机器是正常工作的,那么整个集群对外就是可用的。所以这个时候不会出现错误,也不会出现停止服务,整个扩容过程对用户是无感知的。

工作原理

读操作

Leader/Follower/Observer 都可直接处理读请求,从本地内存中读取数据并返回给客户端即可。

由于处理读请求不需要服务器之间的交互,Follower/Observer 越多,整体系统的读请求吞吐量越大,也即读性能越好。

image-20221129230403761

写操作

 所有的写请求实际上都要交给 Leader 处理。Leader 将写请求以事务形式发给所有 Follower 并等待 ACK,一旦收到半数以上 Follower 的 ACK,即认为写操作成功。

写 Leader

image-20221129230720173

由上图可见,通过 Leader 进行写操作,主要分为五步:

  1. 客户端向 Leader 发起写请求。
  2. Leader 将写请求以事务 Proposal 的形式发给所有 Follower 并等待 ACK。
  3. Follower 收到 Leader 的事务 Proposal 后返回 ACK。
  4. Leader 得到过半数的 ACK(Leader 对自己默认有一个 ACK)后向所有的 Follower 和 Observer 发送 commit。
  5. Leader 将处理结果返回给客户端。

注意

  • Leader 不需要得到 Observer 的 ACK,因为 Observer 无投票权。
  • Leader 不需要得到所有 Follower 的 ACK,只要收到过半的 ACK 即可,同时 Leader 本身对自己有一个 ACK。如果有 4 个 Follower,只需其中两个返回 ACK 即可。
  • Observer 虽然无投票权,但仍须同步 Leader 的数据从而在处理读请求时可以返回尽可能新的数据。

写 Follower/Observer

image-20221129232030313

Follower/Observer 均可接受写请求,但不能直接处理,而需要将写请求转发给 Leader 处理。

除了多了一步请求转发,其它流程与直接写 Leader 无任何区别。

事务

 对于来自客户端的每个更新请求,ZooKeeper 具备严格的顺序访问控制能力。为了保证事务的顺序一致性,ZooKeeper 采用了递增的事务ID(zxid)来标识事务。

 Leader 服务会为每一个 Follower 服务器分配一个单独的队列,然后将事务提议(Proposal )依次放入队列中,并根据 FIFO(先进先出) 的策略进行消息发送。Follower 服务在接收到 Proposal 后,会将其以事务日志的形式写入本地磁盘中,并在写入成功后反馈给 Leader 一个 Ack 响应。当 Leader 接收到超过半数 Follower 的 Ack 响应后,就会广播一个 Commit 消息给所有的 Follower 以通知其进行事务提交,之后 Leader 自身也会完成对事务的提交。而每一个 Follower 则在接收到 Commit 消息后,完成事务的提交。

 所有的提议(proposal)都在被提出的时候加上了 zxid。zxid 是一个 64 位的数字,它的高 32 位是 epoch 用来标识 Leader 关系是否改变,每次一个 Leader 被选出来,它都会有一个新的 epoch,标识当前属于那个 leader 的统治时期。低 32 位用于递增计数。

zxid 的设计

 一个 zxid 是64位,高 32 是纪元编号(epoch),每经过一次 leader 选举产生一个新的 leader,新 leader 会将 epoch 号 +1。低 32 位是事务计数器,每更新一次数据,这个值 +1,新 leader 选举后这个值重置为 0。这样保证了 zxid 的全局递增性。

image-20221130214950541

 这样设计的好处是旧的 leader 挂了后重启,它不会被选举为 leader,因为此时它的 zxid 肯定小于当前的新 leader。当旧的 leader 作为 follower 接入新的 leader 后,新的 leader 会让它将所有的拥有旧的 epoch 号的未被 COMMIT 的 事务 proposal 清除。

会话

 ZooKeeper 客户端通过 TCP 长连接连接到 ZooKeeper 服务集群。会话 (Session) 从第一次连接开始就已经建立,之后通过心跳检测机制来保持有效的会话状态。通过这个连接,客户端可以发送请求并接收响应,同时也可以接收到 Watch 事件的通知。

 每个 ZooKeeper 客户端配置中都配置了 ZooKeeper 服务器集群列表。启动时,客户端会遍历列表去尝试建立连接。如果失败,它会尝试连接下一个服务器,依次类推。一旦一台客户端与一台服务器建立连接,这台服务器会为这个客户端创建一个新的会话。每个会话都会有一个超时时间,若服务器在超时时间内没有收到任何请求,则相应会话被视为过期。一旦会话过期,就无法再重新打开,且任何与该会话相关的临时 znode 都会被删除。

 通常来说,会话应该长期存在,而这需要由客户端来保证。客户端可以通过心跳方式(ping)来保持会话不过期。

实际应用

数据发布订阅

我们可基于 ZK 的 Watcher 监听机制实现数据的发布与订阅功能。ZK 的发布订阅模式采用的是推拉结合的方式实现的,实现原理如下:

  1. 订阅者客户端向 ZK 注册 watcher 监听特定节点,并从节点拉取数据获取配置信息;
  2. 当发布者变更配置时,节点数据发生变化,ZK 会发送 watcher 事件给各个客户端;客户端在接收到 watcher 事件后,会从该节点重新拉取数据获取最新配置信息。

注意:Watch 具有一次性,所以当获得服务器通知后要再次添加 Watch 事件。

image-20221211152556549

基于Zookeeper的数据发布订阅功能,我们可以在微服务系统架构中将Zookeeper作为配置中心来统一管理配置。

统一命名服务

 命名服务是指通过指定的名字来获取资源或者服务的地址、提供者等信息。以 znode 的路径为名字,znode 存储的数据为值,可以很容易构建出一个命名服务。例如 Dubbo 使用 ZK 来作为其命名服务,如下

image-20221211154019954
  • 所有 Dubbo 相关的数据都组织在 /dubbo 的根节点下;
  • 二级目录是服务名,如 com.foo.BarService
  • 三级目录有两个子节点,分别是 providersconsumers ,表示该服务的提供者和消费者;
  • 四级目录记录了与该服务相关的每一个应用实例的 URL 信息,在 providers 下的表示该服务的所有提供者,而在 consumers 下的表示该服务的所有消费者。举例说明, com.foo.BarService 的服务提供者在启动时将自己的 URL 信息注册到 /dubbo/com.foo.BarService/providers 下;同样的,服务消费者将自己的信息注册到相应的 consumers 下,同时,服务消费者会订阅其所对应的 providers 节点,以便能够感知到服务提供方地址列表的变化。

集群管理

 基于 ZK 的临时节点和 watcher 监听机制可实现集群管理。集群管理通常指监控集群中各个主机的运行时状态、存活状况等信息。如下图所示,主机向 ZK 注册临时节点,监控系统注册监听集群下的临时节点,从而获取集群中服务的状态等信息。

image-20221211154136807

Master 选举

 ZK 中某节点同一层子节点,名称具有唯一性,所以,多个客户端创建同一节点时,只会有一个客户端成功。利用该特性,可以实现 maser 选举,具体如下:

image-20221211154430176
  1. 多个客户端同时竞争创建同一临时节点/master-election/master,最终只能有一个客户端成功。这个成功的客户端成为 Master,其它客户端置为 Slave。
  2. Slave 客户端都向这个临时节点的父节点/master-election 注册一个子节点列表的 watcher 监听。
  3. 一旦原 Master 宕机,临时节点就会消失,zk 服务器就会向所有 Slave 发送子节点变更事件,Slave 在接收到事件后会竞争创建新的 master 临时子节点。谁创建成功,谁就是新的 Master。

分布式锁

基于 Znode 临时顺序节点+Watcher 机制实现公平分布式锁

image-20221211133309898

 在根目录“/”下创建分布式锁“/Lock”节点目录,/Lock 节点本身可以是永久节点,用于存放客户端抢占创建的临时顺序节点。此时假设有两个 ZK 客户端 A 和 B 同时调用 Create 函数,在”/Lock”节点下创建临时顺序节点,A 比 B 网络延时更小,先创建,ZK 分配节点名称为”/Lock/Seq0001”, B 晚于 A 创建成功,ZK 分配节点名为”/Lock/Seq0002”,ZK 负责维护这个递增的顺序节点名。

 分布式锁实现的具体流程如下图,客户端 A、B 同时在”/Lock”节点下创建临时顺序子节点,可以理解为同时抢占分布式锁,A 先于 B 创建成功,此时分配的节点为“/Lock/seq-0000001”,由于 A 创建成功,并且临时顺序节点的顺序值序号最小,代表它是最先获取到该锁,此时加锁成功。

image-20221211133516269

 客户端 B 晚于 A 创建临时顺序节点,此时 ZK 分配的节点顺序值为“/Lock/seq-0000002”,B 创建成功之后,它的顺序值大于 A 的顺序值,不是最小顺序值,此时说明 A 已经抢占到分布式锁,这个时候 B 就使用 Watcher 监听机制,监听次小于自己的临时顺序节点 A 的状态变化。

 当 A 客户端因宕机或者完成处理逻辑而断开链接时,A 创建的临时顺序节点会随之消失,此时由于客户端 B 已经监听了 A 临时顺序节点的状态变化,当消失事件发生时,Watcher 监听器逻辑会回调客户端 B,B 重新开始获取锁。注意此时不是 B 再次创建节点,而是获取”/Lock”下的临时顺序节点,发现自己的顺序值最小,那么就加锁成功。

 如果有 C、D 甚至更多的客户端同时抢占,原理都是一致的,他们会依次排队,监听自己之前(节点顺序值次小于自己)的节点,等待他们的状态发生变化时,再去重新获取锁。

 另外,只使用临时节点可以实现非公平分布式锁。

分布式队列

 基于 ZK 的临时顺序节点和 Watcher 机制可实现简单的 FIFO 分布式队列。ZK 分布式队列和上节中的分布式锁本质是一样的,都是基于对上一个顺序节点进行监听实现的。具体原理如下:

image-20221211154721409
  1. 利用顺序节点的有序性,为每个数据在/FIFO 下创建一个相应的临时子节点;且每个消费者均在/FIFO 注册一个 watcher;
  2. 消费者从分布式队列获取数据时,首先尝试获取分布式锁,获取锁后从/FIFO 获取序号最小的数据,消费成功后,删除相应节点;
  3. 由于消费者均监听了父节点/FIFO,所以均会收到数据变化的异步通知,然后重复 2 的过程,尝试消费队列数据。依此循环,直到消费完毕。

README

作者:银法王

参考:

 深入浅出Zookeeper

 https://zhuanlan.zhihu.com/p/62526102

 Zookeeper 教程

 ZooKeeper 与 Zab 协议

 ZooKeeper 核心通识【腾讯技术工程】

 《从Paxos 到 Zookeeper 分布式一致性原理与实践》倪超 2015年

修改记录:

​ 2022-04-01 第一次修订


分布式协调组件之Zookeeper
http://jackpot-lang.online/2022/04/01/系统技术架构设计/分布式协调组件之Zookeeper/
作者
Jackpot
发布于
2022年4月1日
许可协议