本文大量内容系转载自以下文章,有删改,并参考其他文档资料加入了一些内容:
图解:什么是Raft算法?
作者: 无敌码农
分布式一致性算法 Paxos是什么梗?
作者: 无敌码农
Raft Vs Zab
作者: 黄靠谱
本文主要讨论分布式协议,包括Paxos、Raft、Zab等。
像 2PC 和 3PC 都需要引入一个协调者的角色,当协调者 down 掉之后,整个事务都无法提交,参与者的资源都出于锁定的状态,对于系统的影响是灾难性的,而且出现网络分区的情况,很有可能会出现数据不一致的情况。有没有不需要协调者角色,每个参与者来协调事务呢,在网络分区的情况下,又能最大程度保证一致性的解决方案呢。此时 Paxos 出现了。
Paxos 算法是 Lamport 于 1990 年提出的一种基于消息传递的一致性算法。由于算法难以理解起初并没有引起人们的重视,Lamport在八年后重新发表,即便如此Paxos算法还是没有得到重视。2006 年 Google 的三篇论文石破天惊,其中的 chubby 锁服务使用Paxos 作为 chubbycell 中的一致性,后来才得到关注。
Google 的粗粒度锁服务 Chubby 的设计开发者 Burrows 曾经说过:“所有一致性协议本质上要么是 Paxos 要么是其变体”。其实也不为过,像非常有名的 Raft 算法、Zab 算法等都是基于 Paxos 的简化和改进。
Paxos 协议是一个解决分布式系统中,多个节点之间就某个值(提案)达成一致(决议)的通信协议。它能够处理在少数节点离线的情况下,剩余的多数节点仍然能够达成一致。即每个节点,既是参与者,也是协调(决策)者。
例如一个cache集群有3个节点,每个节点都可以写入。
集群内各个节点需要做数据同步,如果没有一致性算法做保证,3个节点内数据就可能混乱,例如:
Paxos 就是用来解决这个问题,保证各节点数据的绝对一致,不会混乱。
Zab协议是在Paxos协议基础上进行改进和实践。
参考Zab
Paxos把每次对数据的更改称为一个提案,就像一个委员会,其中一人发起一个提案,委员会成员对这个提案投票,票数过半的通过,其中有3个角色:
一次Paxos算法的执行实例中,只批准一个value,过程分为2个阶段:
Acceptor 如何判断是否同意提案呢?下面是整个流程。
首先需要知道,Acceptor 持有3个变量:
然后看流程图:
对照上面那个示例,使用Paxos算法后,流程可能就是这样的:
节点1收到 x=1 的请求,节点1成为Proposer,拿到提案编号1,发起提案,得到了其他节点的同意,然后发送 accept(1,1)请求,请求大家接受。
节点1顺利同意,但由于网络问题,节点2、3暂时没有收到,由于此提案没有得到超过半数的同意,所以现在没有生效。
这时节点2提出x=2的提案,顺利得到大家的同意,因为节点1已经接受了(1,1),会将其返回给节点2。
节点2收到大家的同意确认,但发现节点1的返回信息中含有已经接受的提案accept(1,1),就将其提案内容作为自己的提案,发送accept(2,1).注意这里已经改为1了,而不是2。
大家收到后,记录提案内容,返回确认信息,节点2的提案生效了。
在此之后节点2、3才收accept(1,1)请求,由于这个请求的提案编号1小于自己已经接受的提案编号2,所以不会同意,直接拒绝。
最终,3个节点都是x=1,保持一致。
为了方便理解,下面以5个节点为例。
结果是所有节点的数据一致为x。
节点1发起提案,因为网络问题,节点3最先接受,节点1、2没有接受。
然后节点5发起提案,发给节点3、4、5。
节点3一看编号比自己的大,表示同意,同时返回自己已经接受的(1,x)。
节点5收到回复后,从所有返回值中找到编号最大的值x,作为自己的提案内容,发起接受请求accept(2,x)。
节点3、4、5接到后,记录提案,提案生效。
同时,节点1、2也收到了节点1的accept请求,记录提案。
结果是所有节点的数据一致为x。
节点1发起提案,节点1、2、3同意,但因为网络问题,节点1最先接受。
在节点2、3还未接受时,节点5发起提案,发给了3、4、5。
节点3、4、5一看编号比自己的大,同意。
此时,节点3收到了节点1的accept请求,因为此时节点3自己记录的编号是2,大于节点1的编号,所以,节点3决绝了节点1的accept请求。
节点5收到大家的确认后,从返回值中没有找到已接受的提案,所以使用自己的提案内容y作为提案内容,发起accept(2,y)请求。
节点3、4、5接受,提案y生效。
节点1的提案没有得到多数同意,所以失效,节点1、2需要接受已经生效的提案。
以上是Paxos最基本的思路,如果有兴趣,可以好好看看这两篇文章:
https://segmentfault.com/a/1190000018844326
https://zh.wikipedia.org/zh-cn/Paxos算法
Paxos 是论证了一致性协议的可行性,但是论证的过程据说晦涩难懂,缺少必要的实现细节,而且工程实现难度比较高,广为人知实现只有 zk 的实现 zab 协议。然后斯坦福大学RamCloud项目中提出了易实现,易理解的分布式一致性复制协议 Raft。Java,C++,Go 等都有其对应的实现。
所有一致性算法都会涉及到状态机,而状态机保证系统从一个一致的状态开始,以相同的顺序执行一些列指令最终会达到另一个一致的状态。
以上是状态机的示意图。所有的节点以相同的顺序、相同命令处理日志,那么最终x、y、z的值在多个节点中都是一致的。
Leader 不会修改自身日志,只会做追加操作,日志只能由Leader转向Follower。
例如:
不依赖各个节点物理时序保证一致性,而是通过逻辑递增的term-id
和log-id
保证。
所有节点
启动时或接收Leader心跳超时
服务器启动时初始状态都是follower,如果在超时时间内没有收到leader发送的心跳包,则将其当前term加一然后进入candidate状态等待一段随机的时间后发起一次Leader选举
Candidate收到大多数节点的选票则转换为Leader;发现Leader或者收到更高任期的请求则转换为Follower
Leader在收到更高任期的请求后转换为Follower
图解选主过程可参考图解:什么是Raft算法
如图所示,Raft将时间分为多个 term(任期),term 以连续的整数来标识,每个 term 表示一个选举的开始。
例如Follower 节点 1。在 term1 和 term2 连接处的时间,联系不到Leader,将currentTerm编号加1,变成2,进入了到term2任期,在term2的蓝色部分选举完成,绿色部分正常工作。
当然一个任期不一定能选出Leader,那么会将currentTerm继续加1,然后继续进行选举,例如上图中的t3。
Raft 使用心跳(heartbeat)触发Leader选举。
当服务器启动时,所有节点初始化为Follower;在当选后Leader会向所有Followers周期性发送heartbeat。如果Follower在选举超时时间内没有收到Leader的heartbeat,就会等待一段随机的时间后发起一次Leader选举。
选举的原则是,每一轮选举每个选民一张选票最开始选自己,投票的请求先到且选民发现候选人节点的日志id大于等于自己的,就会投票,否者不会投票。获得半数以上的票的节点成为主节点(注意这并不是说选出来的事务id一定是最大的)。
如果没有事务id(如刚启动时),就遵循投票请求先来先投。然后Leader将最新的日志复制到各个节点,再对外提供服务。
当然除了这些选举限制,还会有其他的情况。如commit限制等保证,Leader选举成功一定包含所有的commit和log
如果一次投票未选出合适的,则会发生超时,本次term无leader选出,节点会将currentTerm继续加1,等待随机时间后重新投票选举出新的leader。
term >= currentTerm
,则自己切换为follower状态,注意:一个term周期内,每个节点最多只能投一张票,按照先到先得原则
多个follower 同时转化成了candidate,投票可能会分裂导致没有一个candidate能获得大多数投票。当此情况出现后,每个candidate将会在随机的time out后开始新的选举并将自己的term加1。那么必须采取其他措施否则这种投票僵持的结果可能会一直持续下去,因为大家都一起投票且都投自己。
比如5个节点ABCDE,leader A 挂掉后,还剩4个节点,raft协议约定,每个服务器在一个term只能投一张票,假设B,D分别有最新的日志,且同时发起选举投票,则可能出现B和D分别得到2张票的情况,如果这样都得不到大多数确认,无法选出leader。
为了避免这种情况发生,raft采用随机的选举超时时间去确保分裂投票很少并能很快解决。随机超时时间从一个固定的区间产生T,2T。
这样一来,由于每个服务器的超时时间不同,则leader挂掉后,超时时间最短且拥有最多日志的follower最先开始选主,并成为leader。一旦candidate成为leader,就会向其他服务器发送心跳包阻止新一轮的选举开始。
我们再来考虑另外一种极端的情况,假设集群因为某些原因(如网络切断)导致集群被切割为两块,有Leader节点的一块继续运行、没有Leader节点的一块则进入选举状态,并选出自己新的Leader节点,如图:
此时c、d、e三个节点组成集群选举产生了新的Leader节点e,并且选举任期更新为2,比节点b更高,同时两个Client分别向这两块集群发起了SET请求。
由于网络分区,所以节点b不能复制到大多数节点,所以一直处于Uncommit状态,而节点c因为能够复制到大多数节点所以可以正常commit。
当集群分裂问题恢复后,原先的Leader节点B收到更高任期Leader请求就会主动下台变为Follower,而A和B两个节点将回滚它们未提交的条目,并匹配新leader节点c的日志,从而实现日志在整个集群中的一致性(实际上是少数节点的集群学习多数节点的集群的leader,进而完成一致性过程)。
日志由有序编号(log index)的日志条目组成。每个日志条目包含它被创建时的任期号(term),和用于状态机执行的命令。
如果一个日志条目被复制到大多数Follower服务器上,就被认为可以提交(commit)了。
Leader选出后,就开始接收客户端的写请求。Leader把请求作为日志条目(Log entries)加入到它的日志中,然后并行的向其他服务器发起 AppendEntries RPC 复制日志条目(心跳也是用的这种机制,只不过内容为空)。
当这条日志被复制到大多数服务器上,Leader将这条日志应用到它的状态机并向客户端返回执行结果。
x=1
的请求后,会写入本地日志,然后将x=1的日志广播给所有follower。某些Followers可能没有成功的复制日志,Leader会无限的重试 AppendEntries RPC
直到所有的Followers最终存储了所有的日志条目。具体发送的内容是:(term,leaderId,prevLogIndex,prevLogTerm,leaderCommitIndex)拆分的看leader流程:
备注:
拆分的看Follower处理流程:
备注:默认情况下,如果日志不匹配,会按logIndex逐条往前推进,直到找到match的位置,有一个简单的思路是,每次往前推进一个term,这样可以减少了网络交互,尽快早点match的位置,代价是可能传递了一些多余的日志。
日志复制的本质是让 Follower 和 Leader 的已提交的日志顺序和内容都完全一样,用于保证一致性,具体的原则:
term,logIndex
都相同,那么两段日志中日志内容完全一样。如何保证
第一个原则只需要在创建 logIndex 的时候使用新的 logIndex,保证 logIndex 的唯一性,而且创建之后不去更改,那么在 leader 复制到 follower 之后,logIndex,term 和日志内容都没变。
第二个原则,在 Leader 复制给 Follower 时,要传递当前最新日志 currenTermId 和currentLogIndex,以及上一条日志 preCurrentTermId 和 preCurrentLogIndex。
follwer接收日志时,会在相同任期号和索引位置找前一条日志,如果存在且匹配,则接收日志;否则拒绝,leader会减少日志索引位置并进行重试,直到某个位置与follower达成一致。然后follower删除索引后的所有日志,并追加leader发送的日志,一旦日志追加成功,则follower和leader的所有日志就保持一致。只有在多数派的follower都响应接受到日志后,表示事务可以提交,才能返回客户端提交成功。
如下图,在 d 节点,term7,logIndex12。在给节点节点 a 同步时,发送(term7,logIndex11),(term7,logIndex12),a 节点没有找到(term7,logIndex11)的日志,会让Leader,d 节点重新发送。d 节点会重新发(term6,logIndex10)(term7,logIndex11),还是没有(term6,logIndex10)的日志,依然会拒绝同步。接着发(term6,logIndex9)(term6,logIndex10)。现在a节点有了(term6,logIndex9)。那么 leader节点就会将(term6,logIndex9) ~ (term7,logIndex11)日志内容给节点 a,节点 a 将会和节点d有一样的日志。
上图阐述了一些Followers可能和新的Leader日志不同的情况。一个Follower可能会丢失掉Leader上的一些条目,也有可能包含一些Leader没有的条目,也有可能两者都会发生。丢失的或者多出来的条目可能会持续多个任期。
leader处理日志不一致的措施:
Leader通过强制Followers复制它的日志来处理日志的不一致,Followers上的不一致的日志会被Leader的日志覆盖。
Leader为了使Followers的日志同自己的一致,Leader需要找到Followers同它的日志一致的地方,然后覆盖Followers在该位置之后的条目。
Leader会从后往前试,每次AppendEntries失败后尝试前一个日志条目,直到成功找到每个Follower的日志一致位点,然后向后逐条覆盖Followers在该位置之后的条目。
怎么找最近一致的点:
leader节点为每一个follower节点维护了一个nextIndex.当一个节点选举成为leader后,leader 为所有follower 维护的nextIndex都是leader节点日志最新index值的后一个值(即 last index + 1)
如果follower节点与leader日志不一致,AppendEntries一致性检查将在下一次AppendEntries RPC时失败。
在follower拒绝后,leader将nextIndex减1,然后再重试AppendEntries RPC。
最终nextIndex会到达一个两者日志匹配的点。找到后,删除Follower的冲突日志条目,然后追加从leader中过来的日志的AppendEntries将会成功。一旦成功,follower与leader两者日志一致,在接下来的其他term也将仍然按此逻辑进行。
Zookeeper的核心是原子广播,这个机制保证了各个Server之间的同步。实现这个机制的协议叫做Zab协议。
Paxos 虽然解决了分布式系统中,多个节点就某个值达成一致性的通信协议。但是还是引入了其他的问题。由于其每个节点,都可以提议提案,也可以批准提案。当有三个及以上的 proposer 在发送 prepare 请求后,很难有一个 proposer 收到半数以上的回复而不断地执行第一阶段的协议,在这种竞争下,会导致选举速度变慢。
所以 zookeeper 在 paxos 的基础上,提出了 ZAB 协议,本质上是,只有一台机器能提议提案(Proposer),而这台机器的名称称之为 Leader 角色。其他参与者扮演 Acceptor 角色。为了保证 Leader 的健壮性,引入了 Leader 选举机制。
ZAB协议还解决了这些问题
可参考ZooKeeper的工作原理
建立连接对象不同
sync
Zk中的读请求,直接由客户端连接的那个Node处理,不需要和leader汇报,也就是Consul中的stale模式。这种模式可能导致读取到的数据是过时的,但是可以保证一定是半数节点之前确认过的数据
为了避免Follower的数据过时,Zk有sync()方法,可以保证读取到最新的数据。可以调用sync()之后,再查询,确保所有的数据一致后再返回结果。
角色不同
日志和状态机
Zab和Raft都是同时存在 log[](还有快照技术)和状态机(内存树)的存储结构。Zab中的日志和Raft中的日志模型很像,都是超过半数节点完成复制之后,该命令才会被commit,再结合半数节点选举confirm之后的节点才有可能成为新leader,这两点保证了集群的一致性。
信息流向不同
A投票给自己
,然后B接收到A的这个消息之后,会PK A的数据,如果B更适合当leader(数据更新或者myid更大),B会归档A的这个投票,但是不会更新自己的数据,也不会广播任何消息,除非发现A的数据比B当前存储的数据更适合当leader,就更新自己的数据,且广播自己的最新的投票消息(A)。一个任期内可投票次数不同
成为leader的必要条件不同
选举成功的term数不同
ZK集群中,成为公认的leader条件更苛刻,raft模式下,只要新leader发一个命令为空的Log出来,大家就会认同这个节点为leader,但是在ZK集群中,追随leader的2种条件都很苛刻
要么recvset中半数节点的选举following投票给A,才会认可A为自己的leader
要么outofelection中半数节点都认可A为leader,自己才会认可A为自己的leader
事务操作分解不同
操作流程当中,Zab的即时性做的更好。
关于Raft的更多讨论可参考: