zookeeper算法研究

来源:互联网 发布:优雅的淘宝收件人名字 编辑:程序博客网 时间:2024/06/04 00:46
Paxos分布式一致性算法
Paxos是一个基于消息传递的一致性算法,近几年被广泛应用于分布式计算中,Google的Chubby,Apache的Zookeeper都是基于它的理论来实现的,Paxos还被认为是到目前为止唯一的分布式一致性算法,其它的算法都是Paxos的改进或简化。Paxos只有在一个可信的计算环境中才能成立,这个环境是不会被入侵所破坏的。
Paxos描述了这样一个场景,有一个叫做Paxos的小岛(Island)上面住了一批居民,岛上面所有的事情由一些特殊的人决定,他们叫做议员(Senator)。议员的总数(Senator Count)是确定的,不能更改。岛上每次环境事务的变更都需要通过一个提议(Proposal),每个提议都有一个编号(PID),这个编号是一直增长的,不能倒退。每个提议都需要超过半数((Senator Count)/2 +1)的议员同意才能生效。每个议员只会同意大于当前编号的提议,包括已生效的和未生效的。如果议员收到小于等于当前编号的提议,他会拒绝,并告知对方:你的提议已经有人提过了。这里的当前编号是每个议员在自己记事本上面记录的编号,他不断更新这个编号。整个议会不能保证所有议员记事本上的编号总是相同的。现在议会有一个目标:保证所有的议员对于提议都能达成一致的看法。
现在议会开始运作,所有议员一开始记事本上面记录的编号都是0。有一个议员发了一个提议:将电费设定为1元/度。他首先看了一下记事本,嗯,当前提议编号是0,那么我的这个提议的编号就是1,于是他给所有议员发消息:1号提议,设定电费1元/度。其他议员收到消息以后查了一下记事本,哦,当前提议编号是0,这个提议可接受,于是他记录下这个提议并回复:我接受你的1号提议,同时他在记事本上记录:当前提议编号为1。发起提议的议员收到了超过半数的回复,立即给所有人发通知:1号提议生效!收到的议员会修改他的记事本,将1好提议由记录改成正式的法令,当有人问他电费为多少时,他会查看法令并告诉对方:1元/度。
现在看冲突的解决:假设总共有三个议员S1-S3,S1和S2同时发起了一个提议:1号提议,设定电费。S1想设为1元/度, S2想设为2元/度。结果S3先收到了S1的提议,于是他做了和前面同样的操作。紧接着他又收到了S2的提议,结果他一查记事本,咦,这个提议的编号小于等于我的当前编号1,于是他拒绝了这个提议:对不起,这个提议先前提过了。于是S2的提议被拒绝,S1正式发布了提议: 1号提议生效。S2向S1或者S3打听并更新了1号法令的内容,然后他可以选择继续发起2号提议。
现在让我们来对号入座,看看在ZK Server里面Paxos是如何得以贯彻实施的。
小岛(Island)——ZK Server Cluster
议员(Senator)——ZK Server
提议(Proposal)——ZNode Change(Create/Delete/SetData…)
提议编号(PID)——Zxid(ZooKeeper Transaction Id)
正式法令——所有ZNode及其数据
在所有议员中设立一个总统,只有总统有权发出提议,如果议员有自己的提议,必须发给总统并由总统来提出。
总统——ZK Server Leader
现在我们假设总统已经选好了,下面看看ZK Server是怎么实施的。
情况一:
屁民甲(Client)到某个议员(ZK Server)那里询问(Get)某条法令的情况(ZNode的数据),议员毫不犹豫的拿出他的记事本(local storage),查阅法令并告诉他结果,同时声明:我的数据不一定是最新的。你想要最新的数据?没问题,等着,等我找总统Sync一下再告诉你。
情况二:
屁民乙(Client)到某个议员(ZK Server)那里要求政府归还欠他的一万元钱,议员让他在办公室等着,自己将问题反映给了总统,总统询问所有议员的意见,多数议员表示欠屁民的钱一定要还,于是总统发表声明,从国库中拿出一万元还债,国库总资产由100万变成99万。屁民乙拿到钱回去了(Client函数返回)。
情况三:
总统突然挂了,议员接二连三的发现联系不上总统,于是各自发表声明,推选新的总统,总统大选期间政府停业,拒绝屁民的请求。

