Bootstrap

【Apache Zookeeper】

一、简介

1、场景

如何让⼀个应⽤中多个独⽴的程序协同⼯作是⼀件⾮常困难的事情。开发这样的应⽤,很容易让很多开发⼈员陷⼊如何使多个程序协同⼯作的逻辑中,最后导致没有时间更好地思考和实现他们⾃⼰的应⽤程序逻辑;又或者开发⼈员对协同逻辑关注不够,只是⽤很少的时间开发了⼀个简单脆弱的主协调器,导致不可靠的单⼀失效点。

ZooKeeper 可以在分布式系统中协作多个任务,其设计保证了健壮性,这就使得应⽤开发⼈员可以更多关注应⽤本⾝的逻辑,⽽不是协同⼯作上。ZooKeeper从⽂件系统API得到启发,提供⼀组简单的API,使得开发⼈员可以实现通⽤的协作任务,包括选举主节点、管理组内成员关系、管理元数据等。ZooKeeper包括⼀个应⽤开发库(主要提供Java和C两种语⾔的API)和⼀个⽤Java实现的服务组 件。ZooKeeper的服务组件运⾏在⼀组专⽤服务器之上,保证了⾼容错性和可扩展性。

整个ZooKeeper的服务器集群管理着应⽤协作的关键数据。ZooKeeper 不适合⽤作海量数据存储。对于需要存储海量的应⽤数据的情况,我们有很多备选⽅案,⽐如说数据库和分布式⽂件系统等。因为不同的应⽤有不同的需求,如对⼀致性和持久性的不同需求,所以在设计应⽤时,最佳实践还是应该将应⽤数据和协同数据独⽴开。

异步系统可能存在的问题:消息延迟、处理器性能、时钟偏移等。没有收到⼀个进程发送的消息, 可能是该进程已经崩溃,或是最新消息发⽣了⽹络延迟,或是其他情况导 致进程延迟,或者是进程时钟发⽣了偏移。

ZooKeeper的精确设计简化了这些问题的处理,ZooKeeper并不是完全消除这些问题,⽽是将这些问题在应⽤服务层⾯上完全透明化,使得这些 问题更容易处理。ZooKeeper实现了重要的分布式计算问题的解决⽅案,直观为开发⼈员提供某种程度上实现的封装,⾄少这是我们⼀直希望的。

分布式系统中的进程通信有两种选择:直接通过⽹络进⾏信息交换,或读写某些共享存储。ZooKeeper 使⽤共享存储模型来实现应⽤间的协作和同步原语。对于共享存储本⾝,又需要在进程和存储间进⾏⽹络通信。⽹络通信是分布式系统中并发设计的基础。

提供的服务包括:统一命名服务、统一配置管理、统一集群管理、服务器节点动态上下线、软负载均衡等。

2、使用实例

  • Apache HBase

HBase是⼀个通常与Hadoop⼀起使⽤的数据存储仓库。在HBase中, ZooKeeper⽤于选举⼀个集群内的主节点,以便跟踪可⽤的服务器,并保存集群的元数据。

  • Apache Kafka

Kafka是⼀个基于发布-订阅(pub-sub)模型的消息系统。其中 ZooKeeper⽤于检测崩溃,实现主题(topic)的发现,并保持主题的⽣产和 消费状态。

  • Apache Solr

Solr是⼀个企业级的搜索平台。Solr的分布式版本命名为SolrCloud,它 使⽤ZooKeeper来存储集群的元数据,并协作更新这些元数据。

  • Yahoo!Fetching Service

Yahoo!Fetching Service是爬⾍实现的⼀部分,通过缓存内容的⽅式⾼效地获取⽹页信息,同时确保满⾜⽹页服务器的管理规则(⽐如robots.txt ⽂件)。该服务采⽤ZooKeeper实现主节点选举、崩溃检测和元数据存储。

  • Facebook Messages

