ZooKeeper

引言

前面的文章中,我已经总结了分布式系统的一般方案和各种一致性算法,接下来就让我们来了解一下分布式系统在工业上的实现ZooKeeper。更多关于分布式系统的文章均收录于<分布式系列文章>中。

基本概念

ZooKeeper是一个开放源代码的分布式协调服务,由知名互联网公司雅虎创建,是Google Chubby的开源实现。ZooKeeper的设计目标是将那些复杂且容易出错的分布式一致性服务封装起来,构成一个高效可靠的原语集,并以一系列简单易用的接口提供给用户使用。

原语: 操作系统或计算机网络用语范畴。是由若干条指令组成的,用于完成一定功能的一个过程。具有不可分割性·即原语的执行必须是连续的,在执行过程中不允许被中断。

ZooKeeper是一个典型的分布式数据一致性的解决方案,分布式应用程序可以基于它实现诸如数据发布订阅、负载均衡、命名服务、分布式协调通知、集群管理、Master选举、分布式锁和分布式队列等功能。

角色

在 ZooKeeper 中没有选择传统的 Master/Slave 概念,而是引入了Leader、Follower 和 Observer 三种角色。如下图所示
roles
ZooKeeper 集群中的所有机器通过一个 Leader 选举过程来选定一台称为 “Leader” 的机器,Leader 既可以为客户端提供写服务又能提供读服务。除了 Leader 外,Follower 和 Observer 都只能提供读服务。Follower 和 Observer 唯一的区别在于 Observer 机器不参与 Leader 的选举过程,也不参与写操作的“过半写成功”策略,因此 Observer 机器可以在不影响写性能的情况下提升集群的读性能。
role-responsibility

会话

Session 指的是 ZooKeeper 服务器与客户端会话。在 ZooKeeper 中,一个客户端连接是指客户端和服务器之间的一个 TCP 长连接。客户端启动的时候,首先会与服务器建立一个 TCP 连接,从第一次连接建立开始,客户端会话的生命周期也开始了。通过这个连接,客户端能够通过心跳检测与服务器保持有效的会话,也能够向Zookeeper服务器发送请求并接受响应,同时还能够通过该连接接收来自服务器的Watch事件通知。 Session的sessionTimeout值用来设置一个客户端会话的超时时间。当由于服务器压力太大、网络故障或是客户端主动断开连接等各种原因导致客户端连接断开时,只要在sessionTimeout规定的时间内能够重新连接上集群中任意一台服务器,那么之前创建的会话仍然有效。

在为客户端创建会话之前,服务端首先会为每个客户端都分配一个sessionID。由于 sessionID 是 Zookeeper 会话的一个重要标识,许多与会话相关的运行机制都是基于这个 sessionID 的,因此,无论是哪台服务器为客户端分配的 sessionID,都务必保证全局唯一。

ZNode

在谈到分布式的时候,我们通常说的“节点”是指组成集群的每一台机器。然而,在Zookeeper中,“节点”分为两类,第一类同样是指构成集群的机器,我们称之为机器节点;第二类则是指数据模型中的数据单元,我们称之为数据节点一一ZNode。

Zookeeper将所有数据存储在内存中,数据模型是一棵树(Znode Tree),由斜杠(/)的进行分割的路径,就是一个Znode,例如/foo/path1。每个Znode上都会保存自己的数据内容,同时还会保存一系列属性信息。

在Zookeeper中,node可以分为持久节点和临时节点两类。所谓持久节点是指一旦这个ZNode被创建了,除非主动进行ZNode的移除操作,否则这个ZNode将一直保存在Zookeeper上。而临时节点就不一样了,它的生命周期和客户端会话绑定,一旦客户端会话失效,那么这个客户端创建的所有临时节点都会被移除。 另外,ZooKeeper还允许用户为每个节点添加一个特殊的属性:SEQUENTIAL.一旦节点被标记上这个属性,那么在这个节点被创建的时候,Zookeeper会自动在其节点名后面追加上一个整型数字,这个整型数字是一个由父节点维护的自增数字。

版本

在前面我们已经提到,Zookeeper 的每个 ZNode 上都会存储数据,对应于每个ZNode,Zookeeper 都会为其维护一个叫作 Stat 的数据结构,Stat 中记录了这个 ZNode 的三个数据版本,分别是version(当前ZNode的版本)、cversion(当前ZNode子节点的版本)和 aversion(当前ZNode的ACL版本)。

Watcher

Watcher(事件监听器),是Zookeeper中的一个很重要的特性。Zookeeper允许用户在指定节点上注册一些Watcher,并且在一些特定事件触发的时候,ZooKeeper服务端会将事件通知到感兴趣的客户端上去,该机制是Zookeeper实现分布式协调服务的重要特性。

ACL

Zookeeper采用ACL(AccessControlLists)策略来进行权限控制,类似于 UNIX 文件系统的权限控制。Zookeeper 定义了如下5种权限。
acl
其中尤其需要注意的是,CREATE和DELETE这两种权限都是针对子节点的权限控制。

ZAB协议

Paxos 算法应该可以说是 ZooKeeper 的灵魂了。但是,ZooKeeper 并没有完全采用 Paxos算法 ,而是使用 ZAB 协议作为其保证数据一致性的核心算法。另外,在ZooKeeper的官方文档中也指出,ZAB协议并不像 Paxos 算法那样,是一种通用的分布式一致性算法,它是一种特别为Zookeeper设计的崩溃可恢复的原子消息广播算法。