Zookeeper中的FastLeaderElection算法
我们知道,在经典的paxos算法中每一个peer都是proposer,但是这就不可避免的产生提案冲突,为了减少这种冲突带来的系统消耗与时间延迟,就产生了Leader这个角色,整个系统中,就只允许Leader可以发出提案。ZooKeeper就是按照这个思路来实现的。本文主要讨论ZooKeeper中的FastLeaderElection算法,来讲解Leader是如何产生的。
Zookeeper中的每一个peer在绝大多数情况下是共用同一个配置文件的,所以每一个peer得到的其他peer的信息(编号、ip地址、选举端口号)是一样的。Zookeeper系统启动之后,在为客户提供服务之前,是必须要产生一个Leader的。所以就必须先要执行选举Leader算法。
在每一个peer的选举算法开始之后,默认采用FastLeaderElection算法,每一个peer一方面向所有的peer发送一张自己的选票,同时另一方面它也接受其他peer发送过来的选票,然后不停的统计票数。当然在此之前,每一个peer都会创建一个Listener来监听自己的端口号,以此来接受所有peer发送过来的选票。从这一个过程中,实际上是每一个peer都是coordinator。这样每一个peer都是coordinator话,系统就会产生n*n个网络连接,无疑消耗了大量的系统资源。但是ZooKeeper从中也是做过一些优化工作的。
首先,每一个节点在发送选票之前都会判断这张选票是否是发送给自己的,如果是则直接存入统计选票的集合中,而不会通过网络连接发送给自己。
其次,每一个peer节点只接受编号比自己大的peer的网络连接请求,这样产生的网络连接数就是(n-1)*(n-1)/2。
另外,这个Leader的选举过程中是不会发生活锁的。因为在每一轮投票之后,每一个peer都会产生一张新选票,而每一个peer产生一张新选票的决策规则(peer数据版本号+编号)是一样的,同时这个决策规则依赖的节点数据更新程度和节点编号在任何两个peer之间是不可能重复的。
最后,我还想再补充一点的就是统计票数问题。ZooKeeper采用了一个高度抽象的表决器(QuorumVerifier)。例如,这个表决器可以仅仅只依赖票数,也可以依赖(票数+选票权重),等...决策策略。

