ZooKeeper是一个分布式的,开放源码的分布式应用程序协调服务,它包含一个简单的原语集,分布式应用程序可以基于它实现同步服务,配置维护和命名服务等。Zookeeper是hadoop的一个子项目,其发展历程无需赘述。在分布式应用中,由于工程师不能很好地使用锁机制,以及基于消息的协调机制不适合在某些应用中使用,因此需要有一种可靠的、可扩展的、分布式的、可配置的协调机制来统一系统的状态。Zookeeper的目的就在于此。本文简单分析zookeeper的工作原理,对于如何使用zookeeper不是本文讨论的重点。

一、Zookeeper的基本概念

1.1 角色


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

角色 描述
领导者(Leader) 领导者负责进行投票的发起和决议,更新系统状态
学习者(Learner) 跟随者(Follower) Follower用于接收客户端请求并向客户端返回结果,在选举过程中参与投票
观察者(ObServer) ObServer可以接收客户请求并向客户端返回结果,在选举过程中不参与投票
客户端(Client) 请求发起方

系统模型如图所示:

Zookepper - 图1

1.2 设计目的


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

二、ZooKeeper的工作原理


Zookeeper的核心是原子广播,这个机制保证了各个Server之间的同步。实现这个机制的协议叫做Zab协议。Zab协议有两种模式,它们分别是恢复模式(选举)和广播模式(同步)。当服务启动或者在领导者崩溃后,Zab就进入了恢复模式,当领导者被选举出来,且大多数Server完成了和leader的状态同步以后,恢复模式就结束了。状态同步保证了leader和Server具有相同的系统状态。

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

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

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

2.1 选举流程


当leader崩溃或者leader失去大多数的follower,这时候zk进入恢复模式,恢复模式需要重新选举出一个新的leader,让所有的Server都恢复到一个正确的状态。Zk的选举算法有两种:一种是基于basic paxos实现的,另外一种是基于fast paxos算法实现的。系统默认的选举算法为fast paxos。先介绍basic paxos流程:

1 .选举线程由当前Server发起选举的线程担任,其主要功能是对投票结果进行统计,并选出推荐的Server;
2 .选举线程首先向所有Server发起一次询问(包括自己);
3 .选举线程收到回复后,验证是否是自己发起的询问(验证zxid是否一致),然后获取对方的id(myid),并存储到当前询问对象列表中,最后获取对方提议的leader相关信息(id,zxid),并将这些信息存储到当次选举的投票记录表中;
4 .收到所有Server回复以后,就计算出zxid最大的那个Server,并将这个Server相关信息设置成下一次要投票的Server;
5 .线程将当前zxid最大的Server设置为当前Server要推荐的Leader,如果此时获胜的Server获得n/2 + 1的Server票数, 设置当前推荐的leader为获胜的Server,将根据获胜的Server相关信息设置自己的状态,否则,继续这个过程,直到leader被选举出来。

通过流程分析我们可以得出:要使Leader获得多数Server的支持,则Server总数必须是奇数2n+1,且存活的Server的数目不得少于n+1.
每个Server启动后都会重复以上流程。在恢复模式下,如果是刚从崩溃状态恢复的或者刚启动的server还会从磁盘快照中恢复数据和会话信息,zk会记录事务日志并定期进行快照,方便在恢复时进行状态恢复。选举的具体流程图如下所示:
Zookepper - 图2
fast paxos流程是在选举过程中,某Server首先向所有Server提议自己要成为leader,当其它Server收到提议以后,解决epoch和zxid的冲突,并接受对方的提议,然后向对方发送接受提议完成的消息,重复这个流程,最后一定能选举出Leader。其流程图如下所示:

Zookepper - 图3

2.2 同步流程


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

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

流程图如下所示:
Zookepper - 图4

2.3 工作流程

2.3.1 Leader工作流程


Leader主要有三个功能:

1 恢复数据;
2 维持与Learner的心跳,接收Learner请求并判断Learner的请求消息类型;
3 Learner的消息类型主要有PING消息、REQUEST消息、ACK消息、REVALIDATE消息,根据不同的消息类型,进行不同的处理。

PING消息是指Learner的心跳信息;
REQUEST消息是Follower发送的提议信息,包括写请求及同步请求;
ACK消息是Follower的对提议的回复,超过半数的Follower通过,则commit该提议;
REVALIDATE消息是用来延长SESSION有效时间。