介绍

ZAB(ZooKeeper Atomic Broadcast 原子广播) 协议是为分布式协调服务 ZooKeeper 专门设计的一种支持崩溃恢复的原子广播协议。 在 ZooKeeper 中,主要依赖 ZAB 协议来实现分布式数据一致性,基于该协议,ZooKeeper 实现了一种主备模式的系统架构来保持集群中各个副本之间的数据一致性。

所有事务请求必须由一个全局唯一的服务器来协调处理,这样的服务器被称为 Leader服务器,而余下的其他服务器则成为Follower 服务器。Leader服务器负责将一个客户端事务请求转换成一个事务 Proposal(提议),并将该 Proposal分发给集群中所有的Follower服务器。之后 Leader服务器需要等待所有Follower服务器的反馈,一旦超过半数的 Follower服务器进行了正确的反馈后,那么Leader就会再次向所有的 Follower服务器分发Commit消息,要求其将前一个Proposal进行提交。

从上面的介绍中,我们已经了解了ZAB协议的核心,现在我们就来详细地讲解下 ZAB协议的具体内容。ZAB协议包括两种基本的模式,分别是崩溃恢复和消息广播。当整个服务框架在启动过程中,或是当Leader服务器出现网络中断、崩溃退出与重启等异常情况时,ZAB协议就会进入恢复模式并选举产生新的Leader 服务器。当选举产生了新的Leader服务器,同时集群中已经有过半的机器与该Leader服务器完成了状态同步之后ZAB协议就会退出恢复模式。其中,所谓的状态同步是指数据同步,用来保证集群中存在过半的机器能够和Leader服务器的数据状态保持一致。

当集群中已经有过半的Follower服务器完成了和Leader服务器的状态同步,那么整个服务框架就可以进入消息广播模式了。当一台同样遵守ZAB协议的服务器启动后加入到集群中时,如果此时集群中已经存在一个Leader服务器在负责进行消息广播,那么新加入的服务器就会自觉地进入数据恢复模式:找到 Leader所在的服务器,并与其进行数据同步,然后一起参与到消息广播流程中去。正如上文介绍中所说的,ZooKeeper设计成只允许唯一的一个 Leader服务器来进行事务请求的处理。Leader 服务器在接收到客户端的事务请求后,会生成对应的事务提案并发起一轮广播协议:而如果集群中的其他机器接收到客户端的事务请求,那么这些非Leader服务器会首先将这个事务请求转发给Leader 服务器。

消息广播

ZAB协议的消息广播过程使用的是一个原子广播协议,类似于一个二阶段提交过程。针对客户端的事务请求,Leader服务器会为其生成对应的事务 Proposal,并将其发送给集群中其余所有的机器,然后再分别收集各自的选票,最后进行事务提交,如下图所示,就是ZAB协议消息广播流程的示意图。
zab-broadcast
我们在前面的文章中,详细讲解了关于二阶段提交协议的内容,而此处 ZAB 协议中涉及的二阶段提交过程则与其略有不同。在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协议中的消息广播过程。ZAB协议的这个基于原子广播协议的消息广播过程,在正常情况下运行非常良好,但是一旦Leader服务器出现崩溃,或者说由于网络原因导致 Leader服务器失去了与过半 Follower的联系,那么就会进入溃恢复模式,在ZAB协议中,为了保证程序的正确运行,整个恢复过程结束后需要选举出一个新的 Leader服务器。因此,ZAB协议需要一个高效且可靠的Leader 选举算法,从而确保能够快速地选举出新的 Leader,同时,Leader 选举算法不仅仅需要让 Leader自己知道其自身已经被选举为Leader,同时还需要让集群中的所有其他机器也能够快速地感知到选举产生的新的Leader 服务器。

ZAB协议需要确保提交已经被 Leader 提交的事务 Proposal,同时丢弃已经被跳过的事务 Proposal。针对这个要求,如果让 Leader选举算法能够保证新选举出来的Leader服务器拥有集群中所有机器最高编号(即ZXID最大)的事务 Proposal,那么就可以保证这个新选举出来的 Leader一定具有所有已经提交的提案。更为重要的是,如果让具有最高编号事务 Proposal 的机器来成为 Leader,就可以省去 Leader 服务器检查Proposal的提交和丢弃工作的这一步操作了。

完成 Leader选举之后,在正式开始工作(即接收客户端的事务请求,然后提出新的提案)之前,Leader服务器会首先确认事务日志中的所有Proposal是否都已经被集群中过半的机器提交了,即是否完成数据同步。

所有正常运行的服务器,要么成为Leader,要么成为 Follower 并和 Leader 保持同步。Leader服务器需要确保所有的 Follower服务器能够接收到每一条事务 Proposal,并且能够正确地将所有已经提交了的事务Proposal应用到内存数据库中去。Leader 服务器会为每一个Follower服务器都准备一个队列,并将那些没有被各Follower服务器同步的事务以Proposal消息的形式逐个发送给 Follower服务器,并在每一个Proposal 消息后面紧接着再发送一个Commit消息,以表示该事务已经被提交。等到Follower 服务器将所有其尚未同步的事务Proposal 都从 Leader服务器上同步过来并成功应用到本地数据库中后,Leader服务器就会将该 Follower 服务器加入到真正的可用 Follower列表中,并开始之后的其他流程,

