zookeeper原理

3,826 阅读45分钟

一、ZooKeeper 是什么

 Apache ZooKeeper 由 Apache Hadoop 的子项目发展而来,是Java语言开发,于 2010 年 11 月正式成为了 Apache 的顶级项目。 ZooKeeper 是一个开放源代码的分布式协调服务。它具有高性能、高可用的特点,同时也具有严格的顺序访问控制能力(主要是写操作的严格顺序性)。基于对 ZAB 协议(ZooKeeper Atomic Broadcast,ZooKeeper 原子消息广播协议)的实现,它能够很好地保证分布式环境中数据的一致性。也正是基于这样的特性,使得 ZooKeeper 成为了解决分布式数据一致性问题的利器。

二、ZooKeeper 基本概念

2.1、设计目的

  • 最终一致性:client不论连接到哪个Server,展示给它都是同一个视图,这是zookeeper最重要的性能。
  • 可靠性:具有简单、健壮、良好的性能,如果消息m被到一台服务器接受,那么它将被所有的服务器接受。
  • 实时性:Zookeeper保证客户端将在一个时间间隔范围内获得服务器的更新信息,或者服务器失效的信息。但由于网络延时等原因,Zookeeper不能保证两个客户端能同时得到刚更新的数据,如果需要最新数据,应该在读数据之前调用sync()接口。
  • 等待无关(wait-free):慢的或者失效的client不得干预快速的client的请求,使得每个client都能有效的等待。
  • 原子性:更新只能成功或者失败,没有中间状态。
  • 顺序性:包括全局有序和偏序两种:全局有序是指如果在一台服务器上消息a在消息b前发布,则在所有Server上消息a都将在消息b前被发布;偏序是指如果一个消息b在消息a后被同一个发送者发布,a必将排在b前面。

2.2、ZooKeeper角色

Zookeeper中的角色主要有以下三类,如下表所示:

这里写图片描述

2.3、ZooKeeper 架构模型

这里写图片描述

ZooKeeper 由两部分组成:ZooKeeper 服务端和客户端。  

ZooKeeper 服务器采用集群的形式。值得一提的是,只要集群中存在超过一半的、处于正常工作状态的服务器,那么整个集群就能够正常对外服务。组成 ZooKeeper 集群的每台服务器都会在内存中维护当前的 ZooKeeeper 服务状态,并且每台服务器之间都互相保持着通信。

客户端在连接 ZooKeeper 服务集群时,会按照一定的随机算法选择集群中的某台服务器,然后和它共同创建一个 TCP 连接,使客户端连上到那台服务器。而当那台服务器失效时,客户端自动会重新选择另一台服务器进行连接,从而保证服务的连续性。 当其中一个客户端修改数据时,ZooKeeper 会将修改同步到集群中所有的服务器上,从而使连接到集群中其它服务器上的客户端也能立即看到修改后的数据,很好地保证了分布式环境中数据的一致性。

2.4、ZooKeeper 数据模型

这里写图片描述

ZooKeeper 的数据模型采用类似于文件系统的树结构。树上的每个节点称为 ZNode,而每个节点都可能有一个或者多个子节点。ZNode 的节点路径标识方式是由一系列使用斜杠”/”进行分割的路径表示。 

可以向 ZNode 节点写入、修改、读取数据,也可以创建、删除 ZNode 节点或 ZNode 节点下的子节点。值得注意的是,ZooKeeper 的设计目标不是传统的数据库存储或者大数据对象存储,而是协同数据的存储,因此在实现时 ZNode 存储的数据大小不应超过 1MB。 另外,每一个节点都有个 ACL(Access Control List,访问控制列表),据此控制该节点的访问权限。

具体实现:DataTree 和 DataNode,见下图


抛出 2 个问题:

  1. DataTree 中 nodes 是 Map,表示所有的 ZK 节点,那其内部 key 是什么
      Re:ZNode 的唯一标识 ,path 作为 key
  1. ephemerals 是Map,用于存储临时节点,那其内部 key 是什么?value 又是什么?
      Re:  临时节点是跟 Session 绑定的,sessionId 作为 key

ZNode 数据节点是有生命周期的,其生命周期的长短取决于数据节点的节点类型。节点类型共有 4 种:持久节点(PERSISTENT)、持久顺序节点(PERSISTENT_SEQUENTIAL)、临时节点(EPHEMERAL)、临时顺序节点(EPHEMERAL_SEQUENTIAL)。

持久节点与临时节点
节点的类型在创建时就被确定下来,并且不能改变。
持久节点的存活时间不依赖于客户端会话,只有客户端在显式执行删除节点操作时,节点才消失。
临时节点的存活时间依赖于客户端会话,当会话结束,临时节点将会被自动删除(当然也可以手动删除临时节点)。利用临时节点的这一特性,我们可以使用临时节点来进行集群管理,包括发现服务的上下线等。
ZooKeeper规定,临时节点不能拥有子节点。

//创建了一个持久节点/module1,且其数据为”module1”。
create /module1 module1
//创建了一个临时节点 /module1/app1,数据为”app1”。
create -e /module1/app1 app1
//关闭会话,然后输入命令
get /module1/app1
Node does not exist: /module1/app1

有序节点
一个有序znode节点被分配唯一个单调递增的整数。当创建有序节点时,一个序号会被追加到路径之后,如/tasks/task0000000001。有序znode通过提供了创建具有唯一名称的znode的简单方式。同时也通过这种方式可以直观地查看znode的创建顺序。