Leader写操作,主要分五步:
1、客户端向leader发起写请求
2、leader将写请求以proposal的形式给所有Follower并等待ACK
3、Follower收到Leader的Proposal后返回ACK
4、Leader得到过半数的ACK(Leader对自己默认有一个ACK)后向所有的Follower和Observer发送Commit
5、Leader将处理结果返回给客户端

注意
Leader并不需要得到Observer的ACK,即Observer无投票权
Leader不需要得到所有Follower的ACK,只需要收到过半的ACK即可,同时Leader本身对自己有一个ACK。
Observer虽然无投票权,但仍须同步Leader的数据从而在处理读请求时可以返回尽可能新的数据。


Leader的工作流程简图如下所示,在实际实现中,流程要比下图复杂得多,启动了三个线程来实现功能。
Zookepper - 图5
Zookepper - 图6

2.3.2 Follower工作流程


Follower主要有四个功能:

1 .向Leader发送请求(PING消息、REQUEST消息、ACK消息、REVALIDATE消息);
2 .接收Leader消息并进行处理;
3 .接收Client的请求,如果为写请求,发送给Leader进行投票;
4 .返回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个线程来实现功能的。

Zookepper - 图7

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

Follower/Observer均可接受写请求,但不能直接处理,而需要将写请求转发给Leader处理
除了多一步请求转发,其它流程与直接写Leader无任何区别

三、FastLeaderElection原理

1、基本概念

1.1、sid

每个服务器都需要在数据文件夹下创建一个名为myid的文件,该文件包含整个集群唯一的ID(整数)
例如,集群包含三台服务器,hostname分别为zoo1,zoo2,zoo3其myid分别为1、2和3,则在配置文件中其ID与hostname必须一一对应,如下所示,sever.后面的数据即为sid
server.1=zoo1:2888:3888
server.2=zoo2:2888:3888
server.3=zoo3:2888:3888

1.2、zxid

类似于RDBMS中的事务ID,用于标识一次更新操作的Proposal ID。为了保证顺序性,该zkid必须单调递增。因此zookeeper使用一个64位的数字来表示,高32位是Leader的epoch,从1开始,每次选出新的Leader,epoch加一。低32位为该epoch内的序号,每次epoch变化,都讲低32位的序号重置。这样保证了zxid的全局递增性。

1.3、服务器状态

LOOKING 不确定Leader状态。该状态下的服务器认为当前集群汇总没有Leader,会发起Leader选举。
FOLLOWING 跟随着状态。表明当前服务器角色是Follower,并且它知道Leader是谁。
LEADING 领导者状态。表明当前服务器角色是Leader,它会维护与Follower间的心跳。
OBSERVING 观察者状态。表明当前服务器角色是Observer,与Follower唯一不同的在于不参与选举,也不参与集群写操作时的投票。

1.4、选票数据结构

每个服务器在进行领导选举时,会发送如下关键信息:

  • version每个服务器会维护一个自增的整数,名为version,它表示这是该服务器发起的第多次轮投票
  • state 当前服务器的状态
  • id 当前服务器的myid
  • zxid 当前服务器上所保存的数据最大的zxid
  • electionEpoch 被推举的服务器的sid
  • peerEpoch 被推举的Leader的epoch

1.5、投票流程

自增选举轮次:zookeeper规定所有有效的投票必须在同一轮次中。每个服务器在开始新一轮投票时,会先对自己维护的version进行自增操作。
初始化选票:每个服务在广播自己的选票前,会将自己的投票箱清空,该投票箱记录了所收到的选票。例:服务器2投给服务器3,服务器3投票给服务器1,则服务器1的投票箱为(2,3),(3,1),(1,1)。票箱中只会记录每一轮投票者的最后一票,如投票者更新自己的选票,则其他服务器收到该新选票后会在自己票箱中更新该服务器的选票
发送初始化选票:每个服务器最开始都是通过广播把票投给自己
接收外部投票:服务器会尝试从其他服务获取投票,并记入自己的投票箱内。如果无法获取任何外部投票,则会确认自己是否与集群中其他服务器保持着有效连接。如果是,则再次发送自己的投票;如果否,则马上与之建立连接。
判断选举轮次:收到外部投票后,首先会根据投票信息所包含的round来进行不同处理

  • 外部投票的round大于自己的round。说明该服务的选举轮次落后于其他服务器的选举轮次,立即清空自己的投票箱并将自己的round更新为收到的round,然后再对比自己之前的投票与收到的投票以确定是否需要变更自己的投票,最终再次将自己的投票广播出去。
  • 外部投票的round小于自己的round。当前服务器直接忽略该投票,继续处理下一个投票
  • 外部投票的round与自己的相等。当时进行选票PK。