上面讲到的是正常情况下的数据同步逻辑,下面来看ZAB协议是如何处理那些需要被丟弃的事务 Proposal 的。在ZAB协议的事务编号 ZXID设计中,ZXID是一个 64 位的数字,其中低32位可以看作是一个简单的单调递增的计数器,针对客户端的每一个事务请求,Leader服务器在产生一个新的事务Proposal的时候,都会对该计数器进行加1操作:而高32位则代表了Leader周期epoch 的编号,每当选举产生一个新的 Leader 服务器,就会从这个Leader服务器上取出其本地日志中最大事务 Proposal 的 ZXID, 并从该ZXID中解析出对应的epoch值,然后再对其进行加1操作,之后就会以此编号作为新的 epoch,并将低32位置0来开始生成新的ZXID。ZAB协议中的这一通过epoch 编号来区分Leader周期变化的策略,能够有效地避免不同的 Leader 服务器错误地使用相同的ZXID编号提出不一样的事务Proposal的异常情况,这对于识别在 Leader 崩溃恢复前后生成的 Proposal非常有帮助,大大简化和提升了数据恢复流程。

基于这样的策略,当一个包含了上一个Leader周期中尚未提交过的事务 Proposal 的服务器启动时,其肯定无法成为 Leader,原因很简单,因为当前集群中一定包含一个Quorum集合,该集合中的机器一定包含了更高 epoch 的事务 Proposal,因此这台机器的事务Proposal肯定不是最高,也就无法成为Leader了。这样的机器加入集群后,会以Follower的角色连上Leader并清除自己的脏数据。

通讯流程

ZAB的整个工作流程中各节点的消息收发情况如下图所示,各消息的信息分别是:

  • CEPOCH:Follower 进程向准Leader发送自己处理过的最后一个事务 Proposal 的 epoch值。
  • NEWEPOCH: 准 Leader 进程根据接收的各进程的 epoch,来生成新一轮周期的epoch 值。
  • ACK-E: Follower 进程反馈准 Leader进程发来的 NEWEPOCH 消息。
  • NEWLEADER: 准 Leader进程确立自己的领导地位,并发送 NEWLEADER 消息给各进程。
  • ACK-LD: Follower 进程反馈 Leader 进程发来的 NEWLEADER 消息。
  • COMMIT-LD: 要求Follower进程提交相应的历史事务 Proposal.
  • PROPOSE: Leader进程生成一个针对客户端事务请求的 Proposal,
  • ACK: Follower进程反馈 Leader 进程发来的 PROPOSAL 消息。
  • COMMIT: Leader发送 COMMIT消息,要求所有进程提交事务 PROPOSE.

在正常运行过程中,ZAB协议会反复地进行消息广播。如果出现 Leader崩溃或其他原因导致 Leader缺失,那么此时ZAB协议会再次进入选举阶段,重新选举新的 Leader。
zab-message

运行分析

在ZAB协议的设计中,每一个进程都有可能处于以下三种状态之一。

  • LOOKING: Leader 选举阶段
  • FOLLOWING: Follower 服务器和 Leader保持同步状态
  • LEADING: Leader服务器作为主进程领导状态

组成 ZAB协议的所有进程启动的时候,其初始化状态都是LOOKING状态,此时进程组中不存在Leader。所有处于这种状态的进程,都会试图去选举出一个新的Leader,随后,如果进程发现已经选举出新的Leader了,那么它就会马上切换到FOLLOWING状态,并开始和Leader保持同步。这里,我们将处于FOLLOWING状态的进程称为Follower,将处于LEADING状态的进程称为Leader。考虑到Leader进程随时会挂掉,当检测出Leader已经崩溃或者是放弃了领导地位时,其余的Follower进程就会转换到LOOKING状态,并开始进行新一轮的Leader选举。因此在ZAB协议运行过程中,每个进程都会在 LEADING, FOLLOWING 和 LOOKING 状态之间不断地转换。

完成 Leader选举以及数据同步之后,ZAB协议就进入了原子广播阶段。在这一阶段中,Leader会以队列的形式为每一个与自己保持同步的Follower创建一个操作队列。同一时刻,一个Follower只能和一个Leader保持同步,Leader进程与所有的Follower进程之间都通过心跳检测机制来感知彼此的情况。如果Leader能够在超时时间内正常收到心跳检测,那么Follower就会一直与该Leader保持连接。而如果在指定的超时时间内Leader无法从过半的Follower进程那里接收到心跳检测,或者是TCP连接本身断开了,那么Leader就会终止对当前周期的领导,并转换到 LOOKING 状态,所有的 Follower 也会选择放弃这个 Leader,同时转换到 LOOKING 状态。之后,所有进程就会开始新一轮的Leader选举,并在选举产生新的Leader之后开始新一轮的主进程周期。

对比Paxos

ZAB协议并不是Paxos算法的一个典型实现,在讲解ZAB和Paxos之间的区别之前,我们首先来看下两者的联系

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

在Paxos算法中,一个新选举产生的主进程会进行两个阶段的工作。第一阶段被称为读阶段,在这个阶段中,这个新的主进程会通过和所有其他进程进行通信的方式来收集上一个主进程提出的提案,并将它们提交。第二阶段被称为写阶段,在这个阶段,当前主进程开始提出它自己的提案。在Paxos算法设计的基础上,ZAB协议额外添加了一个同步阶段。在同步阶段之前,ZAB协议也存在一个和 Paxos算法中的读阶段非常类似的过程,称为发现(Discovery)阶段。在同步阶段中,新的Leader会确保存在过半的 Follower已经提交了之前 Leader 周期中的所有事务 Proposal。这一同步阶段的引入,能够有效地保证 Leader在新的周期中提出事务Proposal之前,所有的进程都已经完成了对之前所有事务Proposal的提交。一旦完成同步阶段后,那么ZAB就会执行和Paxos算法类似的写阶段。