Facebook推出的这个应⽤(http://on.fb.me/1a7uViK)集成了email、短 信、Facebook聊天和Facebook收件箱等通信通道。该应⽤将ZooKeeper作为 控制器,⽤来实现数据分⽚、故障恢复和服务发现等功能。

总结:

当开发⼈员使⽤ ZooKeeper进⾏开发时,开发⼈员设计的那些应⽤往往可以看成⼀组连接到ZooKeeper服务器端的客户端,它们通过ZooKeeper的客户端API连接到 ZooKeeper服务器端进⾏相应的操作。

Zookeeper 的客户端API功能强⼤,其中包括:

  • 保障强⼀致性、有序性和持久性

  • 实现通用的同步原语的能⼒

  • 在实际分布式系统中,并发往往导致不正确的⾏为。ZooKeeper提供了⼀种简单的并发处理机制

3、示例:主-从应用

主从架构中,主节点进程负责跟踪从节点状态和任务的有效性,并分配任务到从节点。

(1)主从模式存在的问题:

  • 主节点失效

如果主节点发送错误并失效,系统将⽆法分配新的任务或重新分配已失败的任务。

主节点失效时,我们需要有⼀个备份主节点(backup master)。当主 要主节点(primary master)崩溃时,备份主节点接管主要主节点的⾓⾊,进⾏故障转移,然⽽,这并不是简单开始处理进⼊主节点的请求。新的主要主节点需要能够恢复到旧的主要主节点崩溃时的状态。对于主节点状态的可恢复性,我们不能依靠从已经崩溃的主节点来获取这些信息,⽽需要从其他地⽅获取,也就是通过ZooKeeper来获取。

状态恢复并不是唯⼀的重要问题。假如主节点有效,备份主节点却认为主节点已经崩溃。这种错误的假设可能发⽣在以下情况,例如主节点负载很⾼,导致消息任意延迟,备份主节点将会接管成为主节点的⾓⾊,执⾏所有必需的程序,最终可能以主节点的⾓⾊开始执⾏,成为第⼆个主要主节点。更糟的是,如果⼀些从节点⽆法与主要主节点通信,如由于⽹络分区(network partition)错误导致,这些从节点可能会停⽌与主要主节点的通信,⽽与第⼆个主要主节点建⽴主-从关系。针对这个场景中导致的问题,我们⼀般称之为脑裂(split- brain):系统中两个或者多个部分开始独⽴⼯作,导致整体⾏为不⼀致性。我们需要找出⼀种⽅法来处理主节点失效的情况,关键是我们需要避免发⽣脑裂的情况。

  • 从节点失效

如果从节点崩溃,已分配的任务将⽆法完成。

客户端向主节点提交任务,之后主节点将任务派发到有效的从节点中。从节点接收到派发的任务,执⾏完这些任务后会向主节点报告执⾏状态。主节点下⼀步会将执⾏结果通知给客户端。

如果从节点崩溃了,所有已派发给这个从节点且尚未完成的任务需要重新派发。其中⾸要需求是让主节点具有检测从节点的崩溃的能⼒。主节点必须能够检测到从节点的崩溃,并确定哪些从节点是否有效以便派发崩溃节点的任务。⼀个从节点崩溃时,从节点也许执⾏了部分任务,也许全部执⾏完,但没有报告结果。如果整个运算过程产⽣了其他作⽤,我们还有必要执⾏某些恢复过程来清除之前的状态。

  • 通信故障

如果主节点和从节点之间⽆法进⾏信息交换,从节点将⽆法得知新任务分配给它。

如果⼀个从节点与主节点的⽹络连接断开,⽐如⽹络分区(network partition)导致,重新分配⼀个任务可能会导致两个从节点执⾏相同的任务。如果⼀个任务允许多次执⾏,我们在进⾏任务再分配时可以不⽤验证第⼀个从节点是否完成了该任务。如果⼀个任务不允许,那么我们的应⽤需要适应多个从节点执⾏相同任务的可能性

(2)主-从架构的需求:

  • 主节点选举:这是关键的⼀步,使得主节点可以给从节点分配任务。

  • 崩溃检测:主节点必须具有检测从节点崩溃或失去连接的能⼒。

  • 组成员关系管理:主节点必须具有知道哪⼀个从节点可以执⾏任务的能⼒。

  • 元数据管理:主节点和从节点必须具有通过某种可靠的⽅式来保存分配状态和执⾏状态的能⼒

理想的⽅式是,以上每⼀个任务都需要通过原语的⽅式暴露给应⽤,对开发者完全隐藏实现细节。ZooKeeper提供了实现这些原语的关键机制,因此,开发者可以通过这些实现⼀个最适合他们需求、更加关注应⽤逻辑的分布式应⽤。

4、CAP定律

CAP:⼀致性(Consistency)、可⽤性(Availability)和分区容错性(Partition-tolerance)

该定律指出:⼀个分布式系统最多同时满足两种属性。

ZooKeeper的设计尽可能满⾜⼀致性和可⽤性,当然,在发⽣⽹络分区时ZooKeeper也提供了只读能⼒。

我们⽆法拥有⼀个理想的故障容错的、分布式的、真实环境存在的系统来处理可能发⽣的所有问题。但我们还是可以争取⼀个稍微不那么宏伟的⽬标。⾸先,我们只好对我们的假设或⽬标适当放松,例如,我们可以假设时钟在某种范围内是同步的,我们也可以牺牲⼀些⽹络分区容错的能⼒并认为其⼀直是⼀致的,当⼀个进程运⾏中,也许多次因⽆法确定系统中的状态⽽被认为已经发⽣故障。虽然这些是⼀些折中⽅案,⽽这些折中⽅案允许我们建⽴⼀些印象⾮常深刻的分布式系统。

二、Zookeeper 基础

1、Zookeeper 概述

ZooKeeper是一个开源的分布式的,为分布式框架提供协调服务的 Apache 项目。

从设计模式角度来理解:Zookeeper是一个基于观察者模式设计的分布式服务管理框架,它负责存储和管理大家都关心的数据,然后接受观察者的注册,一旦这些数据的状态发生变化,Zookeeper就将负责通知已经在Zookeeper上注册的那些观察者做出相应的反应。

ZooKeeper 并不直接暴露分布式服务所需要的原语及原语的调用方法。ZooKeeper以类似文件系统的方式存储数据,暴漏出调用这些数据的API,让应用通过ZooKeeper的机制和API,自己来实现分布式相关原语。

ZooKeeper 数据模型的结构与 Unix 文件系统很类似,整体上可以看作是一棵树,每个节点称做一个 ZNode。每一个 ZNode 默认能够存储 1MB 的数据,每个 ZNode 都可以通过其路径唯一标识

1

  • server id:主节点的znode没有数据,表⽰当前还没有选举出主节点
  • /workers节点作为⽗节点,其下每个znode⼦节点保存了系统中⼀个可 用从节点信息。如图2-1所示,有⼀个从节点(foot.com:2181)。
  • /tasks节点作为⽗节点,其下每个znode⼦节点保存了所有已经创建并等待从节点执⾏的任务的信息,主-从模式的应用的客户端在/tasks下添加⼀个znode⼦节点,用来表示⼀个新任务,并等待任务状态的znode节点。
  • /assign节点作为⽗节点,其下每个znode⼦节点保存了分配到某个从 节点的⼀个任务信息,当主节点为某个从节点分配了⼀个任务,就会在/assign下增加⼀个⼦节点。

Zookeeper 特点:

  1. Zookeeper集群:一个领导者(Leader),多个跟随者(Follower)组成
  2. 集群中只要有半数以上节点存活,Zookeeper集群就能正常服务
  3. Zookeeper一般安装奇数台服务器
  4. 全局数据一致:每个Server保存一份相同的数据副本,Client无论连接到哪个Server,数据都是一致的。
  5. 更新请求顺序执行,来自同一个Client的更新请求按其发送顺序依次执行。
  6. 数据更新原子性,一次数据更新要么成功,要么失败。
  7. 实时性,在一定时间范围内,Client能读到最新数据。

1.1 API 概述

znode节点可能含有数据,也可能没有。如果⼀个znode节点包含任何 数据,那么数据存储为字节数组(byte array)。字节数组的具体格式特定 于每个应⽤的实现,ZooKeeper并不直接提供解析的⽀持。我们可以使⽤如 Protocol Buffers、Thrift、Avro或MessagePack等序列化(Serialization)包 来⽅便地处理保存于znode节点的数据格式,不过有些时候,以UTF-8或 ASCII编码的字符串已经够⽤了

ZooKeeper的API暴露了以下⽅法:

  • create/path data :创建⼀个名为/path的znode节点,并包含数据data。

  • delete/path :删除名为/path的znode。

  • exists/path :检查是否存在名为/path的节点。

  • setData/path data:设置名为/path的znode的数据为data。

  • getData/path :返回名为/path节点的数据信息。

  • getChildren/path :返回所有/path节点的所有⼦节点列表。

ZooKeeper并不允许局部写⼊或读取znode节点的数据。当设置⼀个znode节点的数据或读取时,znode节点的内容会被整个替换或全部读取进来。

ZooKeeper客户端连接到ZooKeeper服务,通过API调⽤来建⽴会话(session)。

1.2 Znode不同类型

当新建znode时,还需要指定该节点的类型(mode),不同的类型决定了znode节点的⾏为⽅式。

(1)持久节点和临时节点

znode节点可以是持久(persistent)节点,还可以是临时(ephemeral) 节点。持久的znode,如/path,只能通过调⽤delete来进⾏删除。临时的znode与之相反,当创建该节点的客户端崩溃或关闭了与ZooKeeper的连接时,这个节点就会被删除。

持久znode是⼀种⾮常有⽤的znode,可以通过持久类型的znode为应⽤保存⼀些数据,即使znode的创建者不再属于应⽤系统时,数据也可以保存下来⽽不丢失。例如,在主-从模式例⼦中,需要保存从节点的任务分配情况,即使分配任务的主节点已经崩溃了。

临时znode传达了应⽤某些⽅⾯的信息,仅当创建者的会话有效时这些信息必须有效保存。例如,在主从模式的例⼦中,当主节点创建的znode为临时节点时,该节点的存在意味着现在有⼀个主节点,且主节点状态处于正常运⾏中。如果主znode消失后,该znode节点仍然存在,那么系统将⽆法监测到主节点崩溃。这样就可以阻⽌系统继续进⾏,因此这个znode需要和主节点⼀起消失。我们也在从节点中使⽤临时的znode,如果⼀个从节点失效,那么会话将会过期,之后znode/workers也将⾃动消失。

⼀个临时znode,在以下两种情况下将会被删除:

  • 当创建该znode的客户端的会话因超时或主动关闭⽽中⽌时。

  • 当某个客户端(不⼀定是创建者)主动删除该节点时。

因为临时的znode在其创建者的会话过期时被删除,所以我们现在不允许临时节点拥有⼦节点。在社区讨论中,已经讨论过关于允许临时znode拥有⼦节点的问题,其想法是使其⼦节点也均为临时节点。这个功能也许会出现在未来的发布版本中,但现在还是不可⽤的。

(2)有序节点

⼀个znode还可以设置为有序(sequential)节点。⼀个有序znode节点被分配唯⼀个单调递增的整数。当创建有序节点时,⼀个序号会被追加到路径之后。例如,如果⼀个客户端创建了⼀个有序znode节点,其路径为/tasks/task-,那么ZooKeeper将会分配⼀个序号,如1,并将这个数字追加到路径之后,最后该znode节点为/tasks/task-1。有序znode通过提供了创建具有唯⼀名称的znode的简单⽅式。同时也通过这种⽅式可以直观地查看znode的创建顺序。

总结:znode⼀共有4种类型:持久的(persistent)、临时的(ephemeral)、持久有序的(persistent_sequential)和临时有序的(ephemeral_sequential)。

1.3 监视与通知

ZooKeeper通常以远程服务的⽅式被访问,如果每次访问znode时,客户端都需要获得节点中的内容,这样的代价就⾮常⼤。因为这样会导致更⾼的延迟,⽽且ZooKeeper需要做更多的操作。

1

这是⼀个常见的轮询问题。为了替换客户端的轮询,我们选择了基于通知(notification)的机制:客户端向ZooKeeper注册需要接收通知的znode,通过对znode设置监视点(watch)来接收通知。监视点是⼀个单次触发的操作,意即监视点会触发⼀个通知。为了接收多个通知,客户端必须在每次通知后设置⼀个新的监视点。当节点/tasks发⽣变化时,客户端会收到⼀个通知,并从ZooKeeper读取⼀个新值。

2

因为通知机制是单次触发的操作,所以在客户端接收⼀个znode变更通知并设置新的监视点时,znode节点也许发⽣了新的变化(不要担⼼,你不会错过状态的变化)。假设事件按以下顺序发⽣:

​ 1.客户端c2设置监视点来监控/tasks数据的变化。

​ 2.客户端c1连接后,向/tasks中添加了⼀个新的任务。

​ 3.客户端c2接收通知。

​ 4.客户端c2设置新的监视点,在设置完成前,第三个客户端c3连接后,向/tasks中添加了⼀个新的任务。

客户端c2最终设置了新的监视点,但由c3添加数据的变更并没有触发⼀个通知。为了观察这个变更,在设置新的监视点前,c2实际上需要读取节点/tasks的状态,通过在设置监视点前读取ZooKeeper的状态,最终,c2就不会错过任何变更。

通知机制的⼀个重要保障是,对同⼀个znode的操作,先向客户端传送通知,然后再对该节点进⾏变更。如果客户端对⼀个znode设置了监视点,⽽该znode发⽣了两个连续更新。第⼀次更新后,客户端在观察第⼆次变化前就接收到了通知,然后读取znode中的数据。我们认为主要特性在于通知机制阻⽌了客户端所观察的更新顺序。虽然ZooKeeper的状态变化传播给某些客户端时更慢,但我们保障客户端以全局的顺序来观察ZooKeeper的状态。

ZooKeeper可以定义不同类型的通知,这依赖于设置监视点对应的通知类型。客户端可以设置多种监视点,如监控znode的数据变化、监控znode⼦节点的变化、监控znode的创建或删除。为了设置监视点,可以使⽤任何API中的调⽤来读取ZooKeeper的状态,在调⽤这些API时,传⼊⼀个watcher对象或使⽤默认的watcher。本章后续(主从模式的实现)及第4章会以主从模式的例⼦来展开讨论,我们将深⼊研究如何使⽤该机制。

**注意:谁来管理我的缓存 **

如果不让客户端来管理其拥有的ZooKeeper数据的缓存,我们不得不让ZooKeeper来管理这些应用程序的缓存。但是,这样会导致ZooKeeper的设计更加复杂。事实上,如果让ZooKeeper管理缓存失效,可能会导致ZooKeeper在运⾏时,停滞在等待客户端确认⼀个缓存失效的请求上,因为在进⾏所有的写操作前,需要确认所有的缓存数据是否已经失效。

流程:

  1. 服务器:启动时去注册信息(创建的都是临时节点)
  2. 客户端:获取到当前在线服务器列表,并注册监听
  3. 服务端:服务节点下线
  4. 集群:服务器节点上下线通知
  5. 客户端:process(){ 重新再去获取服务器列表,并注册监听 }

1.4 版本

每⼀个znode都有⼀个版本号,它随着每次数据变化⽽⾃增。两个API操作可以有条件地执⾏:setData和delete。这两个调⽤以版本号作为转⼊参数,只有当转⼊参数的版本号与服务器上的版本号⼀致时调⽤才会成功。当多个ZooKeeper客户端对同⼀个znode进⾏操作时,版本的使⽤就会显得尤为重要。例如,假设客户端c1对znode/config写⼊了⼀些配置信息,如果另⼀个客户端c2同时更新了这个znode,此时c1的版本号已经过期,c1调⽤setData⼀定不会成功。使⽤版本机制有效避免了以上情况。在这个例⼦中,c1在写⼊数据时使⽤的版本⽆法匹配,使得操作失败,图2-4描述了这个情况。

3

2、ZooKeeper架构

4

应⽤通过客户端库来对ZooKeeper实现了调⽤。客户端库负责与ZooKeeper服务器端进⾏交互。

图2-5展⽰了客户端与服务器端之间的关系。每⼀个客户端导⼊客户端库,之后便可以与任何ZooKeeper的节点进⾏通信。

ZooKeeper服务器端运⾏于两种模式下:独⽴模式(standalone)和仲裁模式(quorum)。独⽴模式⼏乎与其术语所描述的⼀样:有⼀个单独的服务器,ZooKeeper状态⽆法复制。在仲裁模式下,具有⼀组ZooKeeper服务器,我们称为ZooKeeper集合(ZooKeeper ensemble),它们之前可以进⾏状态的复制,并同时为服务于客户端的请求。从这个⾓度出发,我们使⽤术语“ZooKeeper集合”来表⽰⼀个服务器设施,这⼀设施可以由独⽴模式的⼀个服务器组成,也可以仲裁模式下的多个服务器组成

2.1 ZooKeeper 仲裁模式

在仲裁模式下,ZooKeeper复制集群中的所有服务器的数据树。但如果让⼀个客户端等待每个服务器完成数据保存后再继续,延迟问题将⽆法接受。在公共管理领域,法定⼈数是指进⾏⼀项投票所需的⽴法者的最⼩数量。⽽在ZooKeeper中,则是指为了使ZooKeeper⼯作必须有效运⾏的服务器的最⼩数量。这个数字也是服务器告知客户端安全保存数据前,需要保存客户端数据的服务器的最⼩个数。例如,我们⼀共有5个ZooKeeper服务器,但法定⼈数为3个,这样,只要任何3个服务器保存了数据,客户端就可以继续,⽽其他两个服务器最终也将捕获到数据,并保存数据。选择法定⼈数准确的⼤⼩是⼀个⾮常重要的事。法定⼈数的数量需要保证不管系统发⽣延迟或崩溃,服务主动确认的任何更新请求需要保持下去,直到另⼀个请求代替它。

为了明⽩这到底是什么意思,让我们先来通过⼀个例⼦来看看,如果法定⼈数太⼩,会如何出错。假设有5个服务器并设置法定⼈数为2,现在服务器s1和s2确认它们需要对⼀个请求创建的znode/z进⾏复制,服务返回客户端,指出znode创建完成。现在假设在复制新的znode到其他服务器之前,服务器s1和s2与其他服务器和客户端发⽣了长时间的分区隔离,整个服务的状态仍然正常,因为基于我们的假设设定法定⼈数为2,⽽现在还有3个服务器,但这3个服务器将⽆法发现新的znode/z。因此,对创建节点/z的请求是⾮持久化的。

这就是第1章中讲述的脑裂场景的例⼦。为了避免这个问题,这个例⼦中,法定⼈数的⼤⼩必须⾄少为3,即集合中5个服务器的多数原则。为了能正常⼯作,集合中⾄少要有3个有效的服务器。为了确认⼀个请求对状态的更新是否成功完成,这个集合同时需要⾄少3个服务器确认已经完成了数据的复制操作。因此,如果要保证集合可以正常⼯作,对任何更新操作的成功完成,我们⾄少要有1个有效的服务器来保存更新的副本(即⾄少在⼀个节点上合理的法定⼈数存在交集)。

通过使⽤多数⽅案,我们就可以容许f个服务器的崩溃,在这⾥,f为⼩于集合中服务器数量的⼀半。例如,如果有5个服务器,可以容许最多f=2个崩溃。在集合中,服务器的个数并不是必须为奇数,只是使⽤偶数会使得系统更加脆弱。假设在集合中使⽤4个服务器,那么多数原则对应的数量为3个服务器。然⽽,这个系统仅能容许1个服务器崩溃,因为两个服务器崩溃就会导致系统失去多数原则的状态。因此,在4个服务器的情况下,我们仅能容许⼀个服务器崩溃,⽽法定⼈数现在却更⼤,这意味着对每个请求,我们需要更多的确认操作。底线是我们需要争取奇数个服务器。我们允许法定⼈数的数量不同于多数原则。

仲裁模式搭建见Zookeeper 集群搭建

2.3 选举机制

(1)Zookeeper 第一次启动

SID:服务器ID。用来唯一标识一台ZooKeeper集群中的机器,每台机器不能重复,和myid一致。

ZXID:事务ID。ZXID是一个事务ID,用来标识一次服务器状态的变更。在某一时刻,集群中的每台机器的ZXID值不一定完全一致,这和ZooKeeper服务器对于客户端“更新请求”的处理逻辑有关。

Epoch:每个Leader任期的代号。没有Leader时同一轮投票过程中的逻辑时钟值是相同的。每投完一次票这个数据就会增加

zookeeper集群-5

5台服务器的集群的选举流程:

① 服务器1启 动,发起一次选举。服务器1投自己一票。此时服务器1票数一票,不够半数以上(3票),选举无法完成,服务器1状态保持为LOOKING;

② 服务器2启动,再发起一次选举。服务器1和2分别投自己一票并交换选票信息:此时服务器1发现服务器2的myid比自己目前投票推举的(服务器1) 大,更改选票为推举服务器2。此时服务器1票数0票,服务器2票数2票,没有半数以上结果,选举无法完成,服务器1,2状态保持LOOKING

③ 服务器3启动,发起一次选举。此时服务器1和2都会更改选票为服务器3。此次投票结果:服务器1为0票,服务器2为0票,服务器3为3票。此时服务器3的票数已经超过半数,服务器3当选Leader。服务器1,2更改状态为FOLLOWING,服务器3更改状态为LEADING;

④ 服务器4启动,发起一次选举。此时服务器1,2,3已经不是LOOKING状态,不会更改选票信息。交换选票信息结果:服务器3为3票,服务器4为 1票。此时服务器4服从多数,更改选票信息为服务器3,并更改状态为FOLLOWING;

⑤ 服务器5启动,同4一样当小弟。

(2)Zookeeper 非第一次启动

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

  • 服务器初始化启动。

  • 服务器运行期间无法和Leader保持连接。

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

  • 集群中本来就已经存在一个Leader

    机器试图去选举Leader时,会被告知当前服务器的Leader信息,对于该机器来说,仅仅需要和Leader机器建立连接,并进行状态同步即可

  • 集群中确实不存在Leader

    假设ZooKeeper由5台服务器组成,SID分别为1、2、3、4、5,ZXID分别为8、8、8、7、7,并且此时SID为3的服务器是Leader。某一时刻,3和5服务器出现故障,因此开始进行Leader选举。

    (EPOCH,ZXID,SID ) (EPOCH,ZXID,SID ) (EPOCH,ZXID,SID )

    ​ SID = 1(1,8,1) SID = 1(1,8,2) SID = 1 (1,7,4)

    选举Leader规则:①EPOCH大的直接胜出 ②EPOCH相同,事务id大的胜出 ③事务id相同,服务器id大的胜出

2.3 会话

在对ZooKeeper集合执⾏任何请求前,⼀个客户端必须先与服务建⽴会话。客户端提交给ZooKeeper的所有操作均关联在⼀个会话上。当⼀个会话因某种原因⽽中⽌时,在这个会话期间创建的临时节点将会消失。

当客户端通过某⼀个特定语⾔套件来创建⼀个ZooKeeper句柄时,它就会通过服务建⽴⼀个会话。客户端初始连接到集合中某⼀个服务器或⼀个独⽴的服务器。客户端通过TCP协议与服务器进⾏连接并通信,但当会话⽆法与当前连接的服务器继续通信时,会话就可能转移到另⼀个服务器上。ZooKeeper客户端库透明地转移⼀个会话到不同的服务器。

会话提供了顺序保障,这就意味着同⼀个会话中的请求会以FIFO(先进先出)顺序执⾏。通常,⼀个客户端只打开⼀个会话,因此客户端请求将全部以FIFO顺序执⾏。如果客户端拥有多个并发的会话,FIFO顺序在多个会话之间未必能够保持。⽽即使⼀个客户端中连贯的会话并不重叠,也未必能够保证FIFO顺序。下⾯的情况说明如何发⽣这种问题:

客户端建立了⼀个会话,并通过两个连续的异步调用来创建/tasks 和/workers。

第⼀个会话过期。

客户端创建另⼀个会话,并通过异步调用创建/assign。

在这个调⽤顺序中,可能只有/tasks和/assign成功创建了,因为第⼀个会话保持了FIFO顺序,但在跨会话时就违反了FIFO顺序。

(1)会话的生命周期(lifetime)

会话的⽣命周期是指会话从创建到结束的时期,⽆论会话正常关闭还是因超时⽽导致过期。

会话状态:CONNECTING、CONNECTED、CLOSED和NOT_CONNECTED。

状态的转换 依赖于发⽣在客户端与服务之间的各种事件:

1

① ⼀个会话从NOT_CONNECTED状态开始,当ZooKeeper客户端初始化后转换到CONNECTING状态(箭头1)

② 正常情况下,成功与ZooKeeper服务器建⽴连接后,会话转换到CONNECTED状态(箭头2)

③ 当客户端与ZooKeeper服务器断开连接或者⽆法收到服务器的响应时,它就会转换回CONNECTING状态(箭头3)并尝试发现其他ZooKeeper服务器

④ 如果可以发现另⼀个服务器或重连到原来的服务器,当服务器确认会话有效后,状态又会转换回CONNECTED状态。否则,它将会声明会话过期,然后转换到CLOSED状态(箭头4)

⑤ 应⽤也可以显式地关闭会话(箭头4 和箭头5)

**注意:发⽣⽹络分区时等待CONNECTING **

如果⼀个客户端与服务器因超时⽽断开连接,客户端仍然保持CONNECTING状态。如果因⽹络分区问题导致客户端与ZooKeeper集合被隔离⽽发⽣连接断开,那么其状态将会⼀直保持,直到显式地关闭这个会话,或者分区问题修复后,客户端能够获悉ZooKeeper服务器发送的会话已经过期。发⽣这种⾏为是因为ZooKeeper集合对声明会话超时负责,⽽不是客户端负责。直到客户端获悉ZooKeeper会话过期,否则客户端不能声明自⼰的会话过期。然⽽,客户端可以选择关闭会话。

创建⼀个会话时,你需要设置会话超时这个重要的参数,这个参数设置了ZooKeeper服务允许会话被声明为超时之前存在的时间。如果经过时间t之后服务接收不到这个会话的任何消息,服务就会声明会话过期。⽽在客户端侧,如果经过t/3的时间未收到任何消息,客户端将向服务器发送⼼跳消息。在经过2t/3时间后,ZooKeeper客户端开始寻找其他的服务器,⽽此时它还有t/3时间去寻找。

**注意:客户端会尝试连接哪⼀个服务器? **

在仲裁模式下,客户端有多个服务器可以连接,⽽在独立模式下,客户端只能尝试重新连接单个服务器。在仲裁模式中,应用需要传递可用的服务器列表给客户端,告知客户端可以连接的服务器信息并选择⼀个进⾏连接。

当尝试连接到⼀个不同的服务器时,⾮常重要的是,这个服务器的ZooKeeper状态要与最后连接的服务器的ZooKeeper状态保持最新。客户端不能连接到这样的服务器:它未发现更新⽽客户端却已经发现的更新。ZooKeeper通过在服务中排序更新操作来决定状态是否最新。ZooKeeper确保每⼀个变化相对于所有其他已执⾏的更新是完全有序的。因此,如果⼀个客户端在位置i观察到⼀个更新,它就不能连接到只观察到i’<i的服务器上。在ZooKeeper实现中,系统根据每⼀个更新建⽴的顺序来分配给事务标识符。

图2-7 描述了在重连情况下事务标识符(zkid)的使⽤。当客户端因超时与s1断开连接后,客户端开始尝试连接s2,但s2延迟于客户端所知的变化。然⽽,s3对这个变化的情况与客户端保持⼀致,所以s3可以安全连接1

2.4 监听器原理

​ 客户端注册监听它关心的目录节点,当目录节点发生变化(数据改变、节点删除、子目录节点增加删除)时,ZooKeeper 会通知客户端。监听机制保证 ZooKeeper 保存的任何的数据的任何改变都能快速的响应到监听了该节点的应用程序。

(1)监听原理详解

​ ① 首先要有一个main()线程

​ ② 在main线程中创建Zookeeper客户端,这时就会创建两个线程,一个负责网络连接通信(connet),一个负责监听(listener)。

​ ③ 通过connect线程将注册的监听事件发送给Zookeeper。

​ ④ 在Zookeeper的注册监听器列表中将注册的监听事件添加到列表中。

​ ⑤ Zookeeper监听到有数据或路径变化,就会将这个消息发送给listener线程。

​ ⑥ listener线程内部调用了process()方法。

(2)常见的监听

​ ① 监听节点数据的变化

get path [watch]

​ ② 监听子节点增减的变化

ls path [watch]

(3)节点的值变化监听

​ ① 在 hadoop104 主机上注册监听/sanguo 节点数据变化

[zk: localhost:2181(CONNECTED) 26] get -w /sanguo 

​ ② 在 hadoop103 主机上修改/sanguo 节点的数据

[zk: localhost:2181(CONNECTED) 1] set /sanguo "xisi"

​ ③ 观察 hadoop104 主机收到数据变化的监听

WATCHER::
WatchedEvent state:SyncConnected 
type:NodeDataChanged 
path:/sanguo

注意:在hadoop103再多次修改/sanguo的值,hadoop104上不会再收到监听。因为注册

一次,只能监听一次。想再次监听,需要再次注册。

(4)节点的子节点变化监听(路径变化)

​ ① 在 hadoop104 主机上注册监听/sanguo 节点的子节点变化

[zk: localhost:2181(CONNECTED) 1] ls -w /sanguo
[shuguo, weiguo] 

​ ② 在 hadoop103 主机/sanguo 节点上创建子节点

[zk: localhost:2181(CONNECTED) 2] create /sanguo/jin "simayi"
Created /sanguo/jin

​ ③ 观察 hadoop104 主机收到子节点变化的监听

WATCHER::
WatchedEvent state:SyncConnected type:NodeChildrenChanged 
path:/sanguo

注意:节点的路径变化,也是注册一次,生效一次。想多次生效,就需要多次注册。

2.5 zookeeper常用客户端命令

addauth scheme auth
close
config [-c] [-w] [-s]
connect host:port
create [-s] [-e] [-c] [-t ttl] path [data] [acl]
delete [-v version] path
deleteall path
delquota [-n|-b] path
get [-s] [-w] path
getAcl [-s] path
history
listquota path
ls [-s] [-w] [-R] path
ls2 path [watch]
printwatches on|off
quit
reconfig [-s] [-v version] [[-file path] | [-members serverID=host:port1:port2;port3[,...]*]] | [-add serverId=host:port1:port2;port3[,...]]* [-remove serverId[,...]*]
redo cmdno
removewatches path [-c|-d|-a] [-l]
rmr path
set [-s] [-v version] path data
setAcl [-s] [-v version] [-R] path acl
setquota -n|-b val path
stat [-w] path
sync path

(1)查询节点

① 查询子节点列表

ls [-s] [-w] [-R] path

ls 列出绝对路径path下的所有子节点信息(列出一级,不递归)

-s 查看节点信息,取代ls2

-R 递归显示子节点

-w 为节点设置监视

② 查看节点数据

get [-s] [-w] path

get 查看节点值

-s 查询节点值+节点属性

-w 为在数据更改上做监视

③ stat 查看节点属性

stat [-w] path

查看当前节点详细数据

[zk: hadoop102:2181(CONNECTED) 5] ls -s /
[zookeeper]cZxid = 0x0
ctime = Thu Jan 01 08:00:00 CST 1970
mZxid = 0x0
mtime = Thu Jan 01 08:00:00 CST 1970
pZxid = 0x0
cversion = -1
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 0
numChildren = 1

① czxid:创建节点的事务 zxid

每次修改 ZooKeeper 状态都会产生一个 ZooKeeper 事务 ID。事务 ID 是 ZooKeeper 中所有修改总的次序。每次修改都有唯一的 zxid,如果 zxid1 小于 zxid2,那么 zxid1 在 zxid2 之前发生。

② ctime:znode 被创建的毫秒数(从 1970 年开始)

③ mzxid:znode 最后更新的事务 zxid

④ mtime:znode 最后修改的毫秒数(从 1970 年开始)

⑤ pZxid:znode 最后更新的子节点 zxid

⑥ cversion:znode 子节点变化号,znode 子节点修改次数

⑦ dataversion:znode 数据变化号

⑧ aclVersion:znode 访问控制列表的变化号

⑨ ephemeralOwner:如果是临时节点,这个是 znode 拥有者的 session id。如果不是

临时节点则是 0。

⑩ dataLength:znode 的数据长度

⑪ numChildren:znode 子节点数量

(2)新增节点

create [-s] [-e] [-c] [-t ttl] path [data] [acl]

-s 要创建的znode是顺序节点

-e 要创建的znode是临时节点

-c 要创建的znode是容器节点

-t znode的失效时间

path 要创建的znode绝对路径

data znode保存的数据

acl znode的访问权限

其中-s为有序节点 -e为临时节点 -t指定ttl节点

当删除掉容器节点container节点下的所有子节点后, container节点本身也会被清除掉,默认被清除的时间是60s

(3)修改节点

set [-s] [-v version] path data

set可以基于版本号进行修改,类似于乐观锁机制。如果传入的版本号dataVersion和当前版本号不一致时,zookeeper会拒绝本次修改

(4)删除节点

delete:删除节点,如果该节点下存在子节点,该节点不允许删除。

deleteall:删除某个节点极其所有后代节点,也可以使用rmr path命令进行递归删除。

删除节点和更新节点一样,也可以传入版本号,当传入的数据版本号和当前节点的数据版本不一致时,zookeeper不会执行删除操作。

[zk: localhost:2181(CONNECTED) 7] delete /wfj
Node not empty: /wfj
[zk: localhost:2181(CONNECTED) 10] deleteall /wfj
[zk: localhost:2181(CONNECTED) 24] delete -v 1 /wfj 
version No is not valid : /wfj
[zk: localhost:2181(CONNECTED) 25] delete -v 0 /wfj

2.6 实现一个原语:通过Zookeeper实现锁

假设有⼀个应⽤由n个进程组成,这些进程尝试获取⼀个锁。再次强调,ZooKeeper并未直接暴露原语,因此我们使⽤ZooKeeper的接⼜来管理znode,以此来实现锁。为了获得⼀个锁,每个进程p尝试创建znode,名为/lock。如果进程p成功创建了znode,就表⽰它获得了锁并可以继续执⾏其临界区域的代码。不过⼀个潜在的问题是进程p可能崩溃,导致这个锁永远⽆法释放。在这种情况下,没有任何其他进程可以再次获得这个锁,整个系统可能因死锁⽽失灵。为了避免这种情况,我们不得不在创建这个节点时指定/lock为临时节点。

其他进程因znode存在⽽创建/lock失败。因此,进程监听/lock的变化,并在检测到/lock删除时再次尝试创建节点来获得锁。当收到/lock删除的通知时,如果进程p还需要继续获取锁,它就继续尝试创建/lock的步骤,如果其他进程已经创建了,就继续监听节点。

2.7 主从例子的实现

主-从模式的模型中包括三个⾓⾊:

  • 主节点:负责监视新的从节点和任务,分配任务给可⽤的从节点。

  • 从节点 :通过系统注册⾃⼰,以确保主节点看到它们可以执⾏任务,然后开始监视新任务。

  • 客户端:创建新任务并等待系统的响应。

(1)主节点角色

因为只有⼀个进程会成为主节点,所以⼀个进程成为ZooKeeper的主节点后必须锁定管理权。为此,进程需要创建⼀个临时znode,名为/master:

[zk: localhost:2181(CONNECTED) 0] create -e /master "master1.example.com:2223"
Created /master 
[zk: localhost:2181(CONNECTED) 1] ls  
[master, zookeeper] 
[zk: localhost:2181(CONNECTED) 2] get /master
"master1.example.com:2223" 
cZxid = 0x67 
ctime = Tue Dec 11 10:06:19 CET 2012 
mZxid = 0x67 
mtime = Tue Dec 11 10:06:19 CET 2012 
pZxid = 0x67 
cversion = 0 
dataVersion = 0 
aclVersion = 0 
ephemeralOwner = 0x13b891d4c9e0005 
dataLength = 26 numChildren = 0 
[zk: localhost:2181(CONNECTED) 3]

在znode中添加了主机信息,以便ZooKeeper外部的其他进程需要与它通信。添加主机信息并不是必需的,但这样做仅仅是为了说明我们可以在需要时添加数据。为了设置znode为临时性的,需要添加-e标志。

假如其他进程不知道已经有⼀个主节点被选举出来,并尝试创建⼀个/master节点:

[zk: localhost:2181,localhost:2182,localhost:2183(CONNECTED) 0] create -e /master "master2.example.com:2223" 
Node already exists: /master 

在/master节点上设置⼀个监视点用来检测主节点是否崩溃,备份主节点需要接替活动主节点的⾓⾊:

[zk: localhost:2181,localhost:2182,localhost:2183(CONNECTED) 0] create -e /master "master2.example.com:2223" 
Node already exists: /master 
[zk: localhost:2181,localhost:2182,localhost:2183(CONNECTED) 1] stat -w /master
cZxid = 0x100000003
ctime = Fri Apr 01 19:28:11 CST 2022
mZxid = 0x100000003
mtime = Fri Apr 01 19:28:11 CST 2022
pZxid = 0x100000003
cversion = 0
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x204628945f00000
dataLength = 7
numChildren = 0

当主节点崩溃时,

[zk: localhost:2181,localhost:2182,localhost:2183(CONNECTED) 2]
WATCHER::
WatchedEvent state:SyncConnected type:NodeDeleted path:/master
[zk: localhost:2181,localhost:2182,localhost:2183(CONNECTED) 3] ls /
[zookeeper]

NodeDeleted事件指出活动主节点的会话已经关闭或过期。同时注意,/master节点已经不存在了。现在备份主节点通过再次创建/master节点来成为活动主节点。

[zk: localhost:2181,localhost:2182,localhost:2183(CONNECTED) 4] create -e /master "master2.example.com:2223" 
Created /master

因为备份主节点成功创建了/master节点,所以现在客户端开始成为活动主节点。

(2)从节点、任务、分配

先创建三个重要的⽗znode,/workers、/tasks和/assign:

[zk: localhost:2181(CONNECTED) 0] create /workers "" 
Created /workers 
[zk: localhost:2181(CONNECTED) 1] create /tasks "" 
Created /tasks 
[zk: localhost:2181(CONNECTED) 2] create /assign "" 
Created /assign 
[zk: localhost:2181(CONNECTED) 3] ls / 
[assign, tasks, workers, master, zookeeper] 
[zk: localhost:2181(CONNECTED) 4]

这三个新的znode为持久性节点,且不包含任何数据。本例中,通过使⽤这些znode可以告诉我们哪个从节点当前有效,还告诉我们当前有任务需要分配,并向从节点分配任务。

在真实的应⽤中,这些znode可能由主进程在分配任务前创建,也可能由⼀个引导程序创建,不管这些节点是如何创建的,⼀旦这些节点存在了,主节点就需要监视/workers和/tasks的⼦节点的变化情况:

[zk: localhost:2181(CONNECTED) 4] ls -w /workers
[]
[zk: localhost:2181(CONNECTED) 5] ls -w /tasks 
[]
[zk: localhost:2181(CONNECTED) 6]

(3)从节点角色

从节点⾸先要通知主节点,告知从节点可以执⾏任务。从节点通过在/workers⼦节点下创建临时性的znode来进⾏通知,并在⼦节点中使⽤主名来标识⾃⼰:

[zk: localhost:2181(CONNECTED) 0] create -e /workers/worker1.example.com "worker1.example.com:2224" 
Created /workers/worker1.example.com 
[zk: localhost:2181(CONNECTED) 1] 

注意,输出中,ZooKeeper确认znode已经创建。之前主节点已经监视了/workers的⼦节点变化情况。⼀旦从节点在/workers下创建了⼀个znode,主节点就会观察到以下通知信息:

WATCHER:: 
WatchedEvent state:SyncConnected type:NodeChildrenChanged path:/workers 

下⼀步,从节点需要创建⼀个⽗znode/assing/worker1.example.com来接收任务分配,并通过第⼆个参数为true的ls命令来监视这个节点的变化,以便等待新的任务。

[zk: localhost:2181(CONNECTED) 0] create -e /workers/worker1.example.com "worker1.example.com:2224" 
Created /workers/worker1.example.com 
[zk: localhost:2181(CONNECTED) 1] create /assign/worker1.example.com ""
[zk: localhost:2181(CONNECTED) 2] ls -w /assign/worker1.example.com

从节点现在已经准备就绪,可以接收任务分配。

(4)客户端角色

客户端向系统中添加任务。假设客户端请求主从系统来运⾏cmd命令。为了向系统添加⼀个任务,客户端执⾏以下操作:

[zk: localhost:2181(CONNECTED) 0] create -s /tasks/task- "cmd" 
Created /tasks/task-0000000000 

我们需要按照任务添加的顺序来添加znode,其本质上为⼀个队列。客户端现在必须等待任务执⾏完毕。执⾏任务的从节点将任务执⾏完毕后,会创建⼀个znode来表⽰任务状态。客户端通过查看任务状态的znode是否创建来确定任务是否执⾏完毕,因此客户端需要监视状态znode的创建事件:

[zk: localhost:2181(CONNECTED) 1] ls -w /tasks/task-0000000000  
[]
[zk: localhost:2181(CONNECTED) 2] 

执⾏任务的从节点会在/tasks/task-0000000000节点下创建状态znode节点,所以我们需要⽤ls命令来监视/tasks/task-0000000000的⼦节点。⼀旦创建任务的znode,主节点会观察到以下事件:

[zk: localhost:2181(CONNECTED) 6] 
WATCHER:: 
WatchedEvent state:SyncConnected type:NodeChildrenChanged path:/tasks 

主节点之后会检查这个新的任务,获取可⽤的从节点列表,之后分配这个任务给worker1.example.com:

[zk: 6] ls /tasks 
[task-0000000000] 
[zk: 7] ls /workers 
[worker1.example.com] 
[zk: 8] create /assign/worker1.example.com/task-0000000000 "" 
Created /assign/worker1.example.com/task-0000000000 
[zk: 9] 

从节点接收到新任务分配的通知:

[zk: localhost:2181(CONNECTED) 3] 
WATCHER:: 
WatchedEvent state:SyncConnected type:NodeChildrenChanged path:/assign/worker1.example.com 

从节点之后便开始检查新任务,并确认该任务是否分配给⾃⼰:

WATCHER:: 
WatchedEvent state:SyncConnected type:NodeChildrenChanged 
path:/assign/worker1.example.com 
[zk: localhost:2181(CONNECTED) 3] ls /assign/worker1.example.com 
[task-0000000000] 
[zk: localhost:2181(CONNECTED) 4] 

⼀旦从节点完成任务的执⾏,它就会在/tasks中添加⼀个状态znode:

[zk: localhost:2181(CONNECTED) 4] create /tasks/task-0000000000/status "done" 
Created /tasks/task-0000000000/status 
[zk: localhost:2181(CONNECTED) 5] 

之后,客户端接收到通知,并检查执⾏结果:

WATCHER:: 
WatchedEvent state:SyncConnected type:NodeChildrenChanged path:/tasks/task-0000000000 

[zk: localhost:2181(CONNECTED) 2] get /tasks/task-0000000000 
"cmd" 
cZxid = 0x7c 
ctime = Tue Dec 11 10:30:18 CET 2012 
mZxid = 0x7c 
mtime = Tue Dec 11 10:30:18 CET 2012 
pZxid = 0x7e 
cversion = 1 
dataVersion = 0 
aclVersion = 0 
ephemeralOwner = 0x0 
dataLength = 5 
numChildren = 1 

[zk: localhost:2181(CONNECTED) 3] get /tasks/task-0000000000/status 
"done" 
cZxid = 0x7e 
ctime = Tue Dec 11 10:42:41 CET 2012 
mZxid = 0x7e 
mtime = Tue Dec 11 10:42:41 CET 2012 
pZxid = 0x7e 
cversion = 0 
dataVersion = 0 
aclVersion = 0 
ephemeralOwner = 0x0 
dataLength = 8 
numChildren = 0 

[zk: localhost:2181(CONNECTED) 4] 

客户端检查状态znode的信息,并确认任务的执⾏结果。可以看到任务成功执⾏,其状态为“done”。当然任务也可能⾮常复杂,甚⾄涉及另⼀个分布式系统。最终不管是什么样的任务,执⾏任务的机制与通过ZooKeeper来传递结果,本质上都是⼀样的。

九、参考资料

;