分布式协调框架:ZooKeeper

应用场景

  1. 分布式协调:对Zookeeper中的数据做监听,一旦数据发生变动都会感知。为客户端进行选举
  2. 元数据管理:存放客户端需要的元数据信息,Dubbo、Kafka等中间件都有用到
  3. 高可用:利用分布式锁实现高可用,多个节点往ZK上注册,注册成功后成为active,没有注册成功的节点阻塞
  4. 分布式锁:可以搞,但高并发下性能差,建议用Redis

基础知识

数据模型

树形结构

使用ZKCli.sh登录到ZooKeeper服务器上,可以看到一个层级关系的数据结构,类似于文件目录,如下图所示,这个数据结构就是 ZooKeeper 中的数据模型。
ZooKeeper数据结构

节点类型与特性

ZooKeeper 中的数据节点也分为持久节点、临时节点和有序节点三种类型:

  1. 持久节点
    这种节点也是在 ZooKeeper 最为常用的,几乎所有业务场景中都会包含持久节点的创建。之所以叫作持久节点是因为一旦将节点创建为持久节点,该数据节点会一直存储在 ZooKeeper 服务器上,即使创建该节点的客户端与服务端的会话关闭了,该节点依然不会被删除。如果我们想删除持久节点,就要显式调用 delete 函数进行删除操作。
  2. 临时节点
    从名称上我们可以看出该节点的一个最重要的特性就是临时性。所谓临时性是指,如果将节点创建为临时节点,那么该节点数据不会一直存储在 ZooKeeper 服务器上。当创建该临时节点的客户端会话因超时或发生异常而关闭时,该节点也相应在 ZooKeeper 服务器上被删除。同样,我们可以像删除持久节点一样主动删除临时节点。
  3. 有序节点
    其实有序节点并不算是一种单独种类的节点,而是在之前提到的持久节点和临时节点特性的基础上,增加了一个节点有序的性质。所谓节点有序是说在我们创建有序节点的时候,ZooKeeper 服务器会自动使用一个单调递增的数字作为后缀,追加到我们创建节点的后边。例如一个客户端创建了一个路径为 works/task- 的有序节点,那么 ZooKeeper 将会生成一个序号并追加到该节点的路径后,最后该节点的路径为 works/task-1。通过这种方式我们可以直观的查看到节点的创建顺序。
    节点的状态结构节点状态结构解释

Watch机制

在日常生活中也有很多订阅发布的场景。比如我们喜欢观看某一个剧集,视频网站会有一个订阅按钮,用户可以订阅自己喜欢的电视剧,当有新的剧集发布时,网站会通知该用户第一时间观看。或者我们在网站上看到一件心仪的商品,但是当前没有库存,网站会提供到货通知的功能,我们开启这个商品的到货通知功能后,商品补货的时候会通知我们,之后就可以进行购买了。ZooKeeper 中的 Watch 机制很像这些日常的应用场景,其中的客户端就是用户,而服务端的数据节点就好像是我们订阅的商品或剧集。
现在我们可以从技术实现的角度分析一下上边提到的这些场景,无论是订阅一集电视剧还是订购一件商品。都有几个核心节点,即用户端注册服务、服务端处理请求、客户端收到回调后执行相应的操作。