Zookeeper核心机制
Zookeeper是Hadoop下的一个子项目,它是一个针对大型分布式系统的可靠的协调系统,提供的功能包括命名服务、配置维护、分布式同步、集群服务等。
Zookeeper是可以集群复制的,集群间通过Zab(Zookeeper Atomic Broadcast)协议来保持数据的一致性。
该协议包括2个阶段:leader election阶段和Actomic broadcast阶段。集群中将选举出一个leader,其他的机器则称为follower,所有的写操作都被传送给leader,并通过broadcast将所有的更新告诉follower。当leader崩溃或者leader失去大多数的follower时,需要重新选举出一个新的leader,让所有的服务器都恢复到一个正确的状态。当leader被选举出来,且大多数服务器完成了和leader的状态同步后,leader election的过程就结束了,将进入Atomic broadcast的过程。Actomic broadcast同步leader和follower之间的信息,保证leader和follower具备相同的系统状态。
Zookeeper集群的结构图如下:
路由和负载均衡的实现
当服务越来越多,规模越来越大时,对应的机器数量也越来越庞大,单靠人工来管理和维护服务及地址的配置信息,已经越来越困难。并且,依赖单一的硬件负载均衡设备或者使用LVS、Nginx等软件方案进行路由和负载均衡调度,单点故障的问题也开始凸显,一旦服务路由或者负载均衡服务器宕机,依赖其的所有服务均将失效。如果采用双机高可用的部署方案,使用一台服务器“stand by”,能部分解决问题,但是鉴于负载均衡设备的昂贵成本,已难以全面推广。
一旦服务器与ZooKeeper集群断开连接,节点也就不存在了,通过注册相应的watcher,服务消费者能够第一时间获知服务提供者机器信息的变更。利用其znode的特点和watcher机制,将其作为动态注册和获取服务信息的配置中心,统一管理服务名称和其对应的服务器列表信息,我们能够近乎实时地感知到后端服务器的状态(上线、下线、宕机)。Zookeeper集群间通过Zab协议,服务配置信息能够保持一致,而Zookeeper本身容错特性和leader选举机制,能保证我们方便地进行扩容。
Zookeeper中,服务提供者在启动时,将其提供的服务名称、服务器地址、以节点的形式注册到服务配置中心,服务消费者通过服务配置中心来获得需要调用的服务名称节点下的机器列表节点。通过前面所介绍的负载均衡算法,选取其中一台服务器进行调用。当服务器宕机或者下线时,由于znode非持久的特性,相应的机器可以动态地从服务配置中心里面移除,并触发服务消费者的watcher。在这个过程中,服务消费者只有在第一次调用服务时需要查询服务配置中心,然后将查询到的服务信息缓存到本地,后面的调用直接使用本地缓存的服务地址列表信息,而不需要重新发起请求到服务配置中心去获取相应的服务地址列表,直到服务的地址列表有变更(机器上线或者下线),变更行为会触发服务消费者注册的相应的watcher进行服务地址的重新查询。这种无中心化的结构,使得服务消费者在服务信息没有变更时,几乎不依赖配置中心,解决了之前负载均衡设备所导致的单点故障的问题,并且大大降低了服务配置中心的压力。
通过Zookeeper来实现服务动态注册、机器上线与下线的动态感知,扩容方便,容错性好,且无中心化结构能够解决之前使用负载均衡设备所带来的单点故障问题。只有当配置信息更新时服务消费者才会去Zookeeper上获取最新的服务地址列表,其他时候使用本地缓存即可,这样服务消费者在服务信息没有变更时,几乎不依赖配置中心,能大大降低配置中心的压力。

分析一下这个算法不难发现,如果有3台服务器启动,第一个向zookeeper“报告”的人会被当选为leader;如果它出现故障,第二个向zookeeper“报告”的人会被当选为leader,以此类推。这是一种非常原始的民主选举制度,有一个象征最高权力的“神器”,得到“神器”的就是大部落的酋长;很多人想要参选大酋长,那么谁跑得快最先抢到“神器”谁就是大酋长;如果在后面的“执政”期间酋长因为“太堕落”被干掉了那么第二名自动接管“神器”变成大酋长。把上面的代码执行两次,最先执行的程序会被选择为leader;杀死第一个进程,第二个进程的控制台会输出自己当选为leader的信息。(第二个进程不是立即输出信息,需要等待几秒钟)

有了zookeeper的一致性文件系统,锁的问题变得容易。锁服务可以分为两类,一个是保持独占,另一个是控制时序。
对于第一类,我们将zookeeper上的一个znode看作是一把锁,通过createznode的方式来实现。所有客户端都去创建 /distribute_lock 节点,最终成功创建的那个客户端也即拥有了这把锁。厕所有言:来也冲冲,去也冲冲,用完删除掉自己创建的distribute_lock 节点就释放出锁。
对于第二类, /distribute_lock 已经预先存在,所有客户端在它下面创建临时顺序编号目录节点,和选master一样,编号最小的获得锁,用完删除,依次方便。