选票PK:选票PK是基于(sid,zxid)与(sid,zxid)的对比

  • 外部投票的round大于自己的round,则将自己的round及自己的选票的round变更为收到的round
  • 若round一致,则对比二者的zxid,若外部投票的zxid比较大,则将自己的票中的zxid与sid更新为收到的票中的zxid与sid并广播出去,另外将收到的票及自己更新后的票放入自己的票箱。如果票箱内已存在(sid,zxid)相同的选票,则直接覆盖
  • 若二者zxid一致,则比较二者的sid,若外部投票的sid比较大,则将自己的票中的sid更新为收到的票中的sid并广播出去,另外将收到的票及自己更新后的票放入自己的票箱

统计选票:如果已经确定有过半的服务器认可了自己的投票,则终止投票。否则继续接收其他服务器的投票。
更新服务器状态:投票结束后,服务器开始更新自身状态。若过半的投票给了自己,则将自己的服务器状态更新为LEADING,否则将自己的状态更新为FOLLOWING。

2、集群启动领导选举

2.1、初始票投给自己

集群刚启动时,所有的round都为1,zxid都为0。各服务器初始化后,都投票给自己并将自己的一票存入自己的票箱
Zookepper - 图9
在上图中,(1,1,0)第一位数代表投出该选票的服务器的round,第二位数代表被推举的服务器sid,第三位代表被推举的服务器的最大sid。由于该步骤中所有选票都投给自己,所以第二位的sid即是自己的sid,第三位的zxid即是自己的zxid。此时各自的票箱中只有自己投给自己的一票。

2.2、更新选票

服务器收到外部投票后,进行选票PK,相应更新自己的选票并广播出去,并将合适的选票存入自己的票箱,如下所示
Zookepper - 图10
服务器1收到服务器2的选票(1,2,0)和服务器3的选票(1,3,0)后,由于所有的round都相等,所有的zxid都想等,因此根据sid判断应该将自己的选票按照服务器3的选票更新为(1,3,0),并将自己的票箱全部清空,再将服务器3的选票与自己的选票存入自己的票箱,接着将自己更新后的选票广播出去。此时服务器1票箱内的选票为(1,3),(3,3)
同理,服务器2收到服务器3的选票后也将自己的选票更新为(1,3,0)并存入票箱然后广播。此时服务器2票箱内的选票为(2,3),(3,3)
服务器3根据上述规则,无须更新选票,自身的票箱内选票仍然为(3,3)
服务器1与服务器2更新后的选票广播出去后,由于三个服务器最新选票都相同,最后三者的票箱内都包含三张投给服务器3的选票。

2.3、根据选票确定角色

根据上述选票,三个服务器一致认为此时服务器3应该是Leader。因此服务器1和2都进入FOLLOWING状态,而服务器3进入LEADING状态,之后Leader发起并维护Follower间的心跳。
Zookepper - 图11

3、Follower重启选举

3.1、Follower重启投票给自己

Follower重启,或者发生网络分区后找不到Leader,会进入LOOKING状态并发起新一轮投票。
Zookepper - 图12

3.2、发现已有Leader后成为Follower

服务器3收到服务器1的投票后,将自己的状态LEADING以及选票返回给服务器1。服务器2收到服务器1的投票后,将自己的状态FOLLOWING及选票返回给服务器1.此时服务器1知道服务器3是Leader,并且通过服务器2与服务器3的选票可以确定服务器确实得到了超过半数的选票。因此服务器1进入FOLLOWING状态。
Zookepper - 图13

4、Leader重启选举

4.1、Follower发起新投票

Leader服务器宕机后,Follower发现Leader不工作了,因此进入LOOKING状态并发起新的一轮投票,并且都将票投给自己。
Zookepper - 图14

4.2、广播更新选票