Zookeeper实现Watch机制的原理大概是:

  • 客户端、服务端分别有ZKWatchManager个WatchManager,用来存放对应的观察者列表

  • 客户端工作内容:

    1. 当发送一个带有Watch事件的请求,客户端首先将该会话标记为Watch事件,之后通过DataWatchRegistration 类保存Watch事件和节点的对应关系
    2. 客户端将请求封装成一个Packet 对象,将该对象添加到等待发送队列 outgoingQueue 中,最后将请求逐个发送给服务端
      最后调用负责处理响应的SendThread 线程类中的readResponse 方法接收服务端的回调。最后调用finishPacket方法将Watch注册到ZKWatchManager 中
      服务端工作内容:
    3. 当zookeeper服务端收到请求时,会判断请求中是否包含Watch事件(底层通过FinalRequestProcessor类中的processRequest方法实现)
      当getDataRequest.getWatch为True时,表明该请求需要进行Watch监控注册
      通过zks.getZKDatabase().getData将Watch事件注册到服务端的WatchManager中
  • 服务端Watch事件触发过程:

    1. 以setData 接口(即“节点数据内容发生变更”)为例,在啊setData方法中执行完对节点数据的变更后会调用WatchManager.triggerWatch 方法触发数据变更事件
    2. triggerWatch方法内容。首先封装了一个具有会话状态、事件类型、数据节点3种属性的WatchedEvent对象;之后查询该节点注册的Watch事件,如果为空说明没有注册Watch事件,存在则将Watch事件添加到Watchers集合中,并将WatchManager中的Watch事件删除,最后通过process方法向客户端发送通知
  • 客户端回调处理过程:

    1. SendThread.readResponse() 方法来统一处理服务端的相应
    2. 反序列化服务器发送请求头信息 replyHdr.deserialize(bbia, “header”),并判断相属性字段 xid 的值为 -1,表示该请求响应为通知类型
    3. 在处理通知类型时,先将已收到的字节流反序列化为WatcherEvent对象
    4. 判断客户端是否配置了chrootPath ,如果配置了chrootPath 属性,需要对接收到的节点路径进行chrootPath 处理
    5. 调用eventThread.queueEvent() 方法将收到的事件交给EventThread线程处理
    6. eventThread.queueEvent() 方法中分为2步。首先按照通知事件类型,会从ZKWatchManager中查询在客户端注册过的Watch事件信息,查询到后将Watch信息从ZKWatchManager中删除(这里也说明Watch事件是一次性的,要想一直使用需要每次触发完Watch事件之后重新注册);然后在获取到Watch事件信息之后,将查询到的Watch存储到waitingEvents队列中,调用EventThread 类中的run方法循环取出Watch事件进行处理,最后调用processEvent(event) 方法来最终执行实现了 Watcher 接口的 process()方法。

ACL机制

ACL机制是ZooKeeper的权限控制机制,类似于linux或者windows系统的用户组,用户对文件或文件夹的访问权限。

授权方式:

  • IP方式:使用的授权对象可以是一个 IP 地址或 IP 地址段
  • Digest 或 Super 方式:对应于一个用户名
  • World 方式:授权系统中所有的用户

授权内容:

  • 数据节点(create)创建权限,授予权限的对象可以在数据节点下创建子节点;
  • 数据节点(wirte)更新权限,授予权限的对象可以更新该数据节点;
  • 数据节点(read)读取权限,授予权限的对象可以读取该节点的内容以及子节点的信息;
  • 数据节点(delete)删除权限,授予权限的对象可以删除该数据节点的子节点;
  • 数据节点(admin)管理者权限,授予权限的对象可以对该数据节点体进行 ACL 权限设置。

ACL实现原理:

  • 客户端在 ACL 权限请求发送过程的步骤比较简单:

    1. 首先是封装该请求的类型
    2. 之后将权限信息封装到 request 中并发送给服务端。
  • 服务器的实现比较复杂:

    1. 首先分析请求类型是否是权限相关操作
    2. 之后根据不同的权限模式(scheme)调用不同的实现类验证权限最后存储权限信息。

集群架构

ZooKeeper集群架构
角色:

  • leader(领导者):为客户端提供读和写的功能,负责投票的发起和决议,集群里面只有leader才能接受写的服务。
  • follower(跟随者):为客户端提供读和写的功能,负责投票的发起和决议,集群里面只有leader才能接受写的服务。
  • observer(观察者):为客户端提供读服务,如果是写服务就转发个leader。不参与leader的选举投票。也不参与写的过半原则机制。在不影响写的前提下,提高集群读的性能,此角色于zookeeper3.3系列新增的角色。
  • client:连接zookeeper集群的使用者,请求的发起者,独立于zookeeper集群的角色。

选举机制

选举机制中的几个概念

  • Serverid:服务器ID,比如有三台服务器,编号分别是1,2,3。编号越大在选择算法中的权重越大。
  • Zxid:数据ID,服务器中存放的最大数据ID.,值越大说明数据越新,在选举算法中数据越新权重越大。
  • Epoch:逻辑时钟,或者叫投票的次数,同一轮投票过程中的逻辑时钟值是相同的。每投完一次票这个数据就会增加,然后与接收到的其它服务器返回的投票信息中的数值相比,根据不同的值做出不同的判断。
  • Server状态:选举状态。LOOKING,竞选状态;FOLLOWING,随从状态,同步leader状态,参与投票;OBSERVING,观察状态,同步leader状态,不参与投票;LEADING,领导者状态。

