从 Paxos 到 ZooKeeper

分布式一致性

分布式文件系统、缓存系统和数据库等大型分布式存储系统中,分布式一致性都是一个重要的问题。

什么是分布式一致性?分布式一致性分为哪些类型?分布式系统达到一致性后将会是一个什么样的状态?

如果失去了分布式一致性,分布式系统是否还可以依赖?

如果一味地追求一致性,对系统的整体架构和性能会有多大的影响?


每次写需求的时候总会思考,在某些特殊恶劣的条件下,这些代码的运行结果会是什么?
尽管在实习期间,已经遇到了很多次 因为代码漏洞导致线上环境数据不一致而导致被迫改数据库的问题,对此已见怪不怪;
但还是希望,经由我手写出来的代码能别这么脆弱!
敲代码为了混口饭吃固然没错,但也要为自己通过键盘录入的每一个字符负责的。

一、概述

用户在使用计算机产品时,对于数据一致性的需求是不一样的:

  • 有些系统,要求快速地响应用户,同时还要保证系统的数据对于任意客户端都是真实可靠的,就像火车站的售票系统
  • 有些系统,需要未用户保证绝对可靠的数据安全,虽然在一致性上存在延时,但最终务必保证严格的一致,就像银行的转账系统
  • 还有些系统,虽然向用户展示了一些 “错误”的过时数据,但是在整个系统使用的过程中,一定会在某一个流程上对系统数据进行准确无误的检查,从而避免用户发送不必要的损失,就像网购系统

分布式一致性需要考虑更新的并发性问题,如果逻辑控制流在时间上是重叠的,那么它们就是并发的。同一时间点上进行的多个程序操作,可能会修改内存中某个变量的值。

分布式一致性还需要考虑数据的复制问题,如数据库之间复制的延时问题。分布式系统对于数据的复制需求来自于两个原因:

  1. 增加系统的可用性,防止单点故障
  2. 提高系统的整体性能,通过负载均衡,让分布在不同地方的数据副本都能为用户提供服务

因此在分布式环境中引入了数据复制机制后,不同数据节点间因为延时等原因就有可能出现不一致的情况。

但是,我们无法找到一种能够满足分布式系统所有系统属性的分布式一致性解决方案,每一个具体的分布式系统都需要在一致性和系统性能之间进行考虑和权衡。于是,一致性级别由此诞生:

  • 强一致性:要求系统写入什么,读出的就是什么,符合用户直觉,但是对系统的性能影响较大。
  • 弱一致性:系统写入后,不要求立即能读到最新的值,也不要求多久之后一定能读到最新的值,但会尽可能保证一定时间后能读到最新的值
    • 会话一致性:保证对于写入的值,同一个客户端会话中可以读到一致的值,其他的会话不保证
    • 用户一致性:保证对于写入的值,同一个用户可以读到一致的值,其他的用户不保证
  • 最终一致性:是弱一致性的特例,系统保证最终能达到一致,它是非常重要的一种一致性模型。

二、分布式架构

2.1 从集中式到分布式

自 20世纪 60年代大型主机被发明出来以后,集中式的计算机系统架构成为了主流。由于大型主机卓越的性能和良好的稳定性,其在单机处理能力方面的优势非常明显,使得 IT系统快速进入了集中式处理阶段,其对应的计算机系统称为集中式系统。

但从 20世纪 80年代之后,计算机系统向网络化和微型化的发展日益明显,传统的集中式处理模式越来越不能适应人们的需求:

  • 大型主机操作复杂,运营人才培养成本高
  • 昂贵
  • 单点故障
  • 普通 PC机性能不断提升,网络技术快速普及

集中式的特点:

  1. 由一台或多台主计算机组成中心节点,数据集中存储在中心节点上,所有业务单元集中部署在中心节点上;其他终端或客户端机器仅负责数据的录入和输出,而数据的存储和控制完全交由主机来完成。
  2. 部署结构简单,基于底层性能卓越的大型主机,无需考虑对服务进行多个节点的部署,因此不需要考虑多个节点间的分布式协作问题。

分布式的特点:

分布式系统是一个硬件或软件组件分布在不同的网络计算机上,彼此之间仅仅通过消息传递进行通信和协调的系统,它一般具体以下几个特征:

  1. 分布性:多台计算机在空间上随意分布,且分布情况随时变动
  2. 对等性:分布式系统中的计算机没有主从之分,组成分布式系统的所有计算机节点都是对等的。通过数据副本和服务副本对数据和服务提供冗余,可以解决数据丢失、服务失效等问题。每个节点都有能力接收外部请求,处理数据。
  3. 并发性:同一个分布式系统中的多个节点,可能会并发地操作一些共享的资源,因此需要准确、高效地协调并发操作。
  4. 缺乏全局时钟:分布式系统中的进程间通过交换消息进行相互通信,但是很难定义两个事件的先后顺序,因为缺乏一个全局的时钟序列控制。
  5. 故障总会发生:组成分布式系统的所有计算机,都有可能发生任何形式的故障。

分布式环境的各种问题:

  1. 通信异常:节点之间通过网络进行交互,但是网络本身是不可靠的;且因为基于网络,其延时会远大于单机操作,网络延迟会可能会导致消息的丢失和延迟。
  2. 网络分区:因为网络时延的影响,导致部分节点间交互断裂,集群中仅剩下小部分节点能正常通信,即“脑裂”。当脑裂问题产生后,产生的局部小集群可能会继续工作,就会产生事务、数据方面的问题。
  3. 三态:分布式系统因为网络的影响,每一次请求与响应,存在”三态“的概念:成功、失败和超时。超时即表示消息丢失,它可能发生在请求阶段,也可能发生在响应阶段。
  4. 节点故障:组成分布式系统的服务器节点出现宕机或假死现象,每个节点都可能发生。

2.2 从 ACID到 CAP/BASE

分布式系统在 事务处理和 数据一致性上容易遇到麻烦。

ACID

事务(Transaction)指的是由一系列的数据操作所组成的一个逻辑单元(Unit)。当多个应用程序并发访问数据库时,事务可以提供隔离;当数据库操作失败后,事务可以提供从失败中恢复回正常的方法,事务具有四个特征,分别是原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability),简称为事务的 ACID特性。

  • 原子性:事务必须是一个原子的操作序列单元,要么全部成功执行,要么全部失败不执行;任何一项操作的失败都会导致整个事务的失败,同一事务内之前到操作都会回滚。
  • 一致性:事务的执行不得破坏数据库数据的完整性和一致性,一个事务在执行前后,都必须处于一致的状态。事务的执行必须是从一个一致状态转变到另一个一致状态。
  • 隔离性:并发的事务间相互隔离,事务间不能相互干扰。标准的 SQL规范中定义了 4个事务隔离级别:
    • 读未提交(Read Uncommited):事务 A可以读取到事务 B未提交的修改数据,即允许脏数据的读取,其隔离级别最低。
    • 读已提交(Read Committed):事务 A在运行期间可以读取到事务 B刚完成提交的数据,解决了脏读问题,但在事务 A的运行过程中某个值可能是不确定的,因此会有不可重复读的问题。
    • 可重复读(Repeatable Read):事务 A 在运行期间多次读取一个值,其值和事务开始时的值是一样的,能避免读取其他事务的提交而发生修改的值,解决了不可重复读问题,但是无法避免读到因其他事务的提交而新创建的值,因此会有幻读的问题。
    • 串行化(Serializable):最严格的隔离级别,所有事务串行执行。
隔离级别 脏读 不可重复读 幻读
读未提交 存在 存在 存在
读已提交 不存在 存在 存在
可重复读 不存在 不存在 存在
串行化 不存在 不存在 不存在
  • 持久性:一个事务一旦提交,它对数据库中的数据的操作就应该是永久性的。即使之后发生系统故障,已提交的事务结果也必须能复现。

分布式事务

事务在分布式领域内也得到了广泛的应用,在单机数据库中,我们能很容易地实现满足 ACID特性的事务处理系统,但在分布式环境下,数据分散在不同的机器上,事务的参与者、支持事务的服务器、资源服务器和事务管理器都可能位于不同的节点上,因此事务的功能就很难实现。

CAP

2000年 7月,来自加州大学伯克利分校的 Eric Brewer教师再 ACM PODC会议上,提出了 CAP猜想;两年后,来自 MIT的 Seth Gilbert和 Nancy Lynch从理论上证明了 CAP猜想的可行性。

CAP理论告诉我们,一个分布式系统不可能同时满足一致性、可用性和分区容错性这三个基本需求,最多只能同时满足其中的两项。

  • 一致性(Consistency):在分布式环境中,数据在多个副本间能否保持一致性的特性。在分布式系统中,如果能做到针对一个数据项的更新,所有其他用户都能读取立刻读取到最新的值,就称之为强一致性
  • 可用性(Availability):分布式系统提供的服务必须一直处于可用的状态,对于用户的每一个请求都必须在有限的时间内返回结果,如果超过了时间,就会认为是不可用的。它强调具体的某个服务。
  • 分区容错性(Partition Tolerance):分布式系统在遇到任何网络分区故障的时候,仍然需要能保证对外提供满足一致性和可用性的服务。此外,分布式系统中每一个节点的加入和退出都可以看作是一个特殊的网络分区变化。它强调整体的部署。
放弃 CAP 定理 说 明
放弃 C 放弃一致性,指的并不是完全放弃。
而是放弃强一致性,但是会追求 最终一致性;也就是说,系统不保证数据实时一致,但是最终一定会达到一个一致的状态。
放弃 A 一旦遇到系统网络分区或者其他故障时,那么期间的受灾节点可以允许不提供正常的服务
放弃 P 放弃 P就意味着放弃分布式系统的扩展性
因为放弃 P只有一个方式,就是将所有的数据都存到一个节点上,一荣俱荣,一损俱损,等于单机

因此可以明确:分布式系统不能同时满足 CAP三个特性;对于分布式系统而言,P是一个必须满足的特性。

首先我们应有个基本的理解, 系统部署在多个结点, 各个结点是需要进行通信, 数据共享的. 假如我们有3个结点.

如果满足C(一致性), 也就是说当Server1收到一条最新的数据, 需要把这条数据广播到Server2和Server3, 在广播期间, 为了保证一致性, Server是不对外保证服务的. 所以在有限时间内, 不能保证所有结点的可用性, 也就是CAP中的A.
如果满足A(可用性), 很显然, Server1收到一条新的数据更新, 在数据更新到Server2和Server3期间, 如果有新的请求到Server2和Server3, 那么Server1和Server2或者Server1和Server3之间的数据是不一致的, 也就是满足不了CAP中的C.

BASE

BASE是 Basiclly Available、Soft state和 Eventually consistent三个短语的简写,是由来自 eBay的架构师 Dan Pritchett在其文章 BASE: An Acid Alternative中第一次明确提出的。它是对 CAP中一致性和可用性权衡的结果,是基于 CAP定理逐步演化而来的,其核心思想是即使无法做到强一致性(Strong consistency),但每个应用都可以根据自身的业务特点,采用适当的方式实现最终一致性。

  • 基本可用:指当分布式系统在出现不可预知的故障时,允许损失部分可用性。
    • 允许响应时间上的损失
    • 允许功能上的损失
  • 弱状态:允许系统中的数据存在中间状态,并认为该中间状态不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
  • 最终一致性:强调系统中所有的数据副本,在经过一段时间的同步后,最终能达到一个一致的状态。最终一致性,可以看作是一种特殊的弱一致性,允许在没有发生故障的前提下,数据达到一致状态的时间延迟。实际工程实践中,最终一致性有以下五类变种:
    • 因果一致性(Causal consistency):如果进程 A在更新完某个数据项后通知进程 B,那么进程 B之后对该数据项的访问一定呢给你获取到进程 A更新后的值,而与进程 A无因果关系的进程 C对该数据项的访问则不保证。
    • 读己之所写(Read your writes):进程 A更新一个数据项之后,自己一定能访问到更新过的新值,而不会获取到旧值。
    • 会话一致性(Session consistency):将对系统数据的访问过程框定在一个会话中,系统保证在同一个有效会话中实现【读己之所写】的一致性。
    • 单调读一致性(Monotonic read consistency):一个进程在读取到新值之后,再次反复读取,一定不会再读到旧值
    • 单调写一致性(Monotonic write consistency):一个进程的写从中,被顺序地执行

在现代关系型数据库中,许多都会采用最终一致性模型,它们会采用同步和异步的方式来实现主备数据复制技术。

  • 在同步方式中,数据的复制过程通常是更新事务的一部分,因此在事务完成后,主备数据库的数据就会达到一致;
  • 在异步方式中,备库的更新往往会存在延时,这取决于事务日志在主备数据库之间传输的时间长短;

2.3 小结

BASE理论面向大型高可用可扩展的分布式系统,和传统的 ACID特性相反,它完全不同于 ACID的强一致性模型,而是提出通过牺牲强一致性来获得可用性,并允许数据在一段时间内是不一致的,但最终达到一致状态。同时,在实际的分布式场景中,不同业务单元和组件对数据一致性的要求是不同的,因此在具体的分布式系统架构设计过程中,ACID特性与 BASE理论又往往会结合在一起使用。

♥ 三、一致性协议

2.1 2PC 与 3PC

在分布式系统中,每一个机器节点仅能获知自己在事务操作中的结果是成功或失败,但无法获知其他分布式节点的操作结果。

因此当一个事务需要跨越多个分布式节点的时候,为了保持事务处理的 ACID特性,就需要引入一个”协调者“(Coordinator)的组件来统一调度所有分布式节点的执行逻辑,这些被调度的分布式节点被称为“参与者”(Participant)。Coordinator负责调度 Participant的行为,并最终决定这些 Participant是否要把事务真正进行提交。

2PC

2PC,是 Two-Phase Commit的缩写,即二阶段提交,是一种一致性协议,能保证分布式系统数据的一致性。目前,绝大多数的关系型数据库都是采用 2PC来完成分布式事务处理的,利用该协议能够非常方便地完成所有分布式事务参与者的协调,统一决定事务的提交或回滚。

顾名思义,二阶段提交协议就是将事务的提交过程分成两个阶段来进行处理,执行流程如下:

  1. 阶段一:提交事务请求
    1. 事务询问:Coordinator向所有的 Participants发生事务内容,询问是否可以开始事务操作,并等待各 Participant的响应
    2. 执行事务:各 Participant执行事务操作,并将 Undo和 Redo信息记录到事务日志中
    3. 反馈事务:如果 Participant成功执行了事务操作,那么就反馈给 Coordinator Yes响应;否则反馈 No响应
  2. 阶段二:执行事务提交
    • 执行事务提交:如果 Coordinator接收到的都是 Yes响应,那么就会执行事务提交
      1. 发送提交请求:Coordinator向所有的 Participants发送 Commit请求
      2. 事务提交:各个 Participant接收到 Commit请求后,会正式执行事务提交操作,并在完成提交后释放在整个事务执行期间占用的事务资源
      3. 反馈事务提交结果:各个 Participant在完成事务之后,向 Coordinator发送 Ack信息
      4. 完成事务:Coordinator接收到所有的 Ack消息后,认定完成了事务
    • 中断事务:Coordinator一旦接收到了一个 No响应,或者在等待超时后,就会中断事务
      1. 发送回滚请求:Coordinator向所有的 Participants发送 Rollback请求
      2. 事务回滚:各个 Participant利用其记录的 Undo信息来执行事务的回滚操作,并在完成回滚后释放在整个事务执行期间占用的事务资源
      3. 反馈事务回滚结果:各个 Participant在回滚事务之后,向 Coordinator发送 Ack信息
      4. 中断事务:Coordinator接收到所有的 Ack消息后,认定回滚了事务

二阶段提交将一个事务的处理过程分为了【投票】和【执行】两个阶段,其核心是对每个事务都采用先尝试后提交的处理方式,因此可以看作是一个强一致性的算法。

2PC的优点:原理简单,实现方便

2PC的缺点:同步阻塞、单点问题、脑裂、太过保守

  • 同步阻塞:在 2PC的提交过程中,所有参与事务的逻辑都处于阻塞状态,也就是说各个 Participant在等待的其他 Participant响应的过程中,都无法进行其他任何的操作。
  • 单点问题:一旦 Coordinator出现问题,整个 2PC都无法允许,更为严重的是,当 Coordinator在第二阶段出现问题,那么其他的所有 Participants都会一直处于锁定事务资源的状态,无法继续完成事务操作
  • 脑裂:在 2PC的第二阶段进行事务提交的时候,当 Coordinator向所有的 Participants发送 Commit请求时,若发生了局部网络异常或者 Coordinator在尚未发生完全部 Commit请求前,自身先发生了崩溃,那么将导致只有部分 Participants会进行事务的提交,而其他没有收到 Commit请求的参与者则无法进行事务提交,于是整个分布式系统便会出现数据不一致现象。
  • 太过保守:如果在 2PC的第一阶段询问时,便有某 Participant出现故障,导致 Coordinator无法收到明确的 Yes/No响应,那么其实 Coordinator只能依靠自身的超时机制来判断是否需要中断事务

3PC

3PC,是 Three-Phase Commit的缩写,即三阶段提交,是 2PC的改进版,将其 2PC的 一阶段又进行了划分,形成了由 CanCommit、PreCommit和 DoCommit三个阶段组成的事务处理协议。

  1. 阶段一:CanCommit

    1. 事务询问:Coordinator向所有的 Participants发送一个包含事务内容的 canCommit请求,询问是否可以执行事务提交的操作,并且开始等待各 Participant的响应
    2. 反馈事务:各 Participant在接收到来自 Coordinator的 canCommit请求后,正常情况下,如果其自身认为可以顺利执行事务,那么会反馈 Yes,并进入预备状态;否则返回 No
  2. 阶段二:PreCommit

    • 执行事务预提交:如果 Coordinator从所有的 Participants获得的反馈都是 Yes,那么就会执行事务的预提交

      1. 发送预提交请求:Coordinator向所有的 Participants发送 preCommit请求,并进入到 【预提交】阶段
      2. 事务预提交:各个 Participant接收到 preCommit请求后,会执行事务,并将 Undo和 Redo信息记录到日志中
      3. 反馈事务:各 Participant向 Coordinator反馈事务执行的响应,如果成功则返回 Ack
    • 中断事务:如果 Coordinator收到了一个 No响应,或者等待超时,那么就会中断事务

      1. 发送中断请求:Coordinator向所有的 Participants发送 abort请求
      2. 中断事务:各个 Participant在收到 abort请求,或者等待超时,都会中断事务
  3. 阶段三:DoCommit

    • 执行提交:如果 Coordinator收到了所有 Participants的 Ack反馈
      1. 发送提交请求:如果 Coordinator处于正常工作状态,并且接收到了来自所有 Participants的 Ack响应,那么它将从【预提交】状态切换到【提交】状态,并向所有的 Participants发送 doCommit请求
      2. 事务提交:各个 Participant接收到 doCommit请求后,会正式执行事务提交操作,并在完成后释放资源
      3. 反馈事务:各个 Participant在完成事务后,向 Coordinator发送 Ack消息
      4. 完成事务:Coordinator接收到所有 Participants的 Ack消息,完成事务
    • 中断事务:如果 Coordinator收到 No响应,或者等待超时,那么就会中断事务
      1. 发送中断请求:Coordinator向所有的 Participants发送 abort请求
      2. 事务回滚:各个 Participant接收到 abort请求后,会利用其在阶段二中记录的 Undo日志来回滚事务,并在完成后释放资源
      3. 反馈事务:各个 Participant在完成事务回滚之后,向 Coordinator发送 Ack消息
      4. 中断事务:Coordinator接收到所有 Participants的 Ack消息,中断事务

需要注意的是:一旦进入阶段三,可能会存在以下两种故障:

  1. Coordinator出现问题
  2. Coordinator和 Participant之间的网络出现问题

无论出现哪种情况,最终都会导致 Participant无法及时接收到来自 Coordinator的 doCommit或是 abort请求,针对这样的异常情况,Participant会在等待超时后,继续进行事务提交。

3PC的优点:降低了 Participant的阻塞范围,且能够在产生单点故障后继续达成一致

3PC的缺点:当 Participant在接收到 preCommit请求后,如果出现网络分区,Coordinator和 Participant无法通信,那么 Participant依旧会进行事务的提交,这必然出现数据的不一致。

2.2 Paxos算法简介

Paxos算法是 Leslie Lamport于 1990年提出的一种基于消息传递且有高度容错特性的一致性算法,是目前公认的解决分布式一致性问题最有效的算法之一。

在常见的分布式系统中,总会发生诸如机器宕机或网络异常等情况。Paxos算法解决的就是如何在一个可能发生上述异常的分布式系统中,快速且正确地在集群内部对某一个数据的值达成一致,并且保证不论发生以上任何异常,都不会破坏整个系统的一致性。

拜占庭将军问题

拜占庭帝国有许多支军队,不同军队的将军之间必须制定一个统一的行动计划,从而做出进攻或是撤退的决定,同时,各个将军在地理上都是被分隔开来的,只能依靠军队的通讯员来进行通讯。然而,在所有的通讯员中可能会存在叛徒,这些叛徒可以任意篡改消息,从而达到欺骗将军的目的。


从理论上来说,在分布式计算领域,视图在异步系统和不可靠通道上来达到一致性状态是不可能的,因此在对一致性的研究过程中,都往往假设信道是可靠的。

而事实上,大多数系统都是部署在同一个局域网中的,因此消息被篡改的情况非常罕见;并且,由于硬件和网络原因而造成的消息不完整的问题,也可以通过校验算法避免。

因此,在实际的工程实践中,可以假设不存在拜占庭问题,也即假设所有消息都是完整的,没有被篡改的。

Lamport在 1990年提出了一个理论上的一致性解决方案,通过类比阐述了”拜占庭将军问题“

在古希腊有一个叫做 Paxos的小岛,岛上采用议会的形式来通过法令,议会中的议员通过信使进行消息的传递。并且,议员和信使都是兼职的,他们随时可能离开议会厅(宕机),并且信使可能会重复传递消息(消息重复),也可能一去不复返(消息丢失)。因此,议会协议要保证在这种情况下法令仍然能够正确地产生,并且不会产生冲突

Leslie Lamport介绍

Lamport是 2013年图灵奖得主,先后多次荣获 ACM和 IEEE以及其他各类计算机重大奖项。Lamport对时间时钟、面包店算法、拜占庭将军问题以及 Paxos算法的创造性研究,极大地推动了计算机科学尤其是分布式计算的发展。

Lamport早在 1990年就已经将其对 Paxos算法的研究论文 The Part-Time Parliament提交给了 ACM TOCS Jnl.的评委,但是由于 Lamport创造性地使用故事的方式来进行算法的讨论,导致当时评委都无法理解其中的意思,时任主编要求 Lamport使用严谨的数据证明方式来描述该算法,但 Lamport拒绝修改,并主动撤销了这篇论文的提交。

1996年,来自微软的 Butler Lampson在 WDAG96大会上提出重新审视这篇分布式论文的建议,在次年的 WDAG97大会上,MIT的 Nancy Lynch也公布了其根据 Lamport的原文重新修改后的 Revisiting the Paxos Algorithm,使用数学的形式化术语定义并证明了 Paxos算法。于是在 1998年的 ACM TOCS上,这篇延迟了 9年的论文终于被接受了,也标志着 Paxos算法正式被计算机科学接受并开始影响更多的工程师解决分布式一致性问题。

后来在 2001年,Lamport本人做出让步,放弃了故事的描述方式,而是使用通俗易懂的语言重新讲述了原文,并发表了 Paxos Made Simple 论文。由于 Lamport个人自负固执的性格,使得 Paxos理论的诞生一波三折。

2.3 Paxos算法详解

Paxos算法的核心是一个一致性算法,也就是论文 The Part-Time Parliament中提到的 ”synod“算法。

问题描述

假设有一组可以提出提案的进程集合,那么对于一个一致性算法来说,需要保证以下几点:

  • 在这些被提出的提案中,只有一个提案会被选定(只会认定 a=1)
  • 如果没有提案被提出,那么就不会有提案被选定
  • 当一个提案被选定之后,进程应该可以获取被选定的提案信息(都能看到 a=1)

对于一致性来说,安全性(Safety)需求如下:

  • 只有被提出的提案才能被选定(Chosen)
  • 只能有一个值被选定
  • 如果某个进程认为某个提案被选定了,那么这个提案必须是真的被选定的那一个(不能只是一个人觉得,必须是所有人觉得,否则就不一致了)

从整体上来说,Paxos算法的目标就是要保证最终有一个提案会被选定(分布式节点间能针对某一个值形成共识),当提案(value)被选定之后,进程最终也能够获取到被选定的提案。

在该一致性算法中,有三个参与角色:Proposer、Acceptor和 Learner。在具体的实现中,一个进程可能充当不止一种角色。假设不同参与者之间可以通过收发消息 来进行通信,那么:

  • 每个参与者以任意的速度执行,可能会因为出错而停止,也可能会重启。同时,即使一个提案被选定后(一个值被确认后),所有的参与者也都可能失败而重启,因此除非那些失败或重启的参与者可以记录某些信息(需要持久化),否则将无法确定最终的值。
  • 消息在传输过程中可能会出现不可预知的延迟,也可能会重复或丢失,但是消息不会被损坏,即消息内容不会被篡改(拜占庭式的问题)。

我们暂且认为提案 == 求同的值 == value,而求同,便是指三个角色都认为某个变量可以是某个值,进而达成一致。

  • 只要 Proposer发出的提案被 Acceptor接收,它就认为该 Value被 Chosen了
  • 只要 Acceptor接收了某个提案,它就认为该 Value被 Chosen了
  • 只要 Learner被通知了与该变量相关的消息,它就认为该 Value被 Chosen了

提案的选定

要选定一个唯一提案的最简单方式:只允许一个 Acceptor存在。这样的话,Proposer只能发送提案给该 Acceptor,Acceptor会选择它接收到的第一个提案作为选定的提案。这种方式很简单,但是会出现单点故障,一旦 Acceptor宕机,整个系统就无法工作了。因此,需要使用多个 Acceptor来避免 Acceptor的单点问题。

在存在多个 Acceptor的情况下,提案的选取过程为:Proposer向一个 Acceptor集合发送提案,集合中每个的 Acceptor都可能会批准(Accept)该提案,也有可能拒绝(Reject)这个提案,当有【足够多】的 Acceptor批准这个提案的时候,我们就认为该提案被选定了。此外,我们再规定每一个 Acceptor最多只能批准一个提案,那么就能保证只有一个提案被选定了。

推导过程

在没有失败和消息丢失的情况下,如果我们希望即使在只有一个提案被提出的情况下(只有一个人想说话,那么这句话就必须被听到),仍然可以选出一个提案,那么就需要:

P1:一个 Acceptor必须批准它收到的第一个提案(注意:前面规定了[每一个 Acceptor最多只能批准一个提案])

但是这样的需求存在一个问题:如果有多个提案被不同的 Proposer同时提出,这可能会导致虽然每一个 Acceptor都批准了它收到的第一个提案,但是没有一个提案是由多数人批准的,如下图。

另外,即使只有两个提案被提出,如果每个提案都被差不多一半的 Acceptor批准了,此时即使只有一个 Acceptor出错,都有可能导致无法确定选定哪一个提案,如下图。假设共有 5个 Acceptor,其中两个同意了 “提案 1”,另外三个同意了 “提案 2”,此时如果批准 “提案2”的一个 Acceptor出错了,那么此时两个提案的批准者都变成了 2个,那么就无法选定最终的提案了。

因此,在【P1】的基础上,再加上一个提案被选定需要由半数以上的 Acceptor批准的需求暗示着一个 Acceptor必须能够批准不止一个提案(打破了前面规定的[每一个 Acceptor最多只能批准一个提案]的假设)。

因为一个 Acceptor可能批准多个提案,那么就需要使用一个全局编号来唯一标识每一个被 Acceptor批准的提案,当一个具有某 Value值的提案被半数以上的 Acceptor批准后,我们就认为该 Value被选定了,即该提案被选定了。注意,此时 提案 != Value ,而是使用【编号,Value】的二元组形式定义一个提案。

我们虽然允许多个提案被选定,但是同时必须要保证所有被选定的提案都具有相同的 Value值,由此可以得到如下定义:

P2:如果编号为 M0、Value值为 V0的提案(即【M0, V0】)被选定了(所有节点都认定了 V0这个值),那么所有比 M0编号更高,且被选定的提案,其 Value值必须也是 V0

因为提案的编号是全序的,条件【P2】就保证了只有一个 Value值被选定。同时,一个提案要被选定,其首先就必须被至少一个 Acceptor批准,因此我们通过满足如下条件来满足【P2】:

P2a:如果编号为 M0、Value值为 V0的提案(即【M0, V0】)被选定了,那么所有比 M0编号更高的,且被 Acceptor批准的提案,其 Value值必须也是 V0

至此,我们仍然需要【P1】来保证提案会被选定,但是因为通信是异步的,一个提案可能会在某个 Acceptor还未收到任何提案的时候就被选定了,如下图。在 Acceptor1没有收到任何提案的情况下,其他 4个 Acceptor已经批准了来自 Proposer2的提案【M0, V1】,而 Proposer1产生了一个具有其他 Value值的、编号更高的提案【M1, V2】并发送给了 Acceptor1。根据【P1】,就需要 Acceptor1批准该提案,但是这与【P2a】矛盾,因此需要对【P2a】进行强化:

P2b:如果一个提案【M0, V0】被选定后,那么之后任何 Proposer产生的编号更高的提案,其 Value值都是 V0

数学归纳法证明

我们需要证明如下结论:

假设编号在 M0到 Mn-1之间的提案,其 Value值都是 V0,那么编号为 Mn的提案的 Value值也为 V0

因为编号为 M0的提案已经被选定了,这就意味着肯定存在一个由半数以上的 Acceptor组成的集合 C,C中的每一个 Acceptor都批准了该提案。因此,这就意味着:

C中的每一个 Acceptor都批准了一个编号在 M0到 Mn-1范围内的提案(至少 M0肯定是被批准了的),并且每个编号在 M0到 Mn-1范围内的被 Acceptor批准的提案,其 Value值都为 V0

又因为任何包含半数以上 Acceptor的集合 S 都至少包含 集合C 中的一个 Acceptor成员,因此我们可以认为如果保持了下面 【P2c】的不变性,那么编号为 Mn的提案的 Value也为 V0。

P2c:对于任意的 Mn和 Vn,如果提案【Mn, Vn】被提出,那么肯定存在一个由半数以上的 Acceptor组成的集合 S,满足以下两个条件中的任何一个
	1. S中不存在任何批准过编号小于 Mn的提案的 Acceptor(Mn要够大)
	2. 选取 S中所有 Acceptor批准的编号小于 Mn的提案,其中编号最大的那个提案,其 Value就是 Vn(Vn必须被提出过)

实际上,【P2c】规定了每个 Proposer如何产生一个提案:对于产生的每个提案【Mn, Vn】,需要满足如下条件:

存在一个由超过半数的 Acceptor组成的集合 S:

  • 要么 S中没有 Acceptor批准过的编号小于 Mn的任何提案
  • 要么 S中的所有 Acceptor批准的所有编号小于 Mn的提案中,编号最大的那个提案的 Value值就是 Vn

当每个 Proposer都按照这个规则来产生提案时,就可以保证满足【P2b】了。

接下来我们证明【P2c】

首先假设提案【M0, V0】被选定了,设比该提案编号大的提案为【Mn, Vn】,我们需要证明的就是在 P2c的前提下,对于所有的【Mn, Vn】,存在 Vn = V0

  1. 当 Mn=M0+1时,因为【M0, V0】已被选定,那么一定存在由超过半数 Acceptor组成的集合 S,S中所有 Acceptor批准的所有编号小于 Mn的提案中,编号最大的提案的 Value值为 Vn,又因为 Mn=M0+1,比 Mn小,但又最大的只有 M0,所以 Vn = V0
  2. 当 Mn比 M0大很多时,因为,V0被选定,所以编号在 M0+1 到 Mn-1区间内的所有提案的 Value值为 V0,那么同样的肯定存在集合 S批准了小于 Mn的提案,那么 Mn的提案的 Value值只能是 S中编号小于 Mn,但为最大编号的那个提案的 Value,即 V0。

Proposer生成提案

对于 Proposer来说,获取那些已经被通过的提案远比预测未来可能会通过的提案来的简单,因此,当 Proposer在产生一个编号为 Mn的提案时,必须要知道当前某一个将要或已经被半数以上 Acceptor批准的编号小于 Mn但为最大编号的提案。并且,Proposer会要求所有的 Acceptor都不要再批准任何编号小于 Mn的提案,由此引出了提案生成算法:

  1. Proposer选择一个新的提案编号 Mn,然后向某个 Acceptor集合的成员发送【Prepare请求】,要求该集合中的 Acceptor做出如下回应
    • 向 Proposer承诺,保证不再批准任何编号小于 Mn的提案
    • 如果 Acceptor已经批准过任何提案,那么就向 Proposer反馈当前 Acceptor已经批准过、编号小于 Mn但为最大编号的那个提案的 Value值(省点事儿,直接用别人已经提出的 Value)
  2. 如果 Proposer收到了来自半数以上的 Acceptor的响应结果,那么它就可以产生【Mn, Vn】的提案,这里的 Vn是所有响应中编号最大的提案的 Value值;但是,如果半数以上的 Acceptor之前都没有批准过任何提案,那么这个 Vn值就可以由该 Proposer自行选择(也就是说,第一个吃螃蟹的人必须自己整一个 Vn的值)

在确定了提案之后,Proposer就会将该提案以【Accept请求】的方式,再次发送给某一个 Acceptor集合,并期望获得批准。注意:此时接收 Accept请求的 Acceptor集合不一定是之前响应 Prepare请求的 Acceptor集合,但是二者一定至少包含一个公共 Acceptor(两个大于一半的集合间肯定存在非空交集)。

Acceptor批准提案

一个 Acceptor可能会收到来自 Proposer的两种请求,分别是 【Prepare请求】和 【Accept请求】

  • Prepare请求:Acceptor可以在任何时候响应一个 Prepare请求
  • Accept请求:在不违背 Accept现有承诺的前提下,可以任意响应 Accept请求

因此,对 Acceptor逻辑处理的约束条件,大体可以定义如下:

P1a:一个 Acceptor只要尚未响应过任何编号大于 Mn的 Prepare请求,那么它就可以接受这个编号为 Mn的提案(因为之前就已经限制过了提案的 Value,所以这里直接接受就行)

Paxos算法允许 Acceptor忽略任何请求而不用担心破坏其算法的安全性

算法优化

之前通过全局唯一编号,获得了一个满足安全性需求的提案选择算法,对此可以进行优化:Acceptor应尽可能地忽略 Prepare请求

假设一个 Acceptor收到了一个编号为 Mn的 Prepare请求,但此时该 Acceptor已经对编号大于 Mn的 Prepare请求做出了响应,因此它肯定不会再批准任何新的编号为 Mn的提案,那么很明显,Acceptor就没有必要对这个 Prepare请求做出响应,于是 Acceptor可以选择忽略这样的 Prepare请求。同时,Acceptor也可以忽略掉那些它已经批准过的提案的 Prepare请求。

通过这个优化,每个 Acceptor只需要记住它已经批准的提案的最大编号已经它已经做出 Prepare请求响应的提案的最大编号,以便在出现故障或者节点重启情况下,也能保证【P2c】的不变性。而对于 Proposer来说,只要它可以保证不会产生具有相同编号(编号全局唯一)的提案,那么就可以随意丢弃任意的提案以及它所有的运行时状态信息。

算法陈述

