Raft 共识算法:让多台机器达成一致
在分布式系统中,为了提高可用性,关键数据通常会在多台机器上保存副本。一旦某台机器宕机,其他机器仍能继续提供服务。然而副本带来了一个新问题:如何保证所有副本的数据始终一致?当网络延迟、消息丢失、节点崩溃随时可能发生时,让多台机器对"数据是什么、操作顺序是什么"达成一致,就是分布式共识问题。
Raft 是目前应用最广泛的共识算法之一。它在设计之初就把易于理解作为首要目标,在保证正确性的前提下,将共识问题拆解为几个相对独立的子问题。本文介绍 Raft 的基本模型、领导者选举、日志复制、安全性保证,以及成员变更和日志压缩等工程问题。
一、复制状态机模型
Raft 基于复制状态机(Replicated State Machine)模型。其核心思想是:
- 每个节点都维护一个状态机,例如一个键值存储;
- 每个节点都维护一份日志,日志中按顺序记录了对状态机的操作指令;
- 如果所有节点的日志内容和顺序完全相同,并且状态机是确定性的,那么所有节点按顺序执行日志后,必然得到相同的状态。
这样,"让所有副本数据一致"的问题就转化为"让所有节点的日志一致"的问题。共识算法的职责,就是保证日志的一致性。
在这个模型下,只要集群中超过半数的节点正常工作并能够相互通信,系统就能继续提供服务。例如,一个 5 节点的集群可以容忍 2 个节点故障,一个 3 节点的集群可以容忍 1 个节点故障。
二、从 Paxos 到 Raft
在 Raft 出现之前,Paxos 长期被视为分布式共识的标准算法。Paxos 在理论上严谨而优雅,但以难以理解著称。原始论文主要描述了如何就单个值达成一致,而实际系统需要对一连串操作达成一致,从单值 Paxos 扩展到完整系统的过程中,有大量细节需要工程实现者自行补充,不同实现之间差异很大。
2014 年发表的 Raft 论文明确提出,要设计一个以可理解性为目标的共识算法。为此,Raft 采取了两个主要策略:
- 问题分解:将共识问题分解为领导者选举、日志复制、安全性三个子问题,分别解决;
- 减少状态空间:通过引入强领导者等约束,减少需要考虑的情形,使系统行为更容易推理。
三、节点角色与任期
三种角色
Raft 集群中的每个节点,在任意时刻处于以下三种角色之一:
- 领导者(Leader):负责接收客户端请求、管理日志复制。一个集群在正常情况下只有一个领导者;
- 跟随者(Follower):被动响应领导者和候选者的请求,不主动发起任何操作;
- 候选者(Candidate):在领导者选举过程中出现的临时角色。
正常运行时,集群中有一个领导者,其余节点都是跟随者。
任期
Raft 将时间划分为连续编号的任期(Term)。每个任期以一次选举开始,如果选举成功,该任期内由选出的领导者负责管理集群;如果选举失败(例如票数分散),该任期没有领导者,随即进入下一个任期重新选举。
任期编号在 Raft 中扮演逻辑时钟的角色。每个节点保存自己所知的当前任期号,节点之间通信时会交换任期号:
- 如果一个节点发现对方的任期号比自己大,就更新自己的任期号,并转为跟随者;
- 如果一个节点收到任期号比自己小的请求,就拒绝该请求。
通过任期号,节点能够识别出过时的领导者和过期的消息。
四、领导者选举
心跳与选举超时
领导者会周期性地向所有跟随者发送心跳消息,以维持自己的领导地位。每个跟随者维护一个选举超时计时器:只要在超时时间内收到领导者的心跳,就重置计时器;如果超时时间内一直没有收到心跳,跟随者就认为领导者可能已经失效,开始发起选举。
选举过程
发起选举的节点执行以下步骤:
- 将自己的任期号加 1;
- 转变为候选者;
- 给自己投一票;
- 向其他所有节点发送请求投票消息。
其他节点收到请求投票消息后,按照以下规则决定是否投票:
- 在同一个任期内,每个节点最多只投一票,先到先得;
- 候选者的日志必须至少和自己一样新(具体规则见后文的安全性部分)。
候选者的选举可能有三种结果:
- 获得超过半数的选票:成为领导者,立即向所有节点发送心跳,宣告自己的领导地位;
- 收到其他领导者的心跳:如果对方的任期号不小于自己,说明已经有合法领导者,自己转为跟随者;
- 选举超时,无人胜出:进入下一个任期,重新发起选举。
随机化超时
如果多个跟随者同时超时、同时发起选举,选票可能被瓜分,导致没有任何候选者获得多数票。如果每次都同时超时,这种情况会无限重复。
Raft 用一个简单的办法解决这个问题:每个节点的选举超时时间在一个区间内随机选取,例如 150 到 300 毫秒。这样在大多数情况下,会有一个节点率先超时并赢得选举,在其他节点超时之前就发出了心跳。即使偶尔发生选票分散,下一轮的随机超时也会大概率打破僵局。
随机化是 Raft 为可理解性所做的典型取舍:用一个简单的概率机制,替代复杂的确定性排名规则。
五、日志复制
日志的结构
每个节点的日志由一系列日志条目组成,每个条目包含三项信息:
- 索引:该条目在日志中的位置;
- 任期号:该条目被领导者创建时的任期;
- 指令:需要应用到状态机上的操作。
复制流程
领导者选出后,开始处理客户端请求:
- 客户端向领导者发送一条指令;
- 领导者将该指令作为新条目追加到自己的日志中;
- 领导者并行地向所有跟随者发送追加条目消息,要求它们复制该条目;
- 当该条目被超过半数的节点复制后,领导者认为该条目已提交(committed);
- 领导者将已提交的条目应用到自己的状态机,并将结果返回给客户端;
- 领导者在后续的追加条目消息(包括心跳)中告知跟随者最新的提交位置,跟随者随之将已提交的条目应用到各自的状态机。
一个条目一旦被提交,Raft 保证它最终会被所有正常节点执行,并且不会被撤销。
如果某些跟随者响应缓慢或暂时不可达,领导者会无限重试发送追加条目消息,直到这些跟随者最终追上。这不影响已经获得多数确认的条目的提交。
一致性检查
网络和节点故障可能导致跟随者的日志与领导者不一致:跟随者可能缺少一些条目,也可能多出一些来自旧任期、未被提交的条目。
Raft 通过一致性检查修复这种不一致。领导者在发送追加条目消息时,会附带新条目前一个条目的索引和任期号。跟随者收到后,检查自己的日志中该索引位置的条目任期号是否匹配:
- 如果匹配,说明此前的日志一致,接受新条目;
- 如果不匹配,拒绝该请求。
领导者收到拒绝后,将该跟随者对应的发送位置向前回退,再次尝试,直到找到两者日志一致的位置。此后,领导者用自己的日志覆盖跟随者在该位置之后的所有条目。
这一机制保证了 Raft 的日志匹配性质:
- 如果两个日志中某个索引位置的条目任期号相同,那么它们存储的指令也相同;
- 如果两个日志中某个索引位置的条目任期号相同,那么该位置之前的所有条目也完全相同。
在 Raft 中,数据只从领导者流向跟随者,领导者从不删除或覆盖自己日志中的条目。这种单向的数据流大大简化了一致性的维护。
六、安全性
上述选举和复制机制还不足以保证正确性。考虑以下情形:某个跟随者在一段时间内宕机,错过了若干已提交的条目;恢复后,它被选为新的领导者,并用自己的日志覆盖了其他节点,已提交的条目就会丢失。
Raft 通过对选举和提交增加限制来避免这类问题。
选举限制
Raft 要求:只有包含所有已提交条目的节点,才能当选领导者。
这通过投票规则实现。候选者在请求投票时附带自己最后一个日志条目的索引和任期号,投票者比较双方日志的新旧程度:
- 最后一个条目任期号更大的日志更新;
- 任期号相同时,日志更长的更新。
投票者只把票投给日志至少和自己一样新的候选者。
由于一个条目被提交意味着它存在于多数节点上,而当选领导者也需要多数节点的投票,这两个多数集合必然至少有一个节点重叠。该节点包含该已提交条目,只会投票给日志至少同样新的候选者,因此当选的领导者一定包含所有已提交的条目。
提交限制
还有一个更隐蔽的问题:领导者不能仅凭多数副本就提交前一任期的日志条目。
在某些特定的故障序列下,一个来自旧任期的条目即使已经被复制到多数节点,仍可能被之后当选的另一个领导者覆盖。Raft 的解决办法是:领导者只通过计算副本数的方式提交当前任期的条目;一旦当前任期的某个条目被提交,根据日志匹配性质,它之前的所有条目也随之间接提交。
实践中,新领导者上任后通常会立即追加一条空操作条目,以便尽快提交当前任期的条目,从而确认此前所有日志的提交状态。
七、成员变更
实际运维中,集群的节点配置需要调整,例如替换故障机器、扩容或缩容。直接从旧配置切换到新配置是危险的:由于各节点切换的时刻不同,可能在某一瞬间,按旧配置计算的多数派和按新配置计算的多数派互不重叠,从而选出两个领导者。
Raft 论文提出了两种解决方案。
联合共识:配置变更分两个阶段进行。第一阶段进入一个过渡配置,此时任何决策都需要同时获得旧配置和新配置各自的多数派同意;过渡配置提交后,再切换到新配置。这种方式可以一次性完成任意的成员变更。
单节点变更:每次只增加或删除一个节点。可以证明,当新旧配置只相差一个节点时,两者的多数派必然存在交集,因此可以直接切换,无需过渡阶段。需要变更多个节点时,依次执行多次单节点变更即可。由于实现更简单,许多工程实现采用这种方式。
新加入的节点日志为空,如果立即参与投票,可能在追赶日志期间拖慢提交。因此常见做法是让新节点先以学习者(Learner)身份加入,只接收日志、不参与投票,待其日志基本追上后,再正式转为投票成员。
八、日志压缩与快照
随着系统持续运行,日志会无限增长,占用大量存储空间,节点重启后重放日志的时间也越来越长。
Raft 采用快照机制解决这一问题。每个节点定期将当前状态机的完整状态写入快照,并记录快照所对应的最后一个日志条目的索引和任期号,随后删除该位置之前的所有日志。
当某个跟随者严重落后,所需的日志条目已被领导者压缩掉时,领导者会直接将快照发送给该跟随者。跟随者加载快照后,再从快照之后的位置继续接收日志。
快照的生成通常由各节点独立进行,不需要经过领导者协调,这与 Raft 强领导者的原则略有偏离,但由于快照内容本身来自已提交的日志,不会破坏一致性。
九、读请求的处理
写请求需要经过日志复制和多数确认,而读请求是否可以直接由领导者返回本地数据?
答案是不一定安全。在网络分区的情况下,一个旧领导者可能尚未意识到自己已被取代,仍然认为自己是领导者,此时从它读取的数据可能已经过时。
常见的解决方法包括:
- 读请求也写入日志:将读操作作为一个日志条目走完整的复制流程,最简单但开销最大;
- 读索引(ReadIndex):领导者记录当前的提交位置,然后向多数节点发送一轮心跳,确认自己仍是领导者,再等待状态机执行到该提交位置后返回结果;
- 租约读(Lease Read):领导者在收到多数节点的心跳响应后,在一个略短于选举超时的时间窗口内,认为自己不会被取代,直接响应读请求。这种方式性能最好,但依赖各节点时钟速率大致一致的假设。
此外,也可以允许跟随者处理读请求:跟随者向领导者询问当前提交位置,等待本地状态机追上后再返回,从而分担领导者的读负载。
十、Raft 的应用
凭借清晰的设计和丰富的开源实现,Raft 已成为许多基础设施软件的核心组件:
- 分布式协调服务与配置中心:用于存储集群元数据、服务发现和分布式锁,容器编排系统的元数据存储便是典型例子;
- 分布式数据库:将数据划分为多个分片,每个分片由一个独立的 Raft 组管理副本,通过大量 Raft 组并行运行实现水平扩展;
- 消息队列:一些消息系统使用 Raft 管理集群元数据,取代了对外部协调服务的依赖。
在分片数据库中,一个集群可能同时运行数以万计的 Raft 组。这带来了新的工程挑战,例如合并多个组的心跳消息以减少网络开销、批量写入日志以提高磁盘吞吐等。
十一、局限与注意事项
写入性能受限于领导者。 所有写请求都必须经过领导者,单个 Raft 组的写吞吐受限于领导者的处理能力和网络带宽,需要通过分片进行扩展。
跨地域部署延迟较高。 每次提交至少需要一次到多数节点的网络往返,当节点分布在相距遥远的地域时,写入延迟会显著增加。
只处理崩溃故障。 Raft 假设节点可能崩溃、消息可能丢失或延迟,但不会出现恶意行为。如果节点可能被攻破或发送伪造消息,就需要使用拜占庭容错类的共识算法。
正确实现并不容易。 尽管 Raft 以易于理解著称,但工程实现中仍有大量细节需要谨慎处理,例如持久化的时机、各种边界条件下的状态转换。成熟的实现通常需要借助大量的故障注入测试和形式化验证来保证正确性。
结语
Raft 并没有在理论上超越 Paxos,它解决的是同一个问题。它的主要贡献在于证明了:一个共识算法可以既正确又易于理解。通过强领导者、随机化选举、日志匹配等设计,Raft 将一个出了名困难的问题拆解成了几个可以分别推理的部分。
这种"以可理解性为设计目标"的思路本身也值得借鉴。在复杂系统中,一个能被开发者正确理解和实现的方案,往往比一个理论上更优但难以掌握的方案更有价值。
- 点赞
- 收藏
- 关注作者
评论(0)