两种类型的队列:
1、同步队列,当一个队列的成员都聚齐时,这个队列才可用,否则一直等待所有成员到达。
2、队列按照 FIFO 方式进行入队和出队操作。
第一类,在约定目录下创建临时目录节点,监听节点数目是否是我们要求的数目。
第二类,和分布式锁服务中的控制时序场景基本原理一致,入列有编号,出列按编号。
终于了解完我们能用zookeeper做什么了,可是作为一个程序员,我们总是想狂热了解zookeeper是如何做到这一点的,单点维护一个文件系统没有什么难度,可是如果是一个集群维护一个文件系统保持数据的一致性就非常困难了。

对zookeeper来说,它采用的方式是写任意。通过增加机器,它的读吞吐能力和响应能力扩展性非常好,而写,随着机器的增多吞吐能力肯定下降(这也是它建立observer的原因),而响应能力则取决于具体实现方式,是延迟复制保持最终一致性,还是立即复制快速响应。

如何在zookeeper集群中选举出一个leader,zookeeper使用了三种算法,具体使用哪种算法,在配置文件中是可以配置的,对应的配置项是”electionAlg”,其中1对应的是LeaderElection算法,2对应的是AuthFastLeaderElection算法,3对应的是FastLeaderElection算法.默认使用FastLeaderElection算法.其他两种算法我没有研究过,就不多说了.
要理解这个算法,最好需要一些paxos算法的理论基础.
1) 数据恢复阶段
首先,每个在zookeeper服务器先读取当前保存在磁盘的数据,zookeeper中的每份数据,都有一个对应的id值,这个值是依次递增的,换言之,越新的数据,对应的ID值就越大.
2) 首次发送自己的投票值
在读取数据完毕之后,每个zookeeper服务器发送自己选举的leader,这个协议中包含了以下几部分的数据:
1)所选举leader的id(就是配置文件中写好的每个服务器的id) ,在初始阶段,每台服务器的这个值都是自己服务器的id,也就是它们都选举自己为leader.
2)服务器最大数据的id,这个值大的服务器,说明存放了更新的数据.
3)逻辑时钟的值,这个值从0开始递增,每次选举对应一个值,也就是说:如果在同一次选举中,那么这个值应该是一致的 2)逻辑时钟值越大,说明这一次选举leader的进程更新.
4)本机在当前选举过程中的状态,有以下几种:LOOKING,FOLLOWING,OBSERVING,LEADING,顾名思义不必解释了吧.
每台服务器将自己服务器的以上数据发送到集群中的其他服务器之后,同样的也需要接收来自其他服务器的数据,它将做以下的处理:
1) 如果所接收数据服务器的状态还是在选举阶段(LOOKING 状态),那么首先判断逻辑时钟值,又分为以下三种情况:
a) 如果发送过来的逻辑时钟大于目前的逻辑时钟,那么说明这是更新的一次选举,此时需要更新一下本机的逻辑时钟值,同时将之前收集到的来自其他服务器的选举清空,因为这些数据已经不再有效了.然后判断是否需要更新当前自己的选举情况.在这里是根据选举leader id,保存的最大数据id来进行判断的,这两种数据之间对这个选举结果的影响的权重关系是:首先看数据id,数据id大者胜出;其次再判断leader id,leader id大者胜出.然后再将自身最新的选举结果(也就是上面提到的三种数据广播给其他服务器).
代码如下:
    if (n.epoch > logicalclock) {
    logicalclock = n.epoch; 
    recvset.clear(); 
    if(totalOrderPredicate(n.leader, n.zxid,getInitId(), getInitLastLoggedZxid())) 
       updateProposal(n.leader, n.zxid); 
    else 
    updateProposal(getInitId(),getInitLastLoggedZxid()); 
    sendNotifications(); 
其中的totalOrderPredicate函数就是根据发送过来的封包中的leader id,数据id来与本机保存的相应数据进行判断的函数,返回true说明需要更新数据,于是调用updateProposal函数更新数据
b) 发送过来数据的逻辑时钟小于本机的逻辑时钟
说明对方在一个相对较早的选举进程中,这里只需要将本机的数据发送过去就是了
c) 两边的逻辑时钟相同,此时也只是调用totalOrderPredicate函数判断是否需要更新本机的数据,如果更新了再将自己最新的选举结果广播出去就是了.
实际上,在处理选票之前,还有一个预处理的动作,它发生在刚刚接收到关于vote的message的时候,具体过程如下:
    1.判断message的来源是不是observer,如果是,则告诉该observer我当前认为的Leader的信息,否则进入2 
    2.判断message是不是vote信息,是则进入3 
    3.根据message创建一张vote 
    4.如果当前server处理LOOKING状态,将vote放入自己的投票箱,而且如果vote源server处于LOOKING状态同时vote源server选举时旧的,则当前server通知它新的一轮投票; 
    5如果当前server不处于LOOKING状态而vote源server处理LOOKING状态,则当前server告诉它当前的Leader信息。