//使用命令create加上-s参数,可以创建顺序节点 s-->sequence的意思
create -s /tasks/task data
//输出
Created /tasks/task0000000001
//创建了一个持久顺序节点 /tasks/task0000000001。如果再执行此命令,则会生成节点 /tasks/task0000000002。

节点的属性

//输出app2
get /module1/app2
 // 节点数据内容
//节点状态属性
cZxid = 0x20000000e ctime = Thu Jun 30 20:41:55 HKT 2016 
mZxid = 0x20000000e 
mtime = Thu Jun 30 20:41:55 HKT 2016 
pZxid = 0x20000000e 
cversion = 0 
dataVersion = 0 
aclVersion = 0 
ephemeralOwner = 0x0 
dataLength = 4 
numChildren = 0

版本号乐观锁类似cas确保原子性操作
对于每个znode来说,均存在三个版本号:
•dataVersion
数据版本号,每次对节点进行set操作,dataVersion的值都会增加1(即使设置的是相同的数据)。
•cversion
子节点的版本号。当znode的子节点有变化时,cversion 的值就会增加1
•aclVersion
ACL(Access Control List,访问控制)的版本号。

事务ID
对于zk来说,每次的变化都会产生一个唯一的事务id,zxid(ZooKeeper Transaction Id)。通过zxid,可以确定更新操作的先后顺序。例如,如果zxid1小于zxid2,说明zxid1操作先于zxid2发生。
需要指出的是,zxid对于整个zk都是唯一的,即使操作的是不同的znode。
•cZxid
Znode创建的事务id。
•mZxid
Znode被修改的事务id,即每次对znode的修改都会更新mZxid。

2.5、Watcher——ZNode 数据变化通知

ZooKeeper 的 Watcher 机制,概括为三个过程:客户端注册 Watcher 成为订阅者、服务端处理 Watcher 以及客户端回调 Watcher。 客户端在自己需要关注的位于 ZooKeeper 服务器里的 ZNode 节点上注册一个 Watcher 监听后,一旦这个 ZNode 节点发生变化,则在该节点上注册过 Watcher 监听的所有客户端会收到 ZNode 节点变化通知。在收到通知时,客户端通过回调 Watcher 做相应的处理,从而实现特定的功能。

监视与通知机制,避免了客户端获取数据做轮询

  • 通知机制是单次触发的操作,为了收到多个通知,需要每次收到通知之后设置一个新的监视点。
  • 客户端可以设置多种监视点:监控znode数据变化、监控znode子节点变化、监控znode创建或者删除。调用读取zookeeper的API时,传入一个watcher对象或者默认的watcher。

三、ZooKeeper的工作原理

Zookeeper的核心是原子广播,这个机制保证了各个Server之间的同步。实现这个机制的协议叫做Zab协议。ZAB 协议全称:Zookeeper Atomic Broadcast(Zookeeper 原子广播协议)。

Zab协议有两种模式,它们分别是恢复模式(选主)和广播模式(同步)。当服务启动或者在领导者崩溃后,Zab就进入了恢复模式,当领导者被选举出来,且大多数Server完成了和leader的状态同步以后,恢复模式就结束了。状态同步保证了leader和Server具有相同的系统状态。

为了保证事务的顺序一致性,zookeeper采用了递增的事务id号(zxid)来标识事务。所有的提议(proposal)都在被提出的时候加上了zxid。实现中zxid是一个64位的数字,它高32位是epoch用来标识leader关系是否改变,每次一个leader被选出来,它都会有一个新的epoch,标识当前属于那个leader的统治时期。低32位用于递增计数(低32位从0开始,表示该leader统治期间事务编号)。

每个Server在工作过程中有三种状态:

  • LOOKING:当前Server不知道leader是谁,正在搜寻
  • LEADING:当前Server即为选举出来的leader
  • FOLLOWING:leader已经选举出来,当前Server与之同步

注意

  • 对于exists、getData、getChildren等只读请求,收到该请求的zk服务器在本地处理。因为每个服务器看到的视图内容都是一致的。
  • 对于create、setData、delete等写请求,统一转发给leader处理。leader需要决定编号、执行操作,这个过程称为一个事务。
  • 只有
    集群完全从崩溃恢复模式中选出leader并过半的follower都同步了数据,才会对外服务
    。其他时会阻塞请求。恢复期间,服务不可用.

3.1、广播模式

为了保证分区容错性,zookeeper是要让每个节点副本必须是一致的

  1. 在zookeeper集群中数据副本的传递策略就是采用的广播模式
  2. Zab协议中的leader等待follower的ack反馈,只要半数以上的follower成功反馈就好,不需要收到全部的follower反馈。


zookeeper中消息广播的具体步骤如下:

1. 客户端发起一个写操作请求

2. Leader服务器将客户端的request请求转化为事物proposql提案,同时为每个proposal分配一个全局唯一的ID,即ZXID。

3. leader服务器与每个follower之间都有一个队列,leader将消息发送到该队列

4. follower机器从队列中取出消息处理完(写入本地事物日志中)毕后,向leader服务器发送ACK确认。

5. leader服务器收到半数以上的follower的ACK后,即认为可以发送commit

6. leader向所有的follower服务器发送commit消息。

 zookeeper采用ZAB协议的核心就是只要有一台服务器提交了proposal,就要确保所有的服务器最终都能正确提交proposal。这也是CAP/BASE最终实现一致性的一个体现。