结合 Proposer和 Acceptor对提案的处理逻辑,可以得到如下类似于 2PC的执行过程:

  1. 阶段一

    1. Proposer选择一个提案编号 Mn,然后向 Acceptor的某个超过半数的子集成员发送编号为 Mn的 Prepare请求

    2. 如果一个 Acceptor收到一个编号为 Mn的 Prepare请求,且编号 Mn大于该 Acceptor已经响应的所有 Prepare请求的编号,那么该 Acceptor会将已批准过的最大编号的提案作为响应反馈给 Proposer,同时承诺不会再批准任何编号小于 Mn的提案。

      比如说,假定一个 Acceptor已经响应过的所有 Prepare请求对应的提案编号分别为 1、2、3 … 和 7,那么该 Acceptor在接收到一个编号为 8的 Prepare请求后,就会将编号为 7的提案作为响应反馈给 Proposer。

  2. 阶段二

    1. 如果 Proposer收到来自半数以上的 Acceptor对于其发出的编号为 Mn的 Prepare请求的响应,那么它就会发送一个针对【Mn, Vn】提案的 Accept请求给 Acceptor。注意:Vn的值就是收到的响应中编号最大的提案的值,如果响应中不包含任何提案,那么它由 Proposer自己指定。
    2. 如果 Acceptor收到针对【Mn, Vn】提案的 Accept请求,只要该 Acceptor尚未对编号大于 Mn的 Prepare请求做出响应,它就可以通过这个提案。

在实际运行中,每个 Proposer都可能会产生多个提案,但只要每个 Proposer都遵循如上所述的算法运行,就一定能保证算法执行的正确性。每个 Proposer都可以在任意时刻丢弃一个提案,哪怕针对该提案的请求和响应在提案被丢弃后会到达,但根据 Paxos算法的一系列规约,依然可以保证其在提案选定上的正确性。

事实上,如果某个 Proposer已经在试图生成编号更大的提案了,那么丢弃一些旧的提案未尝不是一个好的选择。因此,如果一个 Acceptor因为已经收到过更大编号的 Prepare请求而忽略某个编号更小的 Prepare请求或者 Accept请求,那么它也应当通知其对应的 Proposer,以便该 Proposer也能够将该提案进行丢弃。

提案的获取

  • 方案一
    • Learner获取一个已经被选定的提案的前提是,该提案已经被半数以上的 Acceptor批准。因此,最简单的做法就是一旦 Acceptor批准了一个提案,就将该提案发送给所有的 Learner。
    • 但是,这需要让每个 Acceptor与所有的 Learner逐个通信,很低效。
  • 方案二
    • 让所有的 Acceptor将它们对提案的批准情况,统一发送给一个特定的 Learner(主 Learner)。当主 Learner被通知一个提案已被选定后,它会负责通知其他的 Learner。
    • 这个方案虽然减少了通信次数,主 Learner存在单点故障问题
  • 方案三
    • 将主 Learner的范围扩大,即 Acceptor可以将批准的提案发送给一个特定的 Learner集合
    • 这个 Learner集合中的 Learner个数越多,可靠性越好,但同时复杂度越高。

通过选取主 Proposer保证算法的活性

假设存在这样一种极端情况,有两个 Proposer依次提出一系列编号递增的议案,但是最终都无法被选定:

P1提出了一个编号为 M1的提案,并完成了上述 阶段一的流程。但于此同时,P2也提出了编号为 M2(M2 > M1)的提案,同样也完成了 阶段一的流程,于是 Acceptor已经承诺不再批准编号小于 M2的提案了。

因此,当 P1进入 阶段二的时候,其发出的 Accept请求被 Acceptor忽略,于是 P1又进入 阶段一并发出了编号为 M3(M3 > M2)的提案,而这又导致 P2在 阶段二的 Accept请求被忽略,以此类推,形成了死循环。

为了保证 Paxos算法流程的可持续性,以避免陷入上述提到的“死循环”,必须有一个主 Proposer,并规定只有主 Proposer才能提出提案。这样一来,只要主 Proposer和过半的 Acceptor能够正常进行网络通信,那么但凡主 Proposer提出一个编号更高的提案,该提案终将被批准。

当然,如果 Proposer发现当前算法流程中已经有一个编号更大的提案被提出或正在接收批准,那么它会丢弃当前这个编号较小的提案,并最终能够选出一个编号足够大的提案。因此,如果系统中有足够多的组件能正常工作,那么通过一个主 Proposer,整套 Paxos算法流程就能够保持活性。

2.4 Multi Paxos

之前介绍的是原始的 Paxos(Basic Paxos),只能对一个值形成决议,且决议的形成需要进行两个阶段。

而实际应用中几乎都需要连续确定多个值,因此 Multi Paxos算法被提出,它基于 Basic Paxos做出了两点改进:

  1. 针对一个待确定的值,运行一次 Paxos算法实例(Paxos Instance),每个实例通过 InstanceID标识
  2. 在所有的 Proposers中,选举一个 Leader,由 Leader唯一地提交提案给 Acceptors进行表决,可以避免 Proposer间的竞争,进而跳过 Prepare阶段,使原本的二阶段削减为一阶段即可

Multi Paxos首先需要进行 Leader的选举,其本身就是一次 Paxos决议,选出了 Leader后只能由 Leader来发布提案。

为了区分连续提交多个 Instance实例(这多个 Instance之间其实是独立的),为了区分,就需要使用 InstanceID。

Multi Paxos允许存在多个 Leader节点发布提案,但是这就会导致 Multi Paxos退化为 Basic Paxos。

如果 Multi Paxos算法中,多个 Instance均对同一个值进行修改,那么 Leander在学习的时候就会依照 InstanceID的大小依次学习,避免出现 Leander学习时候的不一致。

Chubby和 ZK都使用的是 Multi Paxos。

2.5 小结

三种一致性协议都是非常优秀的分布式一致性协议,都从不同方面不同程度地解决了分布式数据一致性的问题。

2PC解决了分布式事务的原子性问题,但存在同步阻塞、无限等待和脑裂等问题。

3PC在 2PC的基础上添加了 PreCommit过程,避免了无限等待问题。

Paxos引入了“过半”的理念,支持节点角色间的轮换,避免了单点问题,既解决了无限等待问题,又解决了脑裂问题。

三、Paxos的工程实践

3.1 Chubby

Google Chubby是一个大名鼎鼎的分布式锁服务,GFS和 Big Table等都依赖它解决分布式协作、元数据存储和 Master选举等一系列与分布式锁相关的问题。其底层实现就是以 Paxos算法为基础的,Google曾公开 Chubby的论文 The Chubby lock service for loosely-coupled distributed systems

概述

Chubby是一个面向松耦合分布式系统的锁服务,通常用于为一个由大量小型计算机构成的松耦合分布式系统提供高可用的分布式锁服务。Chubby允许它的客户端进程同步彼此的操作,并对当前所处环境的基本状态信息达成一致。

针对此,Chubby提供了粗粒度的分布式锁服务,开发人员仅需调用 Chubby的锁服务接口即可实现分布式系统中多个进程之间粗粒度的同步控制,从而保证数据的一致性。

Chubby的客户端接口设计得类似 UNIX文件系统结构,应用程序可以通过 Chubby客户端实现对 Chubby服务器上的整个文件进行读写操作,添加锁控制,订阅文件变动事件等。

应用场景

最为典型的是集群中服务器的 Master选举,如在 GFS中使用 Chubby锁服务实现对 GFS Master服务器的选举。在 Big Table中 Chubby用来定位 Master服务器,存储系统运行时的元数据。

设计目标

Chubby被设计成一个完整的、需要访问中心化节点的分布式锁服务,因为锁服务具有以下 4个传统算法库不具有的优点:

  1. 对上层应用程序的侵入性更小
  2. 便于提供数据的发布与订阅
  3. 开发人员对基于锁的接口更为熟悉
  4. 更便捷地构建更可靠的服务

因此,Chubby被设计成一个需要访问中心化节点的分布式锁服务,同时,在 Chubby的设计过程中,提出了以下几个设计目标:

  1. 提供一个完整的、独立的分布式锁服务,而非仅仅是一个一致性协议的客户端库
  2. 提供粗粒度的锁服务,更适用于长期持有锁的场景(细粒度的锁往往被设计为锁服务一旦失效,就释放锁,因为细粒度的锁持有时间短,所以放弃锁的代价小)
  3. 在提供锁服务的同时提供对小文件的读写功能
  4. 高可用、高可靠
  5. 提供事件通知机制

技术架构

Chubby的整个系统结构主要由服务的和客户端两部分组成,客户端通过 RPC调用与服务器端进行通信

一个典型的 Chubby集群,或称为 Chubby Cell,通常由 5台服务器组成。这些副本服务器采用 Paxos协议,通过投票的方式来选举 Master。每个 Master都会有一个租期,期间 Chubby保证不会再产生其他 Master服务器。并且每个 Master运行期间可以通过续租来延长租期。因此只有在 Master宕机或集群刚启动的时候才会进行 Master的选举。

集群中的每个服务器都持有副本,但是只有 Master才能进行写操作,其他服务器都是使用 Paxos协议从 Master服务器上同步数据。

客户端定位服务器

Chubby客户端通过向记录有 Chubby服务器机器列表的 DNS来请求获取所有的 Chubby服务器列表,然后逐个发起请求询问是否为 Master。在询问过程中,如果不是 Master,那么该服务器会将当前 Master所在的服务器的标识反馈给客户端。

一旦客户端定位到到 Master服务器,只要 Master正常,就会将所有的请求都打到 Master上。

  • 针对写请求,Master会通过一致性协议将其广播给所有其他节点,并且在过半的服务器都接受了该写请求后,再对客户端做出响应
  • 针对读请求,Master单独处理

如果 Master崩溃了,那么其他服务器会在 Master的租期到期后,重新开启新一轮的选举,预计一半耗时几秒。如果非 Master服务器崩溃,则不会影响集群工作。新加入集群的服务器首先进行数据同步,之后才会加入到正常的 Paxos运作流程中与其他服务器副本一起协同工作。

目录与文件

Chubby的数据结构可以看作是一个由文件和目录组成的树,每个节点都可以表示为一个使用斜杠分割的字符串,如 /ls/foo/wombat/pouch

其中,ls是所有 Chubby节点所共有的前缀,代表着锁服务,是 Lock Service的缩写;foo则指定了 Chubby集群的名字,从 DNS可以查询到由一个或多个服务器组成该 Chubby集群;剩余的 /wombat/pouch则是一个真正包含业务含义的节点名字,由 Chubby服务器内部解析并定位到数据节点。

Chubby的命名空间,包括文件和目录,称为节点(nodes)。在同一个 Chubby集群中,每一个 node都是全局唯一的。

Chubby上的每个数据节点都分为 持久节点和 临时节点两大类,其中持久节点需要显式地调用接口 API来进行删除,而临时节点则会在其对应的客户端会话失效后自动删除。因此,临时节点通常可以用来进行客户端会话有效性的判断。

此外 Chubby上的数据节点中都包含少量的元数据信息,包括 4个单调递增的 64位编号:

  • 实例编号:标识 Chubby创建该数据节点的顺序,按事件排序,唯一
  • 文件内容编号:标识文件内容的变化情况,会在文件写入时增加
  • 锁编号:标识节点锁状态变更情况,会在节点锁从自由(free)状态转换为被持有(hold)时增加
  • ACL编号:标识节点的 ACL信息变更情况,会在节点的 ACL配置信息被写入时增加

同时 Chubby还会标识一个 64位的文件内容校验码,标识此文件是否变更。

锁与序列器

在分布式系统中,因为网络通信的不确定性,锁机制变得十分复杂,消息的延迟或乱序都可能导致锁的失效。

一个典型的分布式锁错乱案例(消息接收顺序紊乱导致)

客户端 C1获取到互斥锁 L,并且在锁 L的保护下发出了请求 R1,但请求 R1迟迟没有到达服务端(网络延迟或反复重发等原因),此时应用程序认为请求失败。

于是便会位另一个客户端 C2分配锁 L,然后再重新发起之前的请求 R2,此时原来的请求 R1也到达了服务端,R1因为曾经持有过锁,因此可能不受此时 L的控制,会覆盖 R2的操作,导致数据不一致。

在 Chubby中,任意一个数据节点都可以充当一个读写锁来使用:

  • 一种是单个客户端以排他锁(写锁)模式持有这个锁
  • 另一种是任意数目的客户端以共享锁(读锁)模式持有这个锁

并且 Chubby舍弃了严格的强制锁,客户端可以在没有获取到任何锁的情况下访问 Chubby的文件,也就是说,持有锁 F既不是访问文件 F的必要条件,也不能阻止其他客户端访问文件 F。

在 Chubby中,主要采用【锁延迟】和【锁序列器】两种策略来解决由于 消息延迟 和 重排序引起的分布式锁问题。

  • 锁延迟(lock-delay):如果锁是正常被释放的,那么其他客户端可以立即获取该锁;但是如果锁是异常被释放的,那么 Chubby服务器会额外保留该锁一段时间
  • 锁序列器:该策略需要 Chubby的上层应用配合;锁的持有者请求锁时需要传递锁名称、锁模式和锁序号,当客户端在使用锁时,会将锁序列器发送给服务端,服务端接收后会先检查序列器是否有效,以及是否处于恰当的锁模式等,如果没有通过检查,则拒绝该请求。

事件通知机制

为了避免大量客户端的轮询,Chubby提供了事件通知机制。客户端可以向服务端注册事件通知,当触发这些事件后,服务端就会向客户端发送对应的事件通知。具体的消息通知都是通过异步的方式发送给客户端的,常见的 Chubby事件如下:

  • 文件内容变更
  • 节点删除:一般在临时节点中比较常见
  • 子节点新增、删除
  • Master服务器转移

缓存

为了提高性能,降低因频繁的读请求给服务端带来的压力,Chubby除了提供事件通知机制外,还在客户端中实现了缓存,会对客户端文件内容和元数据信息进行缓存。

缓存机制能提高系统性能,但也提高了复杂性,Chubby中通过租期机制来保证缓存的一致性。

Chubby缓存的生命周期和 Master的租期机制紧紧相关,Master会维护每一个客户端的数据缓存情况,并通过向客户端发送过期信息的方式来保证客户端数据的一致性。在这种机制下,Chubby要么从缓存访问到一致的数据,要么访问出错,一般不会访问不到不一致的数据。而一旦租期到期,客户端就需要向服务端续租来维持缓存。

当文件数据或元数据信息被修改时,Chubby会先阻塞该修改操作,然后由 Master向所有可能缓存了该数据的客户端发送缓存过期的信号,等到 Master接收到所有相关客户端针对该过期信息的应答(客户端明确要求更新缓存 或 客户端允许缓存租期过期)后,才会继续之前的修改操作。

Chubby的缓存数据保证了强一致性!

会话和会话激活

Chubby客户端和服务端之间通过 TCP进行通信,称之为会话(Session)。

会话是有生命周期的,存在超时时间,Chubby通过心跳检测来保持会话的活性,使会话周期得以延续,称之为 KeepAlive(会话激活)

如果在 KeepAlive过程中 Session能延续,那么客户端创建的句柄、锁和缓存数据等都依然有效。

KeepAlive请求

当 Master在接收到客户端的 KeepAlive请求时,会先阻塞该请求,并等到该客户端的当前 Session即将过期时,才为其续租该会话租期,之后再响应这个 KeepAlive请求,并同时将最新的 Session过期时间反馈给客户端,

Master对于会话续期时间一般设置为 12秒,具体会根据实际的运行情况进行自动的调整。

如果 Master处于高负载下,那么会适当延迟 Session的续期时间,以减少客户端 KeepAlive请求的发送频率。

客户端在接收到来自 Master的响应后,会立即发起一个新的 KeepAlive请求,再由 Master阻塞 。。。。。。

由此可见,每一个 Chubby客户端总会有一个 KeepAlive请求阻塞在 Master服务器上;除了为客户端进行会话续租外,Master还将通过 keepAlive响应来传递 Chubby事件通知和缓存过期通知给客户端。如果 Master发现服务端已经触发了针对该客户端的事件通知或缓存过期通知,那么就会提前将 KeepAlive响应反馈给客户端。

会话超时

因为 KeepAlive响应在网络中传输的耗时,可能会导致客户端和服务器端的不一致问题,因此 Chubby客户端也会维持一个和 Master端类似的会话租期。

如果 Chubby客户端检测到本地会话超时,但是并没有接收到 Master的 KeepAlive响应,那么它将无法确定 Master是否已终止当前会话,进入 “危险状态”。此时,Chubby客户端会清空本地缓存,并标记其为不可用。之后客户端进入一个称为 “宽期限” 的时间周期,默认为 45秒。如果到期前,成功地进行了 KeepAlive,那么客户端会再次开启本地缓存;否则,客户端认为当前会话过期,中止会话。

当客户端进入危险状态时,Chubby的客户端会通过一个 “jeopardy”事件来通知上层应用程序,如果恢复正常,客户端同样会以一个 “safe”事件来通知应用程序可以继续正常运行了;但如果没能恢复,那么客户端会以一个 “expired”事件来通知应用程序当前的 Chubby会话已经超时。

上层应用通过不同的事件类型,可以做出不同的处理,针对短时间内 Chubby服务不可用的情况,客户端可以选择等待而非重启。

Master故障恢复

Master服务器上运行着 “会话租期计时器”,用来管理所有会话的生命周期。如果 Master出现了故障,那么计时器就会停止,直到选出新的 Master后才恢复计时。也就是说,从旧的 Master崩溃到新的 Master选举产生的时间将不计入会话超时的计算中,在等价于延长了客户端的会话租期。

如果新的 Master很快选出来了,那么客户端就可以在本地会话过期前与其创建连接;但如果 Master的选举花费时间很长,就会导致客户端只能清空本地的缓存,并进入 “宽期限”等待。

所以,由于 “宽期限”的存在,会话能够很好地在服务端 Master转换的时候得到维持。

如同,一开始在旧的 Master服务器上维持了会话租期【lease M1】,在客户端上维持了对应的【lease C1】,同时客户端发送 “KeepAlive请求 1”,一直被 Master阻塞着。在一段时间之后,Master向客户端反馈了 “KeepAlive响应 2”,同时开启了新的会话租期【lease M2】,客户端在接收到响应之后又立即发送了 “KeepAlive请求 3”,并同时也开启了新的会话租期【lease C2】。随后 Master发生故障,无法反馈客户端的 “KeepAlive请求 3”。

在这个过程中,客户端检测到会话租期【lease C2】已经过期,它会清空本地缓存并进入“宽限期”,在“宽限期”开始时,客户端会向上层应用发送一个 “jeopardy”事件。此时,客户端无法确定 Master上的会话周期是否也已经过期,因此不会销毁它的本地会话,而是将所有应用程序对它的 API调用也阻塞住,以避免不一致问题的产生。

一段时间之后,服务端选举产生了新的 Master,并为该客户端初始化了新的会话租期【lease M3】,当客户端向新的 Master发送 “KeepAlive请求 4”的时候,新的 Master检测到客户端的 Master周期号(Master epoch numer)已过期,因此会在 “KeepAlive响应 5”中拒绝这个客户端请求,并将最新的 Master周期号发送给客户端。之后客户端会携带新的 Master周期号,再次发送 “KeepAlive请求 6”给 Master。最终,整个客户端和服务端的会话就会再次恢复正常。

所以,可以这么说,只要客户端的 “宽限期”足够长,那么客户端应用程序就可以在没有察觉的清空下,实现 Chubby的故障恢复,但如果 客户端的 “宽限期”很短,那么客户端就会丢弃当前会话,并将这个异常情况通知给上层应用程序。

一旦客户端与新的 Master建立连接之后,客户端和 Master之间就可以通过相互配合实现故障的平滑恢复。一个新的 Master服务器选举产生之后,会进行如下几个主要处理:

  1. 新的 Master选举产生之后,首先需要【确定 Master周期】。Master周期是用来唯一标识一个 Chubby集群的 Master统治轮次,以便区分不同的 Master。一旦新的 Master周期确定下来之后,Master就会拒绝所有携带旧的周期的客户端请求,同时告知其最新的 Master周期。即使新选举的就是原来的那个 Master,也会如此。
  2. 立即响应客户端的 Master寻址操作,但是不会立即开始处理客户端会话相关的请求操作。
  3. Master根据本地数据库中存储的会话和锁信息,来构建服务器的内存状态。
  4. 此时 Master已经能够处理客户端的 KeepAlive请求了,但依然无法处理其他会话相关的操作。
  5. Master会发送一个 “Master 故障切换”事件给每一个会话,客户端收到之后会立刻清空自己的本地缓存并通知上层应用
  6. Master会一直等待客户端的应答,直到每一个会话都应答了这个切换事件
  7. 在 Master接收到了所有的应答之后,就能够开始处理所有的请求操作了
  8. 如果客户端使用了一个在故障切换之前的句柄,Master会为其重新创建一个句柄的内存对象。如果该句柄在之前的 Master周期中已经被关闭了,那么就不会重创了。

Paxos协议实现

Chubby服务端的基本架构可分为三层:

  • 最底层的是【容错日志系统(Fault-Tolerant Log)】,通过 Paxos算法能够保证集群中所有机器上的日志完全一致,同时具备较好的容错性
  • 日志层之上是【Key-Value类型的容错数据库(Fault-Tolerant DB)】,通过下层的日志来保证一致性和容错性
  • 存储层之上就是 Chubby对外提供的【分布式锁服务】和【小文件存储服务】

Paxos算法的作用就在于保证集群内各个副本节点的日志能够保持一致。

Chubby事务日志中的每一个 Value对应 Paxos算法的一个 Instance,由于 Chubby需要对外提供不间断的服务,因此事务日志会无限增长,于是在整个 Chubby运行过程中,会存在多个 Paxos Instance(同时进行多个 value值的确定)。同时,Chubby会为每一个 Paxos Instance都按序号分配一个全局唯一的 InstanceID,并顺序写入到事务日志中。

在Multi Paxos Instance的模式下,为了提升算法执行的性能,就必须选举出一个副本节点作为 Paxos算法的主节点(称为 Leader或 Coordinator),以避免因为每一个 Paxos Instance都提出提案而陷入多个 Paxos Round并存的情况(一个 Proposer提出一个提案,交由一群 Acceptors裁决就可以看作一个 Round)。同时,Paxos会保证在 Leader重启或出现故障而进行切换的时候,允许出现 **短暂的多个 Leader共存 **但不影响副本之间的一致性。

在 Paxos中,每个 Paxos Instance都会进行一轮或多轮的 “Prepare -> Promise -> Propose -> Accept”这样完整的二阶段请求过程来完成对一个提案值的选定(一个 Instance可以看作是一个 Basic Paxos),而多个 Instance之间是完全独立的,每个 Instance可以自己决定每一个 Round的序号(【编号,Value】),仅仅只需要保证在 Instance内部不会出现序号重复即可(保证 Instance内编号的唯一即可)。为了在保证正确性的前提下,尽可能地提高算法运行性能,可以让多个 Instance共用一套序号分配机制将 “Prepare -> Promise”合并为一个阶段(多个 Instance执行同一次 “Prepare -> Promise”,但之后的 “Propose -> Accept”还是独立的),具体做法如下:

  • 当某个副本节点通过选举成为 Leader后,就会使用新分配的编号 N来广播一个 Prepare消息,该 Prepare消息会被所有未达成一致的 Instance和目前还未开始的 Instance共用(N作为编号,而不是 InstanceID)
  • 当 Acceptor接收到 Prepare消息后,必须对多个 Instance同时做出回应,它会将这些反馈信息封装在一个数据包中来实现。假设最多允许 K个 Instance同时进行提案指的选定(K个 Basic Paxos并发执行),那么:
    • 当前至多存在 K个未达成一致的 Instance,将这些未决的 Instance各自最后接受的提案值(若该提案尚未接受,就用 null替代)封装进一个数据包,作为 Promise消息返回。
    • 同时,判断 N是否大于当前 Acceptor的 highestPromisedNum(当前已经接受的最大提案编号),如果大于该值,那么就记录为 highestPromisedNum,之后这些 Instance都不会再接受小于 N的提案
  • 然后 Leader就可以对所有未决 Instance和所有未来 Instance分别执行 “Propose -> Accept”阶段的处理。如果当前 Leader能够一直稳定运行的话,那么在接下来的算法中,不再需要 “Prepare -> Promise”的处理了;但是,一旦 Leader发现了 Acceptor返回了一个 Reject消息,说明集群中已经出现了另一个 Leader,并且试图使用更大的提案编号,那么当前 Leader就需要重新分配新的、更大的提案编号,并再次进行 “Prepare -> Promise”阶段的逻辑处理。

通过该算法,在 Leader稳定的情况下,多个 Instance使用同一个编号(不是 InstanceID)来一次执行每一个 Instance的 “Promise -> Accept”处理。一个 Instance完成后就可以将对应的 Value值写入本地事务日志并广播 COMMIT消息给其他的副本节点,其他副本节点在接收到 COMMIT消息后也会写入本地事务日志。如果某个副本没有收到 COMMIT,它可以主动向集群中的其他副本节点进行查询。至此,就实现了满足一致性的日志副本,并在此基础上实现了一直的状态机副本,即容错数据库层。

副本宕机后恢复

集群中副本宕机后重启,需要恢复原状,最简单的方法是 redo一遍事务日志,但是事务日志可能积累得很多,恢复时间会很长。因此需要定期对状态机做一个快照并存入磁盘,之后就可以将这之前的日志删除。

通常副本宕机后会出现磁盘 未损坏 或 已损坏两种情况。针对前者,只需要快照 + 日志即可恢复。针对后者,无法直接从本地数据恢复。

副本节点在宕机重启后,一般不会立即参与 Paxos Instance的流程,而是需要等待 K个 Paxos Instance流程成功完成之后才能开始参与,这样可以保证新分配的提案编号不会和自己以前发过的重复。

并且得益于 Paxos算法的容错机制,只要任意时刻保证多数派的机器能够正常运行,那么在宕机瞬间未能写入磁盘的数据(没有 flush到磁盘)也可以通过其他正常的副本上复制恢复,因此不需要实时地进行事务日志的 flush操作,可以大幅度提高事务的写入效率。

3.2 Hypertable

Hypertable基于 Google的 BigTable论文为基础,采用与 HBase相似的分布式模型,是一个针对分布式海量数据的高并发数据库。

概述

Hypertable的优势在于:

  • 支持对大量并发请求的处理
  • 支持对海量数据的管理
  • 扩展性良好,在保证可用性的前提下,能够通过随意添加集群中的机器来实现水平扩容
  • 可用性极高,具有非常好的容错性,任何节点的失效,不会造成系统的瘫痪或数据的完整性

Hypertable的核心组件包括 Hyperspace、RangeServer、Master和 DFS Broker四部分。

Hyperspace提供对分布式锁服务的支持和元数据的管理,是保证 Hypertable数据一致性的核心。类似 Chubby。

RangeServer是实际对外提供服务的组件单元,负责数据的读取和写入。

Master是元数据管理中心,管理包括创建表、删除表或是其他表空间变更在内的所有元数据操作,同时检测 RangeServer的工作状态,在 RangeServer宕机后能自动对 Range重新分配。

DFS Broker是底层分布式文件系统的抽象层,衔接上层 Hypertable和底层文件存储。所有对文件系统的读写操作都是通过 DFS Broker完成的。

算法实现

Hyperspace通常以集群的形式部署,由 5~11台服务器组成,其中会选举出一个 Active Server,其余的都是 Standby Server,二者之间会进行数据和状态的实时同步。

在 Hypertable启动初始化的期间,Master模块会随机连接 Hyperspace集群中的任意一台服务器,如果这台 Hyperspace服务器恰好处于 Active状态,那么便完成了初始化;否则,如果处于 Standby状态,那么该服务器会将当前处于 Active状态的服务器地址发送给 Master,Master会重新连接。之后 Master的所有操作都发送给这个 Active Hyperspace服务器。也就是说,只有 Active Hyperspace才能真正对外提供服务。

而在 Hyperspace集群中,BDB(Berkley Database)也很重要。BDB服务也是采用集群部署的,也存在 Master角色,是 Hyperspace底层实现分布式数据一致性的核心。在 Hypertable对外提供服务的时候,任何对于元数据的操作,Master模块都会将其对应的事务请求发送给 Hyperspace服务器。在 BDB服务器接收到该事务请求后,会采用 Paxos的逻辑进行投票,得到半数以上的投票后就会反馈 Hyperspace,再由 Hyperspace反馈给 Master。

当某台处于 Active状态的 Hyperspace服务器出现故障时,集群中剩余的服务器会自动进行 Active选举。其核心逻辑是:根据所有服务器上事务日志的更新时间来确定哪个服务器的数据最新,越新的越可能被选为 Active Hyperspace。完成 Active选举后,剩下的 Standby Hyperspace会和新选出来的服务器进行数据同步,即所有 Hyperspace服务器对应的 BDB数据库的数据都需要和 Master BDB保持一致。

3.3 小结

Paxos算法晦涩难懂,学习成本高;但是其具有超强的容错能力和对分布式数据一致性的可靠保障。

四、ZooKeeper与 Paxos

4.1 初识 ZooKeeper

ZooKeeper为分布式应用提供了高效且可靠的分布式协调服务,提供了诸如统一命名服务、配置管理和分布式锁等基础服务。在解决分布式数据一致性方面,ZooKeeper并没有直接采用 Paxos算法,而是采用了 ZAB算法(ZooKeeper Atomic Broadcast)。

ZooKeeper介绍

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

分布式应用程序可以基于 ZK实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master选举、分布式锁和分布式队列等功能。ZK可以保证如下分布式一致性特性:

  • 顺序一致性:同一个客户端发起的多个事务请求,会严格按照发起顺序被 ZK执行
  • 原子性:所有事务请求的处理结果在整个 ZK集群中的应用情况是一样的,不会出现部分机器应用了,部分没应用的情况
  • 单一视图:无论客户端连接的是哪一个 ZK服务器,其看到的服务端数据模型都是一致的
  • 可靠性:一旦某个事务被成功地应用了,那么它带来的服务端状态变更一定会被保留下来,直到下一个事务的作用
  • 实时性:ZK保证在一定的时间段内,客户端最终一定能够从服务端上读到最新的数据状态

ZooKeeper设计目标

ZK追求高性能、高可用,且具有严格的顺序访问控制能力。

高性能使得 ZK能够应用于那些对系统吞吐有明确要求的大型分布式系统,高可用使得分布式的单点问题得到了很好的解决,严格的顺序访问控制使得客户端能基于 ZK实现一些复杂的同步原语。

ZK的四个设计目标:

  • 简单的数据模型:通过一个共享的、树形结构的名称空间进行协调;由一系列 ZNode数据节点组成,类似文件系统;全量数据都存在内存中,提高服务器的吞吐、减少延迟
  • 可以构建集群:通常由 3~5台机器组成;每台机器都会在内存中维护当前服务器状态,且互相保持通信
  • 顺序访问:对于每一个更新请求,ZK都会分配一个全局唯一的递增编号,反映了所有事务操作的先后顺序
  • 高性能:全量数据都存在内存,直接服务于客户端的所有非事务请求,适用于读操作为主的场景

ZooKeeper从何而来

ZK最早源于雅虎研究院的一个研究小组,雅虎内部很多大型系统基本都需要依赖一个类似的系统来进行分布式协调,但这些系统往往存在单点问题。所以雅虎开发人员试图开发一个通用的无单点问题的分布式协调框架。

在立项之初,考虑到之前内部很多项目都是使用动物的名字命名的,因此雅虎的工程师希望给这个项目也取一个动物名字。时任研究院的首席科学家 Raghu Ramakrishnan开玩笑地说:“再这样下去,我们这儿就变成动物园了!”此话一处,大家都统一以动物园管理者来命名。于是 ZooKeeper的名字由此诞生。

ZooKeeper的基本概念

集群角色(Role)

一般的集群模式是 Master/Slave模式的,在这种模式中,我们把能够处理所有写操作的机器称为 Master机器,把所有通过异步复制方式获取最新数据,并提供读服务的机器称为 Slave机器。

而 ZooKeeper采用了 Leader、Follower和 Observer三种角色。ZK集群中的所有机器通过一个 “Leader选举” 的过程来选定一台被称为【Leader】的机器,它为客户端提供读和写服务。其他角色包括 Follower和 Observer都仅能提供读服务。其中 Observer机器不参与 “Leader选举”过程,也不参与 “过半写成功”的策略,因此 Observer机器可以在不影响写性能的情况下提升集群的读性能。

会话(Session)

在 ZK中,一个客户端连接是指客户端和服务器之间的一个 TCP长连接。ZK对外的服务端口默认为 2181。客户端启动的时候,首先会与服务端建立一个 TCP连接,自此客户端会话的生命周期也开始了。

通过这个连接,客户端能够凭借心跳检测与服务器保持有效的会话,也能够向服务器发送请求并接受响应,同时还能够通过该连接接收来自服务器的 Watch事件通知。

Session的 sessionTimeout值(类似于 Chubby中的“宽期限”)用来设置一个客户端会话的超时时间。当由于服务器压力太大、网络故障或客户端主动断开等各种原因导致会话断开时,只需要在 sessionTimeout时间内能够重新连接上集群中的任意一台服务器,会话就依旧有效。

数据节点(ZNode)

在 ZK中,“节点”一般分为两类,第一类指构成集群的机器(机器节点),第二轮指数据模型中的数据单元(数据节点,ZNode)。

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

在 ZK中,ZNode可以分为【持久节点】和【临时节点】两类。持久节点指一旦创建后,除非显式地进行 ZNode的移除操作,否则这个 ZNode将一直保存着;而临时节点在创建后,一旦客户端会话失效,那么这个客户端创建的所有临时节点都会被移除。

此外,ZK允许用户为每个节点添加一个特殊的属性:SEQUENTIAL。一旦节点被该属性标记,那么在创建的时候,ZK会自动在其节点名后面追加一个整形数字(由父节点维护的自增序列)

版本(Version)

ZK的每个 ZNode上都会存储数据,对应于每个 ZNode,ZK都会维护一个称为 Stat的数据结构。

Stat中记录了这个 ZNode的三个数据版本,分别是 version(当前 ZNode的版本)、cversion(当前 ZNode子节点的版本)、aversion(当前 ZNode的 ACL版本)

事件监听器(Watcher)

ZK允许用户在指定节点上注册一些 Watcher,并在特定事件触发的时候,ZK会将事件通知到感兴趣的客户端处。

访问控制列表(ACL, Access Control Lists)

ZK采用 ACL策略进行权限控制,类似 UNIX文件系统的权限控制。

  • CREATE:创建子节点的权限
  • READ:获取节点数据和子节点列表的权限
  • WRITE:更新节点数据的权限
  • DELETE:删除子节点的权限
  • ADMIN:设置节点 ACL的权限

为什么选择 ZooKeeper

随着分布式架构的出现,越来越多的分布式应用会面临数据一致性的问题。在解决分布式数据一致性上,除了 ZooKeeper外,目前还没有一个成熟稳定且被大规模应用的解决方案。

且 ZK是开源的、免费的、已得到广泛的应用的。

4.2 ZooKeeper的 ZAB协议

ZAB协议

ZooKeeper并没有完全采用 Paxos算法,而是使用了一种称为 ZAB(ZooKeeper Atomic Broadcast)的协议作为其数据一致性的核心算法。

ZAB是为分布式协调服务 ZooKeeper专门设计的一种支持崩溃恢复的原子广播协议,它并不像 Paxos那样是通用的分布式一致性算法,而是特别为 ZooKeeper设计的。

ZAB协议的核心是定义了对于那些会改变 ZooKeeper服务器数据状态的事务请求的处理方式:

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

协议介绍

ZAB协议分为两种基本的模式:崩溃恢复 和 消息广播。

当整个服务框架在启动过程中,或是当 Leader服务器出现网络中断、崩溃退出与重启等异常情况时,ZAB协议就会进入【恢复模式】并选举产生新的 Leader服务器,同时集群中已经由过半的机器与该 Leader服务器完成了状态同步(数据同步)之后,ZAB协议就会退出恢复模式。

当集群中已经有过半的 Follower服务器完成了和 Leader服务器的状态同步,那么整个服务框架就可以计入【消息广播模式】了。当 Leader服务器接收到客户端的事务请求后,会生成对应的事务提案并发起一轮广播协议;而如果集群中的其他机器接收到了客户端的事务请求,那么这些非 Leader服务器会首先将这个事务请求转发给 Leader服务器。

如果一台统一遵守 ZAB协议的服务器启动后加入到集群中,且此时集群内已经存在了一个 Leader服务器在负责消息广播,那么新加入的服务器就会自觉进入【数据恢复模式】,它会找到 Leader,与其进行数据同步,然后一起参与到消息广播流程中去。

当 Leader服务器出现崩溃退出或机器重启,亦或是集群中已经不存在过半的服务器与该 Leader服务器保持正常通信时,那么在下一轮原子广播前,所有进程会先进入【恢复模式】来使彼此再达到一个一致的状态。

消息广播

ZAB的消息广播是原子的,类似一个二阶段提交的过程。

针对客户端的事务请求,Leader服务器会为其生成对应的事务 Proposal,并将其发送给集群中其余所有的机器,然后再分别收集各自的选票,最后进行事务提交。

ZAB的二阶段和 Paxos的二阶段略有不同。在 ZAB协议的二阶段提交过程中,移除了中断逻辑,所有的 Follower服务器要么正常反馈 Leader提出的事务 Proposal,要么抛弃 Leader服务器,这样的好处是当 Leader收到半数以上的 Ack后就可以直接提交事务 Proposal了,无需再等待其他的 Follower。但是这样无法处理 Leader服务器崩溃退出而带来的数据不一致问题。因此,ZAB协议中添加了【恢复模式】。此外,整个【消息广播协议】是基于具有 FIFO特性的 TCP协议来进行网络通信的,因此可以保证消息广播中的顺序性。

在整个消息广播中,Leader服务器会为每个事务请求生成对应的 Proposal来进行广播,且会分配一个全局单调递增的唯一 ID(ZXID)。由于 ZAB协议需要保证消息的顺序性,因此每个事务 Proposal都会严格按照 ZXID的先后顺序来进行排序处理。

具体的,Leader会为每个 Follower都分配一个单独的队列,然后将需要广播的事务 Proposal依次放入这些队列中,且根据 FIFO策略进行发送。每一个 Follower在接收到这个事务 Proposal后,都会首先将其以事务日志的形式写入磁盘,成功后返回 Ack给 Leader。当 Leader收到半数以上 Ack后,会提交事务,之后发出 Commit消息;而 Follower在收到 Commit消息后,也会提交事务。

崩溃恢复

当 Leader出现崩溃或因网络等原因失去了与过半 Follower的联系后,就会进入崩溃恢复模式,期间,需要选出一个新的 Leader服务器。该 Leader选举算法不仅需要让 Leader自己知道自己被选举了,同时还需要让集群中的所有其他机器也能够快速地感知到选举产生的新的 Leader服务器。

为了达到一致性,ZAB的 Leader选举算法要求为:确保提交所有已经被 Leader提交的事务 Proposal,同时丢弃已经被跳过的事务 Proposal。针对此,如果让 Leader选举算法能够保证新选举出来的 Leader服务器拥有集群中最大的 ZXID,那么就可以保证这个新的 Leader一定具有所有已提交的提案。并且,通过此可以省去 Leader服务器检查 Proposal的提交和丢弃工作的这一步操作了。

数据同步

完成 Leader选举后,在正式开始工作前,Leader会首先确认事务日志中的所有 Proposal是否都已经被集群中过半的机器提交了,即是否完成了数据同步。

正常情况下,Leader服务器会为每一个 Follower都准备一个队列,并将那些没有被各个 Follower同步的事务以 Proposal消息的形式逐个发送给 Follower服务器,并在每一个 Proposal消息后面紧接着再发送一个 Commit消息,以表示该事务已经被提交。等到 Follower服务器将所有其尚未同步的事务 Proposal都从 Leader服务器上同步过来并成功应用到本地数据库中后,Leader服务器就会将该 Follower服务器加入到真正的可用 Follower列表中。

而针对那些需要被丢弃的事务 Proposal,在 ZAB中,ZXID是一个 64位的数字,其中低 32位可以看作一个新事务的计数器(每次加一),而高 32位则代表了 Leader周期的 epoch编号,每当选举产生一个新的 Leader服务器,就会从这个 Leader服务器上取出其本地日志中最大事务 Proposal的 ZXID,并解析出 epoch值,然后对其加一后作为新的 epoch,并将低 32位全部置为 0,得到新的 ZXID。ZAB通过 epoch编号来区分 Leader周期变化,这能够有效地避免不同的 Leader服务器错误地使用相同的 ZXID编号。基于此,当一个包含了上一个 Leader周期中尚未提交过的事务 Proposal的服务器启动时,因为此时集群中一定包含了更高的 epoch的事务 Proposal,所以这个新机器的 Proposal一定不是最高的,肯定无法成为 Leader。那么,当该机器加入到集群中之后,Leader会根据自己服务器上最后被提交的 Proposal来和该服务器的 Proposal进行,要求该服务器进行一个回退操作。

4.3 深入 ZAB协议

系统模型

通常在一组进程 Π = {P1, P2, ..., Pn}组成的分布式系统中,其每个进程都有各自的存储设备,各进程之间通过相互通信来实现消息的传递。其中每一个进程都有可能出现一次或多次的崩溃,同样的在崩溃恢复后也会再次加入到进程组 Π中。如果一个进程正常工作,我们称该进程处于 UP状态;否则称其处于 DOWN状态。当集群中存在过半的处于 UP状态的进程组成了一个进程子集之后,就可以进行正常的消息广播了。我们称这样的一个进程子集为 Q(Quorum),并假设这样的 Q存在,且满足:

\[\Lambda \quad \forall Q, \quad Q\subseteq \Pi \\ \Lambda \quad \forall Q_1 和 Q_2, \quad Q_1\bigcap Q_2 \neq \empty \]

我们使用 Pi和 Pj来分布表示进程组 Π中的两个不同进程,使用 Cij来表示进程 Pi和 Pj之间的网络通信通道,其满足如下两个基本特性:

  1. 完整性(Integrity):进程 Pj如果收到来自进程 Pi的消息 m,那么进程 Pi一定确实发送了消息 m
  2. 前置性(Prefix):如果进程 Pj收到了消息 m,那么一定存在消息 m’:如果消息 m’是消息 m的前置消息,那么 Pj一定先接收 m’,再接收 m

问题描述

Zk是一个高可用的分布式协调服务,它存在大量的客户端进程,且都依赖 ZK完成一系列诸如可靠配置存储和运行时状态记录等分布式协调工作。鉴于这些大型应用对 ZK的依赖,ZK必须具备高吞吐和低延迟的特性,并且能很好地在高并发情况下完成分布式数据的一致性处理,同时能够优雅地处理运行时故障,并具备快速恢复的能力。

ZAB协议是整个 ZK的核心所在,其规定了任何时候都需要保证只有一个 Leader负责消息广播,如果 Leader崩溃了,就需要快速选出新的 Leader。Leader的选举和消息广播是紧密相关的。因为集群中每个进程都有可能成为 Leader,那么随着时间的推移,会出现多个主进程构成一个序列:P1, P2, ..., Pe-1, Pe,其中 Pe∈Π,e表示主进程序列号(epochID)。对于这个序列中的任意两个主进程来说,如果 e小于 e’,那么我们就称 Pe是 Pe’之前的主进程,并且因为一个进程可能会崩溃多次,也可能多次被选为 Leader,所以存在可能:Pe和 Pe’本质上是同一个进程,只是二者处于不同的周期中而已。

主进程周期

为了保证主进程每次广播出来的事务消息具有一致性,ZAB协议必须保证只有在充分完成了崩溃恢复之后,新的主进程才可以开始生成新的事务消息广播。在运行过程中,ZAB协议能够非常明确地告知上层系统(主进程和其他副本进程)是否可以开始事务消息的广播,同时 ZAB还需要为当前主进程设置一个实例值,用于唯一标识当前主进程的周期(epochID)

事务

主进程每次发布状态变更的广播都会调用 transaction(v, z)函数,其中包含两个字段:事务内容 v 和 事务标识 z,且 z = <e, c>,e 表示 主进程周期,c 表示 主进程周期内的事务计数 c。我们使用 epoch(z)来表示一个事务标识中的主进程周期 epoch,使用 counter(z)来表示事务标识中的事务计数。

针对每一个新的事务,主进程都会首先将事务计数 c 递增。如果一个事务标识 z 优先于另一个事务 z’,那么就有两种情况:

  1. 主进程周期优先,即 epoch(z) < epoch(z’)
  2. 事务计数优先,即 epoch(z) = epoch(z’) 且 counter(z) < counter(z’)
  3. 无论哪种都可以标识为 z < z’

算法描述

整个 ZAB协议主要包括 消息广播 和 崩溃恢复 两个过程,进一步可以划分为三个阶段,分别是 发现(Discovery)、同步(Synchronization)和 广播(Broadcast)阶段。组成 ZAB协议的每一个分布式进程会循环执行这三个阶段,我们称一个循环为一个主进程周期。

我们先定义一些专有标识和术语

术语名称 说 明
F·P Follower f 处理过的最后一个事务 Proposal
F·zxid Follower f 处理过的历史事务 Proposal中最后一个事务 Proposal的事务标识 ZXID
hf Follower f 处理过的事务序列
Ie 初始化历史记录,在某一个主进程周期 epoche中,当准 Leader完成 发现阶段后,此时它的 hf 就会被标记为 Ie
CEPOCH Follower向准 Leader发送自己处理过的最后一个事务 Proposal的 epoch值
NEWEPOCH 准 Leader根据接收的各 Follower的 epoch,来生成新一轮周期的 epoch值
ACK-E Follower针对 NEWEPOCH给准 Leader的反馈
NEWLEADER 准 Leader进程确立自己的领导地位,发送给各进程
ACK-LD Follower针对 NEWLEADER给 Leader的反馈
COMMIT-LD Leader要求 Follower提交相应的历史事务 Proposal
PROPOSE Leader进程生成一个针对客户端事务请求的 Proposal
ACK Follower针对 PROPOSE给 Leader的反馈
COMMIT Leader要求所有进程提交事务 PROPOSE

  • 阶段一:发现(Leader选举)

    • 步骤 F.1.1 -> Follwoer将自己最后接受的事务 Proposal的 epoch值 CEPOCH(F.p)发送给准 Leader
    • 步骤 L.1.1 -> 当准 Leader接收到来自过半 Follower的 CEPOCH(F.p)消息后,准 Leader会从中选取出最大的 epoch值,然后对其加一,得到 e’;之后,准 Leader 会生成 NEWEPOCH(e’)消息给这些过半的 Follwer。
    • 步骤 F.1.2 -> 当 Follower接收到来自准 Leader的 NEWEPOCH(e’)消息后,如果其检测到当前的 CEPOCH(F.p)小于 e’,那么就会将 NEWEPOCH(e’)赋值给 e’,同时反馈 Ack-E消息。在这个反馈消息中,包含了当前该 Follower的 epoch CEPOCH(F.p),以及该 Follower的历史事务 Proposal集合: hf
    • 步骤 L1.2 -> 当准 Leader接收到来自过半 Follower的确认消息 Ack之后,准 Leader就会从这过半服务器中选取出一个 ZXID最大的 Follower F,使用其作为初始化事务集合 Ie’
  • 阶段二:同步(数据一致)

    • 步骤 L.2.1 -> 准 Leader会将 e’ 和 Ie’NEWLEADER(e', Ie')消息的形式发送给所有的 Follower
    • 步骤 F.2.1-> 当 Follower接收到来自 Leader的 NEWLEADER(e', Ie')消息后,如果 Follower发现 CEPOCH(F.p) != e',那么之间进入下一轮循环,因为此时该 Follower还处于上一轮(甚至更上一轮),无法参与本次同步;如果 CEPOCH(F.p) == e',那么 Follower机会执行事务应用操作,对于每个事务 Proposal <v, z>∈Ie’,Follower都会接收<e’, <v, z>>。最后,Follower反馈 ACK-LD消息,表明自己已经完成了 Ie’中的所有事务
    • 步骤 L.2.2 -> 当准 Leader接收到来自过半 Follower针对 NEWLEADER(e', Ie')的反馈消息后,就会向所有的 Follower发送 Commit消息。
    • 步骤 F.2.2 -> 当 Follower接收到来自准 Leader的 Commit消息后,就会依次处理并提交所有在 Ie’中未处理的事务
  • 阶段三:广播(发布数据)

    • 步骤 L.3.1 -> Leader接收到客户端新的事务请求后,生成对应的事务 Proposal,并根据 ZXID的顺序向所有 Follower发送提案 <e’, <v, z>>,其中 epoch(z) == e’
    • 步骤 F.3.1 -> Follower根据消息接收的先后次序来处理这些来自 Leader的事务 Proposal,并将它们追加到 hf中去,之后再反馈给 Leader
    • 步骤 L.3.2 -> 当 Leader接收到来自过半 Follower针对事务 Proposal<e’, <v, z>>的 Ack消息后,就会发送 Commit<e‘, <v, z>>消息给所有的 Follower,要求它们进行事务的提交
    • 步骤 F.3.2 -> 当 Follower接收到来自 Leader的 Commit消息后,就会提交对应的事务。并且此时,该 Follower必定已经提交了该事务前的所有事务。

正常运行过程中,ZAB协议会一直运行于阶段三来反复地进行消息广播,如果出现 Leader崩溃或其他原因导致 Leader缺少,那么 ZAB会进入阶段一来重新选举 Leader。并且只有完成了阶段二,即完成了数据同步之后,准 Leader才能真正地成为 Leader。进入到阶段三后,Leader会以队列的形式为每一个与自己保持同步的 Follower创建一个操作队列。

运行分析

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

  • LOOKING:Leader选举阶段
  • FOLLOWING:作为 Follower和 Leader保持同步状态
  • LEADING:作为 Leader领导状态

当组成 ZAB协议的所有进程启动的时候,其初始化状态都是 LOOKING,此时进程组中不存在 Leader。所有处于 LOOKING状态都会试图选举出一个 Leader。如果,进程发现已经选举出 Leader了,那么就会进入到 FOLLOWING状态,并开始和 Leader保持同步。如果被选举为了 Leader,就会进入 LEADING状态。如果 Leader崩溃了,那么之前处于 FOLLOWING状态的进程又会回到 LOOKING状态,并开始新一轮的选举。因此,在 ZAB协议运行过程中,每个进程都会在三个状态间不断切换。

4.4 小结

ZAB并不是 Paxos算法的典型实现

  • 两者都存在一个类似 Leader的角色,负责协调多个 Follower
  • Leader进程都会等待过半 Follower做出正确反馈后才会提交提案
  • 在 ZAB协议中,每个 Proposal都包含了 epoch值,代表当前的 Leader周期;Paxos中也存在类似的值,称为 Ballot

在 Paxos算法中,一个新选出来的 Leader会先和其他进程通信,收集上一个主进程提出的提案,并将它们提交;之后才会开始提出自己的提案。在此基础上,ZAB额外添加了一个同步阶段。在同步阶段中,新的 Leader会确保存在过半的 Follower已经提交了之前 Leader周期中的所有事务 Proposal,正是这一步的引入,保证了 Leader在新的周期中提出新事务前,所有的进程都已经完成了对之前事务的提交。

总体而言,ZAB协议和 Paxos算法的本质区别在于,二者的设计目标不同。ZAB的目标是构建一个高可用的分布式数据主备系统。而 Paxos是为了构建一个分布式的一致性状态机系统。

五、使用 ZooKeeper

5.1 部署与运行

系统环境

GNU/Linux、Sunday Solaris、Win32以及 Mac OS X均可。

ZK使用 Java语言编写,因此需要 Java环境的支持。

集群与单机

ZK有两种运行模式:集群模式 和 单机模式。

  • 集群模式:三台机器模拟 ZK集群

    1. 准备 Java环境

    2. 下载 ZK安装包并解压

    3. 配置文件 conf/zoo.cfg,三台机器上的 zoo.cfg文件都需要一样

      tickTime=2000
      dataDir=/var/lib/zookeeper
      clientPort=2181
      initLimit=5
      syncLimit=2
      server.1=192.168.44.10:2888:3888 # 每一行代表一个机器配置 server.id=host:port:port,id表示集群中的机器序号
      server.2=192.168.44.20:2888:3888
      server.3=192.168.44.30:2888:3888
      
    4. dataDir目录下,创建 myid文件,里面只有一个数字,标识本台机器

      1 # 本机器对应的 server.id中的 id值
      
    5. 使用 bin/zkServer.sh脚本,启动服务器

      bash zkServer.sh start
      
    6. 验证服务器

      telnet 127.0.0.1 2181
      stat
      
  • 单机模式:和集群模式基本一致

    1. 配置 zoo.cfg,在机器列表上只要配一台就行了,其他都一样。

      server.1=192.168.44.10:2888:3888
      
  • 伪集群模式:借助于硬件的虚拟化技术,把一台物理机转换为多台虚拟机

    1. 配置 zoo.cfg,三个机器的ip写成一样的,但是端口写成不同的。

      server.1=192.168.44.10:2888:3888
      server.2=192.168.44.10:2889:3889
      server.3=192.168.44.10:2890:3890
      

运行服务

ZooKeeper的常见启动方式有两种

  • Java命令行启动
  • 使用 ZK自带的启动脚本启动
脚 本 说 明
zkCleanup 清理 ZooKeeper历史数据,包括事务日志文件和快照数据文件
zkCli ZooKeeper的一个简易客户端
zkEnv 设置 ZooKeeper的环境变量
zkServer ZooKeeper服务器的启动、停止和重启脚本

停止服务

使用 zkServer脚本的 stop命令来停止服务

bash zkServer.sh stop

常见异常

  • 端口被占用:Address already in use -> 2181端口已经被其他进程占用了
  • 磁盘没有剩余空间:No space left on device -> 清理磁盘
  • 端口没开启(防火墙的问题,开启后最好再刷新一下防火墙)
  • 无法找到 myid文件:Invalid config, exiting abnormally -> 在 dataDir下创建 myid文件

5.2 客户端脚本

通过 zkCli脚本可以直接连接 zk服务端

bash zkCli.sh
bash zkCli.sh -server ip:port

创建

使用 create命令,可以创建一个 ZooKeeper节点

create [-s] [-e] path data acl
	-s:顺序节点
	-e:临时节点
	acl:权限控制

默认创建持久节点

读取

使用 ls命令,可以列出 ZooKeeper指定节点下的所有子节点

ls path [watch]

默认在根节点 / 下面有一个叫做 /zookeeper的保留节点

使用 get命令,可以读取 ZooKeeper指定节点的数据内容和属性信息

get path [watch]

更新

使用 set命令,可以更新指定节点的数据内容

set path data [version]
	version: 在 zk中,节点的数据是有版本的,这个参数用于指定本次更新操作是基于 ZNode的哪一个数据版本进行的

删除

使用 delete命令,可以删除 ZooKeeper上的指定节点

delete path [version]

无法使用 delete删除一个包含子节点的节点

5.3 Java客户端 API使用

需要事先引入相关依赖

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

创建会话

客户端通过创建一个 ZooKeeper实例来连接服务器。

ZooKeeper(String connectString, int sessionTimeout, Watcher watcher, long sessionId, byte[] sessionPasswd, boolean canBeReadOnly)
  • connectString:指定 ZK服务器列表,eg:192.168.1.1:2181,192.168.1.2:2181 ;也可以直接在列表路径中指定目录,eg:192.168.1.1:2181/foo/bar,/foo/bar称为 Chroot,即客户端隔离命名空间
  • sessionTimeout:会话超时时间,毫秒值;超出该值的时间后还没有心跳,session失效
  • watcher:作为默认的 Watcher处理器
  • canBeReadOnly:标识当前会话是否支持“read-only”模式,如果是的话,在半数以上服务器宕机后,集群还可以读
  • sessionId和 sessionPasswd:分别代表会话 ID和会话密钥,二者唯一确定一个会话。客户端使用这两个参数,可以直接使用之前的会话,实现会话复用、会话恢复效果。

ZK客户端和服务端会话的建立是一个异步的过程,该构造方法会在处理完客户端初始化后立即返回,在大多数情况下,此时并没有真正建立好一个可用的会话。当该会话真正创建完毕后,ZK服务端会向该会话对应的客户端发送一个事件通知,客户端只有在获得了这个通知之后,才算真正建立了对话。

/**
 * 创建一个最基本的 ZK会话实例
 */
public class ZooKeeper_Constructor_Usage_Simple implements Watcher {

    private static CountDownLatch latch = new CountDownLatch(1);

    @Override
    public void process(WatchedEvent watchedEvent) {
        System.out.println("receive watched event: " + watchedEvent);
        if (Event.KeeperState.SyncConnected == watchedEvent.getState()) {
            latch.countDown();
        }
    }


    public static void main(String[] args) throws IOException {
        ZooKeeper zooKeeper = new ZooKeeper("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181",
                5000, new ZooKeeper_Constructor_Usage_Simple());
        System.out.println(zooKeeper.getState());

        try {
            latch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(zooKeeper.getState());
    }

}

/*
CONNECTING
receive watched event: WatchedEvent state:SyncConnected type:None path:null
CONNECTED
*/
/**
 * 复用 sessionId和 sessionPasswd
 */
public class ZooKeeper_Constructor_Usage_With_SID_PASSWD implements Watcher {

    private static CountDownLatch latch = new CountDownLatch(1);

    @Override
    public void process(WatchedEvent watchedEvent) {
        System.out.println("receive watched event: " + watchedEvent);
        if (Event.KeeperState.SyncConnected == watchedEvent.getState()) {
            latch.countDown();
        }
    }


    public static void main(String[] args) throws IOException {
        ZooKeeper zooKeeper = new ZooKeeper("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181",
                5000, new ZooKeeper_Constructor_Usage_With_SID_PASSWD());
        System.out.println(zooKeeper.getState());

        try {
            latch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 获取原连接的 sessionId和 sessionPasswd
        long sessionId = zooKeeper.getSessionId();
        byte[] sessionPasswd = zooKeeper.getSessionPasswd();

        // 使用原来的 sessionId和 sessionPasswd进行连接复用
        zooKeeper = new ZooKeeper("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181",
                5000, new ZooKeeper_Constructor_Usage_With_SID_PASSWD(), sessionId, sessionPasswd);
        
        System.out.println(zooKeeper.getState());
    }
}

创建节点

String create(String path, byte[] data, List<ACL> acl, CreateMode createMode, Stat stat, long ttl); // 同步创建
create(String path, byte[] data, List<ACL> acl, CreateMode createMode, Create2Callback cb, Object ctx, long ttl);  // 异步创建
  • path:数据节点的路径
  • data[]:该节点的值
  • acl:节点的 ACL策略
  • createMode:节点类型
    • PERSISTENT
    • PERSISTENT_SEQUENTIAL
    • EPHEMERAL
    • EPHEMERAL_SEQUENTIAL
  • cb:异步回调函数
  • ctx:上下文信息,回调时使用

无论是同步创建还是异步创建,ZK都不支持递归创建,即无法在父节点不存在的情况下创建子节点,且节点不能重名。

ZK不负责节点内容的序列化,因此传参时开发者必须实现手动序列化。

/**
 * 同步创建节点
 */
public class ZooKeeper_CREATE_API_Sync_Usage implements Watcher {

    private static CountDownLatch latch = new CountDownLatch(1);

    @Override
    public void process(WatchedEvent watchedEvent) {
        System.out.println("receive watched event: " + watchedEvent);
        if (Event.KeeperState.SyncConnected == watchedEvent.getState()) {
            latch.countDown();
        }
    }