应用场景

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

数据发布/订阅

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

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

负载均衡

基于ZooKeeper我们可以实现一个自动化的DNS服务,具体架构图如下所示:
ddns
首先来介绍整个动态DNS系统的架构体系中几个比较重要的组件及其职责。

  • Register集群负责域名的动态注册。
  • Dispatcher集群负责域名解析。
  • Scanner集群负责检测以及维护服务状态(探测服务的可用性、屏蔽异常服务节点等)。
  • SDK提供各种语言的系统接入协议,提供服务注册以及查询接口。
  • Monitor负责收集服务信息以及对DDNS自身状态的监控。
  • Controller是一个后台管理的Console,负责授权管理、流量控制、静态配置服务和手动屏蔽服务等功能,另外,系统的运维人员也可以在上面管理 Register,Dispatcher 和 Scanner 等集群。

命名服务

所谓命名服务,就是得到一个全局唯一的名字,类似于数据库上的主键。我们可以通过ZooKeeper的顺序节点功能,完成这个命名服务。

分布式协调/通知

分布式协调通知服务是分布式系统中不可缺少的一个环节,是将不同的分布式组件有机结合起来的关键所在。对于一个在多台机器上部署运行的应用而言,通常需要一个协调者(Coordinator)来控制整个系统的运行流程,例如分布式事务的处理、机器间的互相协调等。同时,引入这样一个协调者,便于将分布式协调的职责从应用中分离出来,从而可以大大减少系统之间的耦合性,而且能够显著提高系统的可扩展性。下列的即是一些协调例子:

  • 任务注册
  • 热备切换
  • 记录执行状态
  • 心跳检测
  • 工作进度汇报
  • 协调分布式事务

分布式锁

在数据库中,锁的概念其实是非常重要的,常见的关系型数据库就会对排他锁和共享锁进行支持,而 Zookeeper 提供的 API 也可以让我们非常简单的实现分布式锁。

如果多个服务同时要对某个资源进行修改,就可以使用上述的代码来实现分布式锁,假设集群中存在一个资源 /resource,几个服务需要通过分布式锁保证资源只能同时被一个节点使用,我们可以用创建临时顺序节点的方式实现分布式锁;当我们创建临时节点后,通过 getChildren 获取当前等待锁的全部节点,如果当前节点是所有节点中序号最小的就得到了当前资源的使用权限,在对资源进行处理后,就可以通过删除 /resource/lock-00000000x 来释放锁,如果当前节点不是最小值,就会注册一个 Watcher 等待 /resource 子节点的变化直到当前节点的序列号成为最小值。

在集群中争夺同一资源的服务器特别多的情况下为了减少羊群效应,即每次子节点改变时都会通知当前节点,造成资源的浪费,我们其实可以将 getChildren 换成 getData,让当前节点只监听前一个节点的删除事件。我们减少了每一个服务需要关注的事情,只让它们监听需要关心的数据变更,减少 Zookeeper 发送不必要的通知影响效率。

技术内幕

接下来,我们将从系统模型、序列化与协议、客户端工作原理,会话,服务端工作原理以及数据存储等方面来向读者揭示 ZooKeeper的技术内幕,帮助读者更深入地了解ZooKeeper 这一分布式协调框架。

系统模型

在本节中,我们首先将从数据模型、节点特性、版本、Watcher 和 ACL五方面来讲述ZooKeeper的系统模型。

数据模型

ZooKeeper的视图结构和标准的Unix文件系统非常类似,但没有引入传统文件系统中目录和文件等相关概念,而是使用了其特有的“数据节点”概念,我们称之为 ZNode 。ZNode是 ZooKeeper中数据的最小单元,每个ZNode上都可以保存数据,同时还可以挂载子节点,因此构成了一个层次化的命名空间,我们称之为树。

ZNode 的节点路径标识方式和 Unix 文件系统路径非常相似,都是由一系列使用斜杠/进行分割的路径表示,开发人员可以向这个节点中写入数据,也可以在节点下面创建子节点。
znode

节点特性

在 ZooKeeper中,每个数据节点都是有生命周期的,其生命周期的长短取决于数据节点的节点类型。在 ZooKeeper中,节点类型可以分为持久节点(PERSISTENT)、临时节点(EPHEMERAL)和顺序节点 (SEQUENTIAL)三大类,具体在节点创建过程中,通过组合使用,可以生成以下四种组合型节点类型:

  • 持久节点:数据节点被创建后,就会一直存在于ZooKeeper服务器上,直到有删除操作来主动清除这个节点。
  • 持久顺序节点:他的基本特性和持久节点是一致的,额外的特性表现在顺序性上。在ZooKeeper中,每个父节点都会为他的第一级子节点维护一份顺序,用于记录下每个子节点创建的先后顺序。基于这个顺序特性,在创建子节点的时候,可以设置这个标记,那么在创建节点过程中,ZooKeeper会自动为给定节点加上一个数字后缀,作为一个新的、完整的节点名。另外需要注意的是,这个数字后缀的上限是整型的最大值。
  • 临时节点:临时节点的生命周期和客户端的会话绑定在一起,也就是说,如果客户端会话失效,那么这个节点就会被自动清理掉。这里提到的客户端会话失效,而非TCP连接断开。
  • 临时顺序节点:在临时节点基础上,添加了顺序的特性。