2pc协议,也就是两阶段提交,发现流程2pc和zab还是挺像的,区别在于zab协议没有中断回滚逻辑.二阶段提交的要求协调者必须等到所有的参与者全部反馈ACK确认消息后,再发送commit消息。要求所有的参与者要么全部成功要么全部失败。二阶段提交会产生严重阻塞问题,但paxos和2pc没有这要求。

为了进一步防止阻塞,leader服务器与每个follower之间都有一个单独的队列进行收发消息,使用队列消息可以做到异步解耦。leader和follower之间只要往队列中发送了消息即可。如果使用同步方式容易引起阻塞。性能上要下降很多.

背景(什么情况下会崩溃恢复)

zookeeper集群中为保证任何所有进程能够有序的顺序执行,只能是leader服务器接受写请求,即使是follower服务器接受到客户端的请求,也会转发到leader服务器进行处理。

如果leader服务器发生崩溃(重启是一种特殊的崩溃,这时候也没leader),则zab协议要求zookeeper集群进行崩溃恢复和leader服务器选举。

最终目的(恢复成什么样)

ZAB协议崩溃恢复要求满足如下2个要求
  1. 确保已经被leader提交的proposal必须最终被所有的follower服务器提交。 
  2. 确保丢弃只在leader服务器提出的proposal。

新选举出来的leader不能包含未提交的proposal,即新选举的leader必须都是已经提交了的proposal的follower服务器节点。同时,新选举的leader节点中含有最高的ZXID。这样做的好处就是可以避免了leader服务器检查proposal的提交和丢弃工作。(下文选主流程能够保证1,同步过程能保证2)

问题:介于上述1.2两种情况之间的场景呢?最终会恢复成什么样?

Re:不确定,取决于崩溃的服务器的情况,有可能集群执行成功,也有可能被集群丢弃.但是不管如何,这时候集群并没有反馈给客户端是否执行成功的信息,等集群重新连接leader以后,会重新获取是否执行成功的反馈.所以最终也保持的一致性.

3.2、选主流程

当leader崩溃或者leader失去大多数的follower,这时候zk进入恢复模式,恢复模式需要重新选举出一个新的leader,让所有的Server都恢复到一个正确的状态。系统默认的选举算法为fast paxos。

fast paxos流程是在选举过程中,某Server首先向所有Server提议自己要成为leader,当其它Server收到提议以后,解决epoch和zxid的冲突,并接受对方的提议,然后向对方发送接受提议完成的消息,重复这个流程,最后一定能选举出Leader。其流程图如下所示: 

这里写图片描述

选票vote信息包括以下几种属性:

  • id : 被推举leader的节点sid值
  • zxid : 被推举leader的节点事务ID
  • peerEpoch : 被推举的leader的 epoch
  • electionEpoch : 当前节点本轮选举的标识epoch(逻辑时钟),标识每一轮选举,值越大标识本次选举离现在越近
  • state : 当前服务器状态,比如(looking)

选举时要保证半数以上的server的票数才能当选为leader,所以zookeeper集群中server数目一般为奇数个,例如:3,5,7个等,如果有3个Server,则最多允许1个Server挂掉;如果有4个Server,则同样最多允许1个Server挂掉由此,

我们看出3台服务器和4台服务器的的容灾能力是一样的,所以为了节省服务器资源,一般我们采用奇数个数,作为服务器部署个数。

下面分两种场景来说明选举的过程:

QuorumCnxManager:网络I/O

  每台服务器在启动的过程中,会启动一个QuorumPeerManager,负责各台服务器之间的底层Leader选举过程中的网络通信。

(1) 消息队列。QuorumCnxManager内部维护了一系列的队列,用来保存接收到的、待发送的消息以及消息的发送器,除接收队列以外,其他队列都按照SID分组形成队列集合,如一个集群中除了自身还有3台机器,那么就会为这3台机器分别创建一个发送队列,互不干扰。

· recvQueue:消息接收队列,用于存放那些从其他服务器接收到的消息。

· queueSendMap:消息发送队列,用于保存那些待发送的消息,按照SID进行分组。

· senderWorkerMap:发送器集合,每个SenderWorker消息发送器,都对应一台远程Zookeeper服务器,负责消息的发送,也按照SID进行分组。

· lastMessageSent:最近发送过的消息,为每个SID保留最近发送过的一个消息。

(2) 建立连接。为了能够相互投票,Zookeeper集群中的所有机器都需要两两建立起网络连接。QuorumCnxManager在启动时会创建一个ServerSocket来监听Leader选举的通信端口(默认为3888)。开启监听后,Zookeeper能够不断地接收到来自其他服务器的创建连接请求,在接收到其他服务器的TCP连接请求时,会进行处理。为了避免两台机器之间重复地创建TCP连接,Zookeeper只允许SID大的服务器主动和其他机器建立连接,否则断开连接。在接收到创建连接请求后,服务器通过对比自己和远程服务器的SID值来判断是否接收连接请求,如果当前服务器发现自己的SID更大,那么会断开当前连接,然后自己主动和远程服务器建立连接。一旦连接建立,就会根据远程服务器的SID来创建相应的消息发送器SendWorker和消息接收器RecvWorker,并启动。

