1. 什么是Raft算法?
Raft 是英文”Reliable、Replicated、Redundant、And Fault-Tolerant”(“可靠、可复制、可冗余、可容错”)的首字母缩写。Raft算法是一种用于在分布式系统中实现共识的一致性算法。共识的目标是确保在分布式系统中的节点之间就某个值达成一致,这对于保障系统的可靠性和一致性至关重要。Raft算法由Diego Ongaro和John Ousterhout于2013年提出,它以简洁易懂的设计理念和算法流程,成为分布式系统中共识问题的热门解决方案。
2. 三种角色(身份/状态)
在Raft算法中,分布式系统的节点可以处于三种不同的角色/状态:
- Leader(领导者):负责处理所有客户端请求,并决定日志的复制和提交。一个Raft集群只能有一个Leader。
- Follower(追随者):在没有Leader的情况下,Follower处于等待状态,接受Leader的指令。
- Candidate(候选者):在进行Leader选举的阶段,节点首先成为候选者,通过竞选过程竞争成为新的Leader。
三者的关系如下图:
3. 如何选举Leader
3.1 什么是任期
Raft算法中的term(任期)一般包含 election(选举) 和 normal operation(工作期),每个term(任期)由单调递增的 term counter(任期编号)标识,工作期可长可短也可能不存在,比如下图(摘自官网)中 Term4 的 Split Vote(平分选票),因而未成功选举 Leader(领导者),因此工作期就不存在,需要进行下一场选举:
3.2 随机超时
为了选举新的Leader,候选者需要在一个随机超时时间范围内等待响应,避免多个候选者同时发起选举。如果在超时时间内没有接收到有效的Leader心跳,节点会成为候选者并发起新的选举,避免多Candidate选举带来的性能问题,随机超时包含2层含义:
1.Follower(跟随者)等待 Leader(领导者)心跳信息超时的时间间隔是随机的;
2.Candidate(候选人)等待选举超时的时间间隔是随机的,也就是在一个随机时间间隔内,Candidate(候选人)没有赢得 major(大多数)选票,选举就无效,Candidate(候选人)需要发起新一轮的选举;
3.3 通信方式
Raft中的通信是通过RPC(远程过程调用)实现的,节点之间通过RPC进行消息传递。
这里包含三种类型的 RPC:
- RequestVote RPCs:由 Candidate(候选人) 在选举过程中发出
- AppendEntries RPCs:由 Leader(领导者) 发出,用来做日志复制和提供心跳机制
- Snapshot RPCs:当 Follower日志落后 Leader太多,就会以 parallel(并行)的方式发送快照 RPC请求,帮助Follower快速同步日志
3.4 选举核心流程
- 当节点启动时,它处于Follower状态,等待接收来自Leader的心跳消息。
- 如果Follower在超时时间内没有收到心跳消息,它会转变为Candidate状态,发起一次选举。
- 在选举中,Candidate会向其他节点发送请求投票的消息。
- 其他节点收到请求后,会对候选者进行投票,可以投给自己,也可以投给其他候选者,但每个节点只能投一票。
- 如果有节点得到了多数投票,它将成为新的Leader,更新自己的任期,并向其他节点发送心跳消息,使它们转变为Follower状态。
- 如果没有任何候选者在一轮选举中获得多数票,则进入下一轮选举。
3.5 选举详解
Raft算法中的选举是基于多数投票原则,要求候选者获得超过半数的票数。这样做的目的是为了保证选出的Leader得到大多数节点的支持,从而维持系统的稳定性和一致性。
初始状态
初始状态时,每个节点的角色都是 Follower(跟随者),Term任期编号为 0(假设任期编号从0开始),并且每个节点都伴有一个随机超时( 假设节点A:100ms,节点B:150ms,节点C:180ms),如下图:
投票请求
因为节点A 的倒计时是 100ms,3 个节点中最小的,所以,节点A 最先结束倒计时被唤醒,成功晋升为 Candidate(候选人),然后将自己的 Term counter (任期编号) +1,同时为自己先投一票,再向其他的 Follower 发起 RequestVote RPC 请求投票,如下图:
投票响应
Follower(跟随者) 节点B 和 C 收到 Candidate(候选人)节点A 的 RequestVote Rpc 投票请求后,会做如下处理:
if(自己在Term任期编号1的选举中已经投过票){
忽略请求;
}else {
将选票 投给 Candidate(候选人)节点A,并且将自己的任期编号设置为1,重置自己的随机超时;
}
这里假设节点B和C在任期编号为 1 的选举中没有投过票,所以会把选票投给节点A,并且把自己的任期编号设置为 1,重置自己的随机超时,交互如下图:
投票结束
Candidate(候选人)节点A 在任期编号为 1 的选举内赢得了大多数的选票,成为本任期的 Leader(领导者),为了维持自己的 Leader(领导者) 地位,Leader(领导者)节点A 需要不间断的给 Follower(跟随者) 节点B和C 发送心跳,告诉他们自己还存活,让 节点B和C 重置 随机超时,防止节点B和C重新发起投票,整体交互如下图:
到此,一个完整的 Leader选举过程描述结束,该流程是不是和我们读书时代的选班长有异曲同工之妙?
看完上面的选举描述,不知道你会不会产生这样的疑问:假如集群中有 2个或者多个节点同时发起投票,整个过程会怎样了?
多个 Candidate问题
在上述 Leader选举的描述中我们可以发现,每个节点都有一个随机超时,因此节点被唤醒是随机的,这样大大降低了多个节点在同一时刻被唤醒成为 Candidate(候选人) 的概率, 但是小概率的事件不代表不发生,假如有 2个节点同时被唤醒,整个 Leader选举流程会怎样?
这里我们假设节点A和B的随机超时都是 100ms,这样两个节点就会同时被唤醒,成为 Candidate(候选人),首先 节点 A 和 B 会分别为自己投一票,然后向其他节点发起投票请求,如果节点A的投票请求先于节点B到达节点C, 最终,节点A 获取 2张选票,节点B 获取 1张选票,因此,节点A 获取大多数选票成为 Leader(领导者),节点B 的角色会从 Candidate 恢复成 Follower,整个交互如下图:
Split Vote 平票问题
上述描述的都是基于”奇数个节点的集群”,如果集群中的节点是偶数个,结果又是怎样了,为了更好的说明问题,此处采用 4个节点的集群进行说明:
假设节点 A 和 B 的随机超时都是 100ms,这样两个节点就会同时被唤醒成为 Candidate(候选人),首先节点 A 和 B 会分别为自己投一票,然后再向其他节点请求投票,因为节点 A 和 B 已为自己投票, 根据同一任期内最多投 1票的约束,节点 A 和 B 会拒绝给对方投票, 最终 节点 A 和 B 各自只能获取 2票,这里出现了一个经典的问题:Split Vote(平分票数),该如何处理呢?
在这种”平分选票”未选出 Leader(领导者)的情况下,所有节点会全部恢复成 Follower(跟随者) 状态,重新设置随机超时时间,准备下一轮的选举。不过需要提醒的是选举的过程越长越增加了集群不可用的时长,因此要尽量避免 Split Vote问题。整个交互如下图:
脑裂问题
上文我们一直在强调:一个集群中最多只能有一个 Leader,假如在一个集群内部发生网络分区,形成了 2个小分区,会不会出现 2个Leader?如果有,该如何解决?
这里以[A,B,C,D,E] 5个节点组成的集群为例,假如原集群的Leader是节点A,如果内部出现了网络问题,节点[A,B]为一个分区,节点[C,D,E]为一个分区,节点A为原来的 Leader,节点C获得[C,D,E]分区的所有选票也成为 Leader,因此一个集群产生了 2个Leader,这就是我们常说的”脑裂问题”。
Raft是如何解决这种脑裂问题?
答案:当网络恢复正常后,两个分区的 Leader都会向其他节点发送心跳,当节点A 收到 节点C的心跳之后,发现C的任期比自己大,因此节点A恢复成Follower,因此整个集群就恢复成只有一个 Leader的状态。
整体交互如下图:
上文在对任期的描述时讲到,任期通常包含 Leader选举和 normal operation(工作期)两部分,Leader选举过程已分析完成,接下来分析 normal operation(工作期)。
4. 如何复制日志
4.1 什么是日志条目
在Raft算法中,每个节点维护着一份日志,其中包含了系统中所有状态变更的记录。每一次状态变更被称为一个日志条目。
- 索引值:日志条目对应的整数索引值,它是用来标识日志条目的,是一个连续单调递增的整数;
- 任期编号:创建这条日志条目的 Leader(领导者)的任期编号;
- 指令:客户端请求指定的、状态机需要执行的指令;
4.2 日志复制过程
Raft算法通过日志的复制来实现共识。Leader接收客户端的请求,并将请求转换为日志条目,然后将这些日志条目复制到其他节点。当大多数节点都成功地复制了这些日志条目后,Leader可以提交这些日志条目,并向客户端返回成功响应。
- Leader(领导者) 接收到客户端请求后,创建一个 new entry(新日志条目),并 appends(追加)到本地日志中(Leader的日志条目为uncommitted状态);
- Leader(领导者) 以同步的方式向所有 Follower(跟随者) 发送 AppendEntries RPC 日志条目复制请求(Follower的日志条目为uncommitted状态);
- Leader(领导者) 得到 major(大多数) Follower(跟随者)的复制成功的响应后,Leader(领导者)将日志条目应用到它的状态机中(Leader的日志条目为committed状态);
- Leader(领导者) 将执行的结果返回给客户端;
- Leader(领导者) 通过心跳或新的 AppendEntries RPC 将提交了某条日志条目的状态同步给Follower(跟随者),Follower(跟随者)将日志条目状态同步到本地状态机中(Follower的日志条目为committed状态);
- 如果 Follower(跟随者)出现崩溃、运行缓慢、网络丢包,Leader(领导者)会不断地重试 AppendEntries RPCs(即使已经对客户端作出了响应)直到所有的 Follower(跟随者)成功存储了所有的日志条目;
通过上述日志的复制过程可以看出日志的提交过程有点类似两阶段提交(2PC),不过与2PC的区别在于,Leader只需要 majority(大多数)节点的回复即可,只要过半节点处于工作状态则系统就是可用的。 然而,这种是一种比较理想的状态,假如在复制日志的过程中,出现了进程崩溃、服务器宕机等问题,就可能导致日志不一致,Raft 会如何处理呢?
4.3 日志的一致性
在Raft算法中,所有节点的日志必须保持一致。这意味着,如果一个日志条目在某个节点被提交,那么这个日志条目也必须在所有节点上被提交。通过使用多数投票的方式选出Leader,并确保Leader复制的日志达到大多数节点,Raft算法保证了日志的一致性。
图中包含了 1个 Leader 和 1个 Follower的所有日志条目,整个复制过程分以下几个步骤(步骤1-4是一致性检查机制):
1.Leader(领导者) 当前最大日志条目索引是 10,因此 Leader(领导者) 会通过日志复制 RPC 消息将 index=9 的日志发送给 Follower(跟随者),Follower(跟随者) 判断自己没有index=9的日志,因此拒绝更新日志并响应 Leader 失败信息。
2.Leader(领导者) 收到 Follower(跟随者) 的失败响应后,执行index-1,将 index=8的日志发送给 Follower(跟随者),Follower(跟随者) 判断自己index=8日志条目信息为term=4,x->7,和 Leader(领导则)日志条目不相同 ,因此再次拒绝更新,响应 Leader失败信息。
3.Leader(领导者) 收到 Follower 的失败响应后,重复操作上述过程,直到 index=6;
4.Leader(领导者) 将 index=6的日志发送给 Follower(跟随者),Follower判断自己 index=6 日志条目中的 term和command 和 Leader相同,响应日志复制成功。因此,Leader(领导者)就知道在 index=6「term=3,y->1」日志条目位置,Follower(跟随者)的日志条目与自己相同。
5.Leader(领导者) 通过日志复制 RPC消息,强制 Follower(跟随者)复制并更新覆盖 index=6之后的所有日志条目(不一致的日志条目),达到 Follower 与 Leader的日志保持一致;
6.集群中多个 Follower(跟随者),只需要重复上述过程,就能最终实现了集群各节点日志的一致。
5. 节点变更问题
节点变更是分布式系统很常见的问题,比如,服务器扩容需要增加机器,服务器缩容需要减少机器,出现节点故障需要变更机器等等。 在Raft算法中,为了描述节点变更,作者使用 Configuration(配置) 这个重要的概念,可以把”配置”理解为集群中所有节点地址信息的集合。比如节点 A、B、C 组成的集群,那么集群的配置就是[A, B, C]集合。
集群节点的变更可能会导致集群分裂,出现 2个 Leader(领导者),如下图,集群[A,B,C] 增加节点D和E,如果发生网络分区,形成 [A,B] 和 [C,D,E] 两个小分区, 节点A 获取原配置的大多数的选票成为 Leader(领导者),节点E 获取新配置的大多数选票成为 Leader(领导者),出现了 2个 Leader(领导者),违背了Raft算法最多一个 Leader(领导者)的原则。如下图:
5.1 联合共识
在Raft算法中,当节点需要进行变更时,比如加入新节点或移除现有节点,可以通过联合共识来保证变更的一致性。新节点必须和大多数节点达成共识,才能成为集群的一部分。
joint consensus(联合共识)是指 集群从旧配置变更成新配置的过程中使用了一个过渡的中间配置,联合共识配置是新旧配置的并集,此方法允许一次性向集群中插入多个节点而不会出现脑裂等 (safety) 问题,并且整个集群在配置转换的过程中依然能够接收用户请求,从而实现配置切换对集群调用方无感知, 因为在联合共识阶段,集群会出现新旧两种配置,为了更好的工作,联合共识做了如下的约束:
- 约束1. 新旧配置的日志会复制给新、旧配置的所有节点;
- 约束2. 新、旧配置的任何节点都可能成为 Leader(领导者);
- 约束3. 选举和日志复制阶段需要在新老配置上面都超多半数才能被提交生效;
下面摘取了Raft官方关于联合共识阶段配置变更的时间线描述图:
其中,虚线代表已创建但是未提交的配置项,实线代表最新的已提交的配置项。
首先,Leader(领导者) 创建 Cold,new 日志条目,并复制到新旧配置中的大多数,此时所有的日志条目都需要被联合共识。
然后,Leader(领导者) 创建 Cnew 日志条目,并复制到 Cnew(新配置)中的大多数。因此,旧配置和新配置不会存在可以同时做出决策的时间点。
鉴于此图比较晦涩难懂,因此我们以一个实例来进行讲述,假设集群有A、B、C三个节点,需要往集群中添加 D、E两个节点,看看联合共识是如何工作的。
首先, Leader(领导者) 向所有 Follower发送一条配置变更日志 Cold,new[A,B,C,D,E],告知集群要新增两个节点[D,E]。根据约束1,日志会被复制到新旧配置的所有节点。如下图:
其次,根据约束3,配置变更日志Cold,new[A,B,C,D,E] 在新旧配置中都需要大多数节点复制成功,才能被成功应用。换句话说,假设旧配置的大多数为[A,B]、新配置的大多数为[A,B,D], 那么这些节点都需要复制成功,如下图:
最后,Cold,new 被成功应用后,Leader(领导者)再发送一条新的 Cnew RPC日志复制请求,通知集群Follower(跟随者)可以使用新配置。Follower(跟随者)收到日志复制RPC后,在 Raft一致性检查机制保证下切换成新配置,Leader(领导者)因为已经处于新配置状态,所以不需要联合共识,到此,旧配置就平稳过渡到新配置,如下图:
对于新的节点D、E,Raft 会通过日志一致性检查来复制领导者的所有日志条目,从而保证它们同样能够保持日志完整性。
上文我们分析了往集群中新增2节点的流程,接下来分析上述流程为什么不会产生脑裂。我们依然假设集群产生了网络分区,形成了[A,B] 和 [C,D,E] 两个小分区:
1.假如 Leader(领导者)节点A 未发送 Cold,new RPC变更日志请求,[A,B] 分区依然是旧配置,节点A 是领导者;而[C,D,E]分区,当节点C 发起选举时,因为不知道节点D、E 的存在,无法获取到大多数节点的投票。因此两个分区只有一个 Leader(领导者) 节点A,符合预期。
2.假如 Leader(领导者)节点A 已发送 Cold,new RPC变更日志请求,此时发生了网络分区,会出现下面两种情情况:
3.如果 Cold,new 没有被大多数节点确认,那么 Leader(领导者)节点A 无法应用该配置,[A,B] 依然是旧配置对外提供服务,[C,D,E]分区,C任然是旧配置,感知不到D,E的存在吗,所以不可能成为 Leader,D或E任何一个节点获取不到大多数选票也无法成为Leader(领导者),符合预期;
4.如果 Cold,new 已经被大多数节点复制,那么 Leader(领导者)节点A 会应用该配,并向所有 Follower(跟随者)发送 Cnew RPC复制日志请求,因为网络分区导致 Cnew无法被联合共识,领导者 A 后续不会提交任何日志(在一些实现中会自动退位为跟随者);对于分区 [C,D,E] 无法 Cnew RPC复制日志请求,C 任然是旧配置无法获取到大多数选票,节点D,E无法获取到大多数选票,该分区也无法选举出 Leader(领导者)。符合预期。
5.假如 Cnew 阶段产生了分区,因为 Cold,new 已经生效,[A,B] 和 [C,D,E] 两个小分区都拿到了新配置[A,B,C,D,E],因此[A,B]分区无法获取新配置的大多数选票,无法选出新 Leader(领导者),也就不可能发生脑裂,符合预期。
尽管 joint consensus(联合共识)允许一次性向集群中插入多个节点且不会出现脑裂等问题,但由于该方法理解和实现都比较难,所以 Raft作者提出了一种改进的方法:single-server changes(单服务器变更)。
5.2 单服务器变更
单服务器变更,就是每次只能有一个节点服务器成员变更。如果需要变更多个服务器节点,则需要执行多次单服务器变更。 我们还是以图文的方式来进行解释:
假如 集群有节点A、节点B、节点C,现在需要增加 2个节点(节点D,节点E),增加的方式是先增加节点D
- 第一步,Leader(领导者)节点A 向新节点D 同步数据;
- 第二步,Leader(领导者)节点A 将新配置[A, B, C, D]作为一个日志条目,复制到新配置中所有节点(节点 A、B、C、D)上,然后将新配置的日志条目应用(Apply)到本地状态机,完成单节点变更。
同理再增加节点E:
- 第一步,Leader(领导者)节点A 向新节点E 同步数据;
- 第二步,Leader(领导者)节点A 将新配置[A, B, C, D, E]作为一个日志条目,复制到新配置中所有节点(节点 A、B、C、D、E)上,然后将新配置的日志条目应用(Apply)到本地状态机,完成单节点变更。
删除节点E:
- 第一步,先删除 节点 E;
- 第二步,Leader(领导者)节点A 将新配置[A, B, C, D]作为一个日志条目,复制到新配置中所有节点(节点 A、B、C、D)上,然后将新配置的日志条目应用(Apply)到本地状态机,完成单节点变更。
通过上述对单服务器的增加和删除可以看出,每次单服务器节点的增减,可以保证新旧集群至少存在一个交集服务器节点,这样就不会在新旧配置同时存在 2个“大多数”,从而保证集群只能有一个 Leader(领导者)。
特别注意
在作者Diego Ongaro(迭戈·安加罗) bug in single-server membership changes 的文章中特别说明了,单服务器变更的方式在串行化的方式下可以保证一个集群 只能有一个 Leader,但是在并发的、竞争可能导致多个 Leader,从而导致安全违规(脑裂)。
6. Safety
前面章节描述了 Raft 如何做 Leader Election(Leader选举) 和 Log Replication(日志复制)。然而,到目前为止所讨论的机制并不能充分地保证每一个状态机会按相同的顺序执行相同的指令。比如说,一个 Follower(跟随者) 可能会进入不可用状态,在此期间,Leader 可能提交了若干的日志条目,然后这个 Follower 可能被选举为新Leader 并且用新的日志条目去覆盖这些日志条目。这样就会造成不同的状态机执行不同的指令的情况。 对于上述问题,Raft 如何保证安全?
6.1 选举约束
- 同一任期内每个节点最多只能投票 1次,并且按照 first-come-first-served(先来先服务) 的原则
- 日志条目的传送只能从 Leader 到 Follower,Leader 从来不会覆盖本地日志中已有的日志
- Candidate(候选人) 只有获得集群中大多数选票才能成为 Leader(领导者)
- 日志完整性高的 Follower(跟随者)拒绝投票给日志完整性低的 Candidate(候选人),这里的日志指的是已复制未commit状态。也就是说,即便 Candidate(候选人)的 term 大于 Follower(跟随者)的 term,假如 Candidate(候选人) 向 Follower(跟随者)发送了一条投票RPC,如果当前消息中的term 小于 Follower(跟随者)最后一条消息的 term,则 Follower(跟随者) 拒绝给 Candidate(候选人)投票
6.2 Leader只能提交任期内的日志条目
首先我们以图文的方式来展示一个已经被存储到大多数节点的日志条目,仍然有可能会被新 Leader覆盖的场景:
- 在图A中,S1是 Leader,将index=2的日志复制给了S2,此时S1的数据还没有复制大多数节点
- 在图B中,S1宕机了,S5 从 [S2,S3,S4,S5] 获得大多数选票成为 Leader,任期编号为3,然后收到客户端的指令,将日志存放在 index=2 位置上
- 在图C中,S5宕机了,S1重启,假如S1当选为 Leader,然后S1继续将它在任期2的日志条目复制给[S2,S3,S4]成功,但是还未被提交
- 情况1:在图D中,假设S1在提交日志之前宕机,S5重启,因为S5最后日志条目上的任期为3,大于[S2,S3,S4]的任期编号2,所以S5可以得到[S2,S3,S4]大多数选票成为 Leader,然后 S5继续将它在任期3的日志条目复制到大多数节点[S2,s3,S4],因此覆盖了S1复制给[S2,S3]中 index=2处的日志
- 情况2:在图E中,S1在宕机之前把任期3的日志复制到大多数节点的index=3处,那么 S5就不可能成为 Leader,这种情况下,之前所有的日志被提交了
为了解决上图中日志被覆盖的问题,Raft 规定 Leader只能提交任期内的日志条目。
7. 实际使用
Raft算法已经在许多分布式系统中得到了广泛的应用,其中包括分布式数据库、分布式存储系统、分布式文件系统等。以下是一些实际应用场景:
- 分布式数据库:在数据库集群中,Raft算法可以用于实现数据的复制和一致性,确保所有节点的数据保持一致。
- 分布式存储系统:在分布式存储系统中,Raft算法可以用于实现数据的复制和数据一致性,确保数据的可靠性和高可用性。
- 分布式文件系统:在分布式文件系统中,Raft算法可以用于实现元数据的复制和一致性,确保文件系统的正确运行。
- 分布式计算平台:在分布式计算平台中,Raft算法可以用于协调不同的计算节点,确保任务的分发和执行的一致性。
- 分布式消息队列:在分布式消息队列中,Raft算法可以用于实现消息的复制和分发,确保消息队列的可靠性和高可用性。
8. 总结
Raft算法是一种简洁而高效的分布式一致性算法,通过引入Leader选举和日志复制的机制,确保了分布式系统的共识和一致性。它具有易于理解和实现的优点,被广泛应用于各种分布式系统中。
Raft算法的核心思想是将分布式系统的复杂问题简化为几个简单的步骤,通过选举Leader和复制日志的方式来实现共识。这种简单而有效的设计理念,使得Raft算法成为了分布式系统中一种受欢迎的共识算法。
然而,值得注意的是,Raft算法并不是解决分布式系统共识问题的唯一方案。在实际应用中,根据具体的场景和需求,还可以考虑其他的一致性算法,如Paxos算法等。