状态信息

每个数据节点除了存储了数据内容外,还存储了数据节点本身的一些状态信息。
node-status

版本信息

ZooKeeper中为数据节点引入了版本的概念,每个数据节点都具有三种类型的版本信息,对数据节点的任何更新操作都会引起版本号的变化。
node-version
在ZooKeeper中,version属性正是用来实现乐观锁机制中的“写入校验”的。

1
2
3
4
5
6
version = setDataRequest.getVersion();
int currentVersion = nodeRecord.stat.getVersion();
if(version != -1 && version != currentVersion) {
throw new KeeperException.BadVersionException(path);
}
version = currentVersion + 1;

Watcher 功能

在ZooKeeper中,引入了Watcher机制来实现这种分布式的通知功能。ZooKeeper允许客户端向服务端注册一个Watcher监听,当服务器的一些指定事件触发了这个Watcher,那么就会向指定客户端发送一个事件通知来实现分布式的通知功能。
watcher
从图中我们可以看到,ZooKeeper的Watcher机制主要包括客户端线程、客户端WatcherManager和ZooKeeper服务器三部分。在具体工作流程上,客户端在向ZooKeeper服务器注册Watcher的同时,会将Watcher对象存储在客户端的WatcherManager中。当ZooKeeper服务器端触发Watcher事件后,会向客户端发送通知,客户端线程从WatcherManager中取出对应的Watcher对象来执行回调逻辑。

Zookeeper 的 Watcher 功能实际上是会出现丢事件的情况的,虽然在进行 getData(), getChildren(), 和 exists() 操作时,可以通过 flag 表示注册 watcher。但是因为这个 watcher 只是一个一次性的触发器,所以,当 Server 推送当前 Change Event 到下次 Client 发起新的 Watcher 之前数据的变化客户端是感知不到的,这就会出现 ABA 问题,即数据从 null 变为 A 时,客户端接收到了通知,在客户端发起下一轮 Watcher 之前,数据变更为 B,又变更会 A,这时候客户端成功注册 Watcher 之后,感知不到数据之前变成过 B。

ACL 功能

ZooKeeper的ACL权限控制和Unix/Linux操作系统中的ACL有一些区别,读者可以从三个方面来理解ACL机制,分别是:权限模式(Scheme)、授权对象(ID)和权限(Permission),通常使用“scheme:id:permission”来标识一个有效的ACL信息。

Scheme 功能

权限模式用来确定权限验证过程中使用的校验策略。在ZooKeeper中,开发人员使用最多的就是以下四种权限模式。

  • IP:IP模式通过IP地址粒度来进行权限控制。也支持按照网段的方式进行配置。
  • Digest:以类似于“username:password”形式的权限标识来进行权限配置,便于区分不同应用来进行权限控制。
  • World:数据节点的访问权限对所有用户开发,即所有用户可以在不进行任何权限校验的情况下操作ZooKeeper上的数据。另外,World模式也可以看作是一种特殊的Digest模式,他只有一个权限标识,即“world:anyone”。
  • Super:超级用户的意思。
ID

权限赋予的用户或一个指定实体。在不同的权限模式下,授权对象是不同的。

Permission

在ZooKeeper中,所有对数据的操作权限分为以下五大类:

  • CREATE(C)
  • DELETE(D)
  • READ(R)
  • WRITE(W)
  • ADMIN(A)

序列化协议

ZooKeeper的客户端和服务端之间会进行一系列的网络通信以实现数据的传输。对于一个网络通信,首先要解决的就是对数据的序列化和反序列化处理,在ZooKeeper中,使用了Jute这一序列化组件来进行数据的序列化和反序列化操作。同时,为了实现一个高效的网络通信程序,良好的通信协议设计也是至关重要的。

通信协议

基于TCP/IP协议,ZooKeeper实现了自己的通信协议来完成客户端与服务端、服务端与服务端之间的网络通信。ZooKeeper通信协议整体上的设计非常简单,对于请求,主要包含请求头和请求体,对于响应,则主要包含响应头和响应体。

客户端

客户端是开发人员使用ZooKeeper最主要的途径,因此我们有必要对ZooKeeper客户端的内部原理进行详细讲解。ZooKeeper的客户端主要由以下几个核心组件组成。

  • ZooKeeper实例:客户端的入口。
  • ClientWatchManager:客户端Watcher管理器。
  • HostProvider:客户端地址列表管理器。
  • ClientCnxn:客户端核心线程,其内部又包含两个线程,即SendThread和EventThread。前者是一个I/O线程,主要负责ZooKeeper客户端和服务端之间的网络I/O通信;后者是一个事件线程,主要负责对服务端事件进行处理。

客户端的整个初始化和启动过程大体可以分为以下三个步骤。

  • 设置默认Watcher。
  • 设置ZooKeeper服务器地址列表。
  • 创建ClientCnxn。

会话 Session

会话(Session)是ZooKeeper中最重要的概念之一,客户端和服务端之间的任何交互操作都与会话息息相关,这其中就包括临时节点的生命周期、客户端请求的顺序执行以及Watcher通知机制等。

会话状态