(3) 消息接收与发送消息接收:由消息接收器RecvWorker负责,由于Zookeeper为每个远程服务器都分配一个单独的RecvWorker,因此,每个RecvWorker只需要不断地从这个TCP连接中读取消息,并将其保存到recvQueue队列中。消息发送:由于Zookeeper为每个远程服务器都分配一个单独的SendWorker,因此,每个SendWorker只需要不断地从对应的消息发送队列中获取出一个消息发送即可,同时将这个消息放入lastMessageSent中。在SendWorker中,一旦Zookeeper发现针对当前服务器的消息发送队列为空,那么此时需要从lastMessageSent中取出一个最近发送过的消息来进行再次发送,这是为了解决接收方在消息接收前或者接收到消息后服务器挂了,导致消息尚未被正确处理。同时,Zookeeper能够保证接收方在处理消息时,会对重复消息进行正确的处理。

FastLeaderElection:选举算法核心

· 外部投票:特指其他服务器发来的投票。

· 内部投票:服务器自身当前的投票。

· 选举轮次:Zookeeper服务器Leader选举的轮次,即logicalclock。

· PK:对内部投票和外部投票进行对比来确定是否需要变更内部投票。

(1) 选票管理

· sendqueue:选票发送队列,用于保存待发送的选票。

· recvqueue:选票接收队列,用于保存接收到的外部投票。

· WorkerReceiver:选票接收器。其会不断地从QuorumCnxManager中获取其他服务器发来的选举消息,并将其转换成一个选票,然后保存到recvqueue中,在选票接收过程中,如果发现该外部选票的选举轮次小于当前服务器的,那么忽略该外部投票,同时立即发送自己的内部投票。

· WorkerSender:选票发送器,不断地从sendqueue中获取待发送的选票,并将其传递到底层QuorumCnxManager中。

(2) 集群第一次启动

1.自增选举轮次        

 在fastLeaderElection实现中,有一个logicalClock属性,用于标识当前leader的选举轮次,Zookeeper规定了所有有效的选票都必须在同一个轮次中.Zookeeper在开始新一轮的投票时,会首先对logicalClock自增.

2.初始化选票   

 在开始进行新一轮选票之前,每个服务器都会首先初始化自己的选票.上文已经说明了vote的数据结构,初始化就是对 Vote属性的初始化,在初始化阶段,每个服务器都会推荐自己为leader.

3.发送初始化选票                                                                                             

 在完成选票的初始化以后,服务器就会发送第一次选票.zookeeper会讲刚刚初始化好的选票放在sendQueue中队列中   去,由WorkerSender负责发送出去.(广播出去)

4.接收外部选票                                                                                                                                           

 每台服务器都会不断的从recvqueue 队列中去获取外部选票.如果服务器发现无法获取任何的外部选票,那么就会立即确认自己是否和集群其他服务器保持着有效连接.如果发现自己没有建立连接,那么就会马上建立连接.如果已经建立了连接,那么就再次发送自己当前的内部选票(服务器自己的选票,区别于外部选票) 接收选票会查看消息id对应的节点的身份(是不是observer),如果是投票成员,查看其状态是否是LOOKING状态,如果是进入步骤5.

5.判断选举轮次.  

 当发送完初始化选票以后,接下来就要开始处理外部选票了.在处理外部选票的时候,会根据选举轮次进行不同的处理.  

  •  外部投票的选举轮次大于内部投票 如果服务器发现自己的选举轮次已经落后于该外部投票对应服务器的选举轮次,那么就会立即更新. 自己的选举轮次(logicalClock),并且清空所有已经收到的投票,然后使用初始化的投票来进行PK以确定是否变更内部投票(关于PK的逻辑会在步骤6中统一讲解),最终再将内部投票发送出去.        
  •  外部投票的选举轮次小于内部投票 如果接受到的选票的选举轮次落后于服务器自身的,那么zookeeper就会直接忽略该外部投票,不做 任何处理,并返回步骤4.                                      
  •  外部投票的选举轮次和内部选票的一致这也是绝大多数的投票的场景,如果外部选票的选举轮次和内部选票的选举轮次一致的话,那么就开始选票PK.         
6.选票PK           

在步骤5中提到,在收到来自其他服务器的有效的外部投票以后,就要进行选票PK了,逻辑如下     

  • 如果外部投票中被推举的leader服务器的选举轮次大于内部投票,那么就要进行选票变更.                                           
  • 如果选举轮次一致的话,那么就对比两者的ZXID.如果外部投票的ZXID大于内部投票,那么就需要进行选票变更.      
  •  如果2者的ZXID一致,那么就对比两者的SID.如果外部选票的SID大于内部选票的SID,那么就要进行选票变更.                                                              
7.变更选票.              

使用外部选票的选票信息来覆盖内部选票,变更完成以后,再次将这个变更后的内部选票广播发送出去.

8.选票归档.          

无论是否进行了选票变更,都会将刚刚收到的外部选票放入"选票集合"recvset中进行归档.recvset用于记录当前服务器在本轮次的leader选举中收到的所有外部投票----按照服务对应的SID进行区分,例如,{(1.vote1),(2,vote2)..}

9. 统计选票       

 完成了选票归档以后,就可以开始统计选票了.统计选票的过程就是为了统计集群中是否有过半的服务器认可了当前的内部选票.如果确定已经有过半的服务器认可了该内部选票.则终止投票.否则返回步骤4

10. 更新服务器状态   

 统计投票后,如果已经确定可以终止投票,那么就开始更新服务器状态.服务器会首先判断当前被过半服务器认可的投票对应的leader服务器是否是自己.如果是自己,则状态改为leading/following/observering.

另外注意:

再完成步骤9以后,如果已经过半认可当前选票,不会立即进入10,而是会等待一段时间(默认200毫秒)来确认是否有新的更优的选票.     

以上流程可以看出, 

