寻找一种易于理解的共识算法(In Search of an Understandable Consensus Algorithm)这篇文章提出了Raft算法,这是一种容易被理解的分布式共识算法,它开篇就描述了Raft的证明和Paxos等价,并且详细描述了算法的实现逻辑。可以说,Raft就是为了工程化而诞生的算法。
什么是共识
在我们讨论Paxos或者Raft算法的时候,通常会采用“一致性”等词汇来描述算法。但实际上,一致性和共识在计算机领域是有着不同含义的词语。
- 共识(Consensus):指所有节点就某项操作(如选leader、原子事务提交、日志复制、分布式锁管理等)达成一致的实现过程。
- 一致性(Consistency):描述多个节点的数据是否一致,关注数据最终到达稳定状态的结果。
在分布式系统中,节点故障是不可避免的,但部分节点的故障不应该影响系统整体状态。通过增加节点数量,依据“少数服从多数”原则,只要多数节点(N/2 + 1)达成一致,就可以用“多数派”状态代表整体状态。这种依赖于多数节点实现容错的机制成为 Quorum 机制。
我现在对这个机制举一些例子,以便理解:
当集群有3个节点时,最多容忍1[3-(3/2+1)]个节点panic
当集群有4个节点时,最多容忍1[4-(4/2+1)]个节点panic
所以我们一般在设置集群节点数的时候,会刻意设置成:2p + 1个节点(其中p代表最大允许的panic节点数量)。基于 Quorum 的机制,通过“少数服从多数”协商机制达成一致的决策,从而对外表现为一致的运行结果。这一过程被称为节点间的“协商共识”。一旦解决共识问题,便可提供一套屏蔽内部复杂性的抽象机制,为应用层提供一致性保证,满足多种需求。
领导者(Leader)选举
在了解选举机制之前,我们需要了解Raft算法中不同节点所扮演的三类角色,领导者(Leader)、跟随者(Follower)、候选者(Candidate)。
- 领导者(Leader):负责所有客户端的请求,将请求转化为"日志"复制到其他节点,不断地向所有节点广播心跳信息(这一步的目的是向所有从节点说明自己的运行情况)
- 跟随者(Follower):接收并处理领导者的消息,并向领导者反馈日志的写入情况(这一步的目的是让领导者能够了解到哪些日志已经被半数以上的节点接受)。并且当跟随者发现领导者的心跳超时时,它会自荐为候选者(具体做法是投自己一票)。
- 候选者(Candidate):候选者这个角色是跟随者成为领导者的过渡角色,在跟随者变成候选者的时候,他会向所有节点广播请求投票,而这个时候所有接收到投票请求的节点会对比请求中附带的信息与自己具有的信息决定是否对该候选者投票。若候选者赢得多数选票,那么它就会晋升为领导者。
并且,在Raft算法中领导者存在着任期(term)的概念,该任期是一个递增的数字,该数字贯穿于Raft算法的选举、日志复制和一致性维护过程中。对于任期的作用,我将从这三个功能中任期的作用进行说明:
- 选举过程:任期确保了领导者的唯一性。在一次任期内,只有获得多数选票的节点才能成为领导者。
- 日志一致性:任期号会附加到每条日志条目中,帮助集群判断日志的最新程度。
- 冲突检测:通过比较任期号,节点可以快速判断自己是否落后,并切换到跟随者状态。
选举过程如下图所示:
具体而言,选举分为3步:
- 跟随者发现超时后,就会触发选举(任期+1),并且自荐为领导者(此时它为候选者)以及向所有节点广播请求投票(这一步通常是通过RPC请求实现的,我们称为RequestVote RPC)。
- 其他节点在收到请求后如果自身还未投票,则会对比自己最后一条日志的任期号和日志索引,如果发现候选者的任期号比该日志的任期号大并且候选者发送的日志索引大于等于该条日志的索引,则直接投票。
- 如果候选者获得大多数投票(),则成为领导者,同时将消息广播给所有节点(结束正在进行的选举)以及启动定期发送心跳的任务。
但在这3步中,会发生一些情况:
- 如果选举超时,则会重新开始一个新的选举。
- 如果一旦有节点接收到了心跳,并且日志索引是同步的而且任期更高,则会直接变成跟随者。(这一步是为了在网络分区后恢复正常时,分区中的节点能够恢复工作)
你一定会发现,这其实有个问题,如果每一个节点超时时间都是一模一样的,那岂不是每个节点都会自荐导致选举失败?是的,所以我们必须避免所有的节点有着相同的超时时间,而这个解决方法非常简单,每个节点的超时时间都是一个固定数+随机数,这样就能保证超时时间的不同。
日志复制与复制状态机
一旦选出一个公认的领导者,那领导者就会顺理成章地承担起处理系统发生的所有变更,并将变更复制到所有跟随者节点的职责。Raft算法通过复制状态机模型保证每个节点的运行是一致的,具体地说,每条日志保存的是“索引、任期、指令”这些关键信息。
- 指令:表示客户端请求的具体操作内容,也就是待“状态机”执行的操作。
- 索引值:这是日志条目的索引,用于标记该日志是系统中的哪一条日志,它是一个单调递增的数字。
- 任期号:它标志着日志是在哪一个任期下写入的,它与索引值共同标记了一个确定的日志,可以用于解决“脑裂”或者日志不一致的问题。(脑裂:存在着多个领导者节点的分布式系统)
上述提到的日志模型的示意图如下:
在Raft算法中,领导者会通过广播消息(我们称为:AppendEntries PRC)将日志条目复制到所有的跟随者。AppendEntries RPC发送的数据大致如下:
{
"term": 5, // 当前任期
"leaderId": "1", // 标记着系统中哪一个节点是Leader
"prevLogIndex": 8, // 前一条日志条目的索引,从节点用该数据确定自己是否能够接收这条日志
"prevLogTerm": 4, // 前一条日志条目的任期,作用同prevLogIndex
"entries": [
{"index": 9, "term": 5, "command": "set x=4"}
], // 当前要复制的日志条目, 是一个数组,说明可以做批处理,这点很重要
"leaderCommit": 7, // 当前整个系统中为"已提交"状态的日志条目索引号(这个commit状态需要由leader决定)
}
整个系统复制日志的过程如下图所示:
- 若当前节点非领导者,则将请求转发至领导者。
- 领导者接收到请求后,会做两件事:
- 将请求转为日志条目,写入本地存储系统,并且将该日志状态标记为"未提交"
- 通过AppendEntries RPC广播到所有节点
- 跟随者接收到日志后,验证自身当前是否能够接收该日志(通过对比prevLogIndex、prevLogTerm字段)。若验证通过,则接收该日志,并发送确认响应。若不通过,则通知领导者,领导者会发送更早之前的日志。
- 领导者确认日志条目已经成功复制到多数节点后,就标记该日志状态为"已提交",并向上层(调用方)返回结果。Raft算法只保证已提交日志的一致性,并且执行这个指令,将指令应用到状态机。
不过返回结果给调用方并不意味着日志复制过程已经完全结束,只能认为该日志已经被大多数节点应用,不排除少部分节点还没接收。
接下来我们来讨论日志复制的另一种情况。在这种情况下,只有follower1成功追加日志,follower2因为没有通过验证,所以追加失败。要进行验证是因为我们需要保证所有日志应用的顺序一致,才能保证所有状态机状态一致,否则就会导致各个节点的状态不一致。
那么当follower2没有通过验证之后,日志应该如何继续进行更新呢?实际上在Raft算法中是这样解决的:当follower2没有通过验证,则会返回失败响应,并且指明自身的日志中与领导者日志不一致的部分。失败响应示例如下:
{
"success": false,
"term": 4,
"conflictIndex": 4, // 表示发生缺失的日志索引,Follower 的日志中最大索引为 3,所以缺失的索引是 4。
"conflictTerm": 3//缺失日志的“上一个有效日志条目”的任期号
}
当领导者收到这个回复后,自然就知道接下来应该传给该follower哪些日志,这样就能逐步修复日志的不一致问题,直至同步完全。
并且顺带一提,你可能也想到了,对于一个系统来说,日志肯定是会很多的,如果一个follower缺失的节点太多了,修复不一致问题的成本会很高,而且领导者如果保存了很多的日志,那岂不是很耗内存?
是的,这个问题完全正确,在实际的Raft算法使用中,领导者并不会全量保存日志,而是会维护一个环状的日志条目数组。这样做的话,对内存的消耗就会十分的稳定。但是这样的话,修复不一致问题就没有日志可以用了?其实解决方法也很简单,committed状态的日志会被直接应用到状态机中,那么领导者其实可以直接将当前状态机的状态直接复制到跟随者,这样就能够快速的修复一致了。
成员变更
在之前的所有内容中,我们假定了集群节点数量固定,然而在生产环境中,集群通常需要进行节点变更(例如:扩容和缩容)。为了保证集群的可用性,肯定是不能通过关闭集群并且更新配置后重新启动集群的方式来实现变更的。
在讨论如何实现成员动态变更之前,我们需要先搞明白 Raft 集群中“配置”(configuration)的概念。
- 配置:配置即是说明集群由哪些节点组成。例如一个集群有三个节点(Server1、Server2、Server3),那么该集群的配置就是[Server1、Server2、Server3]
如果把“配置”当作Raft中的“特殊日志”,这样一来,成员动态变更需求就直接转化为一个日志一致性的问题了。但需要注意的是,各个节点中的日志应用到状态机是一个异步的过程,不可能同时操作。这个情况下应用配置日志就很容易出现脑裂问题。
举个具体例子,假设有一个由三个节点 [Server1、Server2 和 Server3] 组成的 Raft 集群,当前的配置为 。现在,我们计划增加两个节点 [Server1、Server2、Server3、Server4、Server5],新的配置为 。
由于日志提交是异步的,假设 Server1 和 Server2 比较迟钝,仍在使用老配置,而 Server3、Server4、Server5 的状态机已经应用了新配置。
- 假设 Server5 触发选举并赢得 Server3、Server4、Server5 的投票(满足 配置下的 Quorum 3 要求),成为领导者;
- 同时,假设 Server1 也触发选举并赢得 Server1、Server2 的投票(满足 配置下的 Quorum 2 要求),成为领导者。
那集群就出现了脑裂问题,同一个日志索引可能会对应不同的日志条目,最终导致集群数据不一致。
上述问题的根本原因在于,成员变更过程中形成了两个没有交集的 Quorum,即 [Server1, Server2] 和 [Server3, Server4, Server5] 各自为营。
Raft 的论文中,对此提出过一种基于两阶段的“联合共识”(Joint Consensus)成员变更方案,但这种方案实现较为复杂,Diego Ongaro 后来又提出一种更为简化的方案 —— “单成员变更”(Single Server Changes)。该方案思想的核心是,既然同时提交多个成员变更可能引发问题,那么每次只提交一个成员变更,需要添加多个成员,就执行多次单成员变更操作。这样问题就解决了。
单成员变更方案很容易穷举所有情况,如下图所示:
穷举奇偶数集群下的节点添加/删除情况。如果每次只操作一个节点,的Quorum和的Quorum一定存在交集。交集节点只会进行一次投票,要么投票给要么投给。因此不会出现两个符合条件的Quorm,也就避免了脑裂。
目前,绝大多数的Raft算法实现的系统都采用了单节点变更方案,证明了该方法广泛得到了开发者的青睐。