在ZooKeeper客户端和服务端成功完成连接创建后,就建立了一个会话。ZooKeeper会话在整个运行期间的声明周期中,会在不同的会话状态之间进行切换,这些状态一般可以分为CONNECTING、CONNECTED、RECONNECTING、RECONNECTED和CLOSE等。

如果客户端需要与服务端创建一个会话,那么客户端必须提供一个使用字符串表示的服务器地址列表:“host1:port,host2:port,host3:port”。一旦客户端开始创建ZooKeeper对象,那么客户端状态就会变成CONNECTING,同时客户端开始从上述服务器地址列表中逐个选取IP地址来尝试进行网络连接,直到成功连接上服务器,然后将客户端状态变更为CONNECTED。

通常,伴随着网络闪断或是其他原因,客户端和服务器之间的连接会出现断开情况。一旦碰到这种情况,ZooKeeper客户端会自动进行重连操作,同时客户端的状态再次变为CONNECTING,直到重新连接上ZooKeeper服务器后,客户端状态又会再次转变成CONNECTED。因此,在通常情况下,在ZooKeeper运行期间,客户端的状态总是介于CONNECTING和CONNECTED两者之一。

另外,如果出现诸如会话超时、权限检查失败或是客户端主动退出程序等情况,那么客户端的状态就会直接变为CLOSE。

会话创建

Session
Session是ZooKeeper中的会话实体,代表了一个客户端会话。其包含以下4个基础属性:

  • sessionId:会话id,用来唯一标识一个会话,每次客户端创建新会话的时候,ZooKeeper都会为其分配一个全局唯一的sessionId。
  • TimeOut:会话超时时间。客户端在构造ZooKeeper实例的时候,会配置一个sessionTimeout参数用于指定会话的超时时间。ZooKeeper客户端向服务器发送这个超时时间后,服务器会根据自己的超时时间限制最终确定会话的超时时间。
  • TickTime:下次会话超时时间点。
  • isClosing:该属性用于标记一个会话是否已经被关闭。

sessionId
在SessionTracker初始化的时候,会调用initializeNextSession方法来生成一个初始化的sessionID,之后在ZooKeeper的正常运行过程中,会在该sessionID的基础上为每个会话进行分配,其初始化算法如下:

1
2
3
4
5
6
public static long initializeNextSession(long id) {
long nextSid = 0;
nextSid = (System.currentTimeMillis() << 24) >> 8;
nextSid = nextSid | (id << 56);
return nextSid;
}

上面这个方法就是ZooKeeper初始化sessionID的算法,我们一起深入的探究下。从上面的代码片段中,可以看出sessionID的生成大体可以分为以下5个步骤。

  • 获取当前的毫秒表示。
  • 左移24位。
  • 右移8位。
  • 添加机器标识:SID。
  • 将步骤3和步骤4得到的两个64位表示的数值进行“|”操作。

简单地讲,可以将上述算法概括为:高8位确定了所在机器,后56位使用当前时间的毫秒进行随机。

SessionTracker
SessionTracker是ZooKeeper服务端的会话管理器,负责会话的创建、管理和清理等工作。可以说,整个会话的生命周期都离不开SessionTracker的管理。每一个会话在SessionTracker内部都保留了三份,具体如下。

  • sessionsById:这是一个HashMap<Long, SessionImpl>类型的数据结构,用于根据sessionID来管理Session实体。
  • sessionsWithTimeout:这是一个ConcurrentHashMap<Long, Integer>类型的数据结构,用于根据sessionID来管理会话的超时时间。该数据结构和ZooKeeper内存数据库相连通,会被定期持久化到快照文件中去。
  • sessionSets:这是一个HashMap<Long, SessionSet>类型的数据结构,用于根据下次会话超时时间来归档会话,便于进行会话管理和超时检查。

创建连接
服务端对于客户端的“会话创建”请求的处理,大体可以分为四大步骤,分别是ConnectRequest请求、会话创建、处理器链路处理和会话响应。

会话管理

分桶策略

ZooKeeper的会话管理主要是由SessionTracker负责的,其采用了一种特殊的会话管理方式,我们称之为“分桶策略”。所谓分桶策略,是指将类似的会话放在同一区块中进行管理,以便于ZooKeeper对会话进行不同区块的隔离处理以及同一区块的统一处理。

ZooKeeper将所有的会话都分配在了不同的区块之中,分配的原则是每个会话的“下次超时时间点”(ExpirationTime)。ExpirationTime是指该会话最近一次可能超时的时间点,对于一个新创建的会话而言,其会话创建完毕后,ZooKeeper就会为其计算ExpirationTime,计算方式如下:
ExpirationTime = CurrentTime + SessionTimeout
在ZooKeeper的实际实现中,Zookeeper的Leader服务器在运行期间会定时的进行会话超时检查,其时间间隔是ExpirationInterval,单位是毫秒,默认值是tickTime的值,即默认情况下,每隔2000毫秒进行一次会话超时检查。为了方便对多个会话同时进行超时检查,完整的ExpirationTime的计算方式如下:

1
2
ExpirationTime_ = CurrentTime + SessionTimeout
ExpirationTime = (ExpirationTime_/ExpirationInterval + 1) * ExpirationInterval
会话激活

为了保持客户端会话的有效性,在ZooKeeper的运行过程中,客户端会在会话超时时间过期范围内向服务端发送PING请求来保持会话的有效性,我们俗称“心跳检测”。同时,服务端需要不断地接收来自客户端的这个心跳检测,并且需要重新激活对应的客户端会话,我们将这个重新激活的过程称为TouchSession。会话激活的过程,不仅能够使服务端检测到对应客户端的存活性,也能让客户端自己保持连接状态。