假定server3(3,9)、server4(4,8)、server5(5,8)可以从上述的选举过程中看出来,整个选举如下图所示:


至于集群启动一段时间后的选举,相比集群第一次启动的不同在于每个节点的zxid和epoch有可能不同。当一个新启动的节点加入集群时,它对集群内其他节点发出投票请求,而其他节点已不处于LOOKING状态,此时其他节点回应选举结果,该节点收集这些结果到outofelection中,最终在收到合法LEADER消息且这些选票也构成选举结束条件时,该节点就结束自己的选举行为。

3.3、同步流程

选完leader以后,zk就进入状态同步过程。 

  1. leader等待server连接; 
  2. Follower连接leader,将最大的zxid发送给leader; 
  3. Leader根据follower的zxid确定同步点; 
  4. 完成同步后通知follower 已经成为uptodate状态; 
  5. Follower收到uptodate消息后,又可以重新接受client的请求进行服务了。

流程图如下所示:

这里写图片描述

在leader和follower启动期交互过程中,我们分析到整个集群完成leader选举后,learner会向leader服务器进行注册,当过半的learner服务器向leader完成注册后,就进入数据同步环节。简单讲,数据同步过程就是leader服务器将那些没有在learner服务器上提交过的事务请求同步给learner服务器。

获取Learner状态

在注册learner的最后阶段,learner服务器会发送给leader服务器一个ACKEPOCH数据包,leader会从这个数据包中解析出该learner的currentEpoch和lastZxid。

数据同步初始化

首先,leader会从zookeeper的内存数据库中提取出事务请求对应的提议缓存队列:proposals,同时完成对以下三个ZXID值的初始化。

  • peerLastZxid:该learner服务器最后处理的ZXID。
  • minCommittedLog:leader服务器提议缓存队列committedLog中的最小ZXID。
  • maxCommittedLog:leader服务器提议缓存队列committedLog中的最大ZXID。

数据同步通常分为四类,分别是直接差异化同步(DIFF同步),先回滚在差异化同步(TRUNC+DIFF同步),仅回滚同步(TRUNC同步)和全量同步(SNAP同步)。会根据leader和learner服务器间的数据差异情况来决定最终的数据同步方式。

直接差异化同步

场景:peerLastZxid介于minCommittedLog和maxCommittedLog间。 leader首先会向这个learner发送一个DIFF指令,用于通知“learner即将把一些proposal同步给自己”。实际同步过程中,针对每个proposal,leader都会通过发送两个数据包来完成,分别是PROPOSAL内容数据包和COMMIT指令数据包——这和zookeeper运行时leader和follower间的事务请求的提交过程是一致的。

举例,某时刻leader的提议缓存队列对应的ZXID依次是: 0x500000001,0x500000002,0x500000003,0x500000004,0x500000005 而learner最后处理的ZXID为0x500000003,于是leader依次将0x500000004和0x500000005两个提议同步给learner。

先回滚在差异化同步

场景:A,B,C三台机器,某一时刻B是leader,此时leader_epoch为5,同时当前已被集群大部分机器都提交的ZXID包括:0x500000001,0x500000002。此时leader正处理ZXID:0x500000003,并且已经将事务写入到了leader本地的事务日志中去——就在leader恰好要将该proposal发给其他follower进行投票时,leader挂了,proposal没被同步出去。此时集群进行新一轮leader选举,假 设此次选的leader为A,leader_epoch变更为6,之后A和C又提交了0x600000001,0x600000002两个事务。此时B再次启动并开始数据同步。

简单讲,上面场景就是leader在已经将事务记录到本地事务日志中,但没有成功发起proposal流程时就挂了。 当leader发现某个learner包含一条自己没的事务记录,就让该learner进行事务回滚——回滚到leader上存在的,最接近peerLastZxid的ZXID,上面例子中leader会让learner回滚到ZXID为0x500000002的事务记录。 

仅回滚同步

场景:peerLastZxid大于maxCommittedLog
这种场景就是上述先回滚再差异化同步的简化模式,leader会要求learner回滚到ZXID值为maxCommitedLog对应的事务操作。

全量同步(SNAP同步)

场景1:peerLastZxid小于minCommittedLog
场景2:leader上没有提议缓存队列,peerLastZxid不等于lastProcessedZxid(leader服务器数据恢复后得到的最大ZXID)

这两种场景下,只能进行全量同步。leader首先向learner发送一个SNAP指令,通知learner进行全量同步,随后leader会从内存数据库中获取到全量的数据节点和会话超时时间记录器,将它们序列化后传输给learner,learner接收到后对其反序列化后再入内存数据库中。 

收尾阶段

leader在完成完差异数据后,就会将该learner加入到forwardingFollowers或observingLearners队列中,这俩队列在运行期间的事务请求处理过程中会使用到。随后leader发送一个NEWLEADER指令,用于通知learner已经将提议缓存队列中的proposal都同步给自己了.当learner收到leader的NEWLEADER指令后会反馈给leader一个ack消息,表明自己完成了对提议缓存队列中proposal的同步。

leader收到来自learner的ack后,进入“过半策略”等待阶段,知道集群中有过半的learner机器响应了leader这个ack消息。 一旦满足“过半策略”后,leader会向所有已完成数据同步的learner发送一个UPTODATE指令,用来通知learner已完成数据同步,同时集群已有过半机器完成同步,集群已具有对外服务的能力了。 

learner在收到leader的UPTODATE指令后,会终止数据同步流程,然后向leader再反馈一个ACK消息。