服务器1和2根据外部投票确定是否要更新自身的选票,这里有两种情况:

  • 服务器1和2zxid相同。例如在服务器3宕机前服务器1与2完全与之同步。此时选票的更新主要取决于sid的大小
  • 服务器1和2的zxid不同。在旧Leader宕机之前,其所主导的写操作,只需过半服务器确认即可,而不需要所有服务器确认。换句话说,服务器1和服务器2可能一个与旧Leader同步(即zxid与之相等),另一个不同步。此时选票的更新主要取决于谁的zxid较大

在上图中,服务器1的zxid为11,而服务器3的zxid为10,因此服务器2将自身选票更新为(3,1,11),如下图
Zookepper - 图15

4.3、选出新Leader

经过上一步选票更新后,服务器1与服务器2均将选票投给服务器1,因此服务器1成为新的Leader并维护与服务器2的心跳。
Zookepper - 图16

4.4、旧Leader恢复后发起选举

旧的Leader恢复后,进入LOOKING状态并发起新一轮领导选举,并将选票投给自己。此时服务器1会将自己的LEADING状态及选票(3,1,11)返回给服务器3,而服务器2将自己的FOLLOWING状态及选票(3,1,11)返回给服务器3,如下图所示
Zookepper - 图17

4.5、旧Leader成为Follower

服务器3了解到Leader为服务器1,且根据选票了解到服务器确实得到过半服务器的选票,因此自己进入FOLLOWING状态
Zookepper - 图18

5、commit过的数据不丢失

5.1、Failover前状态

为了更好演示Leader Failover过程,使用5个服务器,A作为Leader,共收到P1、P2、P3三条消息,并且commit了p1和p2,且总体顺序为P1、P2、C1、P3、C2。根据顺序性原则,其它Follower收到的消息的顺序肯定与之相同。其中B与A完全同步,C收到P1、P2、C1,D收到P1、P2,E收到P1,如下图
Zookepper - 图19

这里要注意:

  • 由于A没有C3,意味着收到P3的服务器的总个数不会超过一半,也即包含A在内最多只有两台服务器收到P3,。这里A和B收到P3,其它服务器均未收到P3
  • 由于A已写入C1、C2,说明它已经commit了P1、P2,因此整个集群有超过一半的服务器,即最少三台服务器收到P1、P2.在这里所有服务器都收到了P1,除E外其他服务器也都收到了P2

    5.2、选出新Leader

    旧Leader也即A宕机后,其他服务器根据上述FastLeaderElection算法选出B作为新的Leader。C、D和E称为Follower且以B为Leader后,会主动将自己最大的zxid发送给B,B会将Follower的zxid与自身zxid间的所有被commit过的消息同步给Follower,如下图所示
    Zookepper - 图20

在上图中:

  • P1和P2都被A commit,因此B会通过同步保证P1、P2、C1与C2都存在于C、D和E中
  • P3由于未被A commit,同时幸存的所有服务器中P3未存在大多数服务器中,因此它不会被同步到其他Follower

    5.3、通知Follower可对外服务

    同步数据完成成后,B会向C、D和E发送NEWLEADER命令并等待大多数服务器的ACK(下图中D和E已经返回ACK,加上B自身,已经占集群的大多数),然后向所有服务器广播UPTODATE命令。收到该命令后的服务器即可对外提供服务。
    Zookepper - 图21

6、未commit过的消息对客户端不可见

在上例中,P3未被A commit过,同时因为没有过半的服务器收到P3,因此B也未commit P3(如果有过半的服务收到P3,即使A未commit P3,B会主动commit P3,即C3),所以它不会将P3广播出去
具体做法是,B在成为Leader后,先判断自身未commit的消息(本例中即P3)是否存在于大多数服务器中从而决定是否要将其commit。然后B可得出自身包含的commit过的消息的最小zxid(记为min_zxid)与最大zxid(记为max_zxid)。C、D和E向B发送自身commit过的最大消息zxid(记为max_zxid)以及未被commit过的所有消息(记为zxid_set)。B根据这些信息作出如下操作

  • 如果Follower的max_zxid与Leader的max_zxid相等,说明该Follower与Leader完全同步,无须同步任何数据
  • 如果Follower的max_zxid在Leader的(min_zxid,max_zxid)范围内,Leader会通过TRUNC命令通知Follower将其zxid_set中大于Follower的max_zxid(如果有)的所有消息全部删除