超时检测

在ZooKeeper中,会话超时检查同样是由SessionTracker负责的。SessionTracker中有一个单独的线程专门进行会话超时检查,这里我们称其为“超时检查线程”,其工作机制的核心思路非常简单:逐个依次对会话桶中剩下的会话进行清理。

会话清理

当SessionTracker的会话超时检查线程整理出一些已经过期的会话后,那么就要开始进行会话清理了。会话清理的步骤大致可以分为以下七步。

  1. 标记会话状态为“已关闭”
    为了保证在清理期间不再处理来自该客户端的新请求,SessionTracker会首先将该会话的isClosing属性标记为true。
  2. 发起“会话关闭”请求
    为了使该会话的关闭操作在整个服务端集群中都生效,ZooKeeper使用了提交“会话关闭”请求的方式,并立即交付给PrepRequestProcessor处理器进行处理。
  3. 收集需要清理的临时节点
    在ZooKeeper的内存数据库中,为每个会话都单独保存了一份由该会话维护的所有临时节点集合,因此在会话清理阶段,只需要根据当前即将关闭的会话的sessionID从内存数据库中获取到这份临时节点列表即可。
    实际上,有如下细节需要处理:在ZooKeeper处理会话关闭请求之前,正好有以下请求到达了服务端并正在处理中:
    • 节点删除请求,删除的目标节点正好是上述临时节点中的一个。
    • 临时节点创建请求,创建的目标节点正好是上述临时节点中的一个。
      假定我们当前获取的临时节点列表是ephemerals,那么针对第一类请求,我们需要将所有这些请求对应的数据节点路径从ephemerals中移除,以避免重复删除。针对第二类,我们需要将所有这些请求对应的数据节点路径添加到ephemerals中去,以删除这些即将会被创建但是尚未保存到内存数据库中去的临时节点。
  4. 添加“节点删除”事务变更
    完成该会话相关的临时节点收集后,ZooKeeper会逐个将这些临时节点转换成“节点删除”请求,并放入事务变更队列outstandingChanges中去。
  5. 删除临时节点
    FinalRequestProcessor处理器会触发内存数据库,删除该会话对应的所有临时节点。
  6. 移除会话
    完成节点删除后,需要将会话从SessionTracker中移除。主要就是从上面提到的三个数据结构(sessionById、sessionsWithTimeout和sessionSets)中将该会话移除掉。
  7. 关闭NIOServerCnxn
    最后,从NIOServerCnxnFactory找到该会话对应的NIOServerCnxn,将其关闭。
重连

当客户端和服务端之间的网络连接断开时,ZooKeeper客户端会自动进行反复的重连,直到最终成功连接上ZooKeeper集群中的一台机器。在这种情况下,再次连接上服务端的客户端有可能会处于以下两种状态之一。

  • CONNECTED:重连成功
  • EXPIRED:如果是在会话超时时间以外重新连接上,那么服务端其实已经对该会话进行了会话清理操作,因此再次连接上的会话将被视为非法会话。

当客户端和服务端之间的连接断开后,用户在客户端可能会看到两类异常:CONNECTION_LOSS(连接断开)和SESSION_EXPIRED(会话过期)。

服务器启动

ZooKeeper服务器的启动,大体可以分为以下五个主要步骤:配置文件解析、初始化数据管理器、初始化网络I/O管理器、数据恢复和对外服务。下图是单机版ZooKeeper服务器的启动流程图。
singleton-start
集群版和单机版ZooKeeper服务器启动过程在很多地方是一致的,下图是集群版ZooKeeper服务器的启动流程图。
cluster-start
Leader和Follower启动期交互过程如下图。
leader-follower

选举

Leader选举概述

启动时期的Leader选举

要进行Leader选举的时候,隐式条件便是ZooKeeper的集群规模至少是2台机器,只有一台服务器启动的时候,是无法进行Leader选举的。

  1. 每个Server会发出一个投票
    初始情况,对于Server1和Server2来说,都会投给自己,每次投票包含的最基本的元素包括:所推举的服务器myid和ZXID。
  2. 接收来自各个服务器的投票
    集群中每个服务器在收到投票后,首先会判断投票的有效性,包含检查是否是本轮投票,是否来自LOOKING状态的服务器。
  3. 处理投票
    在接收到来自其他服务器的投票后,针对每个投票,服务器都需要将别人的投票和自己的投票进行PK,PK的规则如下
    • 优先检查ZXID。ZXID比较大d服务器优先作为Leader
    • 如果ZXID相同的话,那么就比较myid。myid比较大的服务器作为Leader服务器。
  4. 统计投票
    每次投票后,服务器都会统计所有投票,判断是否已经有过半机器接收到相同的投票信息。
  5. 改变服务器状态
    一旦确定了Leader,每个服务器就会更新自己的状态:如果是Follower,那么就变更为FOLLOWING,如果是Leader,那么就变更为LEADING。
运行期间的Leader选举

在ZooKeeper集群正常运行过程中,一旦选出一个Leader,那么所有服务器的集群角色一般不会再发生变化,不管是非Leader集群挂了还是新机器加入集群,都不会影响Leader。一旦Leader挂了,那么整个集群将暂时无法对外服务,而是进入新一轮的Leader选举。

算法分析