3.3、工作流程

3.3.1、Leader工作流程

Follower主要有四个功能:

  • 向Leader发送请求(PING消息、REQUEST消息、ACK消息、REVALIDATE消息);
  • 接收Leader消息并进行处理;
  • 接收Client的请求,如果为写请求,发送给Leader进行投票;
  • 返回Client结果。

Follower的消息循环处理如下几种来自Leader的消息:

  1. PING消息: 心跳消息;
  2. PROPOSAL消息:Leader发起的提案,要求Follower投票;
  3. COMMIT消息:服务器端最新一次提案的信息;
  4. UPTODATE消息:表明同步完成;
  5. REVALIDATE消息:根据Leader的REVALIDATE结果,关闭待revalidate的session还是允许其接受消息;
  6. SYNC消息:返回SYNC结果到客户端,这个消息最初由客户端发起,用来强制得到最新的更新。

Follower的工作流程简图如下所示,在实际实现中,Follower是通过5个线程来实现功能的。

这里写图片描述

对于observer的流程不再叙述,observer流程和Follower的唯一不同的地方就是observer不会参加leader发起的投票。

四、ZooKeeper 的典型应用场景 

 通过对 ZooKeeper 中丰富的数据节点类型进行交叉使用,配合 Watcher 事件通知机制,可以非常方便地构建分布式应用中都会涉及的核心功能,如:、、集群管理、Master 选举、分布式锁和分布式队列等。

1、数据发布 / 订阅(即配置中心)

假如有多个服务器都需要连接同一个数据库,我们可以让这多个数据库读取同一个配置文件,这样如果想要更换数据库的话只需要更改配置文件,然后让服务器使用新的配置信息重新连接就好。

 在这种情况下就可以使用发布/订阅模式,让多个服务器共同订阅一个目标,然后当目标将最新的配置文件发布的时候,所有的订阅者都能够收到最新的消息,进而达到动态获取数据的目的,实现配置信息的集中式管理和数据的动态更新。

还是假设有多个服务器需要同时更换数据库,可以使用发布/订阅模式来完成,使用ZooKeeper来实现。最终做到只需要简单的更改一个配置文件,从而让多个服务器都能够拿到最新的信息。 其实这也是利用了ZooKeeper的Watcher监听来完成的,先在ZooKeeper中创建一个节点,然后将数据库的配置信息放在这个节点中,让其它的服务器监听这个节点的数据变化,当该节点的内容发生变化时就重新读取。

2、负载均衡

当服务越来越多,规模越来越大时,对应的机器数量也越来越庞大,单靠人工来管理和维护服务及地址的配置信息,已经越来越困难。并且,依赖单一的硬件负载均衡设备或者使用LVS、Nginx等软件方案进行路由和负载均衡调度,单点故障的问题也开始凸显,一旦服务路由或者负载均衡服务器宕机,依赖其的所有服务均将失效。如果采用双机高可用的部署方案,使用一台服务器“stand by”,能部分解决问题,但是鉴于负载均衡设备的昂贵成本,已难以全面推广。

  一旦服务器与ZooKeeper集群断开连接,节点也就不存在了,通过注册相应的watcher,服务消费者能够第一时间获知服务提供者机器信息的变更。利用其znode的特点和watcher机制,将其作为动态注册和获取服务信息的配置中心,统一管理服务名称和其对应的服务器列表信息,我们能够近乎实时地感知到后端服务器的状态(上线、下线、宕机)。Zookeeper集群间通过Zab协议,服务配置信息能够保持一致,而Zookeeper本身容错特性和leader选举机制,能保证我们方便地进行扩容。

  Zookeeper中,服务提供者在启动时,将其提供的服务名称、服务器地址、以节点的形式注册到服务配置中心,服务消费者通过服务配置中心来获得需要调用的服务名称节点下的机器列表节点。通过前面所介绍的负载均衡算法,选取其中一台服务器进行调用。当服务器宕机或者下线时,由于znode非持久的特性,相应的机器可以动态地从服务配置中心里面移除,并触发服务消费者的watcher。在这个过程中,服务消费者只有在第一次调用服务时需要查询服务配置中心,然后将查询到的服务信息缓存到本地,后面的调用直接使用本地缓存的服务地址列表信息,而不需要重新发起请求到服务配置中心去获取相应的服务地址列表,直到服务的地址列表有变更(机器上线或者下线),变更行为会触发服务消费者注册的相应的watcher进行服务地址的重新查询。这种无中心化的结构,使得服务消费者在服务信息没有变更时,几乎不依赖配置中心,解决了之前负载均衡设备所导致的单点故障的问题,并且大大降低了服务配置中心的压力。

  通过Zookeeper来实现服务动态注册、机器上线与下线的动态感知,扩容方便,容错性好,且无中心化结构能够解决之前使用负载均衡设备所带来的单点故障问题。只有当配置信息更新时服务消费者才会去Zookeeper上获取最新的服务地址列表,其他时候使用本地缓存即可,这样服务消费者在服务信息没有变更时,几乎不依赖配置中心,能大大降低配置中心的压力。

3、命名服务

命名服务是指通过指定的名字来获取资源或者服务的地址,提供者的信息。利用Zookeeper很容易创建一个全局的路径,而这个路径就可以作为一个名字,它可以指向集群中的集群,提供的服务的地址,远程对象等。简单来说使用Zookeeper做命名服务就是用路径作为名字,路径上的数据就是其名字指向的实体。