触发结点

ZooKeeper会在下面几种情况加触发选举机制:

  • 集群启动
  • leader挂掉
  • follower挂掉后leader发现已经没有过半的,leader发现怎么集群不能对外提供服务了,会将自己的状态改为挂掉,重新进行leader选举

选举流程

ZooKeeper选举流程

选举状态

ZooKeeper选举状态

不提供写服务

在进行领导者选举的时候zk是不能对外提供写服务的,但是可以配置开启只读模式提供读的服务

脑裂

假设现在有一个由6台zkServer所组成的一个集群,部署在了两个机房:
在这里插入图片描述
正常情况下,此集群只会有一个Leader,那么如果机房之间的网络断了之后,两个机房内的zkServer还是可以相互通信的,如果不考虑过半机制,那么就会出现每个机房内部都将选出一个Leader。
在这里插入图片描述
一个集群中有多个Leader存在就是脑裂,相当于一个集群被分成多个集群了。但是ZooKeeper是不会存在这种的。

过半机制

为什么说ZooKeeper不会存在脑裂呢?那是因为在领导者选举的过程中,如果某台zkServer获得了超过半数集群机器数(集群机器数在zoo.cfg中可以知道)的选票,则此zkServer就可以成为Leader了。
下面是ZooKeeper过半机制算法代码实现:

public class QuorumMaj implements QuorumVerifier {
    private static final Logger LOG = LoggerFactory.getLogger(QuorumMaj.class);
    int half;
    // n表示集群中zkServer的个数(准确的说是参与者的个数,参与者不包括观察者节点)
    public QuorumMaj(int n){
        this.half = n/2;
    }
    // 验证是否符合过半机制
    public boolean containsQuorum(Set<Long> set){
        // half是在构造方法里赋值的
        // set.size()表示某台zkServer获得的票数
        return (set.size() > half);
    }
}
this.half = n/2;
set.size() > half

从这段代码中可以看到,ZooKeeper采用的是过半机制来实现Leader的选举。
又回到上面的脑裂的问题,根据ZooKeeper的过半机制,如果集群中的机器数量是6,那么选举至少需要4台机器,但是两个机房中都各3台,因此无法选举出Leader,整个集群就无法工作了。

奇数台

在这里插入图片描述

  • 为了解决上面6台机器造成的脑裂问题,引出了奇数台的概念。既然要大于n/2,那么当n为奇数的时候,必然有一边是大于n/2的,这样就能选举出Leader了。
  • 奇数台的作用还有一个,就是减少没必要的机器。如:3台机器允许挂一台,4台机器也允许挂1台。那么为了节省成本,我们通常会选择3台机器。

读写流程

写数据流程:

  1. 当Client向ZooKeeper写入数据时,ZooKeeper会判断写入的节点是不是Leader,是允许直接写入;不是则将写请求转发给Leader
  2. Leader收到写请求时,不会直接将数据写入到ZooKeeper中,而是将数据写入到事务日志中(类似Mysql的binlog)。
  3. 当Leader将事务日志写完后,会将写请求发送给所有节点(包括自己),收到请求后各个节点开始写自己的事务日志,日志写完后,会给Leader回复一个ACK,表示写日志完成。
  4. 当Leader收到集群中半数以上的Follower回复ACK后,Leader发送一个commit消息。
  5. 各个节点收到commit消息就会把内容放到内存中(保证数据可见)
    ZooKeeper写数据流程

读数据流程:
ZooKeeper的读数据流程就比较简单了,读请求属于非事务性请求。无论在leader、follower还是observer上都可以直接读取。非事务请求还有exist

ZAB协议

ZAB 协议的消息广播过程使用的是一个原子广播协议,类似一个 二阶段提交过程。对于客户端发送的写请求,全部由 Leader 接收,Leader 将请求封装成一个事务 Proposal,将其发送给所有 Follwer ,然后,根据所有 Follwer 的反馈,如果超过半数成功响应,则执行 commit 操作(先提交自己,再发送 commit 给所有 Follwer)。