在ZooKeeper中,提供了三种Leader选举的算法,分别是LeaderElection、UDP版本的FastLeaderElection和TCP版本的FastLeaderElection,可以通过在配置文件zoo.cfg中使用electionAlg属性来指定,分别用数字0-3表示。0表示LeaderElection,1表示UDP版本的FastLeaderElection,并且是非授权模式,2表示UDP版本的FastLeaderElection,使用授权模式,3代表TCP版本的FastLeaderElection。从3.4.0版本开始,Zookeeper废弃了0-2这三种算法,只保留了TCP版本的FastLeaderElection选举算法。

术语解释
  • SID:服务器ID
  • ZXID:事务ID
  • Vote:投票
  • Quorum:过半机器数
进入Leader选举

当ZooKeeper集群中的一台服务器出现以下两种情况时,就会开始进入Leader选举

  • 服务器初始化启动
  • 服务器运行期间无法和Leader保持连接

而当一台机器进入Leader选举流程时,当前集群也可能会处于以下两种状态

  • 集群中本来就存在一个Leader
  • 集群中确实不存在Leader

第一种情况,这种情况通常是某一台服务器启动比较晚,在他启动之前,集群已经可以正常工作。针对这种情况,当该机器试图去选举Leader时,会被告知当前服务器的Leader信息,对于该机器来说,仅仅需要和Leader机器建立起连接,并进行状态同步即可。

下面我们看看集群中不存在Leader的情况下,如何进行Leader选举。

开始第一次投票

通常有两种情况会导致集群中不存在Leader,一种是整个服务器刚刚初始化启动时,另一种情况就是运行期间当前Leader所在的服务器挂了。此时,集群中所有机器都处于LOOKING的状态。当一台服务器处于LOOKING状态时,那么他就会向集群中所有其他机器发送消息,我们称这个消息为“投票”。

在这个投票消息中包含了两个最基本的信息:所推举的服务器SID和ZXID,用(SID,ZXID)表示。一般都是投自己。

变更投票

集群中每台机器发出自己的投票后,也会接收到来自集群中其他机器的投票。每台机器都会根据一定的规则,来处理收到的其他机器的投票,并以此来决定是否需要变更自己的投票。这个规则也成了整个Leader选举算法的核心所在。我们首先定义一些术语。

  • vote_sid:接收到的投票中所推举Leader服务器的SID
  • vote_zxid:接收到的投票中所推举Leader服务器的ZXID
  • self_sid:当前服务器自己的SID
  • self_zxid:当前服务器自己的ZXID

对比过程如下:

  • 规则1:如果vote_zxid>self_zxid,就认可当前收到投票,并再次将该投票发送出去。
  • 规则2:如果vote_zxid<self_zxid,就坚持自己的投票,不作任何变更。
  • 规则3:如果vote_zxid=self_zxid,就对比两者的SID。如果vote_sid>self_sid,就认可当前收到的投票,并在此将该投票发出去。
  • 规则4:如果vote_zxid=self_zxid,并且vote_sid<self_sid,那么同样坚持自己的投票,不作变更。

假定Zookeeper由5台机器组成,SID分别为1、2、3、4、5,ZXID分别为9、9、9、8、8,并且此时SID为2的机器是Leader机器,某一时刻,1、2所在机器出现故障,因此集群开始进行Leader选举。在第一次投票时,每台机器都会将自己作为投票对象,于是SID为3、4、5的机器投票情况分别为(3, 9),(4, 8), (5, 8)。

结合上面规则,给出下面的集群变更过程。
leader
经过第二轮投票后,集群中的每台机器都会再次接收到其他机器的投票,然后开始统计投票,如果一台机器收到了超过半数的相同投票,那么这个投票对应的SID机器即为Leader。此时Server3将成为Leader。

由上面规则可知,通常那台服务器上的数据越新(ZXID会越大),其成为Leader的可能性越大,也就越能够保证数据的恢复。如果ZXID相同,则SID越大机会越大。

确定Leader

经过这第二次投票后,集群中每台机器都会再次收到其他机器的投票,然后开始统计投票。如果一台机器收到了超过半数的相同的投票,那么这个投票对应的SID机器即为Leader。

通常哪台服务器上的越新,那么越有可能成为Leader,原因很简单,数据越新,ZXID越大,也就越能够保证数据的恢复。

示意图

leader
总结一下: ZAB 的选举过程相较于 Paxos 来说,ZAB 更加专注于让 epoch 更高的节点当选,而 epoch 相同时又通过 sid 概念来进行排序,这样能够更加高效地完成选举过程,同时后续的数据恢复过程的工作量也更小。新 leader 只有小概率会从别的节点前一个拉取前一个 leader 的遗留数据,因为新 leader 大概率就是保有数据最多的节点。而 Paxos 则更加宽泛,所有节点没有优先级,大家一起竞争,谁运气好谁就是 leader。

参考内容

[1]《从Paxos到ZooKeeper 分布式一致性原理与实践》
[2] Snailclimb ZooKeeper 总结
[3] 详解分布式协调服务 ZooKeeper
[4] ZooKeeper技术内幕
[5] Zookeeper的Leader选举

贝克街的流浪猫 wechat
您的打赏将鼓励我继续分享!
  • 本文作者: 贝克街的流浪猫
  • 本文链接: https://www.beikejiedeliulangmao.top/distributed/zookeeper/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
  • 创作声明: 本文基于上述所有参考内容进行创作,其中可能涉及复制、修改或者转换,图片均来自网络,如有侵权请联系我,我会第一时间进行删除。