三种情况的处理完毕之后,再处理两种情况:
1)服务器判断是不是已经收集到了所有服务器的选举状态,如果是那么根据选举结果设置自己的角色(FOLLOWING还是LEADER),然后退出选举过程就是了.
2)即使没有收集到所有服务器的选举状态,也可以判断一下根据以上过程之后最新的选举leader是不是得到了超过半数以上服务器的支持,如果是,那么尝试在200ms内接收一下数据,如果没有新的数据到来,说明大家都已经默认了这个结果,同样也设置角色退出选举过程.
代码如下:
    /*
    * Only proceed if the vote comes from a replica in the
    * voting view.
    */ 
    if(self.getVotingView().containsKey(n.sid)){ 
    recvset.put(n.sid, new Vote(n.leader, n.zxid, n.epoch)); 
    //If have received from all nodes, then terminate 
    if((self.getVotingView().size() == recvset.size()) && (self.getQuorumVerifier().getWeight(proposedLeader) != 0)){ 
    self.setPeerState((proposedLeader == self.getId()) ? ServerState.LEADING: learningState()); 
    leaveInstance(); 
    return new Vote(proposedLeader, proposedZxid); 
    } else if (termPredicate(recvset,new Vote(proposedLeader, proposedZxid,logicalclock))) { 
    // Verify if there is any change in the proposed leader 
    while((n = recvqueue.poll(finalizeWait,TimeUnit.MILLISECONDS)) != null){ 
    if(totalOrderPredicate(n.leader, n.zxid,proposedLeader, proposedZxid)){ 
       recvqueue.put(n); 
       break; 
    } 
   } 
    /*
    * This predicate is true once we don't read any new
    * relevant message from the reception queue
    */ 
    if (n == null) { 
    self.setPeerState((proposedLeader == self.getId()) ? ServerState.LEADING: learningState()); 
    if(LOG.isDebugEnabled()){ 
    LOG.debug("About to leave FLE instance: Leader= " + proposedLeader + ", Zxid = " + proposedZxid + ", My id = " + self.getId() + ", My state = " + self.getPeerState()); 
    } 
    leaveInstance(); 
    return new Vote(proposedLeader,proposedZxid); 
    } 
    } 
    } 
2) 如果所接收服务器不在选举状态,也就是在FOLLOWING或者LEADING状态
做以下两个判断:
a) 如果逻辑时钟相同,将该数据保存到recvset,如果所接收服务器宣称自己是leader,那么将判断是不是有半数以上的服务器选举它,如果是则设置选举状态退出选举过程
b) 否则这是一条与当前逻辑时钟不符合的消息,那么说明在另一个选举过程中已经有了选举结果,于是将该选举结果加入到outofelection集合中,再根据outofelection来判断是否可以结束选举,如果可以也是保存逻辑时钟,设置选举状态,退出选举过程.
代码如下:
    if(n.epoch == logicalclock){ 
    recvset.put(n.sid, new Vote(n.leader, n.zxid, n.epoch)); 
    if((n.state == ServerState.LEADING) || (termPredicate(recvset, new Vote(n.leader,n.zxid, n.epoch, n.state))&& checkLeader(outofelection, n.leader, n.epoch)) ){ 
    self.setPeerState((n.leader == self.getId()) ?ServerState.LEADING: learningState()); 
    leaveInstance(); 
    return new Vote(n.leader, n.zxid); 
    } 
    } 
    outofelection.put(n.sid, new Vote(n.leader, n.zxid, n.epoch, n.state)); 
    if(termPredicate(outofelection, new Vote(n.leader,n.zxid, n.epoch, n.state))&& checkLeader(outofelection, n.leader, n.epoch)) { 
       synchronized(this){ 
          logicalclock = n.epoch; 
          self.setPeerState((n.leader == self.getId()) ? ServerState.LEADING: learningState()); 
       } 
       leaveInstance(); 
       return new Vote(n.leader, n.zxid); 
    } 
    break; 
    } 
    } 