    public static void main(String[] args) throws IOException, InterruptedException, KeeperException {
        ZooKeeper zooKeeper = new ZooKeeper("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181",
                5000, new ZooKeeper_CREATE_API_Sync_Usage());
        System.out.println(zooKeeper.getState());

        latch.await();

        String path1 = zooKeeper.create("/zk-test-ephemeral-",
                "".getBytes(StandardCharsets.UTF_8), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
        System.out.println("success on creating znode: " + path1);

        String path2 = zooKeeper.create("/zk-test-ephemeral-",
                "".getBytes(StandardCharsets.UTF_8), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
        System.out.println("success on creating znode: " + path2);

        System.out.println(zooKeeper.getState());
    }
}
/**
 * 异步创建节点
 */
public class ZooKeeper_CREATE_API_ASync_Usage implements Watcher {

    private static CountDownLatch latch = new CountDownLatch(1);

    @Override
    public void process(WatchedEvent watchedEvent) {
        System.out.println("receive watched event: " + watchedEvent);
        if (Event.KeeperState.SyncConnected == watchedEvent.getState()) {
            latch.countDown();
        }
    }


    public static void main(String[] args) throws IOException, InterruptedException, KeeperException {
        ZooKeeper zooKeeper = new ZooKeeper("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181",
                5000, new ZooKeeper_CREATE_API_ASync_Usage());
        System.out.println(zooKeeper.getState());

        latch.await();

        zooKeeper.create("/zk-test-ephemeral-",
                "".getBytes(StandardCharsets.UTF_8), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL, (i, s, o, s1) -> {
                    System.out.println("create path result: [" + i + "," + s + "," + o + "," + s1 + "]");
                }, "I am context");

        zooKeeper.create("/zk-test-ephemeral-",
                "".getBytes(StandardCharsets.UTF_8), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL, (i, s, o, s1) -> {
                    System.out.println("create path result: [" + i + "," + s + "," + o + "," + s1 + "]");
                }, "I am context");
        System.out.println(zooKeeper.getState());
    }
}

异步创建时,所有的结果都会在回调函数中通过 ResultCode来体现

回调方法 void processResult(int rc, String path, Object ctx, String name)

  • rc:ResultCode,服务端响应码,客户端可以通过该值识别出 API的调用结果
    • 0:接口调用成功
    • -4:连接已断开
    • -110:节点已存在
    • -112:会话已过期
  • path:接口调用时传入的节点路径
  • ctx:接口调用时传入的 Object ctx
  • name:实际在服务端创建的节点名称(在顺序创建时,会显式后缀的序号)

删除节点

void delete(String path, int version); // 同步删除
void delete(String path, int version, VoidCallback cb, Object ctx); // 异步删除
  • path:节点路径
  • version:节点的数据版本
  • cb:异步回调
  • ctx:传递的上下文信息

ZK中只允许删除叶子节点,如果一个节点存在至少一个子节点的话,该节点无法被直接删除。

读取数据

GetChildren

List<String> getChildren(String path, Watcher watcher, Stat stat); // 同步获取子节点
void getChildren(String path, boolean watch, Children2Callback cb, Object ctx); // 异步获取子节点
  • path:节点路径
  • watcher:注册的 Watcher,当本次子节点获取之后,子节点列表发送变化,那么就会通过该 Watcher向客户端发送通知
  • watch:是否需要注册一个 Watcher
  • cb:异步回调
  • stat:数据节点的状态信息
  • ctx:传递的上下文信息

ZK客户端在获取到指定节点的子节点列表时,可以订阅该列表的变化通知。当有子节点的增删时,服务端会发送一个 NodeChildrenChanged(EventType.NodeChildrenChanged)类型的事件通知。但是这个通知,仅仅是一个通知而已,客户端获取到之后需要主动重新获取最新的子节点列表。

有时候,我们不仅需要获取子节点列表,还需要获取当前节点的基本信息,此时我们可以传递一个旧的 stat变量,服务端会替换新的 stat。

/**
 * 同步获取子节点列表
 */
public class ZooKeeper_GetChildren_API_Sync_Usage implements Watcher {

    private static CountDownLatch latch = new CountDownLatch(1);
    private static ZooKeeper zooKeeper = null;

    @Override
    public void process(WatchedEvent watchedEvent) {
        if (Event.KeeperState.SyncConnected == watchedEvent.getState()) {
            if (Event.EventType.None == watchedEvent.getType() && null == watchedEvent.getPath()) {
                latch.countDown();
            } else if (Event.EventType.NodeChildrenChanged == watchedEvent.getType()) {
                try {
                    System.out.println("ReGet Children: " + zooKeeper.getChildren(watchedEvent.getPath(), true));
                } catch (KeeperException e) {
                    e.printStackTrace();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }


    public static void main(String[] args) throws IOException, InterruptedException, KeeperException {
        zooKeeper = new ZooKeeper("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181",
                5000, new ZooKeeper_GetChildren_API_Sync_Usage());
        System.out.println(zooKeeper.getState());

        latch.await();

        String path = "/cly";

        zooKeeper.create(path,
                "".getBytes(StandardCharsets.UTF_8), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);

        zooKeeper.create(path + "/c1",
                "".getBytes(StandardCharsets.UTF_8), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);

        // 同步获取,并且注册一个 Watcher,该 watcher是一次性的,触发一次之后就会失效
        List<String> children = zooKeeper.getChildren(path, true);
        System.out.println(children);

        // 变更子节点列表,因为 Watcher的存在,服务端会像客户端发送一个通知
        zooKeeper.create(path + "/c2",
                "".getBytes(StandardCharsets.UTF_8), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
        Thread.sleep(Integer.MAX_VALUE);
    }
}
/**
 * 异步获取子节点列表
 */
public class ZooKeeper_GetChildren_API_ASync_Usage implements Watcher {

    private static CountDownLatch latch = new CountDownLatch(1);
    private static ZooKeeper zooKeeper = null;

    @Override
    public void process(WatchedEvent watchedEvent) {
        if (Event.KeeperState.SyncConnected == watchedEvent.getState()) {
            if (Event.EventType.None == watchedEvent.getType() && null == watchedEvent.getPath()) {
                latch.countDown();
            } else if (Event.EventType.NodeChildrenChanged == watchedEvent.getType()) {
                try {
                    System.out.println("ReGet Children: " + zooKeeper.getChildren(watchedEvent.getPath(), true));
                } catch (KeeperException e) {
                    e.printStackTrace();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }


    public static void main(String[] args) throws IOException, InterruptedException, KeeperException {
        zooKeeper = new ZooKeeper("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181",
                5000, new ZooKeeper_GetChildren_API_ASync_Usage());
        System.out.println(zooKeeper.getState());

        latch.await();

        String path = "/zwb";

        zooKeeper.create(path,
                "".getBytes(StandardCharsets.UTF_8), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);

        zooKeeper.create(path + "/c1",
                "".getBytes(StandardCharsets.UTF_8), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);

        zooKeeper.getChildren(path, true, new AsyncCallback.Children2Callback() {
            @Override
            public void processResult(int i, String s, Object o, List<String> list, Stat stat) {
                System.out.println("result: [" + i + "," + s + "," + o + "m" + list + "," + stat + "]");
            }
        }, "I am context");

        zooKeeper.create(path + "/c2",
                "".getBytes(StandardCharsets.UTF_8), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
        Thread.sleep(Integer.MAX_VALUE);
    }
}

GetData

byte[] getData(String path, boolean watch, Stat stat); // 同步获取
void getData(String path, Watcher watcher, DataCallback cb, Object ctx); // 异步获取

客户端在获取一个节点的数据的时候,也可以注册 Watcher,当该节点的状态发生变更,那么服务端就会向客户端发送一个 NodeDataChanged(EventType.NodeDataChanged)的事件通知。

更新数据

Stat setData(String path, byte[] data, int version); // 同步更新
void setData(String path, byte[] data, int version, StatCallback cb, Object ctx); // 异步更新

version参数用于指定数据的版本,表明本次更新操作是针对指定的版本进行的。其实就是用于 CAS的,保证分布式环境下,数据更新的安全性,并不是说会保存多个版本的数据。version从 0开始计数,-1表示最新版本。

检测节点是否存在

Stat exists(String path, Watcher watcher); // 同步检测
void exists(String path, Watcher watcher, StatCallback cb, Object ctx); // 异步检测

如果传入了监听器,那么在改进的发生变动后,会得到来自服务端的通知。无论节点是否存在,调用 exists()接口都能注册 Watcher,它能对针对该节点(子节点不行)的一切变动进行监听

权限控制

一个 ZK服务器集群可能同时为多个应用提供服务,为了避免存储在 ZK上的数据被其他进程干扰,需要对数据进行权限控制(Access Control)。ZK提供了 ACL的权限控制机制,它通过设置 ZK服务器上 ZNode的 ACL,来控制客户端对该数据节点的访问权限。ZK提供了多种权限控制模式:world、auth、digest、ip和 super。

开发人员如果需要使用 ZK的权限控制功能,需要在会话创建后,手动给该会话添加相关的权限信息。

addAuthInfo(String schema, byte[] auth)
  • schema:权限控制模式,world、auth、digest、ip和 super
  • auth:具体的权限信息
/**
 * 为会话添加权限,并且在该权限下,创建节点
 */
public class AuthSample {
    final static String PATH = "/zwb-cly";

    public static void main(String[] args) throws IOException, InterruptedException, KeeperException {
        // 创建会话
        ZooKeeper zooKeeper = new ZooKeeper("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181",
                50000, null);
        // 添加权限
        zooKeeper.addAuthInfo("digest", "foo:true".getBytes(StandardCharsets.UTF_8));
        // 添加受权限控制的节点,其他无权限的会话将无法获取当前节点的数据
        zooKeeper.create(PATH, "".getBytes(StandardCharsets.UTF_8), ZooDefs.Ids.CREATOR_ALL_ACL, CreateMode.PERSISTENT);
        Thread.sleep(Integer.MAX_VALUE);
    }
}

在 ZK中,几乎所有的 API接口操作,都会受到权限的限制。

但是针对【删除】操作,却比较特别。如果 session1添加了权限 A,创建了节点 /a 和 /a/b;那么任何无 A权限的 session都无法删除二者;但是 session2添加了权限 A,删除了 /a/b,那么之后所有 session都可以删除 /a了(针对 /a的权限丢失了)

5.4 ZkClient

ZkClient是对 ZooKeeper原生 API接口的一个封装,是一个更易用的 ZooKeeper客户端。

其 Maven依赖:

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

<dependency>
    <groupId>com.101tec</groupId>
    <artifactId>zkclient</artifactId>
    <version>0.10</version>
</dependency>

创建会话

ZkClient(IZkConnection zkConnection, int connectionTimeout, ZkSerializer zkSerializer, long operationRetryTimeout);
  • zkServers:指定的 ZK服务器列表,eg: 192.168.26.10:2181,192.168.26.20:2181
  • sessionTimeout:会话超时时间,默认为 30000ms
  • connectionTimeout:连接超时时间,默认为 Integer.MAX_VALUE ms
  • zkConnection:IZkConnection接口的实现类
  • zkSerializer:自定义序列化器
  • operationRetryTimeout:操作超时时间

ZkClient通过包装,将异步的客户端连接建立过程同步化了。IZkConnection接口是对 ZooKeeper原生接口最直接的包装。

在注册完序列化器之后,客户端在进行读写操作的过程中,都会自动地进行序列化和反序列化。

并且 ZkClient引入了 Listener来实现 Watcher的注册。

/**
 * 使用 ZkClient来创建一个 ZooKeeper客户端
 */
public class Create_Session_Sample {
    public static void main(String[] args) {
        ZkClient zkClient = new ZkClient("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181", 5000);
        System.out.println("ZooKeeper session established");
    }
}

创建节点

create(final String path, Object data, final List<ACL> acl, final CreateMode mode);
createEphemeral(String path, Object data, List<ACL> acl);
createEphemeralSequential(String path, Object data, List<ACL> acl);
createPersistent(String path, boolean createParents, List<ACL> acl)
createPersistentSequential(String path, Object data, List<ACL> acl);
  • path:ZNode的路径
  • data:ZNode中存储的值
  • mode:节点类型
  • acl:节点的 ACL策略
  • createParents:指定是否创建父节点

原生接口只允许传入字节数组,而 ZkClient提供的接口,由于支持了自定义序列化器,可以传入复杂对象作为参数。

并且通过 createParents参数,ZkClient能够在内部递归建立父节点。

/**
 * 使用 ZkClient创建节点
 */
public class Create_Node_Sample {
    public static void main(String[] args) {
        ZkClient zkClient = new ZkClient("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181", 5000);
        zkClient.createPersistent("/zkClient/zwb/cly", true);
    }
}

删除节点

boolean delete(final String path, final int version);
boolean deleteRecursive(String path);

在 ZkClient中,通过调用 deleteRecursive(String path)方法,可以实现逐层遍历删除节点。

读取数据

getChildren

List<String> getChildren(final String path, final boolean watch);
List<String> subscribeChildChanges(String path, IZkChildListener listener);

集合中存储的值依旧是子节点的相对路径(相对于 path)。

客户端可以通过调用 subscribeChildChanges()接口,注册针对子列表变更的监听,一旦发生变更,服务端就会发出通知,由这个 listener的handleChildChange(String parentPath, List<String> currentChilds)来进行处理。

  • parentPath:子节点变更后通知对应的父节点的路径
  • currentChilds:当前子节点列表
/**
 * 使用 ZkClient获取子节点列表
 */
public class Get_Children_Sample {
    public static void main(String[] args) throws InterruptedException {
        ZkClient zkClient = new ZkClient("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181", 5000);
        // 注册监听,客户端可以针对一个尚未存在的节点进行监听,且 ZkClient提供的监听器不是一次性的
        zkClient.subscribeChildChanges("/zkClient", (parentPath, currentChilds) -> {
            System.out.println("handleChildChange ... [" + parentPath + "," + currentChilds + "]");
        });

        // 进行一系列的修改
        zkClient.createPersistent("/zkClient/tmp", true);
        Thread.sleep(1000);

        zkClient.delete("/zkClient/tmp");
        Thread.sleep(1000);

        zkClient.deleteRecursive("/zkClient");
        Thread.sleep(1000);

    }
}

getData

<T extends Object> T readData(String path, boolean returnNullIfPathNotExists);
<T extends Object> T readData(final String path, final Stat stat, final boolean watch);
void subscribeDataChanges(String path, IZkDataListener listener)
  • returnNullIfPathNotExists:默认情况下如果节点不存在会报错,如果这个参数设为了 true,就会返回 null
  • stat:获取该 ZNode的 stat数据

客户端可以通过 subscribeDataChanges()接口监听节点数据的变更,如果内容变动,则会使用handleDataChange(String dataPath, Object data)方法处理;如果节点删除,则会使用handleDataDeleted(String dataPath)方法处理。

  • dataPath:事件通知对应的节点路径
  • data:最新的数据内容
/**
 * 使用 ZkClient获取节点数据内容
 */
public class Get_Data_Sample {
    public static void main(String[] args) throws InterruptedException {
        ZkClient zkClient = new ZkClient("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181", 5000);
        zkClient.subscribeDataChanges("/zkClient", new IZkDataListener() {
            @Override
            public void handleDataChange(String dataPath, Object data) throws Exception {
                System.out.println("Node " + dataPath + " changed, new data: " + data);
            }

            @Override
            public void handleDataDeleted(String dataPath) throws Exception {
                System.out.println("Node " + dataPath + " deleted");
            }
        });


        String o = zkClient.readData("/zkClient");
        System.out.println(o);
        Thread.sleep(1000);

        zkClient.writeData("/zkClient", "123");
        Thread.sleep(1000);

        zkClient.delete("/zkClient");
        Thread.sleep(1000);
    }
}

更新数据

void writeData(final String path, Object datat, final int expectedVersion);
Stat writeDataReturnStat(final String path, Object datat, final int expectedVersion);
  • expectedVersion:预期的数据版本,可用于 CAS操作

检测节点是否存在

exists(final String path, final boolean watch);

5.5 Curator

Curator也是对 ZooKeeper原生 API的封装,并且还提供了 ZK的各种场景应用(选举、锁、计数等)

Curator的 Maven依赖:

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

<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-framework</artifactId>
    <version>5.2.0</version>
</dependency>

创建会话

  1. 使用 CuratorFrameworkFactory的静态方法 newClient创建客户端
  2. 使用 CuratorFramework中的 start来创建会话
static newClient(String connectString, int sessionTimeoutMs, int connectionTimeoutMs, RetryPolicy retryPolicy, ZKClientConfig zkClientConfig);
void start();
  • connectString:ZK服务器列表,格式和之前的一样
  • retryPolicy:重试策略
    • ExponentialBackoffRetry:指数补偿
    • RetryNTimes:重试多少次
    • RetryOneTime:重试一次
    • RetryUntilElapsed:直到多少秒之后停止
/**
 * 使用 Curator来创建一个 ZooKeeper客户端
 */
public class Create_Session_Sample {
    public static void main(String[] args) throws InterruptedException {
        // 创建重试策略
        ExponentialBackoffRetry retryPolicy = new ExponentialBackoffRetry(1000, 3);
        // 创建客户端
        CuratorFramework client = CuratorFrameworkFactory.newClient("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181",
                5000, 3000, retryPolicy);
        // 创建 session会话
        client.start();
    }
}

ExponentialBackoffRetry的重试策略如下:

给定一个初始 sleep时间 baseSleepTimeMs,在这个基础上结合重试次数,通过以下公式计算当前需要 sleep的时间:

当前 sleep时间 = baseSleepTimeMs * Math.max(1, random.nextInt(1 << (retryCount + 1)))

也就是说,随着重试的次数增加,sleep的时间会越来越大,如果该 sleep时间大于 maxSleepMs了,那么就使用 maxSleepMs,并且重试次数不能大于 maxRetries。

/**
 * 使用 Fluent风格的 API接口来创建会话
 */
public class Create_Session_Sample_Fluent {
    public static void main(String[] args) throws InterruptedException {
        // 创建重试策略
        ExponentialBackoffRetry retryPolicy = new ExponentialBackoffRetry(1000, 3);
        // 创建客户端
        CuratorFramework client = CuratorFrameworkFactory.builder()
                .connectString("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181")
                .sessionTimeoutMs(5000)
                .retryPolicy(retryPolicy)
            	.namespace("curator") // 创建隔离命名空间
                .build();
        // 创建 session会话
        client.start();
    }
}

创建节点

  • 创建一个节点,初始内容为空 -> client.create().forPath(path)
  • 创建一个节点,附带初始内容 -> client.create().forPath(path, "xxx".getBytes())
  • 创建一个临时节点 -> client.create().withMode(CreateMode.EPHEMERAL).forPath(path)
  • 创建一个节点,递归创建父节点 -> client.create().creatingParentsIfNeeded().forPath(path)
/**
 * 使用 Curator创建节点
 */
public class Create_Node_Sample {
    public static void main(String[] args) throws Exception {
        // 创建重试策略
        ExponentialBackoffRetry retryPolicy = new ExponentialBackoffRetry(1000, 3);
        // 创建客户端
        CuratorFramework client = CuratorFrameworkFactory.newClient("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181",
                5000, 3000, retryPolicy);
        // 创建 session会话
        client.start();

        String path = client.create()
                .creatingParentContainersIfNeeded()
                .withMode(CreateMode.EPHEMERAL)
                .forPath("/curator", "hahaha".getBytes(StandardCharsets.UTF_8));
        System.out.println(path);
    }
}

删除节点

  • 删除一个节点 -> client.delete().forPath(path)
  • 递归删除一个节点 -> client.delete().deletingChildrenIfNeeded().forPath(path)
  • 指定版本删除节点 -> client.delete().withVersion(version).forPath(path)
  • 保证强制删除 -> client.delete().guaranteed().forPath(path)
/**
 * 使用 Curator删除节点
 */
public class Del_Node_Sample {
    public static void main(String[] args) throws Exception {
        // 创建重试策略
        ExponentialBackoffRetry retryPolicy = new ExponentialBackoffRetry(1000, 3);
        // 创建客户端
        CuratorFramework client = CuratorFrameworkFactory.newClient("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181",
                5000, 3000, retryPolicy);
        // 创建 session会话
        client.start();

        String path = client.create()
                .creatingParentContainersIfNeeded()
                .withMode(CreateMode.EPHEMERAL)
                .forPath("/curator");

        Stat stat = new Stat();
        client.getData()
                .storingStatIn(stat)
                .forPath("/curator");

        client.delete()
                .guaranteed()
                .deletingChildrenIfNeeded()
                .withVersion(stat.getVersion())
                .forPath("/curator");
    }
}

guaranteed()方法保证客户端会记录本次删除操作,即使出现网络原因导致客户端无法连接服务端,只要会话能够恢复,客户端后台会反复重试删除操作。

读取数据

  • 读取一个节点的数据 -> client.getData().forPath(path)
  • 读取节点数据的同时,获取该节点的 stat -> client.getData().storingStatIn(stat).forPath(path)
/**
 * 使用 Curator读取数据
 */
public class Get_Data_Sample {
    public static void main(String[] args) throws Exception {
        // 创建重试策略
        ExponentialBackoffRetry retryPolicy = new ExponentialBackoffRetry(1000, 3);
        // 创建客户端
        CuratorFramework client = CuratorFrameworkFactory.newClient("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181",
                5000, 3000, retryPolicy);
        // 创建 session会话
        client.start();

        String path = client.create()
                .creatingParentContainersIfNeeded()
                .withMode(CreateMode.EPHEMERAL)
                .forPath("/curator", "hahaha".getBytes(StandardCharsets.UTF_8));

        Stat stat = new Stat();
        byte[] data = client.getData()
                .storingStatIn(stat)
                .forPath("/curator");
        System.out.println(new String(data));
    }
}

更新数据

  • 更新一个节点的数据内容 -> client.setData().forPath(path)
  • 指定版本更新 -> client.setData().withVersion(version).forPath(path)
/**
 * 使用 Curator设置数据
 */
public class Set_Data_Sample {
    public static void main(String[] args) throws Exception {
        // 创建重试策略
        ExponentialBackoffRetry retryPolicy = new ExponentialBackoffRetry(1000, 3);
        // 创建客户端
        CuratorFramework client = CuratorFrameworkFactory.newClient("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181",
                5000, 3000, retryPolicy);
        // 创建 session会话
        client.start();

        String path = client.create()
                .creatingParentContainersIfNeeded()
                .withMode(CreateMode.EPHEMERAL)
                .forPath("/curator", "hahaha".getBytes(StandardCharsets.UTF_8));

        Stat stat = new Stat();
        byte[] data = client.getData()
                .storingStatIn(stat)
                .forPath("/curator");
        System.out.println(new String(data));

        stat = client.setData()
                .withVersion(stat.getVersion())
                .forPath("/curator", "asdasdad".getBytes(StandardCharsets.UTF_8));

        data = client.getData()
                .storingStatIn(stat)
                .forPath("/curator");
        System.out.println(new String(data));
    }
}

异步接口

Curator引入了 BackgroundCallback接口来处理异步调用后服务端返回的结果

public interface BackgroundCallback {
    public void processResult(CuratorFramework client, CuratorEvent event) throws Exception;
}
  • client:当前客户端实例
  • event:服务端事件,定义了一些列的事件参数
    • 事件类型(CuratorEventType)
      • CREATE -> create()
      • DELETE -> delete()
      • EXISTS -> checkExists()
      • GET_DATA -> getData()
      • SET_DATA -> setData()
      • CHILDREN -> getChildren()
      • SYNC -> sync(String, Object)
      • GET_ACL -> getACL()
      • WATCHED -> watched()/usingWatcher(Watcher)
      • CLOSING ->会话断开
    • 响应码(resultCode)
      • OK(0) -> 接口调用成功
      • CONNECTIONLOSS(-4) -> 客户端和服务端连接已断开
      • NODEEXISTS(-110) -> 指定节点已存在
      • SESSIONEXPIRED(-112) -> 会话已过期

在 ZooKeeper中所有异步通知事件都是由 EventThread这个线程来处理的(串行通知)。因此当面临复杂问题处理时,往往力不从心。所以可以通过 inBackground接口,传入 Executor实例,将那些复杂的事件统一放到一个专门的线程池中。

/**
 * 使用 Curator的异步接口
 */
public class Create_Node_Background_Sample {
    static CountDownLatch latch = new CountDownLatch(2);
    static ExecutorService executor = Executors.newFixedThreadPool(2);

    public static void main(String[] args) throws Exception {
        // 创建重试策略
        ExponentialBackoffRetry retryPolicy = new ExponentialBackoffRetry(1000, 3);
        // 创建客户端
        CuratorFramework client = CuratorFrameworkFactory.newClient("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181",
                5000, 3000, retryPolicy);
        // 创建 session会话
        client.start();

        client.create()
                .creatingParentContainersIfNeeded()
                .withMode(CreateMode.EPHEMERAL)
                .inBackground(new BackgroundCallback() {
                    @Override
                    public void processResult(CuratorFramework client, CuratorEvent event) throws Exception {
                        System.out.println("event[code:" + event.getResultCode() + ", type:" + event.getType() + "]");
                        System.out.println(Thread.currentThread().getName());
                        latch.countDown();
                    }
                }, executor)
                .forPath("/curator", "hahaha".getBytes(StandardCharsets.UTF_8));

        client.create()
                .creatingParentContainersIfNeeded()
                .withMode(CreateMode.EPHEMERAL)
                .inBackground(new BackgroundCallback() {
                    @Override
                    public void processResult(CuratorFramework client, CuratorEvent event) throws Exception {
                        System.out.println("event[code:" + event.getResultCode() + ", type:" + event.getType() + "]");
                        System.out.println(Thread.currentThread().getName());
                        latch.countDown();
                    }
                })
                .forPath("/curator", "hahaha".getBytes(StandardCharsets.UTF_8));

        Thread.sleep(10000);
    }
}

5.6 Curator典型使用场景

Curator不仅为开发者提供了便利的 API接口,而且提供了一些典型场景的使用参考。

Maven依赖:

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

<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-recipes</artifactId>
    <version>5.2.0</version>
</dependency>

事件监听

Curator引入了 Cache来实现对 ZooKeeper服务端事件的监听。Cache是 Curator中对事件监听的包装,它提供的事件监听,类似一个本地缓存视图和远程 ZooKeeper视图对比的过程。Cache分为两类监听类型:

  • 节点监听(NodeCache) -> NodeCacheListener;

    NodeCache(CuratorFramework client, String path);
    NodeCache(CuratorFramework client, String path, boolean dataIsCompressed)
    
    • client:客户端实例
    • pah:节点路径
    • dataIsCompressed:是否进行数据压缩
    /**
     * NodeCache使用
     */
    public class NodeCache_Sample {
        static String PATH = "/curator/asd";
        static CuratorFramework client = CuratorFrameworkFactory.builder()
                .connectString("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181")
                .sessionTimeoutMs(5000)
                .retryPolicy(new ExponentialBackoffRetry(1000, 3))
                .build();
    
        public static void main(String[] args) throws Exception {
            client.start();
            client.create()
                    .creatingParentContainersIfNeeded()
                    .withMode(CreateMode.EPHEMERAL)
                    .forPath(PATH, "123".getBytes(StandardCharsets.UTF_8));
    
            NodeCache cache = new NodeCache(client, PATH, false);
            cache.start(true);
            cache.getListenable().addListener(() -> {
                ChildData child = cache.getCurrentData();
                if (child != null)
                    System.out.println(new String(cache.getCurrentData().getData()));
            });
            client.setData().forPath(PATH, "adasd".getBytes(StandardCharsets.UTF_8));
            Thread.sleep(1000);
            client.delete().deletingChildrenIfNeeded().forPath(PATH);
            Thread.sleep(1000);
        }
    }
    
  • 子节点监听(PathChildrenCache)

    PathChildrenCache(CuratorFramework client, String path, boolean cacheData, boolean dataIsCompressed, final CloseableExecutorService executorService);
    
    • executorService:线程池
    • cacheData:是否把节点的内容缓存起来,用户在收到变更的同时也收到变更后的值
    /**
     * PathChildrenCache使用
     */
    public class PathChildrenCache_Sample {
        static String PATH = "/curator";
        static CuratorFramework client = CuratorFrameworkFactory.builder()
                .connectString("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181")
                .sessionTimeoutMs(5000)
                .retryPolicy(new ExponentialBackoffRetry(1000, 3))
                .build();
    
        public static void main(String[] args) throws Exception {
            client.start();
            PathChildrenCache cache = new PathChildrenCache(client, PATH, true);
            cache.start(PathChildrenCache.StartMode.POST_INITIALIZED_EVENT);
            cache.getListenable().addListener(new PathChildrenCacheListener() {
                @Override
                public void childEvent(CuratorFramework client, PathChildrenCacheEvent event) throws Exception {
                    System.out.println(event.getType() + " " + event.getData() + " " + event.getInitialData());
                }
            });
    
            client.create().withMode(CreateMode.PERSISTENT).forPath(PATH);
            Thread.sleep(1000);
            client.create().withMode(CreateMode.PERSISTENT).forPath(PATH + "/c1");
            client.delete().forPath(PATH + "/c1");
            Thread.sleep(1000);
            client.delete().forPath(PATH);
            Thread.sleep(1000);
    
        }
    }
    

Master选举

借助 ZooKeeper我们可以比较方便地实现 Master选举的功能,大体思路为:

选择一个根节点 /foo,多台机器同时针对该节点发起 create请求,创建 /foo/lock,利用 ZooKeeper的特性,最终只有一台机器能够创建成功,成功的那台机器就是 Master

Curator内有 LeaderSelector类,封装了所有和 Master选举相关的逻辑。

/**
 * 使用 Curator实现分布式 Master选举
 */
public class Recipes_MasterSelect {
	// 代表了一个 Master选举的根节点,表明本次 Master选举都是在该节点下进行的
    static String master_path = "/curator_recipes_master_path";
    static CuratorFramework client = CuratorFrameworkFactory.builder()
            .connectString("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181")
            .sessionTimeoutMs(5000)
            .retryPolicy(new ExponentialBackoffRetry(1000,3))
            .build();

    public static void main(String[] args) throws InterruptedException {
        client.start();

        // Curator在成功获取 Master权限的时候会回调监听器
        LeaderSelector selector = new LeaderSelector(client, master_path, new LeaderSelectorListenerAdapter() {
            // Curator会在竞争到 Master后自动调用该方法,一旦执行完 takeLeaderShip方法,Curator会立即释放 Master权限,然后开始下一轮选举
            @Override
            public void takeLeadership(CuratorFramework client) throws Exception {
                System.out.println("成为 Master角色");
                Thread.sleep(1000);
                System.out.println("完成 Master操作,释放 Master权限");
            }
        });
        selector.autoRequeue();
        selector.start();
        Thread.sleep(Integer.MAX_VALUE);
    }
}

分布式锁

在分布式环境中,为了保证数据的一致性,经常需要在程序的某个运行点进行同步控制。

/**
 * 使用 Curator实现分布式锁
 */
public class Recipes_Lock {
    static String lock_path = "/curator_recipes_lock_path";
    static CuratorFramework client = CuratorFrameworkFactory.builder()
            .connectString("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181")
            .sessionTimeoutMs(5000)
            .retryPolicy(new ExponentialBackoffRetry(1000, 3))
            .build();

    public static void main(String[] args) throws InterruptedException {
        client.start();
        InterProcessMutex lock = new InterProcessMutex(client, lock_path);
        CountDownLatch latch = new CountDownLatch(1);
        for (int i = 0; i < 30; i++) {
            new Thread(() -> {
                try {
                    latch.await();
                    // 获取锁
                    lock.acquire();
                } catch (Exception e) {
                    e.printStackTrace();
                }

                SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss|SSS");
                String orderNo = sdf.format(new Date());
                System.out.println("生成的订单号为 : " + orderNo);
                try {
                    // 释放锁
                    lock.release();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }).start();
        }

        latch.countDown();
    }
}

分布式 Barrier

Barrier是一个用来控制多线程同步的工具,在 JDK中自带了 CyclicBarrier实现。Curator中也提供了类似 Barrier的实现。

/**
 * 使用 Curator实现 barrier
 */
public class Recipes_Barrier {
    static String barrier_path = "/curator_recipes_barrier_path";
    static DistributedBarrier barrier = null;

    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                CuratorFramework client = CuratorFrameworkFactory.builder()
                        .connectString("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181")
                        .sessionTimeoutMs(5000)
                        .retryPolicy(new ExponentialBackoffRetry(1000, 3))
                        .build();
                client.start();
                barrier = new DistributedBarrier(client, barrier_path);
                System.out.println(Thread.currentThread().getName() + "号 barrier设置");
                try {
                    // 设置 barrier
                    barrier.setBarrier();
                    // 等待释放
                    barrier.waitOnBarrier();
                    System.out.println("启动...");
                } catch (Exception e) {
                    e.printStackTrace();
                }

            }).start();
        }
        Thread.sleep(2000);
        // 同时触发所有等待的 barrier的释放
        barrier.removeBarrier();

    }
}
/**
 * 使用 Curator实现 barrier2
 */
public class Recipes_Barrier2 {
    static String barrier_path = "/curator_recipes_barrier_path";

    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                CuratorFramework client = CuratorFrameworkFactory.builder()
                        .connectString("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181")
                        .sessionTimeoutMs(5000)
                        .retryPolicy(new ExponentialBackoffRetry(1000, 3))
                        .build();
                client.start();
                DistributedDoubleBarrier barrier = new DistributedDoubleBarrier(client, barrier_path, 5);
                try {
                    Thread.sleep(Math.round(Math.random() * 3000));
                    System.out.println(Thread.currentThread().getName() + "号 barrier设置");

                    // 进行等待
                    barrier.enter();
                    System.out.println("启动 ... ");

                    Thread.sleep(Math.round(Math.random() * 3000));
                    // 再次等待
                    barrier.leave();
                    System.out.println("退出 ... ");

                } catch (InterruptedException e) {
                    e.printStackTrace();
                } catch (Exception e) {
                    e.printStackTrace();
                }

            }).start();
        }
        Thread.sleep(2000);

    }
}

ZKPaths

ZKPath提供了一些简单的 API来构建 ZNode路径、递归创建和删除节点。

/**
 * 工具类 ZKPaths使用示例
 */
public class ZkPaths_Sample {
    static String PATH = "/curator_zkpath_sample";
    static CuratorFramework client = CuratorFrameworkFactory.builder()
            .connectString("192.168.26.10:2181,192.168.26.20:2181,192.168.26.30:2181")
            .sessionTimeoutMs(5000)
            .retryPolicy(new ExponentialBackoffRetry(1000, 3))
            .build();

    public static void main(String[] args) throws Exception {
        client.start();
        ZooKeeper zooKeeper = client.getZookeeperClient().getZooKeeper();
        System.out.println(ZKPaths.fixForNamespace(PATH, "/sub"));
        System.out.println(ZKPaths.makePath(PATH, "sub"));


        System.out.println(ZKPaths.getNodeFromPath(PATH + "/sub1"));
        ZKPaths.PathAndNode pn = ZKPaths.getPathAndNode(PATH + "/sub1");
        System.out.println(pn.getPath());
        System.out.println(pn.getNode());

        String dir1 = PATH + "/child1";
        String dir2 = PATH + "/child2";
        ZKPaths.mkdirs(zooKeeper, dir1);
        ZKPaths.mkdirs(zooKeeper, dir2);
        System.out.println(ZKPaths.getSortedChildren(zooKeeper, PATH));

        ZKPaths.deleteChildren(client.getZookeeperClient().getZooKeeper(), PATH, true);
    }
}

TestingServer

为了便于开发人员进行 ZK的开发与测试,Curator提供了一种简易的启动 ZK的方式——TestingServer。

Maven依赖:

<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-test</artifactId>
    <version>5.2.0</version>
</dependency>

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

TestingServer允许开发人员使用自定义的 ZK服务器对外的端口和 dataDir,如果没有指定 dataDir,则会默认使用系统的临时目录

/**
 * 工具类 TestingServer的单机使用
 */
public class TestingServer_Sample {
    static String PATH = "/zookeeper";

    public static void main(String[] args) throws Exception {
        TestingServer server = new TestingServer(2181, true);

        CuratorFramework client = CuratorFrameworkFactory.builder()
                .connectString(server.getConnectString())
                .sessionTimeoutMs(5000)
                .retryPolicy(new ExponentialBackoffRetry(1000, 3))
                .build();
        client.start();
        System.out.println(client.getChildren().forPath(PATH));
    }
}
/**
 * 工具类 TestingCluster使用示例
 */
public class TestingCluster_Sample {
    static String PATH = "/zookeeper";

    public static void main(String[] args) throws Exception {
        TestingCluster cluster = new TestingCluster(3);
        cluster.start();
        Thread.sleep(2000);

        TestingZooKeeperServer leader = null;
        for (TestingZooKeeperServer zs : cluster.getServers()) {
            System.out.println(zs.getInstanceSpec().getServerId() + " - ");
            System.out.println(zs.getQuorumPeer().getServerState() + " - ");
            System.out.println(zs.getInstanceSpec().getDataDirectory().getAbsolutePath());

            if (zs.getQuorumPeer().getServerState().equals("leading")) {
                leader = zs;
            }
        }
        leader.kill();
        System.out.println("-- After leader Kill : ");
        for (TestingZooKeeperServer zs : cluster.getServers()) {
            System.out.println(zs.getInstanceSpec().getServerId() + " - ");
            System.out.println(zs.getQuorumPeer().getServerState() + " - ");
            System.out.println(zs.getInstanceSpec().getDataDirectory().getAbsolutePath());
        }
        cluster.stop();
    }
}

5.7 小结

ZooKeeper的部署分为:单机、集群和伪集群三种模式。

对 ZooKeeper的操作,一般可以通过客户端脚本、原生 API和封装后的 API。

六、ZooKeeper的典型应用场景及实现

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

6.1 数据发布/订阅

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

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

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

在我们平常的应用系统开发中,需要一些全局的信息配置,如:机器列表配置、运行时的开关配置、数据库配置信息等。这些全局配置信息通常具备以下特定:

  • 数据量小
  • 动态变化
  • 集群内共享

针对这类配置,可以选择将其存储在本地配置文件或是内存变量中,但是当机器规模变大,配置信息频繁变更或后,这两种配置管理就会很困难。因此,必须寻求一种分布式化的解决方案。

以“数据库切换”的应用场景为例:

配置存储

在配置管理前,必须要先有配置,首先必须先将初始化配置存储到 ZooKeeper上去。可以直接选取一个数据节点用于配置的存储。

我们需要先将集中管理的配置信息写入到该数据节点中去,如:

# DBCP
dbcp.driverClassName=com.mysql.jdbc.Driver
dbcp.dbJDBCUrl=jdbc:mysql:///zk
dbcp.characterEncoding=UTF8
dbcp.username=root
dbcp.passowrd=root
dbcp.maxActive=30
dbcp.maxIdle=10
dbcp.maxWait=10000

配置获取

集群中每台机器在启动初始化阶段,首先会从上面提到的 ZooKeeper配置节点读取数据库信息,同时在该配置节点上注册一个数据变更的 Watcher监听,一旦发生节点数据变更,所有订阅的客户端都能接收到通知。

配置变更

在系统运行过程中,可能会出现数据库切换的情况,此时可以直接修改 ZooKeeper上的配置信息,通过 Watcher,ZooKeeper能将数据变更的通知发送到各个客户端,每个客户端在接收到这个变更通知后,就可以重新进行最新数据的获取。

6.2 负载均衡

负载均衡(Load Balance)用来对多个计算机(计算机集群)、网络连接、CPU、磁盘驱动器或其他资源进行分配负载,以达到优化资源使用、最大化吞吐率、最小化响应时间和避免过载的目的。一般可分为硬件负载均衡和软件负载均衡。ZooKeeper属于软件负载均衡。

分布式系统具有对等性,为了保证系统的高可用,通常采用副本的方式来对数据和服务进行部署,而对于消费者而言,则需要在这些对等的服务提供方中选择一个来执行相关的业务逻辑,其中比较典型的就是 DNS服务。

动态的 DNS服务

DNS(Domain Name System)是域名系统的缩写,它可以看作是一个超大规模的分布式映射表,用于将域名和 IP地址进行相互映射,进而方便人们通过域名来访问互联网站点。

通常情况下,我们可以向域名注册服务商申请域名注册,但是这种方式最大的缺陷在于只能注册有限的域名。因此,在实际开发中,往往使用本地 HOST绑定来实现域名解析的工作。使用本地 HOST绑定的方法,可以很容易地解决域名紧张的问题,基本上每个系统都可以自行确定系统的域名与目标 IP地址。且这种映射可以随意修改,提高了开发调试的效率。但是一旦机器规模变大,逐台机器的配置将变得繁琐。

因此,可以基于 ZooKeeper实现动态 DNS方案(Dynamic DNA)。

域名配置

类似数据发布/订阅,也需要在 ZooKeeper上创建节点来进行域名配置。

每个应用都可以创建一个属于自己的数据节点作为域名配置的根节点,在这个节点上,每个应用都可以将自己的域名节点配置上去

# 单个 IP:PORT
192.168.0.1:8080

# 多个 IP:PORT
192.168.0.1:8080,192.168.0.2:8080

域名解析

在传统的 DNS解析中,所有的解析工作都交给了操作系统的域名和 IP地址映射机制或是专门的域名解析服务器。

而在 DDNS中的域名解析却需要每一个应用自己负责。通常应用都会首先从域名节点中获取一份 IP地址 和 端口的配置,进行自行解析。同时,每个应用还会在域名节点上注册一个数据变更 Watcher监听,以便及时收到域名变更的通知。

域名变更

在 DDNS中,只需要对指定的域名节点进行更新操作,ZooKeeper就会向订阅的客户端发送这个事件通知,应用在接收到这个事件通知后,就会再次进行域名配置的获取。

自动化的 DNS服务

自动化的 DNS服务系统主要是为了实现服务的自动化定位

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

整个系统的核心是 ZooKeeper集群,负责数据的存储以及一系列分布式协调。在整个架构模型中,我们将那些目标 IP地址和端口抽象为服务的提供者,而那些需要使用域名解析的客户端则被抽象成服务的消费者。

域名注册

每个服务提供者在启动的过程中,都会把自己的域名信息注册到 Register集群中去

  1. 服务提供者通过 SDK提供的 API接口,将域名、IP地址和端口发送给 Register集群。
  2. Register获取到域名、IP地址和端口配置后,根据域名将信息写入对应的 ZooKeeper域名节点中。

域名解析

服务消费者在使用域名的时候,会像 Dispatcher发出域名解析请求。Dispatcher收到请求后,会从 ZooKeeper上的指定域名节点读取相应的 IP:PORT列表,通过一定的策略选取其中一个返回给前端应用。

域名探测

DDNS系统需要对域名下所有注册的 IP:PORT的可用性进行检测。探测一般有两种方式:

  1. 服务端主动发起心跳检测,需要在服务端和客户端之间建立一个 TCP长连接
  2. 客户端主动向服务端发起健康度心跳检测

在 DDNS架构中,采用的是服务提供者主动向 Scanner进行状态汇报的模式(第二种)

Scanner会负责记录每个服务提供者最近一次的状态汇报时间,一旦超过 5s没有收到状态汇报,就会认为 IP:PORT不可用,会把它清理掉。

6.3 命名服务

命名服务是分布式系统最基本的公共服务之一。在分布式系统种,被命名的实体通常可以是集群种的机器、提供的服务地址或远程对象等,通过命名服务,客户端应用能够根据指定名字来获取资源的实体、服务地址和提供者的信息等。

Java中的 JNDI就是典型的命名服务。开发人员常常使用 JNDI来完成数据源的配置和管理。

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

ZooKeeper可以创建顺序节点,借助该特性,可以生成全局唯一的 ID

使用 ZooKeeper生成全局唯一 ID的基本步骤:

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

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

6.4 分布式协调/通知

分布式协调/通知,是将不同的分布式组件有机结合起来的关键所在。对于一个在多台机器上部署运行的应用而言,通常需要一个协调者(Coordinator)来控制整个系统的运行流程。同时,引入协调者,便于将分布式协调的职责从应用中分离出来,从而可以大大减少系统之间的耦合性,提高可扩展性。

ZooKeeper中特有的 Watcher注册与异步通知机制,能够很好地实现分布式环境下不同机器,甚至是不同系统之间的协调和通知,从而实现对数据变更的实时处理。基于 ZooKeeper实现分布式协调与通知功能,通常的做法是不同的客户端都对 ZooKeeper上同一个数据节点进行 Watcher注册,监听数据节点的变化,一旦发生了变化,那么所有订阅的客户端都能够收到通知并做出响应。

MySQL数据复制总线:MySQL_Replicator

MySQL数据复制总线是一个实时数据复制框架,用于在不同的 MySQL数据库实例之间进行异步数据复制和数据变化通知。整个系统是一个由 MySQL数据库集群、消息队列系统、任务管理监控平台以及 ZooKeeper集群等组件共同构成的一个包含数据生产者、复制管道和数据消费者等部分的数据总线系统。

在该系统中,ZooKeeper主要负责进行一系列的分布式协调工作,在具体的实现上,根据功能将数据负责组件划分为三个核心子模块:Core、Server和 Monitor,每个模块分别为一个单独的进程,通过 ZooKeeper进行数据交换

  • Core:实现了数据复制的核心逻辑,其将数据复制封装成管道,并抽象处生产者和消费者两个概念,其中生产者通常是 MySQL数据库的 Binlog日志
  • Server:负责启动和停止复制任务
  • Monitor:负责监控任务的运行状态,如果在数据复制期间发生异常或出现故障会进行告警

三个子模块之间的关系如图:

每个模块作为独立的进程运行在服务端,运行时的数据和配置信息均保存在 ZooKeeper上,Web控制台通过 ZooKeeper上的数据获取到后台进程的数据,同时发布控制信息。

任务注册

Core进程在启动的时候,首先会像 /mysql_replicator/tasks节点注册任务,如果在注册过程中发现该子节点已经存在了,说明已经有其他 Task机器注册了该任务,因此自己不需要创建该节点了。

任务热备份

为了应对复杂任务故障或者复制任务所在主机故障,复制组件采用“热备份”的容灾方式,即将同一个复制任务部署在不同的主机上,主、备任务机器通过 ZooKeeper互相检测运行健康状况。

为了实现热备方案,无论在第一步中是否创建了任务节点,每台任务机器都需要在 /mysql_replicator/tasks/tast_name/instances节点上将自己的主机名注册上去。该节点为临时的顺序节点,其中的序列号就是临时顺序节点的精华所在。

在完成该子节点的创建后,每台任务机器都可以获取到自己创建的节点的完整节点名以及所有子节点的列表,然后通过对比,将序号最小的运行状态设置为 RUNNING,其他的设置为 STANDBY。

热备切换

完成运行状态标识后,标记为 RUNNING的客户端机器进行正常的数据复制,一旦它出现故障停止了任务执行,那么会在所有 STANDBY机器中挑选最小序号的机器,设置为 RUNNING状态来执行。具体的做法是,所有 STANDBY机器在 /mysql_replicator/tasks/task_name/instances节点上注册一个“子节点列表变更”的 Watcher,订阅所有任务执行机器的变化情况,一旦 RUNNING机器宕机(与 ZK断开连接),对应的节点就会消失,于是其他机器也会收到该通知,开始新一轮的 RUNNING选举

记录执行状态

因为启用了热备份,STANDBY随时都可能变成 RUNNING,因此,RUNNING机器需要将运行时的上下文状态保留给 STANDBY。可以选择 /mysql_replicator/tasks/task_name/lastCommit作为 Binlog日志消费位点的存储节点,RUNNING机器定时向这个节点写入当前的 Binlog日志消费位点。

控制台协调

在 MySQL_Replicator,Server主要的工作就是进行任务的控制,通过 ZooKeeper来对不同的任务进行控制和协调。Server会将每个复制任务对应生产者的元数据(库名、表名、用户名和密码等)以配置的形式写入任务节点/mysql_replicator/tasks/task_name中,以便该任务的所有任务机器都能够共享该复制任务的配置。

冷备切换

在热备份方案中,针对一个任务,都会至少分配两台任务机器来进行热备份,但是在大规模的互联网公司中,往往有许多 MySQL实例需要进行数据复制,每个数据库实例都会对应一个复制任务,如果每个任务都进行双机热备份的化,需要消耗太多机器。

因此,可以设计一个冷备份的方案:

和热备份的区别在于,Core进程被配置了所属 Group。

加入一个 Core进程被标记了group1,那么在 Core进程启动后,会到对应的 ZooKeeper group1节点下获取 Task列表。假如找到了任务,就会遍历这个 Task列表的 instances节点,但凡还没有子节点的,就会创建一个临时的顺序节点:/mysql_replicator/task_groups/group1/task_name/instances/[Hostname]-1。和热备份中的“小序号优先”一样,顺序小的 Core进程会将自己标记为 RUNNING,不同之处在于,其他 Core进程会自动将自己创建的子节点删除,然后继续遍历下一个 Task节点。

冷热备份对比

在热备份方案中,针对一个任务使用了两台机器进行热备份,借助 ZooKeeper的 Watcher通知机制和临时顺序节点的特性,能够非常实时地进行互相协调,但缺陷是机器资源消耗较大。

在冷备份方案中,采用了扫描机制,虽然降低了任务协调的实时性,但是节省了机器资源。

分布式系统间通信

系统机器间的通信无外乎心跳检测、工作进度汇报和系统调度这三种类型。

心跳检测

分布式环境中,不同机器之间需要检测到彼此是否在正常允许,例如 A机器需要指定 B机器是否正常运行。在传统的开发中,我们通常通过主机之间 PING命令来相互判断,更复杂的话,则会通过机器之间建立长连接,通过 TCP连接固有的心跳检测机制来实现上层机器的心跳检测。

ZooKeeper也可以实现分布式机器间的心跳检测,基于 ZooKeeper的临时节点特性,可以让不同的机器都在 ZooKeeper的一个指定节点下创建临时子节点,不同的机器之间可以根据这个临时节点来判断对应的客户端机器是否存活。通过这种方式,检测系统和被检测系统不需要直接关联,而是通过 ZooKeeper上的某个节点进行关联,降低了系统耦合度。

工作进度汇报

在分布式系统中,当任务被分发到不同的机器上执行后,需要实时地将自己的任务执行进度汇报给分发系统。可以在 ZooKeeper上选择一个节点,每个任务客户端都在这个节点下创建临时节点,这样可以实现两个功能:

  • 通过临时节点是否存在,判断任务机器是否存活
  • 各个任务机器会实时地将自己的任务执行进度写到这个临时节点上去,以便中心系统能够实时获取进度

系统调度

一个分布式系统由控制台和一些客户端系统两部分组成,控制台的职责是将一些指令信息发送给所有的客户端,控制其业务逻辑,后台管理人员在控制台上做一些操作,实际上就是修改了 ZooKeeper上某些节点的数据,而 ZooKeeper进一步把这些数据变更以事件通知的形式发送给了对应的订阅客户端。

通过 ZooKeeper来实现分布式系统机器间的通信,省去了大量底层网络通信和协议设计上的重复工作,还降低了系统间的耦合。

6.5 集群管理

所谓集群管理,包括集群监控与集群控制两大块,前者侧重对集群运行时状态的收集,后者则是对集群进行操作与控制,日常开发中我们会有类似如下的需求:

  • 希望知道当前集群中究竟有多少机器在工作
  • 对集群中每台机器的运行状态进行数据收集
  • 对集群中机器进行上下线操作

传统方式是在集群中的每台机器上部署一个 Agent,由这个 Agent负责主动向指定的一个监控中心系统汇报自己所在机器的情况。而当系统的业务场景增多,集群规模变大之后,该解决方案会有如下弊端:

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

ZooKeeper具有以下两大特性:

  • 客户端监听某一 ZNode,当该 ZNode发生变化后,服务器会向订阅的客户端发送变更通知
  • 针对临时节点,一旦会话失效,节点会自动清除

利用这两大特性,可以实现基于 ZooKeeper的集群机器活性监控系统,如:监控系统在 /cluster_servers节点上注册一个 Watcher监听,每一个进行动态添加机器的操作,都会在该节点下创建一个临时节点:/cluster_servers/[Hostname]。此时,监控系统就能实时检测到机器的变动情况。

分布式日志收集系统

分布式日志数据系统的核心工作就是收集分布在不同机器上的系统日志。传统的日志系统会把所有需要收集的日志机器分为多个组别,每个组别对应一个收集器,用于收集日志。对于大规模的分布式日志收集场景,需要解决如下问题:

  • 变化的日志源机器:每个组别中的日志源机器是在不断变化的
  • 变化的收集器机器:日志收集系统自身也会有机器的变更或扩容,会出现收集器机器的变化

其实本质上的问题就是:如何快速、合理、动态地为每个收集器分配对应的日志源机器。

注册收集器机器

使用 ZooKeeper来进行日志系统收集器的注册,典型的做法就是在 ZooKeeper上创建一个节点作为收集器的根节点,例如 /logs/collector,每个收集器机器在启动的时候,都会在收集器节点下创建自己的节点,如 /logs/collector/[Hostname]

任务分发

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

状态汇报

考虑到收集器随时都有挂掉的可能,针对此,需要有一个收集器的状态汇报机制:每个收集器机器在创建完自己的专属节点后,还需要在对应的子节点上创建一个状态子节点,如 /logs/collector/host1/status,每个收集器都需要定期向其中写入自己的状态信息。

这种方式可以看作定期的心跳检测,日志系统可以根据状态子节点的最后更新时间来判断对应收集器机器是否存活

动态分配

如果收集器机器挂掉或是扩容了,就需要动态地进行收集任务的分配。期间,日志系统始终关注着 /logs/collector这个节点下所有子节点的变更,一旦检测到变动,就会开始进行任务的重新分配,通常有两种做法:

  • 全局动态分配:每当发生变动,日志系统就根据新的收集器机器列表,对所有的日志源机器进行重新分组分配;策略简单,但影响面较大
  • 局部动态分配:在小范围进行任务的动态分配,每个收集器在汇报自己日志收集状态的同时也汇报负载状态。如果收集器挂了,就将它下面的任务分配给那些负载低的机器,如果新收集器加入,就从那些负载高的机器上转移任务

注意事项

通过 ZooKeeper实现分布式日志收集系统,还有一些细节问题:

  • 节点类型:/logs/collector节点下的所有子节点代表收集器,必须为临时节点,但是收集器下还有日志源机器列表,如果单独依靠 ZK的临时节点机制,当收集器宕机后,由于会话失效,节点消失,那么日志源机器也会消失。所以,收集器必须是持久节点,那么不能通过节点是否存在来判断收集器的状态,必须按照 logs/collector/[Hostname]/status子节点来判断
  • 日志系统节点监听:生产环境下,收集器的心跳可能很快,会频繁更新 status,如果系统直接监听节点变化,那么通知会很多。因此必须考虑放弃监听,而是采用日志系统主动轮询的策略,但是会有一定的延时。

在线云主机管理

在线云主机管理,需要进行集群机器的监控,它要求统计机器的在线率,快速地对集群中机器的变更做出响应。

传统方式为通过指定监控端口来对每台机器进行定时检测,或者每台机器定时向监控系统汇报存活状态。这些功能都可以借助 ZooKeeper实现。

机器上/下线

为了实现自动化的线上运维,需要对机器的上/下线情况有一个全局的监控。

在机器新增时,需要首先将指定的 Agent部署到这些机器上,Agent启动后,向 ZooKeeper指定节点注册临时子节点,如 /XAE/machine/[Hostname]

当 Agent在 ZooKeeper上创建完这个临时节点后,对 /XAE/machines节点关注的监控中心就会接收到【子节点变更】事件,于是可以对这个新加入的机器开启相应的管理。而当机器下线后,监控中心也能得到通知。

机器监控

对于一个在线云系统,机器在运行过程中,Agent会定时将主机的运行状态信息写入 ZooKeeper上的主机节点,监控中心能够通过订阅这些节点的数据变更通知来间接地获取主机的运行信息。

6.6 Master选举

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

在分布式环境中,经常碰到这样的场景:集群中的所有系统单元需要对前端业务提供数据,如商品 ID,而这些 ID需要从一系列的海量数据中计算得到,耗费系统 IO和 CPU资源。鉴于计算的复杂性,如果让集群中的所有机器都执行计算逻辑,将耗费非常多的资源。因此可以只让 Master去处理数据计算,一旦得到结果,就共享给整个集群中的其他客户端机器。

以广告投放系统的后台场景为例,整个系统大体上可以分成客户端集群、分布式缓存系统、海量数据处理总线和 ZooKeeper四个部分。

Client集群每天定时通知 ZooKeeper来实现 Master选举,选举产生 Master客户端之后,这个 Master就会负责进行一系列海量数据处理,最终计算得到一个数据结果,将其放置在一个内存/数据库中。同时 Master通知集群中所有的客户端从这个内存/数据库中共享计算结果。

而关于 Master选举,我们可以选择常见的关系型数据库中的主机特性来实现:集群中的所有机器都向数据库中插入一条相同主键 ID的记录,数据库自行检查主键冲突,保证只有一台机器能成功插入,那么就认为成功插入的客户端为 Master。但是,仅通过关系型数据库的话,在 Master宕机后,其他主机并不能立刻得到消息。

而通过 ZooKeeper,其强一致性也能够保证在分布式高并发情况下节点的创建,客户端集群每天都定时往 ZooKeeper上创建一个临时接到 /master_election/2022-05-12/binding,这个过程中,仅有一个客户端能成功创建,那么它就变成了 Master,而其他所有没有成功创建的客户端就都在 /master_election/2022-05-12下注册 Watcher,一旦当前的 Master挂了,那么其余的客户端就可以立即重新进行 Master选举。

其实,如果仅仅想实现 Master选举的话,只需要有一个能够保证数据唯一性的组件即可。

6.7 分布式锁

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

排他锁

排他锁(Exclusive Locks,X锁),又称为写锁或独占锁。如果事务 T1对数据对象 O1加上了排他锁,那么在整个加锁期间,只允许事务 T1对 O1进行读取和更新操作,其他任何事务都不能再对这个数据对象进行任何类型的操作,直到 T1释放了排他锁。

也就是说,排他锁需要保证:保证当前仅有一个事务获得锁,且在锁释放后,能通知所有其他正在等待获取锁的事务。

定义锁

在 ZooKeeper种,可以通过数据节点来表示一个锁,如 /exclusive_lock/lock就可以被定义为一个锁

获取锁

在需要获取排他锁时,所有的客户端都会试图通过调用 create()接口,在 /exclusive_lock节点下创建临时节点 /exclusive_lock/lock,ZooKeeper会保证只有一个客户端能够成功创建,即只有一个客户端能获取到锁。

同时所有没有获取到锁的客户端就需要在 /exclusive_lock节点下注册一个子节点变更的Watcher监听器,以便实时监听到 lock节点的变更情况。

释放锁

因为在获取锁时,添加的是临时节点,因此在以下两种情况下,都有可能释放锁:

  • 当前获取锁的客户端机器发生宕机,该临时节点自动移除
  • 正常完成业务逻辑后,客户端主动删除自己创建的临时节点

无论是什么方式释放锁,ZooKeeper都会通知那些监听了该节点的客户端。这些客户端在接收到通知后,会再次进行分布式锁的获取。

共享锁

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

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

定义锁

和排他锁一样,共享锁也通过 ZooKeeper上的一个数据节点来表示,如 /shared_lock/[Hostname]-请求类型-序号

获取锁

在需要获取共享锁时,所有客户端都会到 /shared_lock这个节点下创建一个临时顺序节点,如果是读请求,那么就会创建 /shared_lock/192.168.1.1-R-000001节点;如果是写请求,就会创建 /shared_lock/192.168.1.1-W-000002节点。

判断读写顺序

不同的事务都可以同时对对一个数据对象进行读取操作,而更新操作必须在当前没有读写操作的情况下进行。基于此,通过 ZooKeeper节点来确定分布式读写顺序需要分成 4个步骤:

  1. 创建完节点后,获取 /shared_lock节点下的所有子节点,并对该节点注册子节点变更的 Watcher监听

  2. 确定自己的节点序号在所有子节点种的顺序

  3. 对于读请求:

    • 如果没有比自己序号小的子节点,或是所有比自己序号小的子节点都是读请求,那么表明自己已经成功获取到了共享锁,同时开始执行读逻辑
    • 如果存在比自己序号小的子节点,且其中有写请求,那么就需要进入等待

    对于写请求:

    • 如果自己不是序号最小的子节点,那么就需要进入等待
  4. 接收到 Watcher通知后,重复步骤 1

释放锁

释放锁的逻辑和排他锁是一致的

羊群效应

如果机器规模扩大之后,在“判断读写顺序”过程中:

  1. Host1机器首先进行读操作,读完后将 Host1-R-000001删除
  2. 此时其他已注册 Watcher,等待锁的节点都会收到通知,然后重新从 /shared_lock节点上获取一份新的子节点列表
  3. 每个节点判断自己的读写顺序,Host2机器检测到自己的序号最小,于是开始了写操作;而余下的其他机器发现没有轮到自己,于是继续等待
  4. 重复上述操作

那么也就是说,当 Host1在移除自己的共享锁后,ZooKeeper会发送变更通知给所有的机器,而最终却只有一台机器能获取到锁,而所有其他机器在收到通知后都要读取子节点列表来判断。那么也就是说,客户端会收到很多和自己并不相干的事件通知,且服务端会因此遭受巨大的网络冲击(同一时间多个锁被释放,进而引发同时向很多的客户端发送事件通知,再进而收到很多客户端的列表读取请求),这便是羊群效应。

其根源在于:判断自己释放是所有子节点中最小的,这个逻辑。其实在等待中的客户端,并不需要直接监听整个列表的变动,只需要关注比自己序号小的相关节点即可。

改进后的分布式锁实现

每个锁竞争者只需要关注 /shared_lock节点下序号比自己小的那个节点是否存在即可,具体实现如下:

  1. 客户端调用 create()方法创建一个类似于 /shared_lock/[Hostname]-请求类型-序号的临时顺序节点
  2. 客户端调用 getChildren()方法获取所有已经创建的子节点列表,此时不注册任何 Watcher
  3. 如果客户端无法获取共享锁,那么就调用 exist()来对比自己小的那个节点注册 Watcher
    • 读请求:向比自己小的最后一个写请求节点注册 Watcher
    • 写请求:向比自己小的最后一个节点注册 Watcher
  4. 等待 Watcher通知,继续进入步骤 2

6.8 分布式队列

分布式队列可分为两类,一种是常规的先进先出队列,另一种则是等到队列中元素聚集之后才统一开始的 Barrier模型。

FIFO:先进先出

使用 ZooKeeper实现 FIFO队列和实现共享锁类似,FIFO队列类似于一个全写的共享锁模型:所有客户端都到 /queue_fifo节点下创建临时顺序节点,如 /queue_fifo/host1-000001

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

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

Barrier:分布式屏障

Barrier一般用于 最终的合并计算需要基于很多并行计算的子结果 的场景。

它其实是在 FIFO队列的基础上进行了增强,设置思路为,开始时,/queue_barrier节点已存在,并且将其节点的数据内容赋值给一个数字 n来代表 barrier值,之后所有的客户端都会到 /queue_barrier节点下创建临时节点,创建完节点后,按照如下 5个步骤来确定顺序:

  1. 通过调用 getData()接口获取 /queue_barrier节点的数据内容:10
  2. 通过调用 getChildren()接口获取 /queue_barrier节点下的所有子节点,即获取队列中的所有元素,同时注册对子节点列表变化的 Watcher
  3. 统计子节点个数
  4. 如果子节点个数不为 10,则需要进入等待
  5. 接收到 Watcher通知后,重复步骤 2

七、ZooKeeper在大型分布式系统中的应用

7.1 Hadoop

Hadoop是 Apache开源的一个大型分布式计算框架,能够开发和运行处理海量数据。Hadoop的核心是 HDFS和 MapReduce,分别提供了对海量数据的存储和计算能力。在 Hadoop中,ZooKeeper主要用于实现 HA(High Availability),HDFS的 NameNode和 YARN的 ResourceManager都是基于此 HA模块来实现自己的 HA功能的。

YARN介绍

YARN是 Hadoop为了提高计算节点 Master(JT)的扩展性,同时为了支持多计算模型和提高资源的细粒度调度而引入的全新一代【分布式调度】框架。

YARN主要由 ResourceManager、NodeManager、ApplicationMaster和 Container四部分组成。其中最核心的就是 ResourceManager,它作为全局的资源管理器,负责整个系统的资源管理和分配。

ResourceManager单点问题

ResourceManager是 YARN中非常复杂的一个组件,负责集群中所有资源的统一管理和分配,同时接收来自各节点(NodeManager)的资源汇报信息,其内部维护了各个应用程序的 ApplicationMaster信息、NodeManager信息以及资源使用信息等。

因此,ResourceManager的工作情况直接决定了 YARN框架是否可以正常运作。

ResourceManager HA

为了解决 ResourceManager的这个单点问题,YARN设计了一套 Active/Standby模式的 ResourceManager HA架构。

在运行期间,会有多个 ResourceManager并存,并且其中只有一个 ResourceManager处于 Active状态,另外的一个或多个则是处于 Standby状态,当 Active节点无法正常工作时,其余处于 Standby状态的节点则会通过竞争选举产生新的 Active节点。

主备切换

ResourceManager使用基于 ZooKeeper实现的 ActiveStandbyElector组件来确定 ResourceManager的状态:Active或 Standby。具体做法如下:

  1. 创建锁节点

    ZooKeeper上有一个类似于 /yarn-leader-election/pseudo-yarn-rm-cluster的锁节点,所有的 ResourceManager在启动的时候,都会去竞争写一个 临时的 Lock子节点 /yarn-leader-election/pseudo-yarn-rm-cluster/ActicveStandbyElectorLock。ZooKeeper保证只有一个 ResearchManager能够创建成功。成功的那一个 ResourceManager就切换未 Active状态,没有成功的那些则切换为 Standby状态。

  2. 注册 Watcher监听

    所有 Standby状态的 ResourceManager都会向 /yarn-leader-election/pseudo-yarn-rm-cluster/ActiveStandbyElectorLock节点注册一个节点变更的 Watcher监听,利用临时节点的特性,能够快速感知到 Active状态的 ResourceManager的运行情况。

  3. 主备切换

    当 Active状态的 ResourceManager宕机后,其在 ZooKeeper上创建的 Lock节点也会随之删除。此时其余各个 Standby状态的 ResourceManager都会接收到来自 ZooKeeper服务端的 Watcher事件通知,然后重复【步骤1】的操作

ActicveStandbyElector组件位于 Hadoop-Common工程的 org.apache.hadoop.ha包中,其封装了 ResourceManager和 ZooKeeper之间的通信与交互过程。HDFS中的 NameNode和 ResourceManager模块都是使用该组件来实现各自的 HA的。

Fencing(隔离)

在分布式环境中,容易出现单机“假死”后,集群脑裂的问题(机器临时无法对外响应,导致主备切换;之后原来的机器恢复,却仍然认为自己是老大)。

YARN中引入了 Fencing机制,借助 ZooKeeper数据节点的 ACL权限控制机制来实现不同 RM之间的隔离,避免脑裂。

其实就是在主备切换的时候,创建的根节点必须携带 ZooKeeper的 ACL权限信息,来防止其他 RM对该节点的更新。(RM1假死后,RM2完成主备切换成了老大;RM1又恢复了,它可能会更新 ZooKeeper下的数据,但是因为 ACL,发现自己不能完成更新,于是自动切回了 Standby状态,避免了脑裂)

ResourceManager状态存储

在 ResourceManager中,RMStateStore能够存储一些 RM的内部状态信息,包括 Application以及它们的 Attempts信息、Delegation Token及 Version Information等。可以使用 ZooKeeper来存储这些信息。

在 ZooKeeper上,ResourceManager的状态信息都被存储在 /rmstore这个根节点下

其中 RMAppRoot节点下存储的是与各个 Application相关的信息,RMDTSecretManagerRoot存储的是与安全相关的 Token等信息。每个 Active状态的 ResourceManager在初始化阶段都会从 ZooKeeper上读取到这些状态信息,并根据这些状态信息进行相应的处理。

7.2 HBase

HBase全称 Hadoop Database,是 Google BigTable的开源实现,是一个基于 Hadoop设计的面向海量数据的高可靠、高性能、面向列、可伸缩的分布式存储系统,利用 HBase技术可以在廉价的 PC服务器上搭建起大规模结构化的存储集群。

与其他 NoSQL不同,HBase针对数据写入具有强一致性,甚至包括索引列也具有强一致性。它采用 ZooKeeper服务来完成对整个系统的分布式协调工作。

系统容错

当 HBase启动的时候,每个 RegionServer服务器都会到 ZooKeeper的 /hbase/rs节点下创建一个信息节点(rs状态节点),如 /hbase/rs/[Hostname]。同时,HMaster会对这个节点注册监听。当某个 RegionServer挂掉,Session失效,ZooKeeper会删除该 RegionServer对应的 rs状态节点。同时 HMaster会接收到该通知,并立即开始容错工作。

HMaster会将该 RegionServer所处理的数据分片(Region)重新路由到其他节点上,并记录到 Meta信息中供客户端查询。

RootRegion管理

对于 HBase集群来说,数据存储的位置信息是记录在元数据分片,也就是 RootRegion上的。每次客户端发起新的请求,需要知道数据的位置,就会去查询 RootRegion,而 RootRegion自身的位置则是记录在 ZooKeeper上的,默认是在 /hbase/root-region-server节点中

当 RootRegion发生变化时,比如 Region的手工移动、Balance或者是 RootRegion所在服务器发生了故障等时,就能够通过 ZooKeeper来感知到这一变化,并做出一系列的容灾处理。

Region状态管理

Region是 HBase中数据的物理切片,每个 Region中记录了全局数据的一小部分,并且不同的 Region之间的数据是相互不重复的。但是对于一个分布式系统而言,因为系统故障、负载均衡、Region分裂合并等原因,Region是会经常发生变更的。一旦 Region发生移动,就会经历 Offline和 Online的过程。

在 Offline期间,数据是不能被访问的,并且 Region的这个状态必须让全局知晓。而对于 HBase集群来说,Region的数量可能会多达 10万级别,甚至更多,因此 Region状态管理也只有依靠 ZooKeeper。

分布式 SplitLog任务管理

当某台 RegionServer服务器挂掉时,由于总有一部分新写入的数据还没有持久化到 HFile中,因此在迁移该 RegionServer的服务时,需要从 HLog中恢复这部分还在内存中的数据。而 HLog是多个 Region共用的,因此需要进行拆分,即 SplitLog。HMaster需要遍历该 RegionServer服务器的 HLog,并按照 Region切分成小块移动到新的地址下,并进行数据的 Replay。

而单个 RegionServer的日志量相对庞大,因此一个可行的方案就是将 SplitLog任务分配给多台 RegionServer来共同处理,而这就又需要一个持久化组件来辅助 HMaster完成任务的分配。

当前的做法是,HMaster会在 ZooKeeper上创建一个节点,如 /hbase/splitlog,将任务分配信息以列表的形式存放到该节点下。各个 RegionServer服务器自行到该节点上领取任务并在任务执行成功或失败后再更新该节点的信息,以通知 HMaster继续进行后面的步骤。ZooKeeper担任的就是分布式集群中互相通知和信息持久化的角色。

Replication管理

Replication是实现 HBase中主备集群间的实时同步的主要模块。和传统关系型数据库不同的是,HBase作为分布式系统,它的 Replication是多对多的,且每个节点随时都有可能挂掉。

HBase同样借助 ZooKeeper来完成 Replication功能,做法是在 ZooKeeper上记录一个节点,如 /hbase/replication,然后把不同的 RegionServer服务对于的 HLog文件名称记录到相应的节点上,HMaster会将新增的数据推送给 Slave集群,并同时将推送信息记录到 ZooKeeper上。

当服务器挂掉时,由于 ZooKeeper上已经保存了断点信息,因此只要有 HMaster就能够根据这些断点信息来协调用来推送 HLog数据的主节点服务器。

ZooKeeper部署

HBase的启动脚本 hbase-env.sh中可以选择是由 HBase启动其自带的默认 ZooKeeper,还是使用一个已有的外部 ZooKeeper集群。一般会采用第二种。

如果一个 ZooKeeper集群需要被多个 HBase集群共用,那么必须为每个 HBase集群指明各自对应的 ZooKeeper根节点配置 zookeeper.znode.parent,以确保各个 HBase集群间互不干扰。

7.3 Kafka

Kafka由 Scala语言开发,于 2012年成为 Apache的顶级项目,主要用于实现低延迟的发送和收集大量的事件和日志数据。

Kafka是一个吞吐量极高的、发布/订阅模式的分布式消息系统。在 Kafka中没有 Master的概念,所有服务器都是对等的,因此可以在不做任何配置更改的情况下实现服务器的添加和删除,同样的,消息的生产者和消费者也能够做到随意的重启和机器的上下线。

术语介绍

  • 消息生产者:即 Producer,是消息产生的源头,负责生成消息并发送到 Kafka服务器上

  • 消息消费者:即 Consumer,是消息的使用方,负责消费 Kafka服务器上的消息

  • 主题:即 Topic,由用户定义并配置在 Kafka服务端,用于建立生产者和消费者之间的订阅关系,生产者发送消息到 Topic下,消费者从 Topic消费消息

  • 消息分区:即 Partition,一个 Topic下会分成多个分区,每个分区可以部署到不同的服务器上,消息分区机制和分区的数量于消费者的负载均衡机制有很大的关系

  • Broker:即 Kafka的服务器,用于存储消息

  • 消费者分组:即 Group,用于归属同类消费者,在 Kafka中,多个消费者可以共同消费一个 Topic下的消息,每个消费者消费其中的部分消息,这些消费者就组成了一个 Group

  • Offset:即 偏移量,消息存储在 Kafka的 Broker上,消费者拉取消息的过程中需要知道消息在文件中的偏移量。

Broker注册

Kafka是一个分布式消息系统,这也体现在其 Broker、Producer和 Consumer的分布式部署上。虽然 Broker是分布式部署并且相互之间是独立运行的,但还是需要有一个注册系统能够将整个集群中的 Broker服务器都管理起来。

在 ZooKeeper上有一个专门用来进行 Broker服务器列表记录的节点 /broker/ids

每个 Broker服务器在启动的时候,都会到 Zookeeper上进行注册 ,如 /broker/ids/[0...N]

也就是说,在 Kafka中,我们使用一个全局唯一的数字来指代每一个 Broker服务器,可以称其为 BrokerID,不同的 Broker必须使用不同的 BrokerID进行注册,如 /broker/ids/1/broker/ids/2分别代表两个 Broker服务器。创建完节点后,每个 Broker会将自己的 IP地址和端口号等信息写入到该节点中。

Topic注册

在 Kafka中,会将同一个 Topic的消息分成多个分区并将其分分布到多个 Broker上,而这些分区信息以及与 Broker的对应关系也都是由 ZooKeeper维护的,由专门的节点来记录,如 /broker/topics

Kafka中的每一个 Topic,都会以 /broker/topics/[topic]的形式记录在这个节点下,如 /broker/topics/login/broker/topics/search

Broker服务器在启动的时候,会到对应的 Topic节点下注册自己的 BrokerID,并写入针对该 Topic的分区总数,如 /broker/topics/login/3 -> 2表明 BrokerID为 3的服务器,对于 login这个 Topic的消息,提供了 2个分区进行消息存储。

这些分区数节点也是临时节点

生产者负责均衡

Kafka是分布式部署 Broker服务器的,会对同一个 Topic的消息进行分区并将其分布到不同的 Broker服务器上。因此,生产者需要将消息合理地发送到这些分布式的 Broker上。对于发消息时的负载均衡,Kafka支持传统的四层负载均衡,也支持使用 ZooKeeper方式来实现负载均衡。

四层负载均衡

四层负载均衡一般就是根据生产者的 IP地址和端口来为其确定一个相关联的 Broker。

好处是整体逻辑简单,不需要引入其他三方系统。

坏处是该方案无法做到真正的负载均衡,因为在系统实际运行过程中,每个生产者发送的消息量是不同的,那么多个 Broker接收到的消息总数也是不均匀的;并且生产者无法感知 Broker的状态。

使用 ZooKeeper进行负载均衡

每当一个 Broker启动的时候,会首先完成 Broker注册过程,并注册与可订阅的 Topic相关的元数据信息。生产者就能够通过这个节点的变化来动态地感知到 Broker服务器列表的变更。

Kafka的生产者会对 ZooKeeper上的关于 Broker变更、Topic变更、二者关联关系变更等事件注册 Watcher监听,这样就能够实现一种动态的负载均衡。在此模式下,能够运行开发人员控制生产者根据一定的规则进行数据分区,而不是简单地随机投递。

消费者负载均衡

与生产者类似,Kafka中的消费者同样需要进行负载均衡来实现多个消费者合理地从对应的 Broker服务器上接收消息。

Kafka有消费者分组的概念,每个消费者分组中都包含了若干个消费者,每一条消息都只会发送给分组中的一个消费者;不同分组消费自己特定 Topic下面的消息,互不干扰。因此消费者的负载均衡可以看作是分组内的负载均衡。

消息分区与消费者关系

对于每个消费者分组,Kafka都会为其分配一个全局唯一的 GroupID,同一个消费者分组内部的所有消费者都共享该 ID。同时,Kafka也会为每个消费者分配一个 ConsumerID,通常采用 [Hostname:UUID]的形式。

在 Kafka中规定了每个 Partition在同一时间只能有一个消费者进行消息的消费,因此需要在 ZooKeeper上记录下 Partition和 Consumer之间的对应关系。每个 Consumer一旦确定了对一个 Partition的消费权力,那么需要将其 ConsumerID写入到对应 Partition临时节点上,如 /consumers/[group_id]/owners/[topic]/[broker_id-partition_id]其中 [broker_id-partition-id]就是一个消息分区的标识,节点内容就是消费该分区上消息的消费者的 ConsumerID

消息消费进度 Offset记录

在 Consumer对指定 Partition进行消息消费的过程中,需要定时地将分区消息的消费进度,即 Offset,记录到 ZooKeeper中,以便于在该 Consumer宕机后,其他 Consumer能够从之前的进度开始继续进行消息的消费。

Offset在 ZooKeeper上的记录由一个专门的节点负载 /consumers/[group_id]/offsets/[topic]/[broker_id-partition_id],其节点的内容就是 Offset值

消费者注册

消费者注册,即消费者加入到 消费者分组中的过程:

  1. 注册到消费者分组

    每个 Consumer在启动的时候,都会到 ZooKeeper的指定节点下创建临时的消费者节点, 如 /consumers/[group_id]/ids/[consumer_id]

    之后,Consumer会将自己订阅的 Topic信息写入该节点,一旦 Consumer发生宕机,节点就会消失

  2. 对分组节点注册列表变化的 Watcher

    每个 Consumer都需要关注所属消费者分组中其他 Consumer的变化情况,即对 /consumers/[group_id]/ids节点注册子节点变化的 Watcher监听

    一旦发现 Consumer的新增或减少,都会触发 Consumer的负载均衡

  3. 对 Broker的变化注册 Watcher

    消费者需要对 /broker/ids/[0..N]中的节点注册监听,如果发现 Broker服务器列表发生了变化,那么可能也会触发 Consuemr的负载均衡

  4. 进行 Consumer负载均衡

    通常消费者分组内的 Consumer列表发生变更或 Broker服务器发送变更,都有可能触发消费者的负载均衡

负载均衡

Kafka借助 ZooKeeper记录 Broker和 Consumer的信息,采用了一套特殊的负载均衡算法。

我们将一个消费者分组的每个 Consumer记录为 C1, C2, …, Ci… CG,对于一个 Consumer Ci,其对应的消息分区分配策略如下:

  1. 设置 PT为指定 Topic所有的 Partition
  2. 设置 CG为同一个消费者分组中的所有 Consumer
  3. 对 PT进行排序,使分布在同一个 Broker服务器上的 Partition尽可能靠在一起
  4. 对 CG进行排序
  5. 设置 i 为 Ci在 CG中的索引值,同时设置 N = size(PT) / size(CG)
  6. 将编号为 i*N ~ (i+1)*N-1的 Partition分配给 Ci
  7. 重新更新 ZooKeeper上 Partition与 Ci 的关系

八、ZooKeeper在阿里巴巴的实践与应用

8.1 消息中间件:Metamorphosis

Metamorphosis即 RocketMQ,是一个高性能、高可用、可扩展的分布式消息中间件,其思路源于 LinkedId的 Kafka。MetaQ具有消息存储顺序写、吞吐量大和支持本地 XA事务等特性,适用于大吞吐量、顺序消息、消息广播和日志数据传输等分布式应用场景。

和传统的消息中间件采用推(Push)模型不同的是,MetaQ是基于拉(Pull)模型构建的,由消费者主动从 MetaQ服务器拉取数据并解析成消息来进行消费,同时大量依赖 ZooKeeper来实现负载均衡和 Offset的存储。

生产者的负载均衡

和 Kafka一样,MetaQ也假定 Producer、Broker和 Consumer都是分布式的集群系统。

Producer可以是一个集群,多台机器上的 Producer可以向相同的 Topic发送消息。而服务器 Broker通常也是一个集群,多台 Broker组成一个集群对外提供一系列的 Topic消息服务,Producer 按照一定的路由规则向既集群里的某台 Broker发送消息,Consumer按照一定的路由规则拉取某台 Broker上的消息。每个 Broker都可以配置一个 Topic的多个 Partition,但是在 Producer看来,会将一个 Topic在所有 Broker上的所有 Partition组成一个完整的 Partition列表来使用。

在创建 Producer的时候,客户端会从 ZooKeeper上获取已经配置好的 Topic对应的 Broker和 Partition列表,Producer在发送消息的时候必须选择一台 Broker上的一个 Partition来发送消息,默认的策略是轮询。

Producer在通过 ZooKeeper获取分区列表之后,会按照 BrokerID和 Partition的顺序排列组织成一个有序的 Partition列表,发送的时候按照从头到尾循环往复的方式逐个 Partition发送消息。

在 Broker因为重启或者故障等因素无法提供服务时,Producer能够通过 ZooKeeper感知到这个变化,同时将失效的 Partition从列表中移除。但是从发生故障到 Producer感知到,这个过程是有一定的延迟的,因此可能在那一瞬间会有部分消息发送失败。

消费者的负载均衡

Consumer的负载均衡比较复杂,它跟 Topic的分区数目和 Consumer的个数相关。

消费者数和 Topic分区数一致

如果单个分组内的 Consumer数目和 Topic总的 Partition数目相同,那么每个 Consumer负责消费一个 Partition中的消息,一一对应。

消费者数大于 Topic分区数

如果单个分组内的 Consumer数量比 Topic总的 Partition数目多,则多出来的消费者不参与消费。

消费者数小于 Topic分区数

如果单个分组内的 Consumer数量比 Topic总的 Partition数目少,则有部分 Consumer需要承担多个 Partition的消费任务。

当分区数目(n)大于单个分组的 Consumer数量(m)时,则有 n%m个消费者需要额外承担 1/n的消费任务,我们假设 n无限大,那么这种策略还是能够达到负载均衡的效果的。

MetaQ的客户端会自动处理 Consumer的负载均衡,将 Consumer列表和 Partition列表分布排序,然后按照上述规则做合理的挂载。而当某个 Consumer宕机后,其他 Consumer会感知到这一变化,然后重新进行负载均衡,以保证所有的分区都有 Consumer进行消费。

消息消费位点 Offset存储

为了保证 Producer和 Consumer在进行消息发送和接收过程中的可靠性和顺序性,同时保证避免出现消息的重复发送和接收,MetaQ会将消息的消费记录 Offset记录到 ZooKeeper中,以尽可能地确保在消费者进行负载均衡的时候,能够正确地识别出指定分区的消息进度。

8.2 RPC服务框架:Dubbo

Dubbo是由 Java语言编写的分布式服务框架,致力于提供高性能和透明化的远程服务调用方案和基于服务框架展开的完整 SOA服务治理方案。

Dubbo的架构

Dubbo的核心部分包含三个模块:

  • 远程通信:提供对多种基于长连接的 NIO框架的抽象封装,包括多种线程模型、序列化,以及“请求-响应”模式的信息交换方式
  • 集群容错:提供基于接口方法的远程过程透明调用,包括对多协议的支持,以及对软负载均衡、失败容错、地址路由和动态配置等集群特性的支持
  • 自动发现:提供基于注册中心的目录服务,使服务消费方能动态地查找服务提供方,使地址透明,使服务提供方可以平滑地增加或减少机器

此外,Dubbo框架还包括负责服务对象序列化的 Serialize组件、网络传输组件 Transport、协议层 Protocol以及服务注册中心 Registry等。

在 Dubbo的实现中,对注册中心进行了抽象封装,可以基于其提供的外部接口来实现各种不同类型的注册中心。

  • /dubbo:是 Dubbo在 ZooKeeper上创建的根节点
  • /dubbo/com.foo.BarService:服务节点,代表 Dubbo的一个服务
  • /dubbo/com.foo.BarService/providers:服务提供者的根节点,其子节点代表每一个服务的真正提供者
  • /dubbo/com.foo.BarService/consumers:服务消费者的根节点,其子节点代表每一个服务的真正消费者

服务注册流程

以 com.foo.BarService为例,说明 Dubbo基于 ZooKeeper实现注册中心的工作流程:

  • 服务提供者

    服务提供者在启动的时候,会首先在 ZooKeeper的 /dubbo/com.foo.BarService/providers节点下创建临时子节点,并写入自己的 URL地址

  • 服务消费者

    服务消费者在启动的时候,会读取并订阅 ZooKeeper上的 /dubbo/com.foo.BarService/providers节点下的所有子节点,并解析出所有提供者的 URL地址来作为该服务地址列表,然后开始发起正常调用;

    同时服务消费者还会在 ZooKeeper上的 //dubbo/com.foo.BarService/consumers节点下创建临时子节点,并写入自己的 URL地址

  • 监控中心

    监控中心在启动的时候,会读取 /dubbo/com.foo.BarService下的所有提供者与消费者的 URL地址,并注册 Watcher来监听其子节点的变化

Dubbo在使用 ZooKeeper的过程中,所有创建的节点都是临时节点。

8.3 基于 MySQL BinLog的增量订阅和消费组件:Canal

Canal是由纯 Java语言编写的基于 MySQL数据库 BinLog实现的增量订阅和消费组件。

Canal的核心思想是模拟 MySQL Slave的交互协议,将自己伪装成一个 MySQL的 Slave机器,然后不断地向 Master服务器发送 Dump请求。Master服务器收到 Dump请求后,就会开始推送相应的 BinLog给该 Slave(也就是 Canal)。Canal收到 BinLog之后,解析出相应的对象后,就可以进行二次消费。

Canal Server主备切换设计

基于对容灾的考虑,一般会配置多个 Canal Server来负责一个 MySQL数据库实例的数据增量复制。并且,为了减少 Canal Server的 Dump请求对 MySQL Master所带来的性能影响,就要求不同的 Canal Server上的 instance在同一时刻只能有一个处于 Running状态,其他的 instance都处于 Standby状态,这就要求 Canal具备主备切换的能力。

  1. 尝试启动

    每个 Canal Server在启动某个 Canal instance的时候都会首先向 ZooKeeper进行一次尝试启动判断。

    具体的做法为:向 ZooKeeper创建一个相同的临时节点,哪个 Canal Server创建成功了,就让哪个启动

  2. 启动 instance

    假设 Host1机器上的 Canal成功创建了该节点,那么就会把自己的机器信息写入到节点中去 {"active":true, "address":"Host1:port1", "cid":1}

    其他的 Canal Server由于没有成功地创建节点,都会将自己设置为 Standby状态,并且监听该注册节点

  3. 主备切换

    如果处于 Running状态的 Canal Server宕机了,那么临时节点会消失,其他处于 Standby状态的 Canal Server会收到消息,它们会重复步骤 1来进行主备切换

针对 Running状态的 Canal假死引发的脑裂问题,在 Canal的设计中,为了保护假死状态的 Canal Server,避免因瞬间 Running节点失效而导致 instance重新分布带来的资源损耗,设计了如下策略:

状态为 Standby的 Canal Server收到主备切换的通知后,会延迟一段时间(默认为 5s)抢占 Running节点;

而原本处于 Running状态的 instance则可以不需要等待延迟,直接取得 Running节点

Canal Client的 HA设计

Canal Client对数据的消费流程如下:

  1. 从 ZooKeeper中读出当前处于 Running状态的 Server

    Canal Client在启动的时候,会读取 Running节点中的数据,得到 Canal Server的地址等详细信息;

    同时 Canal Client也会注册自己到 ZooKeeper中;

  2. 注册 Running节点数据变化的监听

    由于 Canal Server有挂掉的风险,因此 Client需要对 Running节点注册监听,一旦发生 Server的主备切换,Client随时都可以感知到

  3. 连接对应的 Running Server进行数据消费

数据消费位点记录

由于存在 Canal Client的重启或其他变化,为了避免数据消费的重复和乱序,Canal必须对数据消费的位点进行实时记录。

数据消费成功后,Canal Server会在 ZooKeeper中记录下当前最后一次消费成功的 BinLog位点,一旦发生 Client重启,只需要从这最后一个位点继续进行消费即可。

8.4 分布式数据库同步系统:Otter

Otter是一个由纯 Java语言编写的分布式数据库同步系统,主要用于异地双 A机房的数据库数据同步,致力于解决长距离机房的数据同步及双 A机房架构下的数据一致性问题。

分布式 SEDA模型调度

为了更好地提高整个系统的扩展性和灵活性,在 Otter中将整个数据同步流程抽象为类似于 ETL的处理模型,具体分为四个阶段:

  • Select:数据接入
  • Extract:数据提取
  • Transform:数据转换
  • Load:数据载入

其中,Select阶段是为了解决数据来源的差异,比如可以接入来自 Canal的增量数据,也可以接入其他系统的数据源。

Extract/Transform/Load阶段则类似于数据仓库的 ETL模型,具体可分为数据 Join、数据转化和数据 Load等过程。同时为了保证系统的高可用,SEDA的每个阶段都会有多个节点进行协同处理。

整个模型分为 Stage管理和 Schedule调度两部分

Stage管理

Stage管理主要就是维护一组工作线程,在接收到 Schedule的 Event任务信号后,分配一个工作线程来进行任务处理,并在任务处理完成后,反馈信息到 Schedule。

Schedule调度

Schedule调度主要是基于 ZooKeeper来管理 Stage之间的任务消息传递,其具体实现逻辑如下:

  1. 创建节点

    Otter首先会为每个 Stage在 ZooKeeper上创建一个节点,例如 /seda/stage/s1,s1为该 Stage的名称,每个任务事件都会对应于该节点下的一个子节点,如 /seda/stage/s1/RequestA

  2. 任务分配

    当 s1的上一级 Stage完成 RequestA任务后,就会通知 Schedule调度器;根据预先定义的 Stage调度流程,Schedule调度器便会在 Stage s1的目录下创建一个 RequestA的子节点,告知 s1有一个新的请求需要其处理

  3. 任务通知

    每个 Stage都会有一个 Schedule监听线程,利用 ZooKeeper的 Watcher机制来关注 ZooKeeper中对应 Stage节点的子节点变化;当任务完成分配后,ZooKeeper的 Watcher机制就会通知到该 Schedule线程,然后 Schedule就会通知 Stage进行任务处理

  4. 任务完成

    当 s1完成了 RequestA任务后,会删除 s1目录下的 RequestA任务,代表处理完成;然后继续步骤 2,分配下一个 Stage任务

在真正的生产环境下,往往都会由多台机器共同组成一个 Stage来处理 Request,因此会涉及到多个机器节点间的分布式协调。

如果 s1有多个节点协同处理,每个节点都会有该 Stage的一个 Schedule线程,其在 s1目录变化时都会收到通知。在这种情况下,可以采取抢占式的模式,尝试在 RequestA目录下创建一个 lock节点,谁成功创建,就由谁来执行任务。

中美跨机房 ZooKeeper集群的部署

这里需要使用到 ZooKeeper中 Observer的角色,Observer角色只有数据读取的权限,不参与写和投票,主要用于提升整个集群对非事务请求的处理能力。

借助于 ZooKeeper的 Observer,Otter将 ZooKeeper进行三地部署:

  • 杭州机房部署 Leader/Follower集群,可以部署 3个机房,每个机房实例为 1/1/1 或者 3/2/2模式
  • 美国机房部署 Observer集群,可以部署 2个机房,每个机房实例为 1/1
  • 青岛机房也同美国机房

当美国机房的客户端发起一个非事务请求时,就直接从部署在美国的 Observer ZooKeeper读取数据;

而如果是事务请求,那么美国机房的 Observer就会将该事务请求转发到杭州机房的 Leader/Follower集群上进行投票处理,然后再通知美国的 Observer,最后再由美国的 Observer通知给客户端。

♥ 九、ZooKeeper技术内幕

9.1 系统模型

9.1.1 数据模型

ZNode是 ZooKeeper中数据的最小单元,每个 ZNode上都可以保存数据,同时还可以挂载子节点,因此构成了层次化的命名空间。

事务 ID

在 ZooKeeper中,事务是指能够改变 ZooKeeper服务器状态的操作,一般包括数据节点创建或删除、数据节点内容更新和客户端会话创建与失效等操作。

对于每一个事务请求,ZooKeeper都会为其分配一个全局唯一的事务 ID,用 ZXID来表示,通常是一个 64位数字,每一个 ZXID对应一次更新操作,从这些 ZXID中可以间接识别出 ZooKeeper处理这些更新操作请求的全局顺序。

9.1.2 节点特性

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

  • 持久节点(PERSISTENT)

    该数据节点被创建后,就会一直存在于 ZooKeeper服务器上,直到有删除操作来主动清除这个节点

  • 持久顺序节点(PERSISTENT_SEQUENTIAL)

    基本类似持久节点,会自动添加一个数字后缀作为一个新的、完整的节点名,后缀的上限是 Integer.MAX_VALUE

  • 临时节点(EPHEMERAL)

    该数据节点的生命周期和客户端会话绑定在一起,如果会话失效,节点自动清除;临时节点只能作为叶子节点

  • 临时顺序节点(EPHEMERAL_SEQUENTIAL)

    类似临时节点,添加了顺序特性

状态信息

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

状态属性 说 明
czxid 即 Created ZXID,表示该数据节点被创建时的事务 ID
mzxid 即 Modified ZXID,表示该数据节点最后一次被更新时的事务 ID
ctime 即 Created Time,表示该数据节点被创建时的时间
mtime 即 Modified Time,表示该数据节点最后一次被更新时的时间
version 该数据节点的版本号
cversion 子节点的版本号
aversion 该数据节点的 ACL版本号
ephemeralOwner 创建该临时节点的会话 SessionID;如果这是持久节点,则值为 0
dataLength 数据内容长度
numChildren 子节点的个数
pzxid 表示该数据节点的【子节点列表】最后一次被修改时的事务 ID

9.1.3 版本——保证分布式数据原子性操作

ZooKeeper中为数据节点引入了版本的概念,每个数据节点都具有三种类型的版本信息,对数据节点的任何更新操作都会引起版本号的变化。

版本类型 说 明
version 当前数据节点 数据内容的版本号
cversion 当前数据节点 子节点的版本号
aversion 当前数据节点 ACL的版本号

在一个数据节点 /zwb被创建后,节点的 version值是 0,如果对该节点的数据内容进行更新操作(即使内容没变),version的值就会加一。基于版本,可以实现锁。

悲观锁,即 悲观并发控制(Pessimistic Concurrency Control),具有强烈的 独占和 排他性。悲观锁假定不同事务之间的处理一定会出现相互干扰,因此会在事务的整个过程中都加锁,这可以粗暴地解决 数据更新十分激烈 的场景。

乐观锁,即 乐观并发控制(Optimistic Concurrency Control),相比悲观锁更 宽松和 友好。乐观锁假定多个事务在处理过程中不会彼此影响,因此在事务处理的大部分时间都不加锁。如果产生了冲突,那么当前事务回滚,这适用于数据并发竞争不激烈的场景。

乐观锁控制事务可分成三个阶段:数据读取、写入校验和 实际写入。在写入校验阶段,会进行类似 CAS的操作。在 ZooKeeper中 version属性正是用来进行 CAS校验的。

在 ZooKeeper服务端的 PrepRequestProcessor处理器类中,在处理每一个数据更新(setDataRequest)请求时,都会进行版本检查。

// ZooKeeper从 setDataRequest请求中获取到当前请求的版本 version
version = setDataRequest.getVersion();
// 从数据记录 nodeRecord中获取到当前服务器上该数据的最新版本 currentVersion
int currentVersion = nodeRecord.stat.getVersion();

// 如果 version为 -1,说明客户端不要求使用乐观锁,可以忽略版本对比
if (version != -1 && version != currentVersion) {
	throw new KeeperException.BadVersionException(path);
}
version = currentVersion + 1;

9.1.4 Watcher——数据变更的通知

ZooKeeper提供了分布式数据的发布/订阅功能,提供了一种一对多的订阅关系,能够让多个订阅者同时监听某一个主题对象,当这个主题对象自身状况发生变化时,通知所有订阅者。

在 ZooKeeper中,引入了 Watcher机制来实现这种分布式的通知功能。客户端向服务端注册 Watcher监听,当服务端的一些指定事件发生时就通知客户端。

ZooKeeper的 Watcher机制主要包括客户端线程、客户端 WatchManager和 ZooKeeper服务器三个部分。在具体流程为:

  1. 客户端在向服务端注册 Watcher的同时,会将 Watcher对象存储在客户端的 WatchManager中。
  2. 当服务端触发 Watcher事件后后,会向客户端发送通知
  3. 客户端线程从 WatcherManager中取出对应的 Watcher对象来执行回调逻辑

Watcher接口

在 ZooKeeper中,接口类 Watcher表示一个标准的事件处理器,定义了事件通知相关的逻辑,包含 KeeperStateEventType两个枚举类,分布代表了通知状态和事件类型,同时定义了事件的回调方法 process(WatchedEvent event)

Watcher事件

KeeperState EventType 触发条件 说明
SyncConnected(3) None(-1) 客户端与服务端会话创建成功 此时客户端和服务器处于连接状态
NodeCreated(1) 被监听的节点被创建
NodeDeleted(2) 被监听的节点被删除
NodeDataChanged(3) 被监听的节点内容发送变动
NodeChildrenChanged(4) 被监听的节点的子节点列表发生变动
Disconnected(0) None(-1) 客户端与服务端会话断开 此时客户端和服务器处于断开状态
Expired(-112) None(-1) 会话超时 此时客户端会话失效,同时也会收到 SessionExpiredException
AuthFailed(4) None(-1) 使用了错误的 scheme进行权限检查;
或 SASL权限检查失败
同时会收到 AuthFailedException
  • 针对 NodeDataChanged事件,即使变更前后数据内容相同,dataVersion也会变化,因此也会触发 Watcher;

  • 针对 NodeChildrenChanged事件,仅针对子节点的数量和组成情况的变更,而子节点内部具体的内容的变化不会触发;

  • 针对 AuthFailed事件,它的触发条件不是当前客户端会话没有权限,而是授权失败。

    // 使用正确的 Schema进行授权
    zkClient = new ZooKeeper(SERVER_LIST, 3000, new Sample_AuthFailed1());
    zkClient.addAuthInfo("digest", "zookeeper:true".getBytes());
    zkClient.create("/zwb", "".getBytes(), acls, CreateMode.EPHEMERAL);
    
    zkClient_error = new ZooKeeper(SERVER_LIST, 3000, new Sample_AuthFailed1());
    zkClient_error.addAuthInfo("digest", "zookeeper:error".getBytes()); // NoAuthException
    zkClient_error.getData("/zwb", true, null);
        
    // 使用错误的 Schema进行授权
    zkClient = new ZooKeeper(SERVER_LIST, 3000, new Sample_AuthFailed1());
    zkClient.addAuthInfo("digest", "zookeeper:true".getBytes());
    zkClient.create("/zwb", "".getBytes(), acls, CreateMode.EPHEMERAL);
    
    zkClient_error = new ZooKeeper(SERVER_LIST, 3000, new Sample_AuthFailed1());
    zkClient_error.addAuthInfo("digest2", "zookeeper:error".getBytes()); // AuthFailedException
    zkClient_error.getData("/zwb", true, null);
    

回调方法 process()

当 ZooKeeper向客户端发送一个 Watcher事件通知时,客户端就会对相应的 process方法进行回调。

abstract public void process(WatchedEvent event);

WatchedEvent包含了每一个事件的三个基本属性:通知状态(keeperState)、事件类型(eventType) 和 节点路径(path)。ZooKeeper使用 WatchedEvent来封装服务端事件并传递给 Watcher,从而方便回调方法 process对服务端事件进行处理。

处理 WatchedEvent,还有 WatcherEvent,前者代表逻辑事件,用于服务端和客户端执行过程中所需的逻辑对象;后者实现了序列化接口,可以用于网络传输。

服务端在生成 WatchedEvent后,就会调用 getWrapper方法,将自己包装成一个可序列化的 WatcherEvent事件,便于通过网络传输到客户端。客户端在接收到服务端的这个事件对象后,会先将 WatcherEvent事件还原成一个 WatchedEvent事件,并传递给 process方法处理。

但是无论是 WatchedEvent还是 WatcherEvent二者对服务端事件的封装都是非常简单的,只封装了事件状态,客户端在收到 Event后还需要再重新获取数据。

工作机制

ZooKeeper的 Watcher机制分为三个过程:

  • 客户端注册 Watcher
  • 服务端处理 Watcher
  • 客户端回调 Watcher

客户端注册 Watcher

在创建 ZooKeeper客户端对象实例时,可以直接传入一个默认的 Watcher。这个 Watcher将作为会话期间的默认 Watcher,会一直保存在 ZKWatchManager的 defaultWatcher中。并且,ZooKeeper客户端也可以通过 getData、getChildren 和 exist三个接口来注册 Watcher。

public ZooKeeper(String connectString, int sessionTimeout, Watcher watcher);

在向 getData接口注册 Watcher后,客户端首先会对当前客户端请求 request进行标记,将其设置为【使用 Watcher监听】,同时会封装一个 Watcher的注册信息 WatchRegistration对象,用于暂时保存数据节点的路径和 Watcher的对应关系,具体逻辑代码如下:

public Stat getData(final String path, Watcher watcher, Stat stat) {
	...
    WatchRegistration wcb = null;
    if (watcher != null) {
        wcb = new DataWatchRegistration(watcher, clientPath);
    }
    ...
    request.setWatch(watcher != null);
    ReplyHeader r = cnxn.submitRequest(h, request, response, wcb);
}

在 ZooKeeper中,Packet是最小的通信协议单元,用于客户端和服务端之间的网络传输,所有待传输对象都需要封装为 Packet对象。因此在 ClientCnxnWatchRegistration也会被封装到 Packet中,然后放入到发送队列中等待客户端发送:

Packet queuePacket(RequestHeader h, ReplyHeader r, Record request, Record response, AsyncCallback cb, String clientPath, String serverPath, Object ctx, WatchRegistration watchRegistration) {
    Packet packet = null;
    ...
    synchronized(outgoingQueue) {
        packet = new Packet(h, r, request, response, watchRegistration);
        ...
        outgoingQueue.add(packet);
        ...
    }
}

随后,客户端就会向服务端发送这个请求,且会由 SendThread线程的 readResponse方法负责接收来自服务端的响应,finishPacket方法会从 Packet中取出对应的 Watcher并注册到 ZKWatchManager中去:

private void finishPacket(Packet p) {
    if (p.watchRegistration != null) {
        p.watchRegistration.register(p.replyHeader.getErr());
    }
    ...
}

而客户端之前已经将 Watcher暂时封装在 WatchRegistration对象中,现在就需要再次提取出来:

protected Map<String, Set<Watcher>> getWatches(int rc) {
    return watchManager.dataWatches;
}

public void register(int rc) {
    if (shouldAddWatch(rc)) {
        Map<String, Set<Watcher>> watches = getWatches(rc);
        synchronized (watches) {
            Set<Watcher> watchers = watches.get(clientPath);
            if (watchers == null) {
                watchers = new HashSet<Watcher>();
                watches.put(clientPath, watchers);
            }
            watchers.add(watcher);
        }
    }
}

在 register方法中,客户端会将之前暂时保存的 Watcher对象转交给 ZKWatchManager,并最终保存到 dataWatches中去。ZKWatchManager.dataWatches是一个 Map<String, Set<Watcher>>类型的数据结构,用于将数据节点的路径和 Watcher对象进行一一映射后管理起来。

ZooKeeper可能有很多客户端,而客户端又可能注册多个 Watcher,那么这些注册的 Watcher肯定不能全部传递到服务端。而在 Packet具体的序列化过程中,只会就 requestHeader和 request两个属性进行序列化,也就是说尽管整个 WatchRegistration都被封装到了 Packet,但是并没有全部被序列化到底层字节数组中。

public void createBB() {
    try {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        BinaryOutputArchive boa = BinaryOutputArchive.getArchive(baos);
        boa.writeInt(-1, "len"); // We'll fill this in later
        if (requestHeader != null) {
            requestHeader.serialize(boa, "header");
        }
        if (request instanceof ConnectRequest) {
            request.serialize(boa, "connect");
            // append "am-I-allowed-to-be-readonly" flag
            boa.writeBool(readOnly, "readOnly");
        } else if (request != null) {
            request.serialize(boa, "request");
        }
        baos.close();
        this.bb = ByteBuffer.wrap(baos.toByteArray());
        this.bb.putInt(this.bb.capacity() - 4);
        this.bb.rewind();
    } catch (IOException e) {
        LOG.warn("Unexpected exception", e);
    }
}

服务端处理 Watcher

ServerCnxn存储

当服务端收到来自客户端的请求之后,在 FinalRequestProcessor.processRequest()中会判断当前请求是否需要注册 Watcher:

switch (request.type) {
    case OpCode.getData: {
        ...
            byte b[] = zks.getZKDatabase().getData(getDataRequest.getPath(), stat, 
                                                   getDataRequest.getWatch() ? cnxn : null);
        rsp = new GetDataResponse(b, stat);
        break;
    }
}

getDataRequest.getWatch()为 true的时候,ZooKeeper就认为当前客户端请求需要进行 Watcher注册,于是就会将当前的 ServerCnxn对象和数据节点路径传入 getData()方法中去。

ServerCnxn是一个 ZooKeeper客户端和服务器之间的连接接口,代表一个客户端和服务端的连接。它的默认实现是 NIOServerCnxn,也有基于 Netty的 NettyServerCnxn。二者都可以看作是一个 Watcher对象。数据节点的节点路径和 ServerCnxn最终都会被存储到 WatchManager的 watchTable和 watch2Paths中。

WatchManager是 ZooKeeper服务端 Watcher的管理者,负责 Watcher事件的触发,并移除那些已经被触发的 Watcher,内部有 watchTable和 watch2Paths从两个维度对 Watcher进行存储:

  • Map<String, Set<Watcher>> watchTable:从数据节点路径的粒度来托管 Watcher
  • Map<Watcher, Set<String>> watch2Paths:从 Watcher的粒度来控制事件需要触发的数据节点

在服务端,DataTree会托管两个 WatchManager,分别是 dataWatches和 childWatches,分别对应数据变更 Watcher和 子节点列表变更 Watcher。

Watcher触发

对于标记了 Watcher注册的请求,ZooKeeper会将其对于的 ServerCnxn存储到 WatchManager中。

针对 Watcher触发事件,如 NodeDataChanged事件,在 DataTree中代码如下:

public Stat setData(String path, byte[] data, int version, long zxid, long time) throws KeeperException.NoNodeException {
    Stat s = new Stat();
    DataNode n = nodes.get(path);
    if (n == null) {
        throw new KeeperException.NoNodeException();
    }
    byte[] lastdata = null;
    synchronized (n) {
        lastdata = n.data;
        nodes.preChange(path, n);
        n.data = data;
        n.stat.setMtime(time);
        n.stat.setMzxid(zxid);
        n.stat.setVersion(version);
        n.copyStat(s);
        nodes.postChange(path, n);
    }
    // now update if the path is in a quota subtree.
    String lastPrefix = getMaxPrefixWithQuota(path);
    long dataBytes = data == null ? 0 : data.length;
    if (lastPrefix != null) {
        this.updateCountBytes(lastPrefix, dataBytes - (lastdata == null ? 0 : lastdata.length), 0);
    }
    nodeDataSize.addAndGet(getNodeSize(path, data) - getNodeSize(path, lastdata));

    updateWriteStat(path, dataBytes);
    dataWatches.triggerWatch(path, EventType.NodeDataChanged);
    return s;
}

在对指定节点进行数据更新后,通过调用 WatchManager的 triggerWatch方法来触发相关的事件:

@Override
public WatcherOrBitSet triggerWatch(String path, EventType type) {
    return triggerWatch(path, type, null);
}

@Override
public WatcherOrBitSet triggerWatch(String path, EventType type, WatcherOrBitSet supress) {
    WatchedEvent e = new WatchedEvent(type, KeeperState.SyncConnected, path);
    Set<Watcher> watchers = new HashSet<>();
    PathParentIterator pathParentIterator = getPathParentIterator(path);
    synchronized (this) {
        for (String localPath : pathParentIterator.asIterable()) {
            Set<Watcher> thisWatchers = watchTable.get(localPath);
            if (thisWatchers == null || thisWatchers.isEmpty()) {
                continue;
            }
            Iterator<Watcher> iterator = thisWatchers.iterator();
            while (iterator.hasNext()) {
                Watcher watcher = iterator.next();
                WatcherMode watcherMode = watcherModeManager.getWatcherMode(watcher, localPath);
                if (watcherMode.isRecursive()) {
                    if (type != EventType.NodeChildrenChanged) {
                        watchers.add(watcher);
                    }
                } else if (!pathParentIterator.atParentPath()) {
                    watchers.add(watcher);
                    if (!watcherMode.isPersistent()) {
                        iterator.remove();
                        Set<String> paths = watch2Paths.get(watcher);
                        if (paths != null) {
                            paths.remove(localPath);
                        }
                    }
                }
            }
            if (thisWatchers.isEmpty()) {
                watchTable.remove(localPath);
            }
        }
    }
    if (watchers.isEmpty()) {
        if (LOG.isTraceEnabled()) {
            ZooTrace.logTraceMessage(LOG, ZooTrace.EVENT_DELIVERY_TRACE_MASK, "No watchers for " + path);
        }
        return null;
    }

    for (Watcher w : watchers) {
        if (supress != null && supress.contains(w)) {
            continue;
        }
        w.process(e);
    }

    switch (type) {
        case NodeCreated:
            ServerMetrics.getMetrics().NODE_CREATED_WATCHER.add(watchers.size());
            break;

        case NodeDeleted:
            ServerMetrics.getMetrics().NODE_DELETED_WATCHER.add(watchers.size());
            break;

        case NodeDataChanged:
            ServerMetrics.getMetrics().NODE_CHANGED_WATCHER.add(watchers.size());
            break;

        case NodeChildrenChanged:
            ServerMetrics.getMetrics().NODE_CHILDREN_WATCHER.add(watchers.size());
            break;
        default:
            // Other types not logged.
            break;
    }

    return new WatcherOrBitSet(watchers);
}

无论是 dataWatches还是 childWatches管理器,Watcher的触发逻辑都是一直的:

  1. 封装 WatchedEvent

    将 KeeperState、EventType和 Path封装成一个 WatchedEvent对象

  2. 查询 Watcher

    根据节点路径从 watchTable中取出对应的 Watcher,如果没有找到就说明没有注册,如果找到了就从 watchTable和 watch2Paths中删除,这说明 watcher在服务器端是一次性的

  3. 调用 process方法来触发 Watcher

    一次调用从步骤 2中找出来的所有 watcher的 process方法,这个 process方法其实就是 ServerCnxn的对应方法:

    public void process(WatchedEvent event) {
        // 在请求头中标记 -1,表明到期是一个通知	
        ReplyHeader h = new ReplyHeader(ClientCnxn.NOTIFICATION_XID, -1L, 0);
        if (LOG.isTraceEnabled()) {
            ZooTrace.logTraceMessage(
                LOG,
                ZooTrace.EVENT_DELIVERY_TRACE_MASK,
                "Deliver event " + event + " to 0x" + Long.toHexString(this.sessionId) + " through " + this);
        }
    
        // 将 WatchedEvent包装成 WatcherEvent,以便于网络传输序列化
        // Convert WatchedEvent to a type that can be sent over the wire
        WatcherEvent e = event.getWrapper();
    
        // 向客户端发送该通知
        // The last parameter OpCode here is used to select the response cache.
        // Passing OpCode.error (with a value of -1) means we don't care, as we don't need
        // response cache on delivering watcher events.
        sendResponse(h, e, "notification", null, null, ZooDefs.OpCode.error);
    }
    

客户端回调 Watcher

服务端会通过使用 ServerCnxn对应的 TCP连接来向客户端发送一个 WatcherEvent事件。

客户端会经过如下步骤处理回调:

SendThread接收通知

class SendThread extends Thread {
    void readResponse(ByteBuffer incomingBuffer) throws IOException {
        ...
        switch (replyHdr.getXid()) {
            ...
                
            case NOTIFICATION_XID:
                // 反序列化
                WatcherEvent event = new WatcherEvent();
                event.deserialize(bbia, "response");

                // convert from a server path to a client path
                // 处理 chrootPath
                if (chrootPath != null) {
                    String serverPath = event.getPath();
                    if (serverPath.compareTo(chrootPath) == 0) {
                        event.setPath("/");
                    } else if (serverPath.length() > chrootPath.length()) {
                        event.setPath(serverPath.substring(chrootPath.length()));
                    } 
                }

                // 转换为 WatchedEvent
                WatchedEvent we = new WatchedEvent(event);
                // 将 WatchedEvent交给 EventThread处理
                eventThread.queueEvent(we);
                return;
            default:
                break;
        }

        ...
    }
}

对于一个来自服务端的响应,客户端都是由 SendThread.readResponse(ByteBuffer incomingBuffer)方法来同一处理的,如果响应头 repyHdr中标识了 XID为 -1,表明这是一个通知类型的响应,对其处理为:

  1. 反序列化

    将字节流转换为 WatcherEvent对象

  2. 处理 chrootPath

    如果客户端设置了 chrootPath属性,那么就需要对绝对路径进行切割,得到相对路径

  3. 还原 WatchedEvent

    将 WatcherEvent转换成 WatchedEvent

  4. 回调 Watcher

    将 WatchedEvent对象交给 EventThread线程,在下一个轮询周期中进行 Watcher回调

EventThread处理事件通知

EventThread线程是 ZooKeeper客户端中专门用来处理服务端通知事件的线程。SendThread接收到服务端的通知事件后,会通过调用 EventThread.queueEvent方法将事件传给 EventThread线程:

public void queueEvent(WatchedEvent event) {
    queueEvent(event, null);
}

private void queueEvent(WatchedEvent event, Set<Watcher> materializedWatchers) {
    if (event.getType() == EventType.None && sessionState == event.getState()) {
        return;
    }
    sessionState = event.getState();
    final Set<Watcher> watchers;
    if (materializedWatchers == null) {
        // materialize the watchers based on the event
        watchers = watcher.materialize(event.getState(), event.getType(), event.getPath());
    } else {
        watchers = new HashSet<Watcher>();
        watchers.addAll(materializedWatchers);
    }
    WatcherSetEventPair pair = new WatcherSetEventPair(watchers, event);
    // queue the pair (watch set & event) for later processing
    waitingEvents.add(pair);
}

queueEvent方法会首先根据该通知事件,从 ZKWatchManager中取出所有相关的 Watcher:

@Override
public Set<Watcher> materialize(
    Watcher.Event.KeeperState state,
    Watcher.Event.EventType type,
    String clientPath) {
    Set<Watcher> result = new HashSet<Watcher>();

    switch (type) {
        ...
        case NodeDataChanged:
        case NodeCreated:
            synchronized (dataWatches) {
                addTo(dataWatches.remove(clientPath), result);
            }
            synchronized (existWatches) {
                addTo(existWatches.remove(clientPath), result);
            }
            addPersistentWatches(clientPath, result);
            break;
        ...
    }

    return result;
}

客户端在识别出事件类型 EventType后,会从相应的 Watcher存储(dataWatches, existWatches 或 childWatches)中去除对应的 Watcher,说明客户端的 Watcher机制也是一次性的。

在获取到了相关的 Watcher后,会放入 waitingEvents这个队列中。waitingEvents是一个待处理 Watcher的队列,EventThread的 run方法会不停从该队列中获取元素,然后进行处理:

@Override
@SuppressFBWarnings("JLM_JSR166_UTILCONCURRENT_MONITORENTER")
public void run() {
    try {
        isRunning = true;
        while (true) {
            // 获取元素
            Object event = waitingEvents.take();
            if (event == eventOfDeath) {
                wasKilled = true;
            } else {
                // 进行处理
                processEvent(event);
            }
            ...
        } catch (InterruptedException e) {
        }
    }


    private void processEvent(Object event) {
        try {
            if (event instanceof WatcherSetEventPair) {
                // each watcher will process the event
                WatcherSetEventPair pair = (WatcherSetEventPair) event;
                for (Watcher watcher : pair.watchers) {
                    try {
                        // 调用 Watcher的 process方法
                        watcher.process(pair.event);
                    } catch (Throwable t) {
                    }
                }
                ...
            }
        } catch (Throwable t) {
        }
    }

此处 processEvent方法内的 Watcher才是之前客户端真正注册的 Watcehr,调用其 process方法就可以实现 Watcher的回调了

Watcher特性总结

ZooKeeper中的 Watcher具有以下几个特性:

  • 一次性

    • 一旦一个 Watcher被触发,ZooKeeper就会将其从相应的存储中移除,因此需要反复注册!这样能有效减轻服务端的压力。
  • 客户端串行执行

    • 客户端 Watcher回调的过程是一个串行同步的过程,能保证执行顺序
  • 轻量

    • WatchedEvent是 ZooKeeper中整个 Watcher通知机制内最小的通知单元,仅包含三部分内容(KeeperState …)。

    • Watcher通知非常简单,只会告诉客户端发生了什么事件,不会说明具体的事件内容。

    • 当客户端向服务器注册 Watcher的时候,并不会把客户端真实的 Watcher对象传递到服务端,仅仅只是在客户端请求中使用 Boolean属性进行了标记,同时服务端也仅仅保存了当前连接的 ServerCnxn对象

9.1.5 ACL——保障数据的安全

ZooKeeper作为分布式协调框架,其内部存储了一些分布式系统运行时状态的元数据,包括分布式锁、Master选举和分布式协调等应用场景的数据,会直接影响基于 ZooKeeper进行构建的分布式系统的运行状态。

Unix/Linux文件系统中使用的是 UGO(User、Group和 Others)权限控制机制。UGO针对一个文件或目录,对创建者、创建者组和其他人分别配置不同的权限。这是一种粗粒度的权限控制。

ACL,即访问控制列表,是一种细粒度的权限管理方式,可以针对任意用户和组进行细粒度的权限控制。

ACL介绍

ZooKeeper的 ACL权限控制和 Unix/Linux操作系统中的 ACL有区别,它主要由三部分构成:

  • 权限模式(Schema)
  • 授权对象(ID)
  • 权限(Permission)

通常使用 schema:id:permission来标识一个有效的 ACL信息

权限模式:Schema

Schema用来确定权限验证过程中使用的 检验策略。

在 ZooKeeper中,开发人员使用最多的就是以下四种权限模式:

  • IP

    • 通过 IP地址粒度来进行权限控制,如 ip:192.168.1.12表示权限控制都是针对该 IP地址的

    • 也可以按照网段的方式配置 ip:192.168.0.1/24 表示针对 192.168.0.* 这个 IP段进行权限控制

  • Digest

    • 是最常见的权限控制模式,类似于 username:password形式的权限配置
    • ZooKeeper会对 Digest配置的权限标识进行 SHA-1算法加密和 BASE64编码,得到一个无法被辨认的字符串
  • World

    • 可以看作是一种特殊的 Digest模式 world:anyone
    • 数据节点的访问权限对所有用户开放,即所有用户都可以在不进行任何权限校验的情况下操作 ZooKeeper中的数据
  • Super

    • 也是一种特殊的 Digest模式
    • 超级用户可以对 ZooKeeper上任意的数据节点进行任何操作

授权对象:ID

授权对象指的是权限赋予的用户或一个指定的实体,如 IP地址或机器等。

在不同的权限模式下,授权对象是不同的。

权限模式 授权对象
IP 通常是一个 IP地址,或一个 IP地址段
Digest 自定义,通常是 “username:BASE64(SHA-1(username:password))”
World 只有一个 ID,即 anyone
Super 与 Digest模式一致

权限:Permission

权限指的是那些通过权限检查后可以被允许执行的操作,在 ZooKeeper中,所有对权限的操作分为五大类:

  • CREATE:允许授权对象在该数据节点下创建子节点
  • DELETE:允许授权对象在该数据节点下删除子节点
  • READ:允许授权对象访问该数据节点并读取其数据内容或子节点列表
  • WRITE:允许授权对象对该数据节点进行更新操作
  • ADMIN:允许授权对象对该数据节点进行 ACL相关的设置操作

权限扩展体系

除了上面的四种权限模式之外,ZooKeeper还提供了特殊的权限控制插件体系,允许开发人员通过 “Pluggable ZooKeeper Authentication”机制扩展 ZooKeeper的权限。

实现自定义权限控制器

ZooKeeper定义了一个标准权限控制器需要实现的接口:org.apache.zookeeper.server.auth.AuthenticationProvider

public interface AuthenticationProvider {

    /**
     * The String used to represent this provider. This will correspond to the
     * scheme field of an Id.
     * implementor may attach new ids to the authInfo field of cnxn or may use
     * cnxn to send packets back to the client.
     */
    KeeperException.Code handleAuthentication(ServerCnxn cnxn, byte[] authData);

    /**
     * This method is called to see if the given id matches the given id
     * expression in the ACL. This allows schemes to use application specific
     * wild cards.
     */
    boolean matches(String id, String aclExpr);

    /**
     * This method is used to check if the authentication done by this provider
     * should be used to identify the creator of a node. Some ids such as hosts
     * and ip addresses are rather transient and in general don't really
     * identify a client even though sometimes they do.
     */
    boolean isAuthenticated();

    /**
     * Validates the syntax of an id.
     */
    boolean isValid(String id);

    /**
     * <param>id</param> represents the authentication info which is set in server connection.
     * id may contain both user name as well as password.
     * This method should be implemented to extract the user name.
     */
    default String getUserName(String id) {
        // Most of the authentication providers id contains only user name.
        return id;
    }
}

用户可以基于该接口来进行自定义权限控制器的实现,前面的四种权限模式,就是对应了 DigestAuthenticationProviderIPAuthenticationProvider

注册自定义权限控制器

完成自定义权限控制器开发后,需要将该权限控制器注册到 ZooKeeper服务器中,ZooKeeper支持通过系统属性和配置文件两种方式来注册自定义的权限控制器:

  • 系统属性,在 ZooKeeper启动参数中进行如下配置
    • -Dzookeeper.authProvider.1 = com.xxx.CustomAuthenticationProvider
  • 配置文件方式,在 zoo.cfg配置文件中进行如下配置
    • authProvider.1 = com.xxx.CustomAuthenticationProvider

对于权限控制器的注册,ZooKeeper采用延迟加载的方式,只有在第一次处理包含权限控制的客户端请求时,才会进行权限控制器的初始化。

ZooKeeper会将所有的权限控制器都注册到 ProviderRegistry中。

ACL管理

通过 zkCli脚本登录 ZooKeeper服务器后,可以使用两种方式进行 ACL的设置:

  • 创建节点的时候设置
    • create [-s] [-e] path data acl
    • 如:create -e /zwb init digest:foo:asdasdasd
  • setAcl命令单独设置
    • setAcl path acl
    • 如:setAcl /zwb digest:foo:asdasda

Super模式的用法

如果一个持久数据节点包含了 ACL权限,但其创建者已经退出,该如何处理该节点?

此时需要在ACL的 Super模式下,使用超级管理员权限来进行处理。

使用超级管理员,首先需要在 ZooKeeper服务器上开启 Super模式,方法是在 ZooKeeper服务器启动的时候,添加如下系统属性:

-Dzookeeper.DigestAuthenticationProvider.superDigest=foo:asdasdadadad

完成 ZooKeeper服务器的 Super模式开启后,就可以在应用程序中使用了

9.2 序列化与协议

ZooKeeper服务端和客户端之间会进行一系列的网络通信以实现数据的传输,在 ZooKeeper中使用 Jute组件来进行数据的序列化和反序列化操作。

Jute介绍

Jute是 ZooKeeper中的序列化组件,从老版本的 Hadoop上剥离出来。Hadoop自己转而使用 Avro。

时至今日,Jute的序列化能力都不是 ZooKeeper的性能瓶颈。

使用 Jute进行序列化

可以使用 Jute完成 Java对象的序列化和反序列化

@Data
@AllArgsConstructor
@NoArgsConstructor
public class MockReqHeader implements Record {

    private Long sessionId;
    private String type;

    @Override
    public void serialize(OutputArchive archive, String tag) throws IOException {
        archive.startRecord(this, tag);
        archive.writeLong(sessionId, "sessionId");
        archive.writeString(type, "type");
        archive.endRecord(this, tag);
    }

    @Override
    public void deserialize(InputArchive archive, String tag) throws IOException {
        archive.startRecord(tag);
        this.sessionId = archive.readLong("sessionId");
        this.type = archive.readString("type");
        archive.endRecord(tag);
    }


    public static void main(String[] args) throws IOException {
        // 开始序列化
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        BinaryOutputArchive boa = BinaryOutputArchive.getArchive(baos);
        new MockReqHeader(0x1231231L, "ping").serialize(boa, "header");
        // TCP网络传输对象
        ByteBuffer bb = ByteBuffer.wrap(baos.toByteArray());

        // 开始反序列化
        ByteBufferInputStream bbis = new ByteBufferInputStream(bb);
        BinaryInputArchive bbia = BinaryInputArchive.getArchive(bbis);
        MockReqHeader header2 = new MockReqHeader();
        header2.deserialize(bbia, "header");
        bbis.close();
        baos.close();
    }
}

使用 Jute可以分为四步:

  1. 创建实体类,实现 Record接口的 serialize和 deserialize方法
  2. 构建一个序列化器 BinaryOutputArchive
  3. 序列化,调用实体类的 serialize方法,将对象序列化到指定 tag中
  4. 反序列化,调用实体类的 deserialize方法,从指定的 tag中反序列化出数据内容

深入 Jute

Record接口

Jute定义了自己独特的序列化格式 Record,ZooKeeper中所有需要进行 网络传输 或是 本地磁盘存储 的类型定义都实现了该接口,它是 Jute序列化的核心。

Record接口定义了两个最基本的方法:serialize和 deserialize,分别用于序列化和反序列化

@InterfaceAudience.Public
public interface Record {
    void serialize(OutputArchive archive, String tag) throws IOException;
    void deserialize(InputArchive archive, String tag) throws IOException;
}

所有实体类通过实现 Record接口的这两个方法,来定义自己将如何被序列化和反序列化。其中 archive是底层真正的序列化器和反序列化器,每个 archive都可以包含对多个对象的序列化和反序列化,因此两个接口都标记了参数 tag,用于向序列化器和反序列化器标识对象自己。

OutputArchive和 InputArchive

二者分别是 Jute底层的序列化器和反序列化器接口,目前分别由 BinaryXxx(网络传输和本地磁盘存储)、CsvXxx(可视化展示)和 XmlXxx(以XML格式保存和还原)三种实现。

通信协议

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

协议解析:请求部分

请求头中包含了请求最基本的信息,包括 xid和 type。xid用于记录客户端请求发起的先后序号,用来确保单个客户端请求的响应顺序;type代表请求的操作类型,常见的包括创建节点、删除节点和获取节点数据等

根据协议规定,除非是【会话创建】请求,其他所有的客户端请求中都会带上请求头。

协议中的请求体部分是请求的主体内容部分,包含了请求的所有操作内容。不同的请求类型,器请求体部分的结构是不同的。

  • ConnectRequest:会话创建
    • 该请求体中包含了 协议的版本号 protocolVersion、最近一次接收到的服务器 ZXID lastZxidSeen、会话超时时间 timeOut、会话标识 sessionId和会话密码 passwd
  • GetDataRequest:获取节点数据
    • 该请求体中包含了 数据节点的节点路径 path 和 是否注册 Watcher的标识 watch
  • SetDataRequest:更新节点数据
    • 该请求体中包含了 数据节点的节点路径 path、数据内容 data和 节点数据的期望版本号 version

实际上,针对不同的操作,ZooKeeper都会定义不同的请求体

协议解析:响应部分

响应头中包含了每一个响应的最基本信息,包括 xid、zxid和 err。xid同上,zxid代表服务端最新的事务 ID,err是错误码,当请求处理过程中出现异常时,会在该错误码中标识。

响应体部分是响应的主题内容部分,包含了响应的所有返回数据。不同的响应类型,其响应体部分的结构也是不同的。

  • ConnectResponse:会话创建
    • 该响应体中包含了 协议的版本号 protocalVersion、会话的超时时间 timeOut、会话的标识 sessionId 和 会话的密码 passwd
  • GetDataResponse:获取节点数据
    • 该响应体中包含了 数据节点的数据内容 data 和 节点状态 stat
  • :更新节点数据
    • 该响应体中包含了 节点状态 stat

同样的 ZooKeeper针对几乎所有的请求,都会有对应的响应体存在。

9.3 客户端

ZooKeeper的客户端主要由以下几个核心组件构成:

  • ZooKeeper实例:客户端的入口
  • ClientWatchManager:客户端 Watcher管理器
  • HostProvider:客户端地址列表管理器
  • ClientCnxn:客户端核心线程,内部包括 SendThread和 EventThread。前者负责 IO,后者负责事件处理

如果在 ZooKeeper的构造方法的话,那么 ZooKeeper就会将

一次会话的创建过程

初始化阶段

  1. 初始化 ZooKeeper对象
    • 通过调用 ZooKeeper的构造方法来实例化一个 ZooKeeper对象
    • 在初始化过程中,会创建一个客户端的 Watcher管理器 ClientWatchManager
  2. 设置会话默认 Watcher
    • 如果在构造方法中中传入了一个 Watcher对象,它会被保存在 ZKWatchManager的 defaultWatcher中,作为整个客户端会话期间的默认 Watcher。
  3. 构造 ZooKeeper服务器地址列表管理器
    • 客户端会将构造方法中传入的服务器地址存放在服务器地址列表管理器 HostProvider中
  4. 创建并初始化客户端网络连接器
    • 客户端创建网络连接器 ClientCnxn,用来管理客户端与服务端的网络交互
    • 客户端在创建 ClientCnxn的同时,会初始化客户端两个核心队列 outgoingQueue(客户端请求发送队列) 和 pendingQueue(服务端响应等待队列)
    • ClientCnxn连接器的底层 IO处理器是 ClientCnxnSocket,因此还会同时创建 ClientCnxnSocket处理器
  5. 初始化 SendThread和 EventThread
    • 客户端会创建两个核心线程 SendThread和 EventThread
    • 同时会把 ClientCnxnSocket分配给 SendThread作为底层网络 IO处理器
    • 并初始化 EventThread的待处理事件队列 waitingEvents,用于存放所有等待被客户端处理的事件

会话创建阶段

  1. 启动 SendThread 和 EventThread
    • SendThread首先会判断客户端的状态,进行一系列清理性工作
  2. 获取一个服务器地址
    • SendThread首先需要从 HostProvider随机获取一个 ZooKeeper服务器的目标地址
  3. 创建 TCP连接
    • 从 HostProvider中获取地址后,然后委托给 ClientCnxnSocket去创建 TCP连接
  4. 构造 ConnectRequest请求
    • 在网络层完成连接之后,SendThread构造出 ConnectRequest请求,代表客户端试图与服务端创建一个会话
    • 客户端会就爱那个该请求包装成 Packed对象,放入 outgoingQueue中
  5. 发送请求
    • ClientCnxnSocket负责从 outgoingQueue中取出一个待发送的 Packed对象,将其序列化为ByteBuffer后,向服务端发送

响应处理阶段

  1. 接收服务端响应
    • ClientCnxnSocket收到服务端的响应后,会先判断当前客户端是否已初始化
    • 如果未完成初始化,那么该响应就被认为是 会话创建请求的响应,直接交给 readConnectResult方法来处理
  2. 处理 Response
    • ClientCnxnSocket对响应反序列化,得到 ConnectResponse对象,并从中获取到 ZooKeeper服务端分配的会话 sessionId
  3. 连接成功
    • 通知 SendThread线程,进一步对客户端进行会话参数设置,包括 readTimeout和 connectTimeout
    • 通知地址管理器 HostProvider当前成功连接的服务器地址
  4. 生成事件
    • 为了让上层应用感知到会话的成功创建,SendThread会生成 SyncConnected-None事件,并传递给 EventThread线程
  5. 查询 Watcher
    • EventThread在收到事件后,会从 ClientWatchManager管理器中查询出对应的 Watcher,针对 SyncConnected-None事件,它会直接找默认的 Watcher,然后放到 waitingEvents队列中
  6. 处理事件
    • EventThread不断地从 waitingEvents队列中取出待处理的 Watcher对象,然后直接调用其 process方法

服务端地址列表

在使用 ZooKeeper构造方法时,用户传入的 ZooKeeper服务器地址列表,即 connectString参数 192.168.0.1:2181,192.168.0.2:2181

ZooKeeper客户端内部在接收到这个服务器地址列表后,会将其首先放入一个 ConnectStringParser对象中封装;ConnectStringParser解析器将会对传入的 connectString做两个处理:

  • 解析 chrootPath

    如果一个 ZooKeeper客户端设置了 chrootPath为 /zwb/cly,那么对于该客户端来说所有的节点路径都以 /zwb/cly为根节点。通过设置 chrootPath,可以实现一个客户端应用与 ZooKeeper服务端的一颗子树相连,能实现应用之间的隔离。

  • 保存服务器地址列表。

    在 ConnectStringParser处理服务器地址后,会将其封装成一个个的 InetSocketAddress,以集合的形式保存在 ConnectStringParser.serverAddresses属性中,然后被进一步封装到 StaticHostProvider中。

    @InterfaceAudience.Public
        public interface HostProvider {
    
        /**
         * 返回当前服务器地址列表的个数,不能返回 0
         */
        int size();
    
        /**
         * 返回一个服务器地址 InetSocketAddress对象,必须有合法返回值
         */
        InetSocketAddress next(long spinDelay);
    
        /**
         * 回调方法
         * 成功连接后,通过这个方法来通知 HostProvider
         */
        void onConnected();
    
        /**
         * 更新服务端地址列表
         */
        boolean updateServerList(Collection<InetSocketAddress> serverAddresses, InetSocketAddress currentHost);
    
    }
    

HostProvider的默认实现为 StaticHostProvider,它把 ConnectStringParser.serverAddresses集合中的地址再次缓存并 shuffle;

其 next()方法会将打乱的地址拼成一个环形队列,并为其创建两个游标 currentIndexlastIndex,在每次尝试获取地址时,都会将 currentIndex游标按循环队列前移,lastIndex则代表当前正在使用的地址,若二者恰好碰上了,就会进行 spinDelay毫秒时间的等待。

StaticHostProvider只是 ZooKeeper的默认简单实现,HostProvider完全可以自定义:

  • 配置文件方式:可以实现读取配置文件来获取服务器地址列表的功能
  • 动态变更的地址列表管理器:HostProvider可以定时从 DNS或配置中心上解析出 ZK服务器地址并更新
  • 实现同机房优先策略

ClientCnxn:网络 IO

ClientCnxn是 ZooKeeper客户端的核心工作类,负责维护客户端与服务端之间的网络连接并进行一系列网络通信。

Packet是 ClientCnxn内部定义的一个对协议层的封装,作为 ZooKeeper中请求与响应的载体。

Packet中包含了最基本的请求体(RequestHeader)、响应头(ReplyHeader)、请求体(Request)、响应体(Response)、节点路径(ClientPath/ServerPath)和 监听器(WatchRegistration)等信息

Packet的 createBB()方法负责对 Packet对象进行序列化,生成能用于底层网络传输的 ByteBuffer对象。期间只会对 requestHeader, request和 readOnly三个属性进行序列化,其余属性都被保存在客户端的上下文中,不会进行与服务端之间的网络传输。

ClientCnxn中,有两个比较核心的队列:

  • outgoingQueue:客户端的请求发送队列,专门用于存储那些需要发送到服务端的 Packet集合
  • pendingQueue:服务端的响应等待队列,专门存储已经从客户端发到服务端的,但是需要等待响应的 Packet集合

在 outgoingQueue队列中的 Packet一般按照先进先出的顺序进行发送,但是如果检测到客户端和服务端之间正在处理 SASL权限的话,那么那些不含请求头(requestHeader)的 Packet是可以被发送的,其余都无法被发送。

正常情况下,会从 outgoingQueue中提取一个可发送的 Packet对象,同时生成一个客户端请求序号 XID,并设置到 Packet的 requestHeader中,然后将其序列化后发送

请求发送完毕之后,会立即将该 Packet保存到 pendingQueue队列中,以便等待服务端响应返回后进行处理。

客户端获取到来自服务端的完整响应数据后,根据不同的客户端请求类型,会进行不同的处理:

  • 如果当前客户端未完成初始化,那么说明此时正在【会话创建】阶段,那么直接就将 ByteBuffer反序列化为 ConnectResponse
  • 如果当前客户端处于正常的会话周期,并且收到的响应是一个事件,那么 ZooKeeper客户端会将接收到的 ByteBuffer反序列化为 WatcherEvent,放入待处理队列中
  • 如果是一个常规的请求响应,那么会从 pendingQueue中取出一个 Packet,然后检验服务端响应中的 XID值来保证请求处理的顺序性,然后再将接收到的 ByteBuffer反序列化为 Response

最后会在 finishPacket方法中处理 Watcher注册等逻辑

ClientCnxnSocket定义了底层 Socket通信的接口,可以通过 -Dzookeeper.clientCnxnSocket系统变量中配置 ClientCnxnSocket实现类的全类名来指定底层的 Socket通信层的自定义实现。

SendThread是 ClientCnxn内部一个核心的 IO调度线程,用于管理客户端和服务端之间的所有 IO操作:

  • 维护了客户端和服务端之间的会话生命周期,通过在一定的周期频率内向服务端发送一个 PING包来实现心跳检测
  • 会话周期内,如果服务端和客户端间的 TCP断开,会自动且透明化的重连
  • 管理了客户端所有的请求发送和响应接收操作
  • 负责将来自服务端的事件传递给 EventThread

EventThread是客户端 ClientCnxn内部的另一个核心线程,负责客户端的事件处理,并触发客户端注册的 Watcher监听:

  • 内部有 waitingEvents队列,用于临时存放那些需要被触发的 Object,包括客户端注册的 Watcher和异步接口中注册的回调器 AsyncCallback
  • EventThread不断从 waitingEvents中取出 Object,识别出具体类型,并分别调用 process和 processResult方法来实现事件触发或回调

9.4 会话

客户端与服务端之间的任何交互操作都与会话息息相关,包括临时节点的生命周期、客户端请求的顺序执行以及 Watcher通知机制等

会话状态

ZooKeeper会话在整个运行期间内,会在不同的会话状态之间进行切换,包括:CONNECTING、CONNECTED、RECONNECTING、RECONNECTED和 CLOSE等。

一旦客户端开始创建 ZooKeeper对象,那么客户端状态就会变成 CONNECTING,同时客户端开始从服务器地址列表中逐个选取 IP地址来尝试进行网络连接,直到成功连接上服务器,然后状态变更为 CONNECTED。

如果因为网络等原因导致连接断开,ZooKeeper客户端会自动进行重连操作,它会回到 CONNECTING状态,重新连上后又会再次变成 CONNECTED。因此通常情况下,客户端的状态总是介于 CONNECTING和 CONNECTED之间。

但如果出现会话超时、权限检查失败或客户端主动退出等情况,那么客户端状态就会之间变更为 CLOSE。

会话创建

Session是 ZooKeeper中的会话实体,代表一个客户端会话,包含 4个基本属性:

  • sessionID:会话 ID,全局唯一标识一个会话,每次客户端创建新的会话时都会分配到一个新的 sessionID
  • TimeOut:会话超时时间
  • TickTime:会话超时检查时间,就是说每隔多少时间检查一次是否超时
  • isClosing:标记当前会话是否已被关闭,一般超时后就会关闭

因为 sessionID是全局唯一的,所以每次客户端向服务器端发起【会话创建】请求时,服务端都会为其分配一个 sessionID。

在 SessionTracker初始化的时候,会调用 initializeNextSession方法来生成一个初始化的 sessionID,之后 ZooKeeper会在该值的基础上为每个会话进行分配

public static long initializeNextSessionId(long id) {
    long nextSid;
    // 获取当前时间的毫秒表示,左移 24位,右移 8位
    nextSid = (Time.currentElapsedTime() << 24) >>> 8;
    // 添加机器标识 SID,就是 myid文件中的值
    nextSid = nextSid | (id << 56);
    if (nextSid == EphemeralType.CONTAINER_EPHEMERAL_OWNER) {
        ++nextSid;  // this is an unlikely edge case, but check it just in case
    }
    // 高 8位确定了所在机器,后 56位使用当前时间的毫秒表示进行随机
    return nextSid;
}

SessionTracker是 ZooKeeper服务端的会话管理器,负责会话的创建、管理和清理等工作。每一个会话在 SessionTracker内部都保留了三份:

  • sessionsById:是一个 HashMap<Long, SessionImpl> 类型的数据结构,用于根据 sessionID来管理 Session实体
  • sessionsWithTimeout:是一个 ConcurrentHashMap<Long, Integer>类型的数据结构,用于根据 sessionID来管理会话的超时时间,它会被定期持久化到快照文件中
  • sessionSets:是一个 HashMap<Long, SessionSet>类似的数据结构,用于根据下次会话超时时间来归档会话,便于会话管理和超时检查

服务端对于客户端的【会话创建】请求的处理,可以分为四个步骤:

  1. 处理 ConnectRequest请求:NIOServerCnxn接收来自客户端的 会话创建请求,并反序列化为 ConnectRequest
  2. 会话创建:SessionTracker分配 sessionID,并注册到上述的三个数据结构中
  3. 处理器链路处理
  4. 会话响应

会话管理

ZooKeeper的会话管理由 SessionTracker负责,采用了【分桶策略】来管理。它会将类似的会话放在同一区块中进行管理,以便于实现区块隔离和统一处理等。

分桶的原则按照 ExpirationTime(下次超时时间点)进行,ExpirationTime = CurrentTime + SessionTimeout。ZooKeeper的 Leader服务器会定时进行会话超时检查,时间间隔为 ExpirationInterval,默认值为 tickTime

为了保持客户端会话的有效性,客户端会在超时过期时间内向服务端发送 PING请求,保持心跳。服务端每次接收到心跳后,都会重新激活对应的客户端会话,使之保持连接状态,它的具体流程为:

  1. 检验该会话是否已经被关闭,如果已经被关闭了,不会激活
  2. 计算该会话的新的超时时间
  3. 定位该会话当前的区块
  4. 迁移会话,将其从老的区块中取出,放入到新的区块中

只要客户端发来心跳检测,那么服务端就会进行一次会话激活,具体的心跳检测如下:

  • 只要客户端向服务端发送请求,就可以看作是一次会话激活
  • 如果在 sessionTimeout / 3时间内未和服务器进行通信,那么客户端主动发送 PING请求

SessionTracker中有一个单独的线程专门负责超时检查,它会逐个一次地对会话桶中剩下的会话进行清理,这样批量处理会话能提高性能。

会话清理

当 SessionTracker的会话超时检查整理出一部分已过期的会话后,就会开始会话清理流程:

  1. 标记会话状态为 ”已关闭“
    • 设置该会话的 isClosing属性为 true,保证在会话清理期间内不再处理该客户端的请求
  2. 服务端内部发起 ”会话关闭“请求
    • 使对该会话的关闭操作在整个服务端集群中都生效
  3. 收集需要清理的临时节点
    • 在 ZooKeeper的内存数据库中,为每个会话都单独保存了一份它自己的所有临时节点集合,因此可以直接根据 sessionID获取该会话的所有临时节点
    • 如果在会话清理前发起了对临时节点的操作,且事务尚未完成,还没有应用到内存数据库中,为了避免漏删或多删,需要特殊处理
      • 针对节点删除操作,需要将其对应的数据节点路径从临时节点列表中删除
      • 针对节点创建操作,需要将其路径添加到临时节点列表中,以确保删除这些将被创建,但未保存到内存数据库中的临时节点
  4. 添加 ”节点删除“事务变更
    • 将这些待删除的临时节点转换成 “节点删除”请求,放入事务变更队列 outstandingChanges中
  5. 删除临时节点
    • FinalRequestProcessor处理器会触发内存数据库,删除该会话的所有临时节点
  6. 移除会话
    • 将会话从 SessionTracker中移除,及在上述三个数据结构中将与该 sessionID相关的数据全部删掉
  7. 关闭 NIOServerCnxn
    • 从 NIOServerCnxnFactory中找到该会话对应的 NIOServerCnxn,将其关闭

重连

当网络断开时,服务端和客户端间的会话会反复自动重连,连上之后会话可能处于两种状态:

  • CONNECTED:在超时时间内成功重连,视作重连成功
  • EXPIRED:在超时时间外成功重连,此时服务端已经把该会话清除了,因此这是非法会话

因此当连接断开后,客户端可能收到三类异常:

  • CONNECTITON_LOSS:连接断开
    • 说明客户端与服务端间的连接断开,此时会话正在尝试重连,可以捕获后等待
  • SESSION_EXPIRED:会话失效
    • 重连时间过长,超过了超时时间,此时客户端需要重新实例化 ZooKeeper对象,重新创建会话
  • SESSION_MOVED:会话转移
    • 原本会话是建立在服务端机器 A上的,但是重连之后连接到了服务端机器 B上,此时可能会产生数据不一致的问题
    • 因此服务端在处理客户端请求时都会检查自己是不是 Owner,如果不是,就直接抛异常,避免不一致现象发生

9.5 服务器启动

单机版服务器启动

ZooKeeper服务器的启动,可分为五个步骤:

  1. 配置文件解析
  2. 初始化数据管理器
  3. 初始化网络 IO管理器
  4. 数据恢复
  5. 对外服务

预启动阶段:

  1. 统一由 QuorumPeerMain作为启动类

    无论是 单机模式 还是 集群模式启动 ZooKeeper,在 zkServer.cmd和 zkServer.sh中都使用 org.apache.zookeeper.server.quorum.QuorumPeerMain作为启动入口类

  2. 解析配置文件 zoo.cfg

    zoo.cfg中配置了ZooKeeper运行时的基本参数,包括 tickTime、dataDir和 clientPort等参数

  3. 创建并启动历史文件清理器 DatadirCleanupManager

    对事务日志和快照数据文件进行定时清理

  4. 判断当前是集群模式还是单机模式的启动

    -如果是单机模式,就委托给 ZooKeeperServerMain进行启动处理

  5. 再次解析配置文件 zoo.cfg

  6. 创建服务器实例 ZooKeeperServer

    org.apache.zookeeper.server.ZookeeperServer是单机版 ZooKeeper服务端的核心实体类,之后的初始化就是对它的操作

初始化阶段:

  1. 创建服务器统计器 ServerStats

    ServerStats是 ZooKeeper服务器运行时的统计器,包含了最基本的运行时信息

    属 性 说 明
    packetSent 从 ZooKeeper启动开始,或从最近一次服务端统计信息重置开始,服务端向客户端发送的【响应包】次数
    packetReceived 从 ZooKeeper启动开始,或从最近一次服务端统计信息重置开始,服务端接收到来自客户端的请求包次数
    maxLatency
    minLatency
    totalLatency
    从 ZooKeeper启动开始,或从最近一次服务端统计信息重置开始,服务端的请求处理的 最大时延
    最小时延
    总时延
    count 从 ZooKeeper启动开始,或从最近一次服务端统计信息重置开始,服务端处理的客户端请求总次数
  2. 创建数据管理器 FileTxnSnapLog

    FileTxnSnapLog是 ZooKeeper上层服务器和底层数据存储之间的对接层,提供了对事务日志文件、快照数据等的操作接口;

    ZooKeeper根据 zoo.cfg文件中解析出的快照数据目录 dataDir和事务日志目录 dataLogDir来创建 FileTxnSnapLog

  3. 设置服务器 tickTime和会话超时时间限制

  4. 创建 ServerCnxnFactory

    可以根据 -Dzookeeper.serverCnxnFactory来指定使用 NIO还是 Netty来作为服务端网络连接工厂

  5. 初始化 ServerCnxnFactory

    ZooKeeper会先初始化一个 Thread作为整个 ServerCnxnFactory的主线程,然后再初始化 NIO服务器

  6. 启动 ServerCnxnFactory主线程

    此时服务器已经对外开放了 2181端口,但是还不能正常处理客户端请求,因为网络层还不能访问到 ZooKeeper实例

  7. 恢复本地数据

    从本地快照数据文件和事务日志文件中进行数据恢复

  8. 创建并启动会话管理器

    创建 SessionTracker,负责服务端的会话管理

  9. 初始化 ZooKeeper的请求处理链

    ZooKeeper的请求处理属于责任链模式,会有多个请求处理器依次来处理客户端请求,在服务端启动时,会将其串联成请求处理链

  10. 注册 JMX服务

    ZooKeeper会将服务器运行时的信息以 JMX的方式暴露给外部

  11. 注册 ZooKeeper服务器实例

集群版服务器启动

集群版和单机版 ZooKeeper服务器的启动过程类似

预启动:

  1. 统一由 QuorumPeerMain作为启动类

  2. 解析配置文件 zoo.cfg

  3. 创建并启动历史文件清理器 DatadirCleanupManager

  4. 判断当前是集群模式还是单机模式的启动

    由于已经在 zoo.cfg中配置了多个服务器地址,因此此处选择集群模式启动

初始化:

  1. 创建 ServerCnxnFactory

  2. 初始化 ServerCnxnFactory

  3. 创建 ZooKeeper数据管理器 FileTxnSnapLog

  4. 创建 QuorumPeer实例

    Quorum是集群模式下特有的对象,是 ZooKeeper服务器实例(ZooKeeper Server)的托管者,QuorumPeer代表了 ZK集群中的一台机器,它会不断检测当前服务器实例的运行状态,同时根据情况发起 Leader选举

  5. 创建内存数据库 ZKDatabase

    ZKDatabase是 ZooKeeper的内存数据库,负责管理 ZooKeeper的所有会话记录以及 DataTree和事务日志的存储

  6. 初始化 QuorumPeer

    需要将一些组件注册到 QuorumPeer中,包括 FileTxnSnapLog、ServerCnxnFactory和 ZKDatabase;同时 ZooKeeper还会对 QuorumPeer配置一些参数,包括服务器地址列表、Leader选举算法和会话超时时间限制等

  7. 恢复本地数据

  8. 启动 ServerCnxnFactory主线程

Leader选举

  1. 初始化 Leader选举

    ZooKeeper会根据自身的 SID(服务器 ID)、lastLoggedZxid(最新的 ZXID)和当前服务器的 epoch(currentEpoch)来生成一个初始化的投票,也就是说每个服务器会先给自己投票;

    然后,ZooKeeper会根据 zoo.cfg中的配置,创建相应的 Leader选举算法实现。一共有三种:LeaderElection、AuthFastLeaderElection和 FastLeaderElection,可以在 zoo.cfg中使用 electionArg属性来指定(0 ~ 3);

    在初始化阶段,ZooKeeper会首先创建 Leader选举所需的网络 IO层 QuorumCnxManager,同时启动对 Leader选举端口的监听,等待其他服务器创建连接

  2. 注册 JMX服务

  3. 检测当前服务器状态

    QuorumPeer会不断地检测当前服务器的状态,并做出相应的处理;

  4. Leader选举

    集群中的所有机器相互之间进行投票,选举最合适的机器成为 Leader

Leader和 Follower启动期交互过程

  1. 创建 Leader服务器和 Follower服务器

    完成 Leader选举后,每个服务器都会根据自己的服务器角色创建相应的服务器实例,并开始进入各自的主流程

  2. Leader服务器启动 Follower接收器 LearnerCnxAcceptor

    Leader需要和所有其他服务器保持连接,以确定集群的机器存活情况

  3. Learner服务器开始和 Leader建立连接

    Learner在启动完毕后,会按照 Leader选举的结果找到当前的 Leader,然后与之建立联系

  4. Leader服务器创建 LearnerHandler

    每个 LeaderHandler实例都对应一个 Leader与 Learner之间的连接,负责 Leader和 Learner之间几乎所有的消息通信和数据同步

  5. 向 Leader注册

    在连接建立后,Learner会将自己的基本信息发送给 Leader,包括当前服务器的 SID和最新的 ZXID

  6. Leader解析 Learner信息,计算新的 epoch

    Leader会根据 ZXID解析出 epoch_of_learner,和当前 Leader服务器的 epoch_of_leader对比,取最大值后更新

  7. 发送 Leader状态

    Leader会将新的 epoch值以 LEADERINFO消息的形式发送给 Learner

  8. Learner发送 ACK信息

  9. 数据同步

  10. 启动 Leader和 Learner服务器

    过半的 Learner完成数据同步后,Leader和 Learner服务器就可以开始启动了

Leader和 Follower启动

  1. 创建并启动会话管理器 SessionManager

  2. 初始化 ZooKeeper的请求处理链

    不同的角色会有不同的请求处理链路

  3. 注册 JMX服务

9.6 Leader选举

服务器启动时期的 Leader选举

假设有三台服务器,myid分别为 1、2、3,称这三台服务器为 Server1、Server2、Server3。

当仅有 Server1启动时,无法进行选举;当 Server2也启动时,二者能够相互通信,会进入 Leader选举流程:

  1. 每个 Server发出一个投票

    投票格式为 【myid, ZXID】,对于每个 Server,都会先给自己投票;

    也就是说,Server1会投 (1, 0)

  2. 接收来自各个服务器的投票

    服务器在接收到来自其他服务器的投票后,会先判断该投票的有效性(是否为本轮次投票、是否来自 LOOKING状态的服务器)

  3. 处理投票

    针对每一个投票,服务器都需要进行如下校验

    • 检查 ZXID,选择 ZXID较大的服务器优先作为 Leader
    • 如果 ZXID相同,选择 myid较大的服务器作为 Leader

    对于 Server1来说,它自己的投票是 (1, 0),而接收到的投票是 (2, 0),它会先比较 ZXID,无法判断 Leader,再比较 myid,于是会更新自己的投票为 (2, 0),然后重新将投票发出去;而对于 Server2来说,不需要更改自己的投票信息,只是再次向集群中所有机器发出上一次投票信息即可

  4. 统计投票

    每轮投票后,服务器都会统计所有的投票,判断是否已有过半的机器接收到相同的投票信息;那么当 Server1和 Server2都收到相同的投票信息 (2, 0)的时候,即认为已经选出了 Leader

  5. 改变服务器状态

    一旦确定了 Leader之后,每个服务器就会更新自己的状态:

    • Follower由 LOOKING改为 FOLLOWING
    • Leader由 LOOKING改为 LEADING

服务器运行期间的 Leader选举

一旦原来的 Leader挂了,整个集群将暂停对外服务,转而进入新一轮的 Leader选举:

  1. 变更状态

    在 Leader宕机之后,其他的所有非 Observer服务器都会将自己的服务器状态变更为 LOOKING,进入 Leader选举流程

  2. 每个 Server发出一个投票

    生成投票【myid, ZXID】,期间每个服务器上的 ZXID可能不同,每个 Server会先投给自己;

    Server1投 (1, 124),Server2投 (2, 159)

  3. 接收来自各个服务器的投票

  4. 处理投票

    先比较 ZXID,再比较 myid

  5. 统计投票

    此时由于 Server2的 ZXID最大,因此它会成为新的 Leader

  6. 改变服务器状态

Leader选举的算法分析

在 ZooKeeper中提供了三种 Leader选举的方法,分别是:

  • LeaderElection
  • UDP版本的 FastLeaderElection
  • TCP版本的 FastLeaderElection

目前 ZooKeeper废弃了前两种。

Leader选举涉及到四个基本概念:

  • SID:服务器 ID

    和 myid一致,全局唯一

  • ZXID:事务 ID

    标识一次服务器状态的变更,集群内每台服务器的 ZXID不一定一致

  • Vote:投票

  • Quorum:过半机器数

    quorum = (n / 2 + 1)

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

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

当一台机器进入 Leader选举时,当前集群可能处于两种状态:

  • 集群中已存在 Leader
  • 集群中不存在 Leader

若集群中已存在 Leader,那么当该机器试图选举 Leader时,会被告知当前集群 Leader信息,它会立刻建立连接,之后保持状态同步即可。

通常会有两种情况导致集群中不存在 Leader:

  1. 集群刚初始化启动,此时尚未产生 Leader
  2. 集群运行期间,当前 Leader挂了

假设当前 ZooKeeper由 5台机器组成,SID为 1~5,ZXID为 9,9,9,8,8,此时 SID为 2的机器是 Leader,某一时刻 1和 2所在的机器出现故障,集群开始 Leader选举。第一次投票的时候,由于还未检测到集群中其他机器的状态信息,因此都先投自己,也就是说会投 (3, 9)、(4, 8)、(5, 8)。

集群中的每台服务器在发出自己的投票后,也会接收到来自其他服务器的投票,每次对于投票的处理,就是一个对 (vote_sid, vote_zxid)和 (self_id, self_zxid)对比的过程:

  • 如果 vote_zxid > self_zxid,那么就认可当前收到的投票,并再次将该投票发送出去

    对于 Server3来说,收到(4, 8)、(5, 8) ZXID都比自己小,因此不做任何改变

  • 如果 vote_zxid < self_zxid,那么就坚持自己的投票,不做任何变更

    对于 Server4来说,收到(3, 9)、(5, 8),对比后投票给 (3, 9);Server5同理

  • 如果 vote_zxid = self_zxid,那么就对比 SID

    • 如果 vote_sid > selft_sid,那么就认可当前收到的投票,并再次将该投票发送出去

如果一台机器收到了超过半数的相同的投票,那么这个投票对应的 SID机器即为 Leader

也就是说,通常哪台服务器上的数据越新,ZXID越大,就越有可能成为 Leader,这样方便数据的恢复。

Leader选举的实现细节

Leader选举是通过投票来实现的,同时每个投票中包含两个最基本的信息:SID和 ZXID

  • id:SID
  • electionEpoch:自增序列,用来判断多个投票是否在同一轮选举周期中,每次进入新一轮的投票后,都会对该值加一
  • peerEpoch:被推举的 Leader的 epoch

每台服务器启动的时候,都会启动一个 QuorumCnxManager,负责各台服务器之间的底层 Leader选举过程中的网络通信,其内部维护了一系列的队列,用于保存接收到的、待发送的消息:

  • recvQueue:消息接收队列,存放那些从其他服务器接收到的消息
  • queueSendMap:消息发送队列,存放那些待发送的信息,按照 SID进行分组,为集群中的每一台机器分配了一个单独的队列,保证各台机器之间的消息发送相互不影响
  • senderWorkerMap:发送器集合,负责消息的发送,同样也按照 SID分组
  • lastMessageSent:最近发送过的消息

为了能够两两通信,QuorumCnxManager在启动的时候,会创建一个 ServerSocket来监听 Leader选举的通信端口(默认为 3888),在接收到“创建连接”请求后,会交给 receiveConnection函数来处理,ZooKeeper默认只允许 SID大的服务器主动向 SID小的服务器建立连接。

消息的接收过程是由消息接收器 RecvWorker来负责的,ZooKeeper会为每一个远程服务器分配一个单独的 RecvWorker,因此每个 RecvWorker只需要不断地从这个 TCP连接中读取消息并保存到 recvQueue中即可。

消息发送过程也类似,SendWorder也是每个远程服务器独立的,因此只需要不断地从对应的消息发送队列中获取消息,塞入 TCP连接即可;但是如果发送队列为空,就要从 lastMessageSent中取出一个最近发送过的消息来进行发送

选举算法的核心部分

当 ZooKeeper服务器检测到当前服务器状态变成 LOOKING时,会调用 lookForLeader方法来进行 Leader选举

  1. 自增选举轮次

    logicalClock用于标识当前 Leader的选举轮次,所有的投票必须在同一轮次中,每开始新的一轮都会对 logicalClock进行自增

  2. 初始化选票

    即初始化 Vote的数据结构

  3. 发送初始化选票

    发起第一轮投票,放入 sendQueue中,由 WorkerSender负责发送

  4. 接收外部投票

    从 recvQueue队列中获取外部投票,如果无法获取,则检验连接的有效性

  5. 判断选举轮次

    在处理外部投票时,会根据选举轮次来进行不同的处理:

    • 外部投票的轮次更大

      说明自己已经落后了,立即更新自己的轮次,并清空所有收到的投票,然后使用初始化的投票来进行 PK确定是否变更内部投票,最后再就爱那个结果发出去

    • 内部投票的轮次更大

      忽略外部投票,不做处理

    • 一样大

      进行选票 PK

  6. 选票 PK

    比较 ZXID和 SID

  7. 变更投票

    如果确定外部投票优于内部投票,就需要使用外部投票信息来覆盖内部投票,变更完成后再发送出去

  8. 选票归档

    无论之前是否变更投票,都需要就爱那个那份外部投票放入 recvSet进行归档,按照 SID区分

  9. 统计投票

    完成归档之后,就可以开始统计投票了。统计投票能确定是否有过半的服务器认可当前的内部投票

  10. 更新服务器状态

    如果已经得到了半数以上的值,那么就更新服务器状态

上述 4~9步骤可能会循环多次,即使已经有过半的认可了,ZooKeeper也会再等一小会看有没有更好的值。

9.7 各服务器角色介绍

ZooKeeper集群中共有三种角色,分别是 Leader、Follower和 Observer。

Leader

Leader服务器是 ZooKeeper集群工作机制中的核心,主要工作有两个:

  • 事务请求的唯一调度者和处理者,保证集群事务的顺序处理
  • 集群内部各服务器的调度者

ZooKeeper使用责任链模式来处理每一个客户端请求,因此每个服务器启动的时候,都会进行请求处理链的初始化

一共 7个请求处理器组成了 Leader服务器的请求处理链:

  • PrepRequestProcessor

    请求预处理器,能够识别当前客户端请求是否为事务请求,如果是,那么会进行预处理:创建请求事务投、事务体、会话检查、ACL检查和版本检查等

  • ProposalRequestProcessor

    投票处理器,是事务处理流程的发起者,对于非事务请求,会直接将请求流转到 CommitProcessor;对于事务请求,处理交给 CommitProcessor的同时还会根据类型创建对应的 Proposal提议,发送给所有的 Follower投票,同时还会将事务请求交付给 SyncRequestProcessor记录事务日志

  • SyncRequestProcessor

    事务日志记录处理器,将事务记录到日志中,同时触发 ZooKeeper进行数据快照

  • AckRequestProcessor

    Leader特有的处理器,负责在 SyncRequestProcessor完成日志记录后,向 Proposal的投票收集器发送 ACK反馈,表示已经完成了对该 Proposal的事务日志记录

  • CommitProcessor

    事务提交处理器,对于非事务请求,会直接交付给下一级处理器处理;对于事务请求,会等待集群内部的投票结果,可以用来控制事务请求的顺序处理

  • ToBeCommitProcessor

    其内部有一个 toBeApplied队列,专门用来存储那些已经被 CommitProcessor处理过的可被提交的 proposal

  • FinalRequestProcessor

    最后一个请求处理器,进行收尾工作,包括创建客户端请求的响应、将事务应用到内存数据库中

LearnerHandler

为了保证集群内部的实时通信 和 控制所有的 Follower/Observer服务器,Leader服务器会与每一个 Follower/Observer都建立一个 TCP长连接 和 对应的 LearnerHandler实体

LearnerHandler负责进行数据同步、请求转发和 Proposal提议的投票等。Leader中保持了所有 Follower/Observer对应的 LearnerHandler

Follower

Follower为 ZooKeeper集群状态的跟随者,主要工作为:

  • 处理客户端非事务请求,将事务请求转发给 Leader
  • 参与事务请求 Proposal投票
  • 参与 Leader选举投票

和 Leader一样,Follower也采用责任链模式组装的请求处理链来处理每一个客户端请求

  • FollowerRequestProcessor

    是 Follower服务器中的第一个请求处理器,能识别出当前请求是否为事务请求,如果是就转发给 Leader

  • SendAckRequestProcessor

    类似 Leader中的 AckRequestProcessor,会在记录事务日志后通过 ACK消息反馈给 Leader

Observer

Observer是 ZooKeeper中的观察者,类似 Follower,但是不会参与投票,它能够在不影响集群事务处理能力的前提下提升集群的非事务处理能力

Observer也有 SyncRequestProcessor但是实际上并不会使用

集群间消息通信

ZooKeeper的消息类型可分为四类:

  • 数据同步型

    指在 Learner和 Leader服务器进行数据同步的时候,网络通信所用到的消息

    消息类型 发送方 → 接收方 说 明
    DIFF, 13 Leader → Learner 通知 Learner服务器,Leader即将与其进行 DIFF方式的数据同步
    TRUNC, 14 Leader → Learner 触发 Learner服务器进行内存数据库的回滚
    SNAP, 15 Leader → Learner 通知 Learner服务器,Leader即将与其进行 全量方式的数据同步
    UPTODATE, 12 Leader → Learner 通知 Learner服务器,已经完成了数据同步
  • 服务器初始化型

    在集群初始化或新机器加入时,Leader和 Learner间通信使用

    消息类型 发送方 → 接收方 说 明
    OBSERVERINFO, 16 Observer → Leader Observer服务器向 Leader服务器注册自己,表明自己的角色是 Observer,消息中包含 SID和 ZXID
    FOLLOWERINFO, 11 Follower → Leader Follower服务器向 Leader服务器注册自己,表明自己的角色是 Follower,消息中包含 SID和 ZXID
    LEADERINFO, 17 Leader → Learner Leader服务器在收到上述两种消息后,会将自己的基本信息发送给这些 Learner
    ACKEPOCH, 18 Learner → Leader Learner在收到 LEADERINFO后会将自己最新的 ZXID和 EPOCH以 ACKEPOCH的形式发送给 Leader
    NEWLEADER, 10 Leader → Learner Leader服务器向 Learner发送一个阶段性的标识消息,内有最新的 ZXID
  • 请求处理型

    在请求处理时,Leader和 Learner间通信使用

    消息类型 发送方 → 接收方 说 明
    REQUEST, 1 Learner → Leader 请求转发消息,Learner接收到事务请求的时候,会以 REQUEST的形式转发给 Leader
    PROPOSAL, 2 Leader → Follower 提案消息,Leader会将事务请求以 PROPOSAL消息的形式发送给集群中所有的 Follower来进行日志记录
    ACK, 3 Follower → Leader Follower在完成日志记录后的反馈
    COMMIT, 4 Leader → Follower 当收到过半的 ACK消息后,Leader会生成 COMMIT消息,告知所有的 Follower可以提交了
    INFORM, 8 Leader → Observer 将事务请求的内容发送给 Observer,并让它提交
    SYNC, 7 Leader → Learner 通知 Learner服务器已经完成了 Sync操作
  • 会话管理型

    在会话管理时,Leader和 Learner间通信使用

    消息类型 发送方 → 接收方 说 明
    PING, 5 Leader → Learner 心跳检测,客户端会随机地和任意一个服务端保持心跳(不一定和 Leader连接),因此 Leader需要定时发送 PING消息,让 Learner将存活着的客户端列表同样以 PING消息反馈给 Leader
    REVALIDATE, 6 Learner → Leader 校验、激活会话,在客户端重连时,新的服务器需要向 Leader发送 REVALIDATE消息以确定会话是否已经超时

9.8 请求处理

会话创建请求

ZooKeeper服务端对于会话创建的处理,大体可以分为:请求接收、会话创建、预处理、事务处理、事务应用和会话响应。

  • 请求接收

    1. IO层接收来自客户端的请求

      客户端与服务端的所有通信都由 NIOServerCnxn来负责,它统一接收所有来自客户端的请求,并将请求内容从底层网络 IO中完整地读取出来

    2. 判断是否是客户端 “会话创建” 请求

      每个客户端会话都对应一个 NIOServerCnxn实体,对于每个请求,ZooKeeper都会检查当前 NIOServerCnxn是否已经被初始化,如果没有初始化,说明第一个请求必定是 “会话创建” 请求

    3. 反序列化 ConnectRequest请求

      确实是 “会话创建” 请求后,服务端就可以对其进行反序列化,生成一个 ConnectRequest请求实体

    4. 判断是否是 ReadOnly客户端

      如果服务端是以 ReadOnly模式启动的,那么所有来自非 ReadOnly型客户端的请求将无法被处理,因此服务端需要先检查其是否是 ReadOnly客户端,以此来绝对是否接受该 “会话创建” 请求

    5. 检查客户端 ZXID

      一般来说,服务端的 ZXID一定大于客户端的 ZXID,但是如果客户端的更大,那么服务端就不会接受该 “会话创建” 请求

    6. 协商 sessionTimeout

      客户端在构造 ZooKeeper实例的时候,会传入会话超时时间,服务端会根据自己的超时时间限制最终确定 sessionTimeout的实际值(不一定与客户端给的一样),默认在 2个 tickTime到 20个 tickTime之间

    7. 判断是否需要重新创建会话

      根据客户端请求中是否包含 sessionID来判断客户端是否需要重新创建会话,如果已包含则认为需要会话重连,此时服务端只需要重新打开会话,不需要重新创建

  • 会话创建

    1. 为客户端生成 sessionID

      服务端为每个客户端分配一个 sessionID,在服务端初始化的时候就会创建 SessionTracker和基准 sessionID(全局唯一,之后会在其基础上递增)

    2. 注册会话

      向 SessionTracker中注册会话,其中维护了两个重要的数据结构 sessionsWithTimeout(根据 sessionID保存了所有会话的超时时间)和 sessionsById(根据 sessionID保存了所有会话实体)

    3. 激活会话

      采用分桶策略管理会话,为会话安排一个区块,以便于会话清理程序能够快速高效地进行会话清理

    4. 生成会话密码

      服务端在创建客户端会话的时候,会同时为客户端生成一个会话密码,连同 sessionID一起发送给客户端,作为会话在不同服务端机器间转移的凭证

  • 预处理

    1. 将请求交给 ZooKeeper的 PrepRequestProcessor处理器进行处理

      采用责任链模式进行处理,每个客户端请求都由几个不同的请求处理器依次进行处理;

      在提交给第一个请求处理器前,会对给会话进行一次激活操作,确保会话处于激活状态;

    2. 创建请求事务头

      服务端的后续请求处理器都按照请求事务头来识别当前请求是否是事务请求,包含 clientId(唯一标识客户端)、cxid(客户端的操作序号)、zxid、time、type(事务请求类型,如 create、delete等)

    3. 创建请求事务体

      创建事务体 CreateSessionTxn

    4. 注册与激活会话

      和步骤一中的激活一样,处理由非 Leader服务器转发过来的会话创建请求,此时 Leader中的 SessionTracker可能还未注册会话,因此需要一次额外的注册和激活

  • 事务处理

    1. 将请求交给 ProposalRequestProcessor处理器

      开始进行提案处理,请求流程将会进入三个子处理流程:Sync、Proposal和 Commit

      • Sync流程

        使用 SyncRequestProcessor处理器记录事务日志,Leader和 Follower中都有该处理器;

        完成日志记录后,每个 Follower服务器都会向 Leader服务器发送 ACK消息,便于统计投票

      • Proposal流程

        每一个事务请求都需要集群中过半机器投票认可才会被真正认可,因此需要一个 投票-统计 的过程

        1. 发起投票

          如果当前请求是事务请求,那么 Leader就会发起一轮事务投票,它会先检查 ZXID,如果服务端 ZXID不可用,那么将会抛出 XidRolloverException异常

        2. 生成提议 Proposal

          如果当前 ZXID可用,就可用开始事务投票,Leader会将之前创建的请求头、请求体、ZXID和请求本身序列化到 Proposal对象中

        3. 广播提议

          生成提议后,Leader会以 ZXID作为标识,将该提议放入投票箱 outstandingProposals中,并广播给所有的 Follower

        4. 收集投票

          Follower接收到提议后,就会进入 Sync流程,日志记录完成后会发送 ACK反馈;

          Leader收到 ACK反馈后,会统计每个提议的投票情况;

          如果收到了半数以上的 ACK,那么就认为提议通过,会提交该提议

        5. 将请求放入 toBeApplied队列

          在提议被提交前,ZooKeeper会将其放入 toBeApplied队列中

        6. 广播 COMMIT消息

          在 Proposal确定可以被提交后,Leader会向 Follower和 Observer发送 COMMIT消息;

          此时 Observer由于没有参加之前的投票活动,因此很懵逼,Leader会向其发送 INFROM消息,内含之前的 Proposal内容

      • Commit流程

        1. 将请求交付给 CommitProcessor处理器

          CommitProcessor在收到请求后,不会立即处理,而是放入 queuedRequests队列中

        2. 处理 queuedRequests队列请求

          CommitProcessor有一个单独的线程处理 queuedRequests队列中的新请求

        3. 标记 nextPending

          将 nextPending的值标记为本次事务请求,能保证事务请求的顺序性,也便于事务请求投票

        4. 等待 Proposal投票

          在 Commit流程处理的同时,Leader和 Follower正在投票,此时 Commit流程需要等待投票结束

        5. 投票通过

          如果投票通过,那么该请求会被放入 committedRequests队列中,同时唤醒 Commit流程

        6. 提交请求

          对比 nextPending和当前的事务请求是否一致,然后交给 FinalRequestProcessor

  • 事务应用

    1. 交给 FinalRequestProcessor处理器

      FinalRequestProcessor会先检查 outstandingChanges队列中请求的有效性,如果发现该请求落后了,那么就之间丢掉

    2. 事务应用

      将事务变更应用到内存数据库中

    3. 将事务请求放入队列 commitProposal

      commitProposal独立用来保存最近被提交的事务请求,以便于集群间机器进行数据的快速同步

  • 会话响应

    1. 统计处理

      至此,客户端的 “会话创建” 请求已经在 ZooKeeper请求处理链路上的所有请求处理器间完成了流转;

      ZooKeeper会统计花费的时间和客户端连接的基本信息(lastZxid, lastOp, lastLatency)

    2. 创建响应 ConnectResponse

      ConnectResponse中包含了当前客户端与服务端之间的通信协议版本号 protocolVersion、会话超时时间、sessionID和会话密码

    3. 序列化 ConnectResponse

    4. IO层发送响应给客户端

SetData请求

整体上和会话创建请求相似:

  • 预处理

    1. IO层接收来自客户端的请求
    2. 判断是否是客户端 “会话创建”请求
    3. 将请求交给 PrepRequestProcessor处理器
    4. 创建请求事务头
    5. 会话检查
    6. 反序列化请求,创建 ChangeRecord记录
    7. ACL检查
    8. 数据版本检查
    9. 创建请求事务体 SetDataTxn
    10. 保存事务操作到 outstandingChanges队列中
  • 事务处理

    无论是 “会话创建”请求还是 SetData请求,或是其他事务请求,事务处理流程都是一致的;

    都是由 ProposalRequestProcessor处理器发起,通过 Sync、Proposal和 Commit三个子流程相互协作完成的;

  • 事务应用

    1. 交付给 FinalRequestProcessor

    2. 事务应用

      将事务头和事务体直接交给内存数据库 ZKDatabase进行事务应用,同时返回 ProcessTxnResult,内含更新后的 stat

    3. 将事务请求放入队列 commitProposal

  • 请求响应

    1. 统计处理

    2. 创建响应体 SetDataResponse

      SetDataResponse中含有当前数据节点的最新状态 stat

    3. 创建响应头

      响应体是每个请求响应的基本信息,内含 ZXID、请求处理成功的标识 err

    4. 序列化响应

    5. IO层发送响应给客户端

事务请求转发

所有的事务请求必须由 Leader服务器来处理,但并不是所有的 ZooKeeper都和 Leader服务器保持连接,因此所有非 Leader服务器收到来自客户端的事务请求后,都需要转发给 Leader处理。

Follower的第一个处理器 FollowerRequestProcessor和 Observer的第一个处理器 ObserverRequestProcessor都会先检查当前请求是否是事务请求,如果是就将其以 REQUEST消息的形式转发给 Leader服务器。

Leader在接收到该消息后,会解析出客户端的原始请求,然后提交到自己的请求处理链中开始事务请求的处理。

GetData请求

服务端针对 GetData请求,可以分为 3个步骤:预处理、非事务处理和请求响应

  • 预处理
    1. IO层接收来自客户端的请求
    2. 判断是否是客户端 “会话创建”请求
    3. 将请求交给 ZooKeeper的 PrepRequestProcessor处理器进行处理
    4. 会话检查
  • 非事务处理
    1. 反序列化 GetDataRequest请求
    2. 获取数据节点
    3. ACL检查
    4. 获取数据内容和 stat,注册 Watcher
  • 请求响应
    1. 创建响应体 GetDataResponse
    2. 创建响应头
    3. 序列化响应
    4. IO层发送响应给客户端

9.9 数据与存储

在 ZooKeeper中,数据存储分为两部分:内存数据存储 与 磁盘数据存储

内存数据

ZooKeeper的数据模型是一棵树,从使用角度看就像一个内存数据库一样,其中存储了整棵树的内容,包括所有的节点路径、节点数据以及 ACL信息,ZooKeeper会定时将这个数据持久化到磁盘。

DataTree是 ZooKeeper内存数据存储的核心,就是一个 树 的结构,代表了内存中的一份完整的数据。

DataNode是数据存储的最小单元,内部保存了节点的数据内容、ACL列表和节点状态,及父节点引用和子节点列表。在 DataTree中以一个 ConcurrentHashMap的形式存储 DataNode。

private final ConcurrentHashMap<String, DataNode> nodes = new ConcurrentHashMap<>();

nodes这个 map中存放了服务器上所有的数据节点,因此对 ZooKeeper数据的操作,底层都是对这个 Map结构的操作;但对于临时节点,ZooKeeper单独存储

private final Map<Long, HashSet<String>> ephemerals = new ConcurrentHashMap<Long, HashSet<String>>();

ZKDatabase是 ZooKeeper的内存数据库,负责管理 ZooKeeper的所有会话、DataTree存储和事务日志。

它会定时向磁盘 dump快照数据,同时在 ZooKeeper服务器启动的时候,通过磁盘上的事务日志和快照数据文件恢复成一个完整的内存数据库。

事务日志

在部署 ZooKeeper集群时,需要配置 dataDir,该目录默认用于存储事务日志文件,也可以单独配置 dataLogDir

ZooKeeper的日志文件大小都是 64MB,且文件后缀都是一个十六进制数字,其实就是 ZXID,是写入该日志文件的第一条事务记录的 ZXID。

ZXID高 32位代表当前 Leader周期(epoch),低 32位代表操作序列号,这样为日志命名,能清楚地看到当时的 Leader周期

ZooKeeper默认 /bin 目录下有脚本 zkTxnLogToolKit.sh可以用来查看日志内容

FileTxnLog负责维护事务日志对外的接口,包括写入和读取

public synchronized boolean append(TxnHeader hdr, Record txn, TxnDigest digest) // 日志写入

事务日志的写入可分为 6个步骤:

  1. 确定是否有事务日志可写

    在写日志前,先判断 FileTxnLog组件是否已经关联到一个可写的事务日志文件,如果没有就采用 ZXID创建一个;

    构建事务日志文件头信息(包含魔数 magic、事务日志格式版本 version 和 dbid),并立即写入日志中;

    将该文件的文件流放入一个集合:streamsToFlush

  2. 确定事务日志文件是否需要扩容(预分配)

    日志文件采用 “预分配”策略,当检测到当前事务日志文件剩余空间不足 4KB时,就会开始进行文件空间扩容;

    会在现有文件大小的基础上,扩大 64M,然后使用 \0填充;
    由于客户端的每一次事务操作都会被写入日志文件中,因此写入性能至关重要,而文件的不断追加会触发底层磁盘 IO为文件开辟新的磁盘块,为了避免 Seek的频率,就需要进行预分配;

  3. 事务序列化

    对事务头(TxnHeader)、事务体(Record)进行序列化,事务体可分为 会话创建事务(CreateSessionTxn)、节点创建事务(CreateTxn)、节点删除事务(DeleteTxn) 和 节点数据更新事务(SetDataTxn)等

  4. 生成 Checksum

    为了保证事务日志文件的完整性和数据的准确性,ZooKeeper会先对序列化结果计算 Checksum

  5. 写入事务日志文件流

    将序列化后的事务体、事务体和 Checksum写入到文件流中

  6. 事务日志刷入磁盘

    从 streamsToFlush中提取出文件流,并调用 FileChannel.force(boolean metaData)接口来强制刷写数据

如果非 Leader机器上的事务 ID比 Leader服务器上的大,那么 Leader就会发送 TRUNC命令给该机器,要求其信息日志截断,即删除所有更大的事务 ID对应的记录。

数据快照

数据快照用来记录 ZooKeeper服务器上某一个时刻的全量内存数据,一般也会存储在 dataDir目录下 以 snapshot.xxx命名

但是与日志文件不同的是,数据快照没有采用 “预分配”的机制,每个快照文件中的所有内容都是有效的,因此其大小可以直接反映当前 ZooKeeper内存中全量数据的大小。

可以直接使用 bin/zkSnapShotToolkit.sh对数据快照进行分析

针对客户端的每一次事务操作,ZooKeeper都会将它们记录到事务日志中,同时将数据变更应用到内存数据库中;在若干次日志记录后,会将内存数据库的全量数据 Dump到本地文件中,这个过程就是 数据快照。可以使用 snapCount参数来配置每个数据快照之间事务的操作次数。

写入快照的过程就是将内存数据库数据序列化的过程:

  1. 确定是否需要进行数据快照

    每进行一次事务日志记录后,都要进行判断;

    理论上经过 snapCount次事务操作后就会开始数据快照,但出于性能考虑,要避免服务端机器同时进行;

    因此 ZooKeeper采用 “过半随机”策略,当 logCount > (snapCount/2 + randRoll)时进行,randRoll为 1 ~ snapCount / 2间的随机数

  2. 切换事务日志文件

    当前事务日志已经写入了 snapCount个日志,需要重新创建一个新的事务日志

  3. 创建数据快照异步线程

    为了不影响 ZooKeeper的主流程,需要创建一个单独的异步线程

  4. 获取全量数据和会话信息

    从 ZKDatabase中获取所有 数据节点信息(DataTree) 和 会话信息保存到本地磁盘中,

  5. 生成快照数据文件名

    按照快照文件命名规则,根据当前最大的 ZXID来生成数据快照文件名

  6. 数据序列化

    先序列化文件头信息(包含 魔数、版本号和 dbid),然后再对会话信息和 DataTree进行序列化,同时再生成 Checksum

初始化

在 ZooKeeper启动时, 会进行数据初始化工作,将存储在磁盘上的数据文件加载到内存中。主要包括 加载快照数据 和 根据事务日志修正数据 两个过程

  1. 初始化 FileTxnSnapLog

    FileTxnSnapLog用于访问快照和日志,内部分为 FileTxnLog和 FileSnap两部分

  2. 初始化 ZKDatabase

    会先构建 DataTree,它保存了 ZooKeeper上的所有节点信息,每个服务器只有一个,在它初始化的时候会创建默认节点:/, /zookeeper, /zookeeper/quota

    将 FileTxnSnapLog交给 ZKDatabase,用于数据读取,恢复 DataTree中的数据节点;

    创建用于保存所有客户端会话超时时间的记录器 sessionWithTimeouts

  3. 创建 PlayBackListener监听器

    用于接收事务应用过程中的回调,便于之后通过日志对数据进行修正;

    每当成功将一条事务日志应用到内存数据库后,就会调用这个监听器的 void onTxnLoaded(TxnHeader hdr, Record rec)方法,用于对单条事务进行处理;

    PlayBackListener会将这些单条记录应用到 ZKDatabase.committedLog中,并逐条回调接口进行二次处理

  4. 处理快照文件

    通过 FileTxnSnapLog加载快照数据

  5. 获取最新的 100个快照文件

  6. 解析快照文件

    对 100个快照文件由新到旧逐个解析(反序列化),得到 DataTree对象和 sessionWithTimeouts集合,并通过 Checksum校验;

    如果能成功校验,那么就停止继续解析(会解析通过校验且最新的快照文件);

    如果把 100个文件都解析完了都没通过校验,服务器就启动失败;

  7. 获取最新的 ZXID

    此时服务器已经建立了一个完整的 DataTree实例和 sessionWithTimeouts集合了,而根据快照文件名又可以得到最新的 ZXID: zxid_for_snap

  8. 处理事务日志

    通过 FileTxnSnapLog加载日志

  9. 获取所有 zxid_for_snap之后提交的事务

    快照类似 RDB,不会保留最新的数据;而日志类似 AOF,里面记录了最新的数据;

    因此需要获取比 zxid_for_snap大的事务操作;

  10. 事务应用

    将那些 ZXID更大的事务应用到 DataTree和 sessionWithTimeouts中

  11. 获取最新 ZXID

  12. 校验 epoch

    对比最新的 ZXID中的 epochOfZxid 和 磁盘中读取的 currentEpoch和 acceptedEpoch文件

数据同步

在 Leader选举后,Learner会向 Leader注册,在完成注册后,会进入数据同步环节。

Leader会将那些没有在 Learner服务器上提交过的事务请求同步给 Learner服务器

  • 获取 Learner状态

    在注册 Learner的最后阶段,Learner会发送 ACKEPOCH给 Leader;

    Leader可以从中解析出该 Learner的 currentEpoch 和 lastZxid等状态信息

  • 数据同步初始化

    Leader进行数据同步初始化,从 ZooKeeper内存数据库中提取出事务请求对应的缓存队列,同时完成 peerLastZxid(该 Learner服务器最后处理的 ZXID)、minCommittedLog(Leader服务器提议缓存队列中的最小 ZXID) 和 maxCommittedLog(Leader服务器提议缓存队列中的最大 ZXID)三个 ZXID值的初始化;

    然后根据 Leader和 Learner之间的数据差异情况决定数据同步方式

    • 直接差异化同步(DIFF):peerLastZxid 介于 minCommittedLog 和 maxCommittedLog之间

      Leader先向 Learner发送 DIFF指令,之后通过 PROPOSAL数据包和 COMMIT指令包进行数据同步;

      Leader发送完差异数据后,会将该 Learner加入到 forwardingFollowers 或 observingLearners队列中,再发送 NEWLEADER指令,通知 Learner已完成差异数据的发送;

      Learner在收到 DIFF指令后,会将之后的所有 PROPOSAL数据包都直接应用到内存数据库,并在收到 NEWLEADER后返回 ACK,表明自己也完成了数据同步;

      Leader在收到 过半的 ACK后(多个 Learner同时同步),会向所有已完成数据同步的 Learner发送 UPTODATE指令,此时集群中已经有过半的机器完成了数据同步,可以对外提供服务了。

    • 先回滚再差异化同步(TRUNC + DIFF):peerLastZxid 介于 minCommittedLog 和 maxCommittedLog之间,但是在 Leader中没有这条记录

      如果某个 Learner中存在连 Leader都没有的记录时,就要求 Learner先进行事务回滚,回滚到 Leader服务上存在的,同时也是最接近于 peerLastZxid的 ZXID;

      回滚之后,在进行同上步一样的 DIFF同步

    • 仅回滚同步(TRUNC):peerLastZxid 大于 maxCommittedLog

      Learner需要回滚到 ZXID值为 maxCommittedLog对应的事务操作

    • 全量同步(SNAP):peerLastZxid 小于 minCommittedLog 或 Leader服务器上没有提议缓存队列,peerLastZxid 不等于 lastProcessedZxid

      Leader无法使用提议缓存队列和 Learner进行数据同步,被迫只能进行全量同步(Leader会将本机上的全量内存数据都同步给 Learner);

      Leader发送 SNAP指令,随后将内存数据库中的全量数据节点和会话超时时间记录器在序列化后传输给 Learner;

      Learner接收并反序列化后载入到自己的内存数据库中;

9.10 小结

ZooKeeper以树作为内存模型,ZNode是最小的数据单元,每个节点都有版本号,可以实现 CAS;

ZooKeeper使用 Jute作为序列化框架;

ZooKeeper客户端和服务端使用 TCP长连接建立会话,会话可能断开、重连、失效;

服务器启动时会进行数据恢复、Leader选举、数据同步,之后才对外提供服务;

ZooKeeper集群由 Leader、Follower 和 Observer组成,对客户端的请求按照 ZAB规范进行,不同角色的服务器在处理请求时按照不同的请求处理链进行;

对于每个事务请求,Leader会为其分配一个全局唯一且递增的 ZXID;

Leader 和 Follower都会进行事务日志的记录;

ZooKeeper提供 JDK的 File接口实现了数据存储系统,分为事务日志 和 快照数据两部分。

posted @ 2022-05-28 14:53  小么VinVin  阅读(271)  评论(0编辑  收藏  举报