上述操作保证了未被commit过的消息不会被commit从而对外不可见。
上述例子中Follower上并不存在未被commit的消息。但可考虑这种情况,如果将上述数量从五增加到七,服务器F包含P1、P2、C1、P3,服务器G包含P1、P2.此时服务器F、A和B都包含P3,但是因为票数未过半,因此B作为Leader不会commit P3,而会通过TRUNC命令通知F删除P3,如下图所示
Zookepper - 图22

总结

  • 由于使用主从复制模式,所有的写操作都有Leader主导完成,而读操作可通过任意节点完成,因此Zookeeper读性能远好于写性能,更合适读多写少的场景
  • 虽然使用主从复制模式,同一时间只有一个Leader,但是Failover机制保证了集群不存在单点失败(SPOF)的问题
  • ZAB协议保证了Failover过程中的数据一致性
  • 服务器收到数据后先写本地文件再进行处理,保证了数据的持久性

    四、zookeeper节点类型

zookeeper提供了一个类似于Linux文件系统的树形结构。该树形结构中每个节点称为znode,可按如下两个维度分类
1、Persist VS Ephemeral
persist节点,一旦被创建,遍不会意外丢失,即使服务器全部重启也依然存在。每个persist节点即可包含数据,也可包含子节点
ephemeral节点,在创建它的客户端于服务器间的session结束时自动被删除。服务器重启会导致session结束,因此ephemeral类型的znode此时也会删除。
2、sequence vs Non-sequence
non-sequence节点,多个客户端同时创建同一non-sequence节点只有一个可以创建成功,其它均失败,并且创建出的节点名称与创建时指定的节点名称完全一样
sequence节点,创建出的节点名称在指定的名称之后又10位10进制数的序号。多个客户端创建同一名称的节点时,都能创建成功,只是序号不同

五、zookeeper语义保证

zookeeper简单高效,同时提供如下语义保证,从而使得我们可以利用这些特性提供复杂的服务

  • 顺序性:客户端发起的更新会按发送顺序被应用到zookeeper上
  • 原子性:更新操作要么成功要么失败,不会出现中间状态
  • 单一系统镜像:一个客户端无论连接到哪一个服务器都能看到完全一样的系统镜像(即完全一样的树形结构)。注:写操作并不保证更新被所有的Follower立即确认,因此通过部分Follower读取数据并不能保证读到最新的数据,而部分Follower及Leader可读到最新数据。如果一定要保证单一系统镜像,可在读操作前使用sync方法
  • 可靠性:一个更新操作一旦被接受即不会意外丢失,除非被其他更新操作覆盖
  • 最终一致性:写操作最终(而非立即)会对客户端可见

    六、主流应用场景

Zookeeper的主流应用场景实现思路(除去官方示例)

1、配置管理


集中式的配置管理在应用集群中是非常常见的,一般商业公司内部都会实现一套集中的配置管理中心,应对不同的应用集群对于共享各自配置的需求,并且在配置变更时能够通知到集群中的每一个机器。

Zookeeper很容易实现这种集中式的配置管理,比如将APP1的所有配置配置到/APP1 znode下,APP1所有机器一启动就对/APP1这个节点进行监控(zk.exist(“/APP1”,true)),并且实现回调方法Watcher,那么在zookeeper上/APP1 znode节点下数据发生变化的时候,每个机器都会收到通知,Watcher方法将会被执行,那么应用再取下数据即可(zk.getData(“/APP1”,false,null));
以上这个例子只是简单的粗颗粒度配置监控,细颗粒度的数据可以进行分层级监控,这一切都是可以设计和控制的。

2、集群管理


应用集群中,我们常常需要让每一个机器知道集群中(或依赖的其他某一个集群)哪些机器是活着的,并且在集群机器因为宕机,网络断链等原因能够不在人工介入的情况下迅速通知到每一个机器。

Zookeeper同样很容易实现这个功能,比如我在zookeeper服务器端有一个znode叫/APP1SERVERS,那么集群中每一个机器启动的时候都去这个节点下创建一个EPHEMERAL类型的节点,比如server1创建/APP1SERVERS/SERVER1(可以使用ip,保证不重复),server2创建/APP1SERVERS/SERVER2,然后SERVER1和SERVER2都watch /APP1SERVERS这个父节点,那么也就是这个父节点下数据或者子节点变化都会通知对该节点进行watch的客户端。因为EPHEMERAL类型节点有一个很重要的特性,就是客户端和服务器端连接断掉或者session过期就会使节点消失,那么在某一个机器挂掉或者断链的时候,其对应的节点就会消失,然后集群中所有对/APP1SERVERS进行watch的客户端都会收到通知,然后取得最新列表即可。

另外有一个应用场景就是集群选master,一旦master挂掉能够马上能从slave中选出一个master,实现步骤和前者一样,只是机器在启动的时候在APP1SERVERS创建的节点类型变为EPHEMERAL_SEQUENTIAL类型,这样每个节点会自动被编号

我们默认规定编号最小的为master,所以当我们对/APP1SERVERS节点做监控的时候,得到服务器列表,只要所有集群机器逻辑认为最小编号节点为master,那么master就被选出,而这个master宕机的时候,相应的znode会消失,然后新的服务器列表就被推送到客户端,然后每个节点逻辑认为最小编号节点为master,这样就做到动态master选举。

3、分布式锁

七、Zookeeper 监视(Watch) 简介


Zookeeper C API 的声明和描述在 include/zookeeper.h 中可以找到,另外大部分的 Zookeeper C API 常量、结构体声明也在 zookeeper.h 中,如果如果你在使用 C API 是遇到不明白的地方,最好看看 zookeeper.h,或者自己使用 doxygen 生成 Zookeeper C API 的帮助文档。

Zookeeper 中最有特色且最不容易理解的是监视(Watches)。Zookeeper 所有的读操作——getData(), getChildren(), 和 exists() 都 可以设置监视(watch),监视事件可以理解为一次性的触发器, 官方定义如下: a watch event is one-time trigger, sent to the client that set the watch, which occurs when the data for which the watch was set changes。对此需要作出如下理解:

(一次性触发)One-time trigger

当设置监视的数据发生改变时,该监视事件会被发送到客户端,例如,如果客户端调用了 getData(“/znode1”, true) 并且稍后 /znode1 节点上的数据发生了改变或者被删除了,客户端将会获取到 /znode1 发生变化的监视事件,而如果 /znode1 再一次发生了变化,除非客户端再次对 /znode1 设置监视,否则客户端不会收到事件通知。

(发送至客户端)Sent to the client

Zookeeper 客户端和服务端是通过 socket 进行通信的,由于网络存在故障,所以监视事件很有可能不会成功地到达客户端,监视事件是异步发送至监视者的,Zookeeper 本身提供了保序性(ordering guarantee):即客户端只有首先看到了监视事件后,才会感知到它所设置监视的 znode 发生了变化(a client will never see a change for which it has set a watch until it first sees the watch event). 网络延迟或者其他因素可能导致不同的客户端在不同的时刻感知某一监视事件,但是不同的客户端所看到的一切具有一致的顺序。

(被设置 watch 的数据)The data for which the watch was set

这意味着 znode 节点本身具有不同的改变方式。你也可以想象 Zookeeper 维护了两条监视链表:数据监视和子节点监视(data watches and child watches) getData() and exists() 设置数据监视,getChildren() 设置子节点监视。 或者,你也可以想象 Zookeeper 设置的不同监视返回不同的数据,getData() 和 exists() 返回 znode 节点的相关信息,而 getChildren() 返回子节点列表。因此, setData() 会触发设置在某一节点上所设置的数据监视(假定数据设置成功),而一次成功的 create() 操作则会出发当前节点上所设置的数据监视以及父节点的子节点监视。一次成功的 delete() 操作将会触发当前节点的数据监视和子节点监视事件,同时也会触发该节点父节点的child watch。

Zookeeper 中的监视是轻量级的,因此容易设置、维护和分发。当客户端与 Zookeeper 服务器端失去联系时,客户端并不会收到监视事件的通知,只有当客户端重新连接后,若在必要的情况下,以前注册的监视会重新被注册并触发,对于开发人员来说 这通常是透明的。只有一种情况会导致监视事件的丢失,即:通过 exists() 设置了某个 znode 节点的监视,但是如果某个客户端在此 znode 节点被创建和删除的时间间隔内与 zookeeper 服务器失去了联系,该客户端即使稍后重新连接 zookeeper服务器后也得不到事件通知。

watch特点:

  • 主动推送:watch被触发时,由zookeeper服务器主动将更新推送给客户端,而不需要客户端轮询
  • 一次性:数据变化时,watch只会触发一次。如果客户端想得到后续更新的通知,必须要在watch被触发后重新注册一个watch
  • 可见性:如果一个客户端在读请求中附带watch,watch被触发的同时再次读取数据,客户端在得到watch消息之前肯定不可能看到更新后的数据。换句话说,更新通知先于更新结果
  • 顺序性:如果多个更新触发了多个watch,那么watch被触发的顺序与更新的顺序一致

Watch事件类型:

  • ZOO_CREATED_EVENT:节点创建事件,需要watch一个不存在的节点,当节点被创建时触发,此watch通过zoo_exists()设置
  • ZOO_DELETED_EVENT:节点删除事件,此watch通过zoo_exists()或zoo_get()设置
  • ZOO_CHANGED_EVENT:节点数据改变事件,此watch通过zoo_exists()或zoo_get()设置
  • ZOO_CHILD_EVENT:子节点列表改变事件,此watch通过zoo_get_children()
  • zoo_get_children2()设置
  • ZOO_SESSION_EVENT:会话失效事件,客户端与服务端断开或重连时触
  • ZOO_NOTWATCHING_EVENT:watch移除事件,服务端出于某些原因不再为客户端watch节点时触发

八、脑裂问题

主要原因是Zookeeper集群和Zookeeper client判断超时并不能做到完全同步,也就是说可能一前一后,如果是集群先于client发现,那就会出现上面的情况。同时,在发现并切换后通知各个客户端也有先后快慢。一般出现这种情况的几率很小,需要leader节点与Zookeeper集群网络断开,但是与其他集群角色之间的网络没有问题,还要满足上面那些情况,但是一旦出现就会引起很严重的后果,数据不一致。

1、zooKeeper默认采用了Quorums这种方式来防止”脑裂”现象。即只有集群中超过半数节点投票才能选举出Leader。这样的方式可以确保leader的唯一性,要么选出唯一的一个leader,要么选举失败。在zookeeper中Quorums作用如下:
1] 集群中最少的节点数用来选举leader保证集群可用。
2] 通知客户端数据已经安全保存前集群中最少数量的节点数已经保存了该数据。一旦这些节点保存了该数据,客户端将被通知已经安全保存了,可以继续其他任务。而集群中剩余的节点将会最终也保存了该数据。
假设某个leader假死,其余的followers选举出了一个新的leader。这时,旧的leader复活并且仍然认为自己是leader,这个时候它向其他followers发出写请求也是会被拒绝的。因为每当新leader产生时,会生成一个epoch标号(标识当前属于那个leader的统治时期),这个epoch是递增的,followers如果确认了新的leader存在,知道其epoch,就会拒绝epoch小于现任leader epoch的所有请求。那有没有follower不知道新的leader存在呢,有可能,但肯定不是大多数,否则新leader无法产生。Zookeeper的写也遵循quorum机制,因此,得不到大多数支持的写是无效的,旧leader即使各种认为自己是leader,依然没有什么作用。
zookeeper除了可以采用上面默认的Quorums方式来避免出现”脑裂”,还可以可采用下面的预防措施:
2、添加冗余的心跳线,例如双线条线,尽量减少“裂脑”发生机会。
3、启用磁盘锁。正在服务一方锁住共享磁盘,”裂脑”发生时,让对方完全”抢不走”共享磁盘资源。但使用锁磁盘也会有一个不小的问题,如果占用共享盘的一方不主动”解锁”,另一方就永远得不到共享磁盘。现实中假如服务节点突然死机或崩溃,就不可能执行解锁命令。后备节点也就接管不了共享资源和应用服务。于是有人在HA中设计了”智能”锁。即正在服务的一方只在发现心跳线全部断开(察觉不到对端)时才启用磁盘锁。平时就不上锁了。
4、设置仲裁机制。例如设置参考IP(如网关IP),当心跳线完全断开时,2个节点都各自ping一下 参考IP,不通则表明断点就出在本端,不仅”心跳”、还兼对外”服务”的本端网络链路断了,即使启动(或继续)应用服务也没有用了,那就主动放弃竞争,让能够ping通参考IP的一端去起服务。更保险一些,ping不通参考IP的一方干脆就自我重启,以彻底释放有可能还占用着的那些共享资源。