以一个简单的例子来说明整个选举的过程.
假设有五台服务器组成的zookeeper集群,它们的id从1-5,同时它们都是最新启动的,也就是没有历史数据,在存放数据量这一点上,都是一样的.假设这些服务器依序启动,来看看会发生什么.
    1) 服务器1启动,此时只有它一台服务器启动了,它发出去的报没有任何响应,所以它的选举状态一直是LOOKING状态 
    2) 服务器2启动,它与最开始启动的服务器1进行通信,互相交换自己的选举结果,由于两者都没有历史数据,所以id值较大的服务器2胜出,但是由于没有达到超过半数以上的服务器都同意选举它(这个例子中的半数以上是3),所以服务器1,2还是继续保持LOOKING状态. 
    3) 服务器3启动,根据前面的理论分析,服务器3成为服务器1,2,3中的老大,而与上面不同的是,此时有三台服务器选举了它,所以它成为了这次选举的leader. 
    4) 服务器4启动,根据前面的分析,理论上服务器4应该是服务器1,2,3,4中最大的,但是由于前面已经有半数以上的服务器选举了服务器3,所以它只能接收当小弟的命了. 
    5) 服务器5启动,同4一样,当小弟.

集群只有3台机器,所以server.1和server.2启动后,即可选举出Leader。后续Leader和Follower开始数据交互。
1.server启动时默认选举自己,并向整个集群广播
2.收到消息时,通过3层判断:选举轮数,zxid,server id大小判断是否同意对方,如果同意,则修改自己的选票,并向集群广播
3.QuorumCnxManager负责IO处理,每2个server建立一个连接,只允许id大的server连id小的server,每个server启动单独的读写线程处理,使用阻塞IO
4.默认超过半数机器同意时,则选举成功,修改自身状态为LEADING或FOLLOWING
5.Obserer机器不参与选举

在分布式应用, 往往存在多个进程提供同一服务. 这些进程有可能在相同的机器上, 也有可能分布在不同的机器上. 如果这些进程共享了一些资源, 可能就需要分布式锁来锁定对这些资源的访问. 进程需要访问共享数据时, 就在"/locks"节点下创建一个sequence类型的子节点, 称为thisPath. 当thisPath在所有子节点中最小时, 说明该进程获得了锁. 进程获得锁之后, 就可以访问共享资源了. 访问完成后, 需要将thisPath删除. 锁由新的最小的子节点获得.
有了清晰的思路之后, 还需要补充一些细节. 进程如何知道thisPath是所有子节点中最小的呢? 可以在创建的时候, 通过getChildren方法获取子节点列表, 然后在列表中找到排名比thisPath前1位的节点, 称为waitPath, 然后在waitPath上注册监听, 当waitPath被删除后, 进程获得通知, 此时说明该进程获得了锁.
上述的方案并不安全. 假设某个client在获得锁之前挂掉了, 由于client创建的节点是ephemeral类型的, 因此这个节点也会被删除, 从而导致排在这个client之后的client提前获得了锁. 此时会存在多个client同时访问共享资源. 如何解决这个问题呢? 可以在接到waitPath的删除通知的时候, 进行一次确认, 确认当前的thisPath是否真的是列表中最小的节点.


0 0
原创粉丝点击