上篇文章:分布式一致性协议综述(上)
Paxos 是论证了一致性协议的可行性,但是论证的过程据说晦涩难懂,缺少必要的实现细节,而且工程实现难度比较高广为人知实现只有 zk 的实现 zab 协议。
Paxos协议的出现为分布式强一致性提供了很好的理论基础,但是Paxos协议理解起来较为困难,实现比较复杂。
然后斯坦福大学RamCloud项目中提出了易实现,易理解的分布式一致性复制协议 Raft。Java,C++,Go 等都有其对应的实现
之后出现的Raft相对要简洁很多。
引入主节点,通过竞选。
节点类型:Follower、Candidate 和 Leader
Leader 会周期性的发送心跳包给 Follower。每个 Follower 都设置了一个随机的竞选超时时间,一般为 150ms~300ms,如果在这个时间内没有收到 Leader 的心跳包,就会变成 Candidate,进入竞选阶段。
节点状态
termId
:任期号,时间被划分成一个个任期,每次选举后都会产生一个新的 termId,一个任期内只有一个 leader。termId 相当于 paxos 的 proposalId。RequestVote
:请求投票,candidate 在选举过程中发起,收到 quorum (多数派)响应后,成为 leader。AppendEntries
:附加日志,leader 发送日志和心跳的机制election timeout
:选举超时,如果 follower 在一段时间内没有收到任何消息(追加日志或者心跳),就是选举超时。角色 | 描述 |
---|---|
Leader选举 | 每个candidate随机经过一定时间都会提出选举方案,最近阶段中的票最多者被选为leader |
同步1og(复制日志) | leader会找到系统中log(各种事件的发生记录)最新的记录,并强制所有follow来刷新到这个记录。 |
针对简化版拜占庭将军问题,Raft解决方案
假设将军中没有叛军,信使的信息可靠但有可能被暗杀的情况下,将军们如何达成一致性决定?
Raft 的解决方案大概可以理解成 先在所有将军中选出一个大将军,所有的决定由大将军来做。选举环节:比如说现在一共有3个将军 A, B, C,每个将军都有一个随机时间的倒计时器,倒计时一结束,这个将军就会把自己当成大将军候选人,然后派信使去问其他几个将军,能不能选我为总将军?假设现在将军A倒计时结束了,他派信使传递选举投票的信息给将军B和C,如果将军B和C还没把自己当成候选人(倒计时还没有结束),并且没有把选举票投给其他,他们把票投给将军A,信使在回到将军A时,将军A知道自己收到了足够的票数,成为了大将军。在这之后,是否要进攻就由大将军决定,然后派信使去通知另外两个将军,如果在一段时间后还没有收到回复(可能信使被暗杀),那就再重派一个信使,直到收到回复。
① 5个节点一开始的状态都是 Follower,termId=1.
② 在一个节点倒计时结束 (Timeout) 后,这个节点的状态变成 Candidate 开始选举,它给其他几个节点发送选举请求 (RequestVote)
③ 四个节点都返回成功,这个节点的状态由 Candidate 变成了 Leader,并在每个一小段时间后,就给所有的 Follower 发送一个 Heartbeat 以保持所有节点的状态,Follower 收到 Leader 的 Heartbeat 后重设 Timeout。
这是最简单的选主情况,只要有超过一半的节点投支持票了,Candidate 才会被选举为 Leader,5个节点的情况下,3个节点 (包括 Candidate 本身) 投了支持就行。
① Leader 出故障挂掉了,其他四个 Follower 将进行重新选主。
② 4个节点的选主过程和5个节点的类似,选出一个新的 Leader
③ 原来的 Leader 恢复了又重新加入了,这个时候怎么处理?在 Raft 里,第几轮选举是有记录的,重新加入的 Leader 是第一轮选举 (Term 1) 选出来的,而现在的 Leader 则是 Term 2,所有原来的 Leader 会自觉降级为 Follower
假设一开始有4个节点,都还是 Follower。有两个 Follower 同时 Timeout,都变成了 Candidate 开始选举,分别给另外的 Follower 发送了投票请求。
两个 Follower 分别返回了ok,这时两个 Candidate 都只有2票(含自己一票),要3票才能被选成 Leader。两个 Candidate 会分别给另外一个还没有给自己投票的 Follower 发送投票请求。但是因为 Follower 在这一轮选举中,都已经投完票了,所以都拒绝了他们的请求。所以在 Term 2 没有 Leader 被选出来。
这时,两个节点的状态是 Candidate,两个是 Follower,但是他们的倒计时器仍然在运行,最先 Timeout 的那个节点会进行发起新一轮 Term 3 的投票。
两个 Follower 在 Term 3 还没投过票,所以返回 OK,这时 Candidate 一共有三票,被选为了 Leader。如果 Leader Heartbeat 的时间晚于另外一个 Candidate timeout 的时间,另外一个 Candidate 仍然会发送选举请求。
两个 Follower 已经投完票了,拒绝了这个 Candidate 的投票请求。Leader 进行 Heartbeat, Candidate 收到后状态自动转为 Follower,完成选主。
以上是 Raft 最重要活动之一选主的介绍,以及在不同情况下如何进行选主。
Raft 在实际应用场景中的一致性更多的是体现在不同节点之间的数据一致性,客户端发送请求到任何一个节点都能收到一致的返回,当一个节点出故障后,其他节点仍然能以已有的数据正常进行。在选主之后的复制日志就是为了达到这个目的。
① 客户端发送请求给 Leader,储存数据 “Hello”,Leader 先将数据写在本地日志,这时候数据还是 Uncommitted (还没最终确认,浅灰表示)
② Leader 给两个 Follower 发送 AppendEntries 请求,数据在 Follower 上没有冲突,则将数据暂时写在本地日志,Follower 的数据也还是 Uncommitted。
③ Follower 将数据写到本地后,返回 OK。Leader 收到后成功返回,只要收到的成功的返回数量超过半数 (包含Leader),Leader 将数据 “sally” 的状态改成 Committed。( 这个时候 Leader 就可以返回给客户端了)
④ Leader 再次给 Follower 发送 AppendEntries 请求,收到请求后,Follower 将本地日志里 Uncommitted 数据改成 Committed。这样就完成了一整个复制日志的过程,三个节点的数据是一致的。
在 Network Partition 的情况下,部分节点之间没办法互相通信,Raft 也能保证在这种情况下数据的一致性。
一开始有 5 个节点处于同一网络状态下。Network Partition 将节点分成两边,一边有两个节点,一边三个节点。
两个节点这边已经有 Leader 了,来自客户端的数据 “world” 通过 Leader 同步到 Follower。因为只有两个节点,少于3个节点,所以 “world” 的状态仍是 Uncommitted。所以在这里,服务器会返回错误给客户端。
另外一个 Partition 有三个节点,进行重新选主。客户端数据 “Raft” 发到新的 Leader,通过和上节网络状态下相似的过程,同步到另外两个 Follower。
因为这个 Partition 有3个节点,超过半数,所以数据 “Raft” 都 Commit 成功了。
网络状态恢复,5个节点再次处于同一个网络状态下。但是这里出现了数据冲突 “world" 和 “Raft"
三个节点的 Leader 广播 AppendEntries。
两个节点 Partition 的 Leader 自动降级为 Follower,因为这个 Partition 的数据 “world” 没有 Commit,返回给客户端的是错误,客户端知道请求没有成功,所以 Follower 在收到 AppendEntries 请求时,可以把 “world“ 删除,然后同步 ”Raft”,通过这么一个过程,就完成了在 Network Partition 情况下的复制日志,保证了数据的一致性。
Google 的粗粒度锁服务 Chubby 的设计开发者 Burrows 曾经说过:“所有一致性协议本质上要么是 Paxos 要么是其变体”。Paxos 虽然解决了分布式系统中,多个节点就某个值达成一致性的通信协议。但是还是引入了其他的问题。由于其每个节点,都可以提议提案,也可以批准提案。当有三个及以上的 proposer 在发送 prepare 请求后,很难有一个 proposer 收到半数以上的回复而不断地执行第一阶段的协议,在这种竞争下,会导致选举速度变慢。
所以 zookeeper 在 paxos 的基础上,提出了 ZAB 协议,本质上是,只有一台机器能提议提案(Proposer),而这台机器的名称称之为 Leader 角色。其他参与者扮演 Acceptor 角色。为了保证 Leader 的健壮性,引入了 Leader 选举机制。
ZAB协议还解决了这些问题
基本名词
数据节点(dataNode)
:zk 数据模型中的最小数据单元,数据模型是一棵树,由斜杠( / )分割的路径名唯一标识,数据节点可以存储数据内容及一系列属性信息,同时还可以挂载子节点,构成一个层次化的命名空间。事务及 zxid
:事务是指能够改变 Zookeeper 服务器状态的操作,一般包括数据节点的创建与删除、数据节点内容更新和客户端会话创建与失效等操作。对于每个事务请求,zk 都会为其分配一个全局唯一的事务 ID,即 zxid,是一个 64 位的数字,高 32 位表示该事务发生的集群选举周期epoch(集群每发生一次 leader 选举,值加 1),低 32 位表示该事务在当前选择周期内的递增次序(leader 每处理一个事务请求,值加 1,发生一次 leader 选择,低 32 位要清 0)。事务日志
:所有事务操作都是需要记录到日志文件中的,可通过 dataLogDir 配置文件目录,文件是以写入的第一条事务 zxid 为后缀,方便后续的定位查找。zk 会采取“磁盘空间预分配”的策略,来避免磁盘 Seek 频率,提升 zk 服务器对事务请求的影响能力。默认设置下,每次事务日志写入操作都会实时刷入磁盘,也可以设置成非实时(写到内存文件流,定时批量写入磁盘),但那样断电时会带来丢失数据的风险。事务快照
:数据快照是 zk 数据存储中另一个非常核心的运行机制。数据快照用来记录 zk 服务器上某一时刻的全量内存数据内容,并将其写入到指定的磁盘文件中,可通过 dataDir 配置文件目录。可配置参数 snapCount,设置两次快照之间的事务操作个数,zk 节点记录完事务日志时,会统计判断是否需要做数据快照(距离上次快照,事务操作次数等于snapCount/2~snapCount 中的某个值时,会触发快照生成操作,随机值是为了避免所有节点同时生成快照,导致集群影响缓慢)。核心角色
leader
:系统刚启动时或者 Leader 崩溃后正处于选举状态;follower
:Follower 节点所处的状态,Follower 与 Leader 处于数据同步阶段;observer
:Leader 所处状态,当前集群中有一个 Leader 为主进程。节点状态
LOOKING
:节点正处于选主状态,不对外提供服务,直至选主结束;FOLLOWING
:作为系统的从节点,接受主节点的更新并写入本地日志;LEADING
:作为系统主节点,接受客户端更新,写入本地日志并复制到从节点Zookeeper 集群的模式包括两种基本的模式:崩溃恢复 和 消息广播
当整个集群启动过程中,或者当 Leader 服务器出现网络中弄断、崩溃退出或重启等异常时,Zab协议就会 进入崩溃恢复模式,选举产生新的Leader。
一但出现崩溃,会导致数据不一致,ZAB的崩溃恢复开始起作用。有如下两个确保:
针对上两个要求,如果Leader选举算法保证新选举出来的Leader服务器拥有集群中所有机器最高编号(ZXID最大)的事务Proposal,那么就能保证新的Leader 一定具有已提交的所有提案,更重要是,如果这么做,可以省去Leader服务器检查Proposal的提交和丢弃工作的这一步。
一旦Leader服务器出现崩溃,或者说网络原因导致Leader服务器失去了与过半的Follower的联系,那么就会进入崩溃恢复模式。为了保证程序的正常运行,整个恢复过程后需要选举一个新的Leader服务器。因此,ZAB协议需要一个高效可靠的Leader选举算法,从而确保能够快速的选举出新的Leader。同时,新的Leader选举算法不仅仅需要让Leader自己知道其自身已经被选举为Leader,同时还需要让集群中所有的其他机器也能够快速的感知选举产生的新的Leader服务器。
ZAB协议规定了如果一个事务Proposal在一台机器上被处理成功,那么应该在所有的机器上都被处理成功,哪怕机器出现崩溃。
当新的Leader出来了,同时,已有过半机器完成同步之后,ZAB协议将退出恢复模式。进入消息广播模式。这时,如果有一台遵守Zab协议的服务器加入集群,因为此时集群中已经存在一个Leader服务器在广播消息,那么该新加入的服务器自动进入恢复模式:找到Leader服务器,并且完成数据同步。同步完成后,作为新的Follower一起参与到消息广播流程中。
如果集群中其他机器收到客户端事务请求后,那么会先转发Leader服务器,由Leader统一处理。
ZAB协议中涉及的二阶段提交和2pc有所不同。在ZAB协议的二阶段提交过程中,移除了中断逻辑,所有Follower服务器要么正常反馈Leader提出的事务Proposal,要么就抛弃Leader服务器。ZAB协议中,只要集群中过半的服务器已经反馈ACK,就开始提交事务了,不需要等待集群中所有的服务器都反馈响应。这种模型是无法处理Leader服务器崩溃退出而带来的数据不一致问题的,因此在ZAB协议中添加了另一个模式,即采用崩溃恢复模式来解决这个问题。此外,整个消息广播协议是基于具有FIFO特性的TCP协议来进行网络通信的,因此能够很容易保证消息广播过程中消息接受与发送的顺序性。
在整个消息广播过程中,Leader服务器会为每个事务请求生成对应的Proposal来进行广播,并且在广播事务Proposal之前,Leader服务器会首先为这个事务分配一个全局单调递增的唯一ID,我们称之为事务ID(即ZXID)。由于ZAB协议需要保证每一个消息严格的因果关系,因此必须将每一个事务Proposal按照其ZXID的先后顺序来进行排序与处理。
在消息广播过程中,Leader服务器会为每一个Follower服务器各自分配一个单独的队列,然后将需要广播的事务Proposal依次放入这些队列中,并且根据FIFO策略进行消息发送。每一个Follower服务器在接受到这个事务Proposal之后,都会首先将其以事务日志的形式写入到本地磁盘中去,并且在成功写入后反馈给Leader服务器一个ACK响应。当Leader服务器接收到超过半数Follower的ACK响应后,就会广播一个Commit消息给所有Follower服务器以通知其将事务进行提交,同时Leader自身也会完成事务的提交,而每一个Follower服务器收到Commit消息之后,也会完成对事务的提交。
崩溃恢复:在正常情况下运行非常良好,一旦Leader出现崩溃或者由于网络原因导致Leader服务器失去了与过半Follower的联系,那么就会进入崩溃恢复模式。为了程序的正确运行,整个恢复过程后需要选举出一个新的Leader,因此需要一个高效可靠的选举方法快速选举出一个Leader。
消息广播:类似一个两阶段提交过程,针对客户端的事务请求, Leader服务器会为其生成对应的事务Proposal,并将其发送给集群中的其余所有机器,再分别收集各自的选票,最后进行事务提交。
启动过程或Leader出现网络中断、崩溃退出与重启等异常情况时。
当选举出新的Leader后,同时集群中已有过半的机器与该Leader服务器完成了状态同步之后,ZAB就会退出恢复模式。
写入节点后的数据,立马就能被读到,这是错误的
。zk 写入是必须通过 leader 串行的写入,而且只要一半以上的节点写入成功即可。而任何节点都可提供读取服务。例如:zk,有 1~5 个节点,写入了一个最新的数据,最新数据写入到节点 1~3,会返回成功。然后读取请求过来要读取最新的节点数据,请求可能被分配到节点 4~5 。而此时最新数据还没有同步到节点4~5。会读取不到最近的数据。如果想要读取到最新的数据,可以在读取前使用 sync 命令。zk启动节点不能偶数台,这也是错误的
。zk 是需要一半以上节点才能正常工作的。例如创建 4 个节点,半数以上正常节点数是 3。也就是最多只允许一台机器 down 掉。而 3 台节点,半数以上正常节点数是 2,也是最多允许一台机器 down 掉。4 个节点,多了一台机器的成本,但是健壮性和 3 个节点的集群一样。基于成本的考虑是不推荐的而在我们的zookeeper中保证数据一致性用的是ZAB协议。通过这个协议来进行ZooKeeper集群间的数据同步,保证数据的一致性。
我们先来简单看一下zab协议是工作流程(图):
ZooKeeper写数据的机制是客户端把写请求发送到leader节点上,如果发送的是follower节点,follower节点会把写请求转发到leader节点,leader节点会把数据通过proposal请求发送到所有节点,包括自己,所有到节点接受到数据以后都会写到自己到本地磁盘上面,写好了以后会发送一个ack请求给leader,leader只要接受到过半的节点发送ack响应回来,就会发送commit消息给各个节点,各个节点就会把消息放入到内存中,放内存是为了保证高性能,该消息就会用户可见了。
那这个时候,若ZooKeeper要想保证数据一致性,就需考虑如下两个情况
如果是Leader故障,事务型请求未提交的情况,也就是只在Leader服务器上提出但没有提交的操作的操作需要丢弃掉。因为实际上它只是完成了第一阶段。
Leader故障,事务型请求已提交的情况,也就是已在Leader服务器上提交的操作最终被所有的服务器节点都提交。等后面讲到ZAB协议,概念就更清晰了,在某个节点提交应用成功,那这个节点的zxid肯定更大,决定它会被选举为新的leader。然后再进行同步。
好,接下来我们来看一下Leader的选举过程,那么对于Zookeeper集群来说,当出现以下两种情况的时候,就需要进行Leader选举:
然后整个Leader选举的过程,就是一个投票的过程,然后当某一个节点,它接收到超过半数节点的选票的话,就可以认为它当选Leader了。但是这里要投给谁,其实还是有讲究的。
ZooKeeper集群选举里面,当一个节点要决定这个选票要投给谁的时候,会根据两个重要的ID来进行判断:
Leader故障,事务型请求已提交
】而在ZooKeeper里面,有个轮次的概念,就是每一次投票它其实是有时间限制的syncLimit:集群中的follower服务器与leader服务器之间请求和应答之间能容忍的最多心跳数(tickTime的数量)。,如果当前投出去的票,没有收到反馈,那么在等待一段时间后,就当作超时处理,这个时候就可以发起一下轮的投票,诶但是有可能,上一轮的反馈它是因为网络延迟的问题,这个时候才收到,那么因为现在已经是开始了下一轮了,所以这个也只能当做无效选票处理了。
好 有了这些选举的标准和规则之后,就可以来开始选举了,选举算法:
假设一个 Zookeeper 集群中有5台服务器,id从1到5编号,并且它们都是尚未启动的,没有历史数据。
服务器1启动
服务器1启动,发起一次选举,服务器1投自己一票,此时服务器1票数一票,不够半数以上,要大于等于3票,选举无法完成。 所以,这个时候对投票结果:服务器1为1票。
服务器1状态保持为LOOKING。
服务器2启动
服务器2启动,也发起一次选举,服务器1和2分别投自己一票,此时服务器1发现服务器2的服务id比自己大,更改选票投给服务器2。 所以投票结果:服务器1为0票,服务器2为2票。
服务器1,2状态保持LOOKING
服务器3启动
服务器3也启动了,同时也发起一次选举,服务器1、2、3先投自己一票,然后因为服务器3的服务器id最大,两者更改选票投给为服务器3;
这时候,投票结果为:服务器1为0票,服务器2为0票,服务器3为3票。此时服务器3的票数已经超过半数,服务器3当选Leader。
服务器1,2更改状态为FOLLOWING,服务器3更改状态为LEADING
服务器4启动
服务器4启动,也加入进来,发起一次选举,此时服务器1,2,3已经不是LOOKING 状态,不会更改选票信息。交换选票信息结果:服务器3为3票,服务器4为1票。此时服务器4服从多数,更改选票信息为服务器3。
服务器4并更改状态为FOLLOWING。
服务器5启动
与服务器4一样投票给3,此时服务器3一共5票,服务器5为0票。
服务器5并更改状态为FOLLOWING。
最终的结果:
服务器3是 Leader,状态为 LEADING;其余服务器是 Follower,状态为 FOLLOWING。
好,这就是服务器初始化启动时候的选举过程。
那么,我们继续来看一下第二种选举过程,服务器运行期间 Leader 故障。
我们来看一下这个场景:
在 Zookeeper运行期间 Leader 和 follower 各司其职,当有follower 服务器宕机或加入不会影响 Leader,但是一旦 Leader 服务器挂了,那么整个 Zookeeper 集群将暂停对外服务,会触发新一轮的选举。
运行期选举
运行期选举与初始状态投票过程基本类似,大致可以分为以下几个步骤:
- 状态变更。Leader 故障后,剩下的follower节点因为跟Leader联系不上了,这个时候它会将自己的服务器状态变更为LOOKING,然后开始进入Leader选举过程。
- 每个Server会发出投票。
- 接收来自各个服务器的投票,如果其它服务器的数据比自己的新会改投票。
- 处理和统计投票,每一轮投票结束后都会统计投票,超过半数即可当选。
- 改变服务器的状态,宣布当选
初始状态下服务器3当选为Leader,假设现在服务器3故障宕机了,此时每个服务器上zxid可能都不一样,server1为10,server2为12,server4为10,server5为10
好,这里我们以这张图来说明比较好容易理解点:
1)第一次投票,每台机器都会将票投给自己。
2)接着每台机器都会将自己的投票发给其它机器,如果发现其它机器的zxid比自己大,那么就需要改投票重新投一次。比如server1 收到了三张票,发现server2的xzid为102,pk一下发现自己输了,后面果断改投票选server2为老大,同理,因为从图中很明显看到服务器2点xzxid是最大的,所以最后是服务器2当选leader服务器。
简单来将,通常哪台机器服务器上的数据越新,那么越有可能成为leader,原因也很简单,数据越新,那么它的zxid也就越大,也就越能够保证数据的恢复。如果集群中有几个相同的最大的zxid,那么服务器id较大的服务器成为leader。
在选出 Leader 之后,zk 就进入状态同步的过程。其实就是把最新的 zxid 对应的日志数据,应用到其他的节点中。此 zxid 包含 follower 中写入日志但是未提交的 zxid 。称之为服务器提议缓存队列 committedLog 中的 zxid。
同步会完成三个 zxid 值的初始化。
peerLastZxid
:该 learner 服务器最后处理的 zxid。 minCommittedLog
:leader服务器提议缓存队列 committedLog 中的最小 zxid。 maxCommittedLog
:leader服务器提议缓存队列 committedLog 中的最大 zxid。 系统会根据 learner 的peerLastZxid
和 leader 的minCommittedLog
,maxCommittedLog
做出比较后做出不同的同步策略
场景:peerLastZxid
介于minCommittedLogZxid
和maxCommittedLogZxid
间
此种场景出现在,上文提到过的,Leader 发出了同步请求,但是还没有 commit 就 down 了。 leader 会发送 Proposal 数据包,以及 commit 指令数据包。新选出的 leader 继续完成上一任 leader 未完成的工作。
例如此刻Leader提议的缓存队列为 0x20001,0x20002,0x20003,0x20004,此处learn的peerLastZxid为0x20002,Leader会将0x20003和0x20004两个提议同步给learner
此种场景出现在,上文提到过的,Leader写入本地事务日志后,还没发出同步请求,就down了,然后在同步日志的时候作为learner出现。
例如即将要 down 掉的 leader 节点 1,已经处理了 0x20001,0x20002,在处理 0x20003 时还没发出提议就 down 了。后来节点 2 当选为新 leader,同步数据的时候,节点 1 又神奇复活。如果新 leader 还没有处理新事务,新 leader 的队列为,0x20001, 0x20002,那么仅让节点 1 回滚到 0x20002 节点处,0x20003 日志废弃,称之为仅回滚同步。如果新 leader 已经处理 0x30001 , 0x30002 事务,那么新 leader 此处队列为0x20001,0x20002,0x30001,0x30002,那么让节点 1 先回滚,到 0x20002 处,再差异化同步0x30001,0x30002。
peerLastZxid
小于minCommittedLogZxid
或者leader上面没有缓存队列。leader直接使用SNAP命令进行全量同步
欢迎大家学习!
主页:大能老师
实战课:2022全新版!Java分布式架构设计与开发实战