4、分布式协调 / 通知

 ZooKeeper 中特有watcher注册与异步通知机制,能够很好的实现分布式环境下不同系统之间的通知与协调,实现对数据变更的实时处理。使用方法通常是不同系统都对 ZK上同一个znode进行注册,监听znode的变化(包括znode本身内容及子节点的),其中一个系统update了znode,那么另一个系统能 够收到通知,并作出相应处理。

  1.  另一种心跳检测机制:检测系统和被检测系统之间并不直接关联起来,而是通过zk上某个节点关联,大大减少系统耦合。
  2.  另一种系统调度模式:某系统有控制台和推送系统两部分组成,控制台的职责是控制推送系统进行相应的推送工作。管理人员在控制台作的一些操作,实际上是修改 了ZK上某些节点的状态,而zk就把这些变化通知给他们注册Watcher的客户端,即推送系统,于是,作出相应的推送任务。
  3.  另一种工作汇报模式:一些类似于任务分发系统,子任务启动后,到zk来注册一个临时节点,并且定时将自己的进度进行汇报(将进度写回这个临时节点),这样任务管理者就能够实时知道任务进度。

总之,使用zookeeper来进行分布式通知和协调能够大大降低系统之间的耦合。

5、 集群管理

集群机器监控:这通常用于那种对集群中机器状态,机器在线率有较高要求的场景,能够快速对集群中机器变化作出响应。这样的场景中,往往有一个监控系统,实时检测集群机器是否存活。过去的做法通常是:监控系统通过某种手段(比如ping)定时检测每个机器,或者每个机器自己定时向监控系统汇报“我还活着”。 这种做法可行,但是存在两个比较明显的问题:

  • 集群中机器有变动的时候,牵连修改的东西比较多。
  • 有一定的延时。

利用ZooKeeper有两个特性,就可以实时另一种集群机器存活性监控系统:a. 客户端在节点 x 上注册一个Watcher,那么如果 x 的子节点变化了,会通知该客户端。b. 创建EPHEMERAL类型的节点,一旦客户端和服务器的会话结束或过期,那么该节点就会消失。

6、Master选举

在分布式环境中,相同的业务应用分布在不同的机器上,有些业务逻辑(例如一些耗时的计算,网络I/O处理),往往只需要让整个集群中的某一台机器进行执行, 其余机器可以共享这个结果,这样可以大大减少重复劳动,提高性能,于是这个master选举便是这种场景下的碰到的主要问题。

利用ZooKeeper的强一致性,能够保证在分布式高并发情况下节点创建的全局唯一性,即:同时有多个客户端请求创建 /currentMaster 节点,最终一定只有一个客户端请求能够创建成功。

7、 分布式锁

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

在平时的实际项目开发中,我们往往很少会去在意分布式锁,而是依赖于关系型数据库固有的排他性来实现不同进程之间的互斥,但大型分布式系统的性能瓶颈往往集中在数据库操作上。本文我们来看看ZooKeeper如何实现分布式锁,主要讲解排他锁和共享锁两类分布式锁。

排他锁

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

从上面讲解的排它锁的基本概念中,我们可以看到,排他锁的核心是如何保证当前有且仅有一个事务获得锁,并且锁被释放后,所有正在等待获取锁的事务都能够被通知到,下面我们就来看看如何借助Zookeeper实现排他锁。

定义锁

通常的Java开发编程中,有两种常见的方式可以用来定义锁,分别是synchronized机制和JDK5提供的ReentrantLock。在ZooKeeper中,是通过ZooKeeper上的数据节点来表示一个锁,如/exclusive_lock/lock节点就可以被定义为一个锁。如下图所示:


获取锁

在需要获取排他锁时,所有的客户端都会试图通过调用create()接口,在/exclusive_lock节点下创建临时子节点/exclusive_lock/lock。ZooKeeper会保证在所有的客户端中,最络只有一个客户端能够创建成功,那么就可以认为该客户端获取了锁。同时,所有没有获取到锁的客户端就需要到/exclusive_lock节点上注册一个子节点变更的Watcher监听 , 以便实时监听到lock节点的变更情况。

释放锁

在“定义锁”的部分,我们已经提到,/exclusive_lock是一个临时节点,因此在以下两种情况下,可能释放锁:

当前获取锁的客户端机器宕机,那么ZooKeeper上的这个临时节点就会被移除;正常执行完业务逻辑后,客户端就会主动将自己创建的临时节点删除。

无论什么情况下移除了lock节点,ZooKeeper都会通知所有在/exclusive_lock节点上注册了子节点变更Watcher监听的客户端。这些客户端在接收到通知后,再次重新发起分布式锁获取,即重复“获取锁”过程,

整个排他锁的获取和释放流程如下图所示:


共享锁

Shared Locks,简单S锁,同样是一种基本的锁类型。如果事务T1对数据对象O1加上了共享锁,那么当前事务只能对O1进行读取操作,其它事务也只能对这个数据对象加共享锁——直到该数据对象上的所有共享锁都被释放。共享锁和排他锁最根本的区别在于,加上了排他锁后,数据对象只对一个事务可见,而加上了共享锁后,数据对所有事务都可见。下面看如何借助ZooKeeper来实现共享锁。

定义锁

和排他锁一样,同样是通过ZooKeeper上的数据节点来表示一个锁,是一个类似于/shared_lock/[Hostname]-请求类型-序号的临时顺序节点,如:

/shared_lock/192.168.0.1-R-00000001,那么 这个节点就代表了一个共享锁。

获取锁