基本上,整个广播流程分为 3 步骤:

  1. 将数据都复制到 Follwer 中
    复制数据到Follower
  2. 等待 Follwer 回应 Ack,最低超过半数即成功
    等待Follower回应
  3. 当超过半数成功回应,则执行 commit ,同时提交自己
    执行commit
    通过以上 3 个步骤,就能够保持集群之间数据的一致性。实际上,在 Leader 和 Follwer 之间还有一个消息队列,用来解耦他们之间的耦合,避免同步,实现异步解耦。

还有一些细节:

  1. Leader 在收到客户端请求之后,会将这个请求封装成一个事务,并给这个事务分配一个全局递增的唯一 ID,称为事务ID(ZXID),ZAB 兮协议需要保证事务的顺序,因此必须将每一个事务按照 ZXID 进行先后排序然后处理。
  2. 在 Leader 和 Follwer 之间还有一个消息队列,用来解耦他们之间的耦合,解除同步阻塞。
  3. ZooKeeper集群中为保证任何所有进程能够有序的顺序执行,只能是 Leader 服务器接受写请求,即使是 Follower 服务器接受到客户端的请求,也会转发到 Leader 服务器进行处理。
  4. 实际上,这是一种简化版本的 2PC,不能解决单点问题

假设1:Leader 在复制数据给所有 Follwer 之后崩溃,怎么办?
假设2:Leader 在收到 Ack 并提交了自己,同时发送了部分 commit 出去之后崩溃怎么办?

实际上,当 Leader 崩溃,即进入我们开头所说的崩溃恢复模式(崩溃即:Leader 失去与过半 Follwer 的联系)。
针对这些问题,ZAB 定义了 2 个原则:

  1. ZAB 协议确保那些已经在 Leader 提交的事务最终会被所有服务器提交。
  2. ZAB 协议确保丢弃那些只在 Leader 提出/复制,但没有提交的事务。

所以,ZAB 设计了下面这样一个选举算法:
能够确保提交已经被 Leader 提交的事务,同时丢弃已经被跳过的事务。

针对这个要求,如果让 Leader 选举算法能够保证新选举出来的 Leader 服务器拥有集群总所有机器编号(即 ZXID 最大)的事务,那么就能够保证这个新选举出来的 Leader 一定具有所有已经提交的提案。而且这么做有一个好处是:可以省去 Leader 服务器检查事务的提交和丢弃工作的这一步操作。
崩溃恢复
当崩溃恢复之后,需要在正式工作之前(接收客户端请求),Leader 服务器首先确认事务是否都已经被过半的 Follwer 提交了,即是否完成了数据同步。目的是为了保持数据一致。

当所有的 Follwer 服务器都成功同步之后,Leader 会将这些服务器加入到可用服务器列表中。

实际上,Leader 服务器处理或丢弃事务都是依赖着 ZXID 的,那么这个 ZXID 如何生成呢?

答:在 ZAB 协议的事务编号 ZXID 设计中,ZXID 是一个 64 位的数字,其中低 32 位可以看作是一个简单的递增的计数器,针对客户端的每一个事务请求,Leader 都会产生一个新的事务 Proposal 并对该计数器进行 + 1 操作。

而高 32 位则代表了 Leader 服务器上取出本地日志中最大事务 Proposal 的 ZXID,并从该 ZXID 中解析出对应的 epoch 值,然后再对这个值加一。
数据同步
高 32 位代表了每代 Leader 的唯一性,低 32 代表了每代 Leader 中事务的唯一性。同时,也能让 Follwer 通过高 32 位识别不同的 Leader。简化了数据恢复流程。

基于这样的策略:当 Follower 链接上 Leader 之后,Leader 服务器会根据自己服务器上最后被提交的 ZXID 和 Follower 上的 ZXID 进行比对,比对结果要么回滚,要么和 Leader 同步。

整个 Zookeeper 就是在这两个模式之间切换。
简而言之,当 Leader 服务可以正常使用,就进入消息广播模式,当 Leader 不可用时,则进入崩溃恢复模式。

猜你喜欢

转载自blog.csdn.net/qq_38970396/article/details/106350232