在需要获取共享锁时,所有客户端都会到/shared_lock这个节点下面创建一个临时顺序节点,如果当前是读请求,那么就创建例如 /shared_lock/192.168.0.1-R-00000001的节点。如果是写请求,那么就创建例如 /shared_lock/192.168.0.1-W-00000001的节点,如下图所示:

判断读写顺序

根据共享锁的定义,不同的事务都可以同时对同一个数据对象进行读操作,而更新操作必须在当前没有任何事务读写操作的情况下进行。基于这个原则来通过ZooKeeper的节点来确定分布式读写顺序,大概可以分为如下4个步骤:

创建完节点后,获取/shared_lock节点下的所有子节点,并对该节点注册了子节点变更的Wathcher监听确定自己的节点序号在所有子节点中的顺序对于读请求:

如果没有比自己序号小的子节点,或是所有比自己序号小的子节点都是读请求,那么表明 自己已经成功获取到了共享锁,同时开始执行读取逻辑。

如果比自己序号小的子节点中有写请求,那么就需要进入等待;

对于写请求:

如果自己不是序号最小的子节点,那么就需要进入等待。

4.接收到Watcher的通知后,重复步骤1。

释放锁

释放锁的逻辑和排他锁是一致的,这里不再赘述。整个共享锁的获取可以用下图表示。

羊群效应

上面讲解的这个共享锁实现,大体上能满足一般分布式集群竞争锁的需求,并且性能都还可以先——这里说的一般场景是只集群规模不是特别大,一般10台机器以内。但是机器规模扩大后,会有什么问题呢?我们着重来看上面“判断读写顺序“过程的步骤3,结合下图实例,看看实际运行的情况。

针对如上图所示的情况进行分析

1. 192.168.0.1首先进行读操作,完成后将节点/shared_lock/192.168.0.1-R-00000001删除。

2. 余下4台机器均收到这个节点移除的通知,然后重新从/shared_lock节点上获取一份新的子节点列表。

3. 每台机器判断自己的读写顺序,其中192.168.0.2检测到自己序号最小,于是进行写操作,余下的机器则继续等待。

4. 继续…

可以看到,192.168.0.1客户端在移除自己的共享锁后,Zookeeper发送了子节点更变Watcher通知给所有机器,然而除了给192.168.0.2产生影响外,对其他机器没有任何作用。大量的Watcher通知和子节点列表获取两个操作会重复运行,这样会造成系能鞥影响和网络开销,更为严重的是,如果同一时间有多个节点对应的客户端完成事务或事务中断引起节点小时,Zookeeper服务器就会在短时间内向其他所有客户端发送大量的事件通知,这就是所谓的羊群效应。

可以有如下改动来避免羊群效应。

1. 客户端调用create接口常见类似于/shared_lock/[Hostname]-请求类型-序号的临时顺序节点。

2. 客户端调用getChildren接口获取所有已经创建的子节点列表(不注册任何Watcher)。

3. 如果无法获取共享锁,就调用exist接口来对比自己小的节点注册Watcher。对于读请求:向比自己序号小的最后一个写请求节点注册Watcher监听。对于写请求:向比自己序号小的最后一个节点注册Watcher监听。

4. 等待Watcher通知,继续进入步骤2。

此方案改动主要在于:每个锁竞争者,只需要关注/shared_lock节点下序号比自己小的那个节点是否存在即可。

8、分布式队列

分布式队列,目前此类产品大多类似于ActiveMQ、RabbitMQ等,本文主要介绍的是Zookeeper实现的分布式队列,它的实现方式也有两种,一种是FIFO(先进先出)的队列,另一种是等待队列元素聚集之后才统一安排的Barrier模型。

FIFO的队列模型

其大体设计思路也很简单,主要是在/SinaQueue下创建顺序节点,如/SinaQueue/qn-000000000,创建完节点之后,根据下面的4个步骤来决定执行的顺序。
1.通过调用getChildren()接口来获取某一节点下的所有节点,即获取队列中的所有元素。
2.确定自己的节点序号在所有子节点中的顺序。
3.如果自己不是序号最小的子节点,那么就需要进入等待,同时向比自己序号小的最后一个节点注册Watcher监听。
4.接收到Watcher通知后,重复步骤1

同步队列
当一个队列的成员都聚齐时,这个队列才可用,否则一直等待所有成员到达。例如一个班去旅游,看是否所有人都到齐了,到齐了就发车。例如有个大任务分解为多个子任务,要所有子任务都完成了才能进入到下一流程。

实现思路如下:
开始时,/queue_babarrier节点是一个已经存在的默认节点,并且将节点的数据内容赋值为一个数组n代表barrier值,例如n=10表示只有当/queue_barrier节点下的子节点个数达到10后,才会打开Barrier.之后,所有的客户端都会到/queue_babarrier节点下创建临时节点,例如/queue_babarrier/192.168.0.1

  1. 创建完节点以后,根据如下5个步骤来确定执行顺序.
  2. 通过调用getData()接口获取/queue_babarrier节点的数据内容:10
  3. 通过调用getchildren()接口获取/queue_babarrier节点下的所有子节点,即获取队列中的所有元素,同时注册对子节点变更的watcher监听.
  4. 统计子节点的个数.
  5. 如果子节点的个数还不足10个,那么就需要进入等待.
  6. 接到watcher通知后,重复步骤2


参考 : 从Paxos到Zookeeper 分布式一致性原理与实践  (推荐阅读)

www.iteye.com/blog/iwinit… 推荐阅读