概述
在构建大规模分布式系统时,共识算法是一个绕不过去的主题:如何在节点出现故障、网络延迟等异常情况下,让整个集群针对某个状态达成一致? 这就是分布式共识(Distributed Consensus) 问题。无论是分布式数据库、分布式锁服务,还是分布式配置/服务中心,各种系统稳定运行的背后基石,都是共识算法。
Paxos算法曾一度作为共识问题的代名词,但因其理论晦涩、工程实现复杂而“臭名昭著”。为了解决这一问题,斯坦福大学的两位博士在读期间设计了Raft协议并发表了论文。Raft的核心目标,是在提供与Paxos同等级别的容错和性能保证的前提下,最大化地提升算法的可理解性。简单来说,Raft希望让大多数人都能轻松理解并建立对算法的直觉。
Raft 名字来源 = (Reliable, Replicated, Redundant) And Fault-Tolerant
Raft 核心思想
Raft的设计哲学是将复杂问题分解。它将共识问题分解为三个相对独立的子问题:
- 领导者选举 (Leader Election):如果当前Leader节点故障失效,必须选举出一个新的Leader节点。
- 日志复制 (Log Replication):客户端只能发送请求给Leader节点。Leader节点将请求转换为日志条目后,复制到集群中的其他Follower节点,并强制它们与自己的日志保持一致。
- 安全性 (Safety):确保系统在任何情况下都不会执行错误的操作。例如,防止两个节点在状态机的同一索引位置(日志的同一索引)写入/更新不同的值。

为了简化实现,Raft采用了强领导者模型(Strong Leader Model)。在一个正常的集群中,任何时刻都有且仅有一个Leader节点,所有客户端请求和集群数据变更都必须经过这个Leader节点。这使得集群整体的数据流向简单而清晰:从客户端到Leader节点,再由Leader节点分发到所有Follower节点。
复制状态机(RSM)
先来看看实现Raft共识算法的前提。共识算法通常出现在复制状态机(Replicated State Machines, RSM)的场景中。
在RSM方法中,集群中的每个节点都维护一个状态机。只要它们计算出相同的状态副本,那么即使部分节点故障,整个集群依然可用。

RSM算法内部通常使用复制日志来实现。如图所示,每个节点存储一个日志,其中包含一系列命令,节点的状态机按顺序执行这些命令。每个节点的日志包含相同的命令且顺序相同,因此每个状态机处理相同的命令序列。由于状态机是确定性的,所以每个状态机都能计算出相同的状态和输出结果。
共识算法的主要目标就是保持复制日志的一致性。
- 每个节点上的共识服务(模块/进程)接收客户端的命令,并将其添加到日志中。
- 每个节点通过和集群中的其他节点通信,确保最终日志中的命令和顺序都是相同的。
- 这样,即使某些节点发生故障,只要日志命令被正确复制并执行,每个节点的状态机最终都会保持一致,然后将结果返回给客户端。
- 因此,整个集群对外的表现就像是一个单一、高可靠的状态机。
真实的分布式系统中运行的共识算法通常需要满足以下属性/限制条件:
- 在非拜占庭条件下运行正确(永远不会返回错误的结果),这些场景包括网络延迟、分区以及数据包丢失、重复和乱序等。
- 多数票机制。只要集群内多数节点状态健康(通常是
N/2+1),整个集群就是可用的。例如,一个5节点集群可以容忍任意2个节点发生故障,只要有3个节点可用即可。同理,对于RPC请求,也只需要集群中多数节点Ack,无需等待所有节点响应完成(避免因部分节点网络问题影响整体性能)。
- Failover,保证故障节点的检测和恢复过程正确。
- 不依赖时间。因为时钟偏移/错误、极端的消息延迟经常会发生,所以不能依赖时间来确保日志的一致性。即便在最坏情况下,也仅影响集群的可用性,而不会导致一致性错误。
Raft 共识算法
Raft实现共识的第一步是选出一个集群唯一的Leader节点,然后将日志管理权交给它。
Leader节点接收来自客户端的请求,将其封装为日志条目 (Entry) 并复制到Follower节点。在收到超过半数的Follower节点的ACK后,再通知它们将日志条目应用到各自的状态机。设置Leader节点可以极大简化复制日志的管理。例如,Leader节点可以单方面决定新日志条目的位置,无需征询其他节点,数据流也简化为从Leader单向流向Follower。

基于强Leader模型,Raft将共识问题分解为三个相对独立的子问题:
- Leader选举:当前Leader节点故障,必须选出新的Leader节点。
- 日志复制:Leader必须接受来自客户端的请求,将其封装为日志条目并复制到Follower节点,强制其他节点的日志与自己的日志一致。
- 安全性:如果任意节点已经在其状态机中应用了特定日志条目,那么其他节点就不能对
日志的同一索引位置应用不同的命令。
Raft 算法概览

上图展示了Raft共识算法的简要总结(不包括成员变更和日志压缩流程),图中描述的算法实现细节会在下文中一一详解。
这里我们可以先根据算法的描述,给出对应的伪代码框架API,以辅助和强化理解。
# 节点状态/角色
class ServerState(Enum):
FOLLOWER = "FOLLOWER"
CANDIDATE = "CANDIDATE"
LEADER = "LEADER"
# 日志条目实体
class LogEntry:
term: int
index: int
command: Any
# 投票 RPC 响应
class RequestVoteResponse:
term: int
vote_granted: bool
# 日志追加 RPC 响应
class AppendEntriesResponse:
term: int
success: bool
class RaftNode:
def __init__(self, server_id: str):
# 节点共有字段
self.current_term: int = 0
self.voted_for: Optional[str] = None
self.log: List[LogEntry] = []
self.commit_index: int = 0
self.last_applied: int = 0
self.server_id = server_id
# Leader 节点相关字段
self.next_index: Dict[str, int] = {}
self.match_index: Dict[str, int] = {}
...
def request_vote(self, params ...) -> RequestVoteResponse:
...
def append_entries(self, params ...) -> AppendEntriesResponse:
...
Raft 安全属性 (算法核心)

Raft通过一系列属性(规则)来保证,即使在集群出现网络分区和Leader切换等非正常场景下,集群的安全性依然可以得到保障。
1. 选举安全 (Election Safety): 保证在一个给定的任期内,最多只有一个Leader被选举出来。
一个Candidate节点必须获得超过集群半数的投票才能成为Leader。由于两个Candidate不可能同时获得超过半数的选票,这就保证了选举的唯一性。
2. Leader 日志追加写 (Leader Append-Only)
Leader节点永远不会覆盖或删除自己的日志,只会追加写入。这是Leader行为的硬性规定,简化了日志管理的逻辑。
3. 日志匹配 (Log Matching Property)
如果两个不同日志中的两个条目,拥有相同的索引和任期号,那么它们存储的(客户端)请求数据也是相同的,并且该日志条目索引之前的所有日志条目也都是相同的。
这通过AppendEntries RPC的一致性检查机制来实现。Leader节点在发送新的日志条目时,会携带上前一个日志的 {prevLogIndex, prevLogTerm}。只有当Follower节点在前一个位置上的日志与Leader节点完全匹配时,才会接受新的日志条目。
下面通过一个简单例子,展示Leader节点如何修复与Follower节点不一致的日志。

4. Leader 完整性 (Leader Completeness Property)
这是几个属性中最关键的安全属性:如果一个日志条目在某个任期被提交,那么它将出现在所有更高任期的Leader的日志中。
这通过选举过程中的投票规则来实现。一个Candidate要想赢得选举,它必须证明自己的日志至少和集群中大多数节点一样“新”。因此,一个包含了所有已提交条目的节点,才更有可能赢得选举。任何日志“落后”的节点,都会因为其日志不够“新”而被大多数节点拒绝投票。所以,一旦一个日志条目被提交(即存在于大多数节点上),任何新Leader的日志中都必然包含这个条目,从而消除了已提交数据丢失的可能性。
5. 状态机安全(State Machine Safety)
如果某个节点已经将给定索引处的日志条目应用于其状态机,那么其他节点永远不会为同一索引应用不同的日志条目。
例如,节点A在日志索引100写入了数据“hello world”,那么其他节点在日志索引100也只能写入数据“hello world”。
理解了这五个安全属性,Raft算法的核心就了然于胸了。接下来,我们对这五个安全属性的细节一一进行详解。
Raft 基础

在一个Raft集群中,每个节点在任何时刻都扮演着以下三种角色之一:
- Leader (领导者):处理所有客户端请求和日志复制,任何时刻最多只有一个Leader节点。
- Follower (跟随者):完全被动的角色,响应来自Leader节点和Candidate节点的请求,从不主动发起任何操作。所有客户端请求都会被重定向到Leader节点。
- Candidate (候选者):选举过程中的临时角色。当一个Follower节点在一段时间内没有收到Leader节点的心跳时,它会转变为Candidate,并发起新一轮的选举。
Raft将时间划分为一段段任期(term),任期充当Raft中的逻辑时钟,每段任期的长度是任意的。

如图所示,任期用递增的整数来编号。每段任期都以选举开始,其中一名或多名Candidate试图通过选举成为Leader。如果选举成功,某个Candidate赢得选举,那么它将在接下来的任期内担任Leader。如果选举失败,就会开始新的一轮任期和新的选举。
Raft 确保在任何任期内最多有一个Leader。
既然是分布式系统,那么必然会出现的一种场景是:不同的节点在不同的时间观察到的任期可能不同,例如某些节点可能观察不到选举过程。不过这个问题很容易解决。既然任期在Raft中充当逻辑时钟,那么就可以通过任期来判断过期的信息,例如判断当前的Leader节点是否已经过期。因为每个节点都存储了当前任期编号,每个节点在和其他节点通信时,都会交换当前的任期编号。如果一个节点的当前任期小于另一个节点的任期,则将其当前任期更新为较大的值。如果Candidate或Leader发现自己的任期已经过时,就会立即转换为Follower状态;如果Leader节点收到客户端带有过时任期的请求,则会直接拒绝该请求。
Raft使用RPC通信,基本共识算法只需要两种类型的RPC:
- RequestVote RPC(请求投票):由Candidate在选举期间发起。
- AppendEntries RPC(追加条目):由Leader发起,用于复制日志条目(空的日志条目表示心跳)。
除此之外,还有第三种RPC用于在节点之间传输快照。如果节点没有在超时时间内收到响应,就会重试RPC,而且节点会并行地发出RPC以提高性能。
子问题1. Leader 选举
Raft 使用心跳机制来触发 Leader 选举。
节点启动时,默认角色是Follower。只要节点可以正常收到来自Leader或Candidate的RPC,它就会继续保持Follower状态。
Leader定期向所有Follower发送心跳(即不包含日志条目的AppendEntries RPC),以维护它的领导地位。
如果Follower在选举超时(election timeout) 的一段时期内没有收到任何通信,那么它就认为当前已经没有活跃的Leader节点,并发起选举过程以选择新的Leader:
- 为了发起选举,Follower会增加其当前任期,并转换到Candidate状态。
- 然后,它给自己投票,并向集群中的其他节点并行地发出RequestVote RPC,请求其他节点给自己投票。
Candidate是一个相对稳定的状态,直到节点发生以下三种情况之一:(1) 自己被选举为Leader;(2) 另一个节点被选举为Leader;(3) 选举超时,也没有选举出Leader。
下面来分别讨论这三种情况。
情况 1
Raft中节点选举投票时采用“先到先得”机制,投票给第一个RequestVote请求对应的节点。同时,多数票规则(majority rule) 确保集群最多只有一名Candidate可以赢得特定任期的选举(满足“选举安全属性”)。一旦Candidate赢得选举,它立刻成为Leader,然后向所有其他节点发送心跳消息,声明其Leader地位,防止在此期间发生新的选举。
情况 2
在等待投票的过程中,Candidate可能会收到来自另一个自称Leader的节点发送的AppendEntries RPC。如果该Leader的任期(包含在RPC中)不小于该Candidate的当前任期,则该Candidate承认该Leader是合法的,并转变为Follower。如果RPC中的任期小于该Candidate的当前任期,则该Candidate拒绝此RPC,并继续处于Candidate状态。
情况 3
如果许多Follower同时成为Candidate,则可能“平分投票”,导致没有一个Candidate获得多数票。发生这种情况时,每个Candidate都会超时,通过增加其任期并发送下一轮RequestVote RPC,开始新的选举过程。但是,这种简单的重试在极端情况下可能会导致:❌ 选举过程无限重复,陷入死循环。
为了解决这个问题,Raft使用随机的选举超时,以避免选举发生“平分投票”。为了最大可能避免“平分投票”,每个Candidate节点的选举超时参数并不是固定的,而是从一个区间内(例如,150-300ms)随机选择的。通过这种随机方式,可以避免节点集中超时。因此在大多数情况下,第一个超时的节点会赢得选举,并抢先在任何其他节点超时之前发送心跳消息。同样,随机超时机制也可以用于处理“平分投票”已经发生的情况。当新一轮选举开始时,每个Candidate设置随机的选举超时参数,并等待超时之后再开始下一次选举,这降低了在新选举中再次出现“平分投票”的可能性。

如图所示,Raft通过实验数据表明,选举超时参数的少量随机化就足以避免选举中的“平分投票”。这种方法可以快速地选出Leader,同时使用更多的随机性,可以改善极端最坏情况下的行为。
子问题2. 日志复制
Leader节点一旦选举完成,就开始处理客户端请求。
每个客户端请求都包含一条RSM(复制状态机)可以执行的命令。Leader节点将该命令作为新的日志条目追加到自己的日志,然后并行地向Follower节点发出AppendEntries RPC来复制该日志条目。当日志条目被安全地复制/提交后(即Leader节点收到集群中超过半数节点的复制Ack),Leader节点就将该条目应用于自己的状态机,并将执行结果返回给客户端。如果Follower发生故障或网络丢包,Leader节点会无限重试AppendEntries RPC(即使它自己已经回复了客户端),直到所有Follower最终都复制/存储了所有日志条目。
每个日志条目都存储了一条状态机命令,以及Leader节点收到该条目时的任期编号。日志条目中的任期编号用于识别日志之间的不一致,每个日志条目还有一个整数索引,表示它在日志中的位置(类似数组元素的索引)。

日志的组织方式如图所示。日志是由条目组成的,条目按顺序编号 (log index)。每个条目都包含创建它的任期(框中的数字,例如第一行表示Leader节点中第4个条目的任期是2)和状态机的命令 (X <- 2)。
只有当日志条目已经提交(表示这条日志在集群里已经被多数派认可、并且在后续选举中不会丢失),Leader节点才能将日志条目应用于状态机。 Raft保证已提交的日志条目的持久化,并且最终会被所有健康节点的状态机执行。

如图所示,日志条目7已经提交了,因为它已经在3个节点上完成持久化。Leader节点除了复制当前任期内客户端的请求日志条目,还会复制日志中先前的条目,包括由前任Leader创建的条目。

Leader节点会找到待提交条目的最高索引 (commitIndex 属性),并将该索引包含在后续的AppendEntries RPC中。这样Follower节点就可以和Leader保持及时的同步。Follower节点一旦发现某个日志条目已提交,就会立刻将该条目应用于其本地状态机(按日志顺序)。
commitIndex:已提交的最高日志索引。
lastApplied:已应用到状态机的最高日志索引。
必须保证 lastApplied ≤ commitIndex。Leader和Follower只能把commitIndex之前的条目顺序、依次应用到状态机。
Raft维护了两个“日志匹配属性”(Log Matching Property):
1. 如果不同日志中的两个条目具有相同的索引和任期,则它们存储相同的命令(数据)。
Leader节点在给定任期内,对于指定日志索引,最多创建一个条目,并且日志条目永远不会改变它们在日志中的位置。
2. 如果不同日志中的两个条目具有相同的索引和任期,则日志中所有该条目索引前面的条目都是相同的。
这通过AppendEntries RPC执行的简单一致性检查来保证。发送AppendEntries RPC时,Leader节点附带了自己日志中新条目前面那个条目的索引和任期。如果Follower节点在自己的日志中没有找到具有相同索引和任期的条目,那么它就拒绝Leader发过来的新条目。
这个简单的一致性检查,其实就是一个归纳算法:
- 初始化时,日志为空,满足“日志匹配属性”。
- 每当新增日志条目时,一致性检查都会要求继续满足“日志匹配属性”。
- 因此,每当AppendEntries RPC返回成功时,Leader节点就知道Follower节点的日志和自己的日志一直到新条目都是相同的。
正常情况下,Leader节点和Follower节点的日志保持一致,因此AppendEntries RPC的一致性检查不会失败。但是,Leader节点故障可能会导致日志不一致(前任Leader可能没有完全复制其日志中的所有条目)。当Leader和Follower频繁发生故障时,这种不一致的情况就会加剧。
日志中多余的和缺失的条目可能会跨越多个任期。 例如,Follower节点可能缺少Leader节点上存在的条目,也可能具有Leader节点上不存在的条目,或两种情况都有。

上面的图片显示了Follower节点的日志可能与新Leader节点的日志不一致的多种情况。当任期8的Leader选举完成后,Follower节点可能存在 (a-f) 中的任何一种情况。每个方框代表一个日志条目,框中的数字是它的任期。
- Follower a 和 Follower b 缺少日志条目。
- Follower c 和 Follower d 包含多余未提交的日志条目。
- Follower e 和 Follower f 既缺少日志条目,也包含多余未提交的日志条目。
例如在场景 (f) 中:
- 该节点曾是任期2的Leader节点,它向自己的日志中添加了多个条目,但是在提交某个条目之前故障了;
- 虽然它很快重新恢复,成为第3任期的Leader节点,并向自己的日志中添加了更多条目;
- 但是在任期2或任期3中的条目被提交之前,该节点又发生故障了;
- 并且在接下来的几段任期(4, 5, 6, 7)内一直没能恢复,直到任期8才恢复正常;
Raft 通过强制 Follower 节点复制 Leader 节点的日志来处理不一致。
也就是说,Follower节点日志中的冲突条目将会被Leader节点日志中的条目直接覆盖。为了使Follower节点的日志与自己的日志保持一致:
- Leader节点必须找到双方日志中一致的最后一个条目
X。
- 删除Follower节点日志中
X 之后的所有条目。
- 将Leader节点日志中
X 之后的所有条目发送给Follower节点。
这三步操作都是在Follower节点处理并响应AppendEntries RPC执行的一致性检查时进行的。
Leader节点为每个Follower节点维护一个nextIndex索引,这是Leader节点将发送给该Follower节点的下一个日志条目的索引。

Leader节点刚被选举时,会将所有Follower节点的nextIndex索引初始化为:它的日志中最后一个条目之后的索引(上图中是11)。如果Follower节点的日志与Leader节点的不一致,Leader节点再次发送AppendEntries RPC时,一致性检查就会失败,Follower节点拒绝复制日志条目。被拒绝后,Leader节点开始递减nextIndex并重试AppendEntries RPC。最终nextIndex将达到Leader节点和Follower节点日志匹配的位置。此时,AppendEntries RPC返回成功,这将删除Follower节点日志中的所有冲突条目,并追加Leader节点日志中的条目。一旦AppendEntries RPC成功,Follower节点的日志就与Leader节点的日志保持一致,并且在当前任期剩余的时间内,一直保持这种状态。
上述过程中“Leader节点开始递减nextIndex并重试AppendEntries RPC”这一步,理论上存在一个小的优化项:减少被拒绝的AppendEntries RPC的请求数量。例如,拒绝AppendEntries RPC时,Follower节点可以包含冲突条目的任期和它为当前任期存储的第一个索引。有了这些信息,Leader节点可以递减nextIndex来绕过当前任期中的所有冲突条目,每个包含冲突条目的任期只需要一个AppendEntries RPC,而不是每个冲突条目都需要一个AppendEntries RPC。但是实践中,Raft并没有支持这种优化,因为故障很少发生,而且不太可能有很多不一致的日志条目。
通过AppendEntries RPC执行一致性检查机制,Leader节点被选举后,不需要采取任何特殊措施来恢复日志的一致性。它只需要正常开始运行,日志会自动向双方最后的一致性日志条目快速收敛,最后响应AppendEntries一致性检查。Leader节点永远不会覆盖或删除自己日志中的条目(满足“Leader节点仅追加属性”)。
Raft的日志复制机制展示了一个理想的共识属性:只要多数节点正常,Raft就可以接受和复制新的日志条目,然后应用到集群各节点的状态机。正常情况下,通过一轮AppendEntries RPC请求就可以将新条目复制到集群多数节点,至于其中的个别故障/响应慢的Follower节点不会影响集群的整体性能。
子问题3. 安全性
到目前为止,前文中所描述的Leader选举和日志复制机制,还不足以确保每个节点的状态机都以相同的顺序执行完全相同的命令。例如,当Leader节点提交多个日志条目时,某个Follower节点A可能不可用,之后该A节点可能被选为Leader节点,并用新的条目覆盖掉其他Follower节点的日志条目。结果就是,不同的状态机可能会执行不同的命令序列。
本小节通过完善Raft算法,增加了Candidate节点可以被选举为Leader节点的限制条件。这个限制条件可以保证:任意任期的Leader节点,都包含之前任期已经提交的所有条目(满足“Leader节点完整性属性”)。翻译成大白话就是,想要被选举为Leader节点,Candidate节点必须要包含超过集群半数节点都拥有的、足够新的日志。
选举限制
Raft使用了一种简单的方法,保证从选举的那一刻起,之前任期的所有已提交条目都存在于新Leader节点,而且并不需要将这些已提交条目传输给新Leader节点。也就是说,日志条目只在一个方向流动:从Leader节点到Follower节点。对于日志中不包含所有已提交条目的Candidate节点,Raft的投票过程决定了,其不可能赢得选举。
所有已提交条目,必然存在于超过集群半数的节点上。同时,因为Candidate节点必须获取到超过集群半数节点的投票,那么通过RequestVote RPC就可以实现这个限制:RPC包含关于Candidate日志的信息,如果投票节点发现自己的日志比Candidate的日志还新,就会直接拒绝投票。
具体来说,通过比较日志最后条目的索引和任期,就可以确定两个日志中哪一个是最新的:
- 如果日志最后条目的任期不同,则任期编号值越大,表示日志越新。
- 如果日志最后条目的任期相同,则索引编号值越大,表示日志越新。
提交先前任期的日志条目
一旦某个日志条目持久化到多数节点,Leader节点就认为当前任期的这个条目已经提交。如果Leader节点在提交条目之前故障,则未来的新Leader节点将尝试复制这个日志条目。然而,新Leader节点也无法确定,上一任期的日志条目持久化到了多数节点就一定会被提交。
下面的图片说明了一种情况,其中,虽然上一任期的日志条目已经持久化存储到了多数节点,但是仍可能被新的Leader节点覆盖。

- 在 (a) 中,S1是任期2的Leader节点(黑框),S2复制了S1的日志条目
{idx: 2, term:2}。
- 在 (b) 中,S1故障了;S5当选为任期3的Leader节点(黑框),并新加了一个不同的日志条目
{idx: 2, term:3}。
- 在 (c) 中,S5故障了;S1重新启动,被选为Leader节点(黑框),并继续复制日志条目。此时,S1的日志条目
{idx: 2, term:2} 在多数Follower节点上复制,但是未提交。
- 在 (d) 中,S1又故障了,S5再次当选Leader节点(黑框),并用它自己的日志条目
{idx: 2, term:3} 覆盖S1的日志条目 {idx: 2, term:2}。
- 在 (e) 中,因为S1在故障之前,已经将其任期的日志条目2复制到了多数Follower节点,所以此时,S5的日志条目
{idx: 2, term:3} 提交,Follower节点中日志的所有先前的S1的日志条目 {idx: 2, term:2},就被S5的日志条目 {idx: 2, term:3} 覆盖。
论文中的图和描述信息可能不是特别直观,笔者专门又找到了另外一张图,希望帮助读者更好地理解这个错误的“覆盖过程”。

为了消除类似上面图中的这个问题,Raft定义了“安全规则”:不通过计算副本的方式来提交先前任期的日志条目,只有Leader节点当前任期的日志条目,才通过计算副本的方式来提交。通过提交一个自己当前任期的条目,由于需要满足“日志匹配属性”,就等于间接地提交了所有在它之前的条目。
这里的“计算副本的方式”是说,Leader节点不能仅仅因为发现旧任期的日志条目在大多数节点上存在,就直接提交它。因为当Leader节点从先前的任期复制日志条目时,日志条目会保留其原始任期编号,所以Raft的这种实现方法使得对日志条目的推理更容易,因为日志条目在不同时间、不同节点都保持相同的任期编号。
论文中的这个“安全规则”写得过于拗口,下面通过一个小例子进行补充说明。

还是以刚才的图片为例。让我们回到图中S1和S5发生冲突覆盖的地方,也就是图 (c),看看正确的做法:

- 首先是图 (c),S1成为任期4的Leader,创建了一个当前任期的新条目
{idx:4, term:4},同时发现日志条目 {idx:2, term:2} 存在于S1, S2。
- S1强制所有Follower节点的日志与自己保持一致。于是,对于S3和S4,S1需要将
{idx:2, term:2} 复制给它们;对于S5,S1需要强制用自己的 {idx:2, term:2} 覆盖掉S5本地的 {idx:2, term:3}。
- 遵守安全规则:虽然此时满足超过集群半数节点,可以提交,但是S1却不能直接提交日志条目
{idx:2, term:2},因为它来自旧的任期。
- 为了正常提交日志条目
{idx:2, term:2},S1需要将日志条目 {idx:4, term:4} 复制到多数节点,这样旧任期的日志条目 {idx:2, term:2} 也会间接一起提交。
- 以上,1-4步骤,才是图 (c) 的正常运行逻辑。
- 再来看看图 (d) 的情况。如果S1在本地写入日志条目
{idx:4, term:4} 后就直接故障了,那么S5被选举为新的Leader节点后,是否能直接提交日志条目 {idx:2, term:3} 呢?
- 肯定不能! 因为S5选举后的任期是5(假设一次性选举完成),但日志条目的
{idx:2, term:3} 的任期是3。S5不能提交旧任期的日志,所以 {idx:2, term:3} 不能提交,只能等待新的客户端请求到达后,超过半数节点复制了S5的日志条目 {idx:XXX, term:5} 之后,{idx:2, term:3} 才会间接一起提交。
- 以上,6-7步骤,才是图 (d) 的正常运行逻辑。
no-op 日志
虽然通过“安全规则”可以保证日志复制的正确执行,但是其中有一个容易出问题的小细节:Leader节点为了提交旧任期的日志,必须等待新的客户端请求到达,然后再复制&提交新的日志条目,同时间接提交了旧任期的日志条目。那么,如果一直没有新的客户端请求,日志就永远无法复制了。
为了解决这个问题,Raft引入了 no-op 日志。顾名思义,no-op日志就是空日志条目,其中只有index和term,命令/数据为空。Leader节点选举完成后,需要立即追加一条no-op日志并复制到Follower节点。只要no-op日志提交,所有未提交的日志条目便全部间接提交。
安全性论证
基于完整的Raft算法,论文中使用了 反证法 来证明“Leader完整性属性”(Leader Completeness Property)。
- S1 (LeaderT) : 任期T的Leader节点。
- S5 (LeaderU) : 任期U的Leader节点。
- 其中,U > T。
(反证法) 证明: 命题:假设“Leader完整性属性”不成立。
💡 证明 1:假设S1提交了一个日志条目 X,但是该日志条目没有被S5复制。

- 在选举时,S1提交的日志条目
X 肯定不在S5的日志中(因为Leader节点不删除或覆盖日志条目)。
- S1在多数节点上已经复制了日志条目
X (日志已提交),而S5被选为后来任期U的Leader节点(接受了来自多个节点的投票),那么必然至少有一个节点(S3),既复制了S1的日志条目 X,并且也投票给了S5。
- ❌ 这个节点S3就是产生矛盾的关键。
- S3在投票给S5之前,S3必然复制了S1的已提交日志条目
X;否则它将拒绝来自S1的AppendEntries RPC请求(该选民的当前任期将高于S1,当然这种情况下,S1作为Leader节点,不可能成功提交日志条目 X,因为没有获得多数节点的Ack)。
- 同时,S3在投票给S5时,S3的日志必然包含了S1的已提交日志条目
X。而从任期T到U之间的所有Leader节点,都必然包含这个日志条目 X,因为Leader节点不删除或覆盖日志条目,Follower节点只有在和Leader日志条目冲突时才删除日志条目。
- 因为S3投票给S5,根据“Raft选举安全限制”,那么前提必然是S5的日志和S3的日志至少一样新;❌ 于是就产生了下面两个矛盾之一。
- 第一个矛盾:如果S3和S5的日志中的最后一个条目相同,那么S5的日志和S3的日志至少一样新,所以S5也必然包含S1的已提交日志条目
X,这和证明1相矛盾。
- 第二个矛盾:如果S3和S5的日志中的最后一个条目不相同, S5的日志中的最后一个条目的任期必然大于S3,那么S5的前任期Leader节点的日志条目中必然包含S1的已提交日志条目
X。根据“日志匹配属性”,S5也必须包含S1的已提交日志条目 X,这和证明1相矛盾。
- 通过反证法,我们分析出了两种和证明1的矛盾条件,从而证明了所有任期大于T的Leader节点,必须包含来自任期T且在任期T中提交的所有日志条目,最终证明了 “Leader完整性属性”必然成立。一个日志条目一旦被提交,它将永远存在于所有未来的Leader日志中。
- “日志匹配属性”保证未来的Leader节点也包含间接提交的日志条目。
基于“Leader完整性属性”,我们可以证明“状态机安全属性”(State Machine Safety Property)。如果节点已经将给定索引处的日志条目应用于其状态机,那么其它节点就不能再为同一索引处应用不同的日志条目。 当节点将日志条目应用于其状态机时,该日志条目必须已经提交。节点日志必须使用「当前条目索引」和Leader节点的日志中「同一位置索引」比较,确保索引之前的日志条目完全相同。
“Leader完整性属性”保证所有更高任期的Leader将存储相同的日志条目,所以即使任期变化,节点同一索引处都是相同的日志条目。因此,“状态机安全属性”成立。最后,Raft要求节点按日志索引顺序应用日志条目到状态机,再结合“状态机安全属性”,这意味着所有节点将以相同的顺序将完全相同的一组日志条目应用于其状态机。本质上就是按照日志顺序执行到状态机,这个过程是幂等的。

Follower 和 Candidate 故障
Follower和Candidate节点故障比Leader节点故障更容易处理,而且它们的处理方式相同。如果Follower和Candidate节点发生故障,那么发送给它们的RequestVote RPC和AppendEntries RPC将会失败。Raft通过无限重试来处理这些故障。如果故障节点恢复了,那么RPC就会成功。因为Raft的RPC请求都是幂等的,所以节点即使收到重复的RPC也没有任何影响。例如,如果Follower节点收到一个AppendEntries RPC请求,其中包括重复的日志条目,那么它会忽略掉这些重复条目。
时间和可用性
虽然Raft在设计之初就要求安全性不能依赖于时间(时钟):Raft不能仅仅因为某些事件发生得比预期快/慢,就产生错误的结果,但是可用性又不可避免地取决于时间。例如,如果交换消息的网络耗时过长,Candidate可能等不到选举完成就崩溃了;如果迟迟选不出稳定的Leader节点,Raft基本报废,变成不可用了。
Leader选举是Raft对时间要求最严格的部分。只要系统满足以下时间要求,Raft就能选举出并维持稳定的Leader节点:
广播时间 (broadcastTime) << 选举超时 (electionTimeout) << 平均故障间隔 (MTBF)
该不等式中:
- 广播时间是集群中各节点发送RPC并收到响应的平均时间;
- 选举超时在前文中介绍过了;
- MTBF表示一个节点的平均故障间隔。
其中,选举超时应该比广播时间大一个数量级,以保证Leader节点能够可靠地发送心跳消息,避免Follower节点因为超时而再次发起选举;选举超时应该比MTBF小几个数量级,这样系统才能稳定可用。Leader节点故障后,在约等于选举超时的间隔内,系统处于不可用。广播时间和MTBF属于基础设施稳定属性,而选举超时则是中间层/应用层必须选择的。
Raft的RPC通常需要接收节点将消息/日志持久化到本地/远程的稳定存储中。取决于不同的情况,广播时间可能在0.5ms到20ms之间。因此,选举超时可能设置在10ms和500ms之间。
集群成员变更
如何在不中断服务、不产生“脑裂”(Split Brain)的情况下,安全地增加或移除集群中的节点?如果这个过程中有任何手动步骤,那么就存在操作人员失误的风险。为了避免这些问题,Raft决定自动化配置变更并将这个过程一起纳入Raft共识算法本身。
任何直接将节点添加到集群、或者从集群中移除的方法都是不安全的,因为我们无法(从物理概念上)完成原子性瞬间修改。所以在集群变化的过渡期间,集群可能会分裂为两个独立的分区,从而选举出两个Leader,导致系统状态错误。
下面通过一个小例子来说明这种情况,假设集群从3节点扩容到5节点。

- 旧集群
C_old: {S1, S2, S3},S1是Leader。
- 管理员向S1发送指令,要求它将集群配置变更为
C_new = {S1, S2, S3, S4, S5}。
- 直接切换的后果:S1立即开始使用
C_new,并将这个新配置通过日志复制给其他节点。
- ❌ 错误发生: 假设S1刚把
C_new 复制给了S2,然后S1就故障了,集群现在分裂成两个独立的分区。
- 旧集群分区:S3没有收到任何更新,它仍然使用
C_old ({S1, S2, S3})。当它发现S1失联后,它可以和S2(此时也可能因S1故障而重新发起选举)一起,基于C_old的多数节点(2票)选举出一个新的Leader(比如S2)。
- 新集群分区:同时,新加入集群的S4和S5,可能会形成一个基于
C_new ({S1,S2,S3,S4,S5}) 的多数节点投票(需要3票),并选举出另一个新的Leader(比如S5)。
- 结果:集群中出现了两个Leader,出现脑裂。
联合共识
为了避免上述问题,Raft采用了一个两阶段提交过程,确保在任何时刻,集群中都不会出现两个Leader节点。这个过程引入了一个临时的、过渡性的配置状态,称为联合共识集群 C_old,new。
- C_old 的多数节点:
Quorum_old
- C_new 的多数节点:
Quorum_new
- 联合共识集群:
C_old,new,Quorum_old 叠加 Quorum_new(两者的并集)

如图所示,虚线表示已创建但未提交的日志条目,实线表示最新提交的日志条目。Leader节点首先在其日志中创建日志条目并提交给 C_old,new,然后它创建 C_new 日志条目并将其提交到 C_new 的多数节点。任何时刻,C_new 和 C_old 都不能单独做决策。
💡 核心思想
- 日志条目被复制到两个集群(分区)中的所有节点。
- 任一集群(分区)中的任意节点都可以当选Leader。
- 在
C_old,new 状态下,任何决策(如选举或日志提交)都必须同时获得旧集群 C_old 和新集群 C_new 中各自的多数节点支持,也就是必须同时满足 Quorum_old 和 Quorum_new。
两阶段提交过程

阶段一:进入联合共识
- Leader(假设为S1)节点收到一个集群成员变更请求。
- 创建
C_old,new 日志条目。Leader会创建一个特殊的日志条目,内容就是 C_old,new 配置,表示逻辑上C_old和C_new的联合。
- Leader将这个
C_old,new 日志条目像普通日志一样,复制到集群中的所有节点(包括新加入的节点)。
- 从Leader将
C_old,new 日志条目追加到自己的日志的那一刻起,它就立即开始使用联合共识的规则来做决策。其他Follower节点在收到并追加这个日志条目后,也立即切换到联合共识模式。
- 当
C_old,new 日志条目被复制到新旧集群(分区)各自的多数节点后,就被提交。
- 此时,整个集群都安全地进入了联合共识状态。
下面通过一个小例子来说明这种情况,假设集群从3节点扩容到5节点。
- C_old = {S1, S2, S3}, Quorum_old = 2
- C_new = {S1, S2, S3, S4, S5},Quorum_new = 3
在联合共识状态下:
- 日志提交:一个日志条目必须被复制到
C_old 中至少2个节点,和 C_new 中至少3个节点,才能被提交。
- 选举:一个Candidate必须获得
C_old 中至少2个节点的投票,和 C_new 中至少3个节点的投票,才能当选Leader。
阶段二:切换到新集群
- 一旦
C_old,new 日志条目被成功提交,Leader节点就知道整个集群已经为切换做好了准备。它会立即创建一个特殊的日志条目 C_new。
- Leader节点将
C_new 日志条目复制到所有Follower节点。
- Follower节点在收到并追加
C_new 日志条目后,立即切换到只使用 C_new 的规则。
- 当
C_new 日志条目被复制到多数节点后,就被提交。
- 成员变更过程结束,集群现在稳定地运行在
C_new,旧的 C_old 被丢弃。
为什么联合共识是安全的?
联合共识的安全性来自于它的核心要求:任何决策都需要得到新旧两个集群(分区)多数节点的重叠确认。

还是用刚才的集群添加节点为例,看看联合共识是如何避免脑裂问题的。
- 在任何时刻,如果我们要选举Leader,Candidate都必须同时赢得
C_old 和 C_new 多数节点的选票。
C_old 的任何多数节点集合,和 C_new 的任何多数节点集合,之间必然存在交集。
- 这个交集的存在,保证了在同一任期内,只有一个Candidate可以同时赢得
C_old 和 C_new 多数节点的选票。
- 因此,在整个变更过程中,任何任期内都只能选出一个Leader(不会发生脑裂)。
联合共识的三个问题
联合共识 C_old,new 机制中,还有三个需要解决的问题。
1. 新加入的节点日志“落后”太多
新加入到集群的节点刚开始是“一张白纸”,没有任何日志条目。如果直接把它们作为正式成员加入集群(可以进入联合共识参与选举),它们需要花很长时间来从Leader节点复制大量的历史日志。在这段漫长的“追赶”期间,如果有旧节点发生故障了,整个集群可能无法提交任何新的日志条目,从而导致集群不可用。

例如,新加入的节点(S4, S5)日志远远落后Leader节点,它们在追赶上Leader的日志之前,无法接收和确认Leader发来的最新日志条目。如果当前 C_new 等于 {S1,S2,S3,S4,S5},S2此时发生故障,而S4, S5都在忙着复制旧日志,那么Leader最多只能得到S1, S3的确认,永远凑不齐3票。结果就是,所有新的客户端请求都无法被提交,集群变为不可用状态。
为了避免这种情况,Raft在正式的成员变更前增加了一个 “预备阶段”:新加入的节点先作为非投票成员 (non-voting member) 加入集群,先专注快速复制日志(通常是通过快照+增量日志的方式),这中间不参与任何决策。等到Leader发现这些非投票成员的日志已经和自己差不多同步了,它就会自动启动两阶段联合共识变更流程,新加入的节点就可以参与决策过程。
2. Leader 节点被移除
在集群节点缩容场景下,当前的Leader节点可能会在 C_new 中被移除。这种情况下,这个Leader节点在提交了 C_new 日志条目后,会自动“下台”转变为Follower状态。这意味着,在提交 C_new 期间,这个Leader节点正在管理一个不包含它自己的集群;它仍然负责复制日志,但在计算多数节点时,它不会把自己算进去。
为什么Leader节点要提交 C_new 之后才下台?
在 C_new 被提交之前,集群可能还处于 C_old 或 C_old,new 状态。在这些状态下,只有 C_old 的节点(包括这个即将被移除的Leader)才能被选为Leader,C_new 节点无法被选为Leader。为什么呢?因为新节点日志的日志太少了,而且不够“新”,所以无法获得 C_old 旧节点的投票。如果当前这个Leader节点提前下台,可能会导致集群在变更完成前没有Leader节点。假设Leader节点S1提前下台,此时只有 C_old 旧节点 {S1, S2, S3} 才有资格选举投票,但S1已经自己放弃了。那么候选人就只剩下S2和S3。如果S2和S3之间因为网络问题无法顺利完成选举,或者它们中恰好有一个也发生故障了,那么集群就会暂时陷入没有Leader的状态,集群变为不可用状态。当 C_new 被提交后,Raft保证了 C_new 可以独立运行,并且一定能从 C_new 的节点中选举出新的Leader节点,实现平滑过渡。
3. 被移除的节点对集群形成干扰
那些被移除的节点,在集群成员变更完成后就不再是集群的成员了。因此,它们再也收不到来自新Leader的心跳消息。这会导致它们不断地发起选举过程。在发起选举时,它们会增加自己的任期号(会超过当前Leader节点),然后向集群广播RequestVote RPC。当前的Leader节点收到这个带有更高任期号的投票请求时,根据Raft规则,它会被迫下台,转变为Follower节点。虽然很快会有新Leader被选出,但被移除的节点还会再次发起选举,如此循环往复,导致集群的可用性很差。
为了解决这个问题,Raft增加了一个简单的防御机制:设置一个最小选举超时(minimum election timeout)MCT。一个节点在单个 MCT 期间,如果收到过Leader节点的心跳/RPC消息,那么它会忽略收到的RequestVote RPC:既不会更新自己的任期号,也不会投票。
- 对于正常节点:必须等待至少一个
MCT 才会发起选举。所以当节点发起选举时,其他节点也已经 MCT 的时间没有收到Leader心跳/RPC消息了,防御规则不会被触发,正常选举不受影响。
- 对于被移除的节点:集群中的健康节点都在持续不断地(远小于
MCT)收到Leader的心跳/RPC消息。当被移除的节点发来一个带有更高term的投票请求时,健康节点会检查自己上一次收到Leader消息的时间,如果发现远小于 MCT,就直接丢弃这个移除节点的请求。

日志压缩
在一个长期运行的Raft集群中,日志会不断增长。如果不加处理,会带来几个严重的问题:
- 磁盘空间耗尽:日志文件会无限增大,最终占满整个磁盘。
- 启动/恢复时间过长:当一个节点重启时,它需要重放(replay)整个日志来恢复其状态机,大日志文件的恢复过程会极其缓慢。
- 新节点加入困难:一个新节点加入集群,需要从Leader那里复制全部历史日志,这会消耗巨大的网络带宽,过程同样极其缓慢。
压缩日志文件最简单的方法就是使用快照。通过使用快照文件,节点不再保存完整的历史日志条目。这也是分布式系统中的常规操作,例如在Chubby和ZooKeeper中也使用了快照。
一个Raft快照文件,本质上是节点在某个时间点,对应的状态机的完整拷贝,包含两个关键部分:
- 状态机数据:快照的核心数据。例如,对于一个KV存储系统,核心数据就是数据库的所有KV键值对。
- 快照元数据:用于Raft协议自身的一致性检查。
last_included_index: 快照所包含的最后一个日志条目的索引。
last_included_term: 快照所包含的最后一个日志条目所属的任期号。
- 集群配置信息:快照生成时,集群的成员信息。

当节点状态机被持久化到快照文件后,位于 last_included_index 索引之前的日志条目就可以丢弃了。
快照生成过程
Raft的快照生成(日志压缩)是在每个节点独立、异步进行的,通常由一个阈值触发(例如,当日志文件大小超过100MB时)。
- Leader / Follower节点异步持续监控自己的日志是否达到快照阈值。
- 当日志增长到阈值时,节点启动快照生成。
- 节点确定要生成快照的截止时间点,也就是已经应用到状态机的最后一条日志的索引,这里称为
snapshot_index。
- 节点异步地将当前状态机的完整数据写入一个持久化的快照文件。
- 同时,将元数据(
last_included_index = snapshot_index,last_included_term 等)也写入快照文件。
- 这个过程可以复用Linux的“写时复制”机制优化,避免在生成快照时阻塞状态机的正常读写。
- 快照文件持久化后,节点就可以安全地清理
snapshot_index 索引及之前的所有日志条目。
- 现在,该节点的日志索引从
snapshot_index + 1 开始。
快照的传输与应用
快照文件不仅用于节点本地日志压缩,更重要的作用是帮助日志严重落后的Follower节点快速追赶Leader节点。
这里使用一个小例子进行说明,假设:
- Leader的日志文件已经很大,并且刚生成一个快照文件。
- 一个Follower(比如S3)节点,刚刚从长时间的故障中恢复,它的日志严重落后于Leader节点。
下面是具体的快照传输过程。

- Leader尝试给节点S3发送AppendEntries RPC。
- RPC中包含
prevLogIndex,这是Leader认为S3的最后一个日志条目的索引。
- Leader发现,它需要发送给S3的日志条目(比如Index=5000),在自己本地已经被包含进快照并清理了。此时Leader无法发送一个不存在的日志条目。
- Leader知道S3的日志落后太多了,用增量同步的方式效率太低了。
- Leader中断日志复制,向S3发送一个InstallSnapshot RPC,包含了完整的快照文件和快照元数据。
- S3收到InstallSnapshot RPC会检查快照的
last_included_index。如果这个索引小于等于它自己的已提交日志条目,说明这个快照是过时的,S3忽略。如果快照是新的,S3会执行下面的步骤:
- 丢弃自己本地所有的日志和状态机。
- 将快照数据直接作为自己的新状态机。
- 将快照的元数据(
last_included_index, last_included_term)保存下来,并重置自己的日志文件索引。
- 此时,S3的状态机已经和快照时刻的Leader的状态机完全一致了,日志逻辑上从
last_included_index + 1 继续进行。
- S3处理完快照后,向Leader返回成功。Leader收到成功响应后,更新S3的记录(
nextIndex 将被设置为 last_included_index + 1)。从现在开始,Leader可以通过AppendEntries RPC向S3发送增量的日志条目了。
快照特点
- 每个节点独立执行快照生成,独立决定何时进行快照,不影响其他节点。
- 对于严重落后的Follower节点,通过一次性的快照传输,避免了成千上万次低效的增量日志复制RPC,提升性能。
- 通过快照元数据和RPC中的任期号/索引位置对比检查,确保快照的恢复过程不会破坏Raft的一致性保证。
客户端交互
使用Raft构建应用/服务时,客户端主要关心三个问题。
1. 如何发现并连接到Leader节点?
客户端启动时,随便挑一个已知的节点地址连接并发起请求。如果运气好,连接的节点正好是Leader节点,请求被处理。如果连接到的是Follower节点,这个Follower节点会返回Leader节点地址给客户端。客户端收到响应信息后,缓存Leader节点地址,然后转发请求。通过把连接Leader节点的逻辑下放到客户端实现,可以简化服务端实现,并且足够健壮(最差情况下,客户端也只需要两次请求)。
2. 如何实现/保证写操作“最多执行一次”?
换句话说就是,如何保证客户端请求的执行是幂等操作?和第一个问题类似,Raft将这部分实现下放到客户端。客户端需要为每一个命令分配一个全局唯一的序列号 (ID),例如 (client_id, request_id)。Raft节点需要维护一个“去重表”,记录下每个 client_id 最近一次成功执行的 request_id 以及对应的响应。当节点状态机准备应用一条新的日志条目时,它会先检查这个命令ID是否存在于“去重表”中。如果存在,说明这是一个重试的命令,状态机不会再次执行,而是直接从表中查出上次的响应结果并返回。如果不存在,状态机就正常执行命令,然后将这个ID和执行结果存入“去重表”中。
3. 如何实现线性一致性读 (Linearizable Reads)?
线性一致性读的核心要求是:💡 不能返回旧的脏数据。为了实现这一点,Leader节点在响应客户端的读请求前,必须确认两件事:
- 自己的Leader身份是否依然有效。
- 自己的状态机已经包含了所有已提交的日志条目。
具体来说,Leader在响应读请求前,必须和集群中的多数节点进行一次心跳 / RPC通信。如果它能成功地与多数节点通信,就说明当前没有更高任期的Leader存在,自己当前的Leader身份依然有效。这个过程就像是Leader节点在给自己续约。
Leader刚上任时,并不知道当前 commitIndex 在哪里(commitIndex 不会持久化),也就是不知道哪些日志是前任Leader已经提交的。为了解决这个问题,Leader会立即提交一个 no-op 日志条目(关于这一点,前文中已经提到过,这里不再赘述)。

💡 Raft实现的线性一致性读代价略微高昂,一个客户端读请求的延迟,至少等于1次集群内的RPC往返时间。
Raft 结论
Raft的设计目标中很重要的一点是容易理解。因为一个算法从论文到实际可用的系统,中间隔着无数的实现细节和边界情况处理。毕竟,算法再牛,如果没人能真正搞懂它,那它就是个“黑盒子”。当这个“黑盒子”出问题时,没人敢维护和修复。
同时,基于好的设计方法实现的系统,也必然有好的可理解性和正确性。Raft的作者发现,基于这个原则,不仅真的让Raft变得更容易理解,还反过来帮助他们更容易地证明算法的正确性。因为当逻辑清晰、状态简单时,分析系统的所有可能性、找出潜在漏洞也变得更容易了。
全文总结
Raft协议通过将共识问题分解为领导者选举、日志复制和安全性三个子问题,并采用强领导者模型,极大地降低了分布式共识算法的理解和实现门槛。同时,通过减少状态数量来减少状态转换,并提供安全性语义保证,尽可能消除各种不确定性。Raft如今已成为构建分布式系统的首选方案之一,在知名的开源系统(例如etcd、Consul、TiDB)中,随处都可以看到Raft的身影。希望本文的深度剖析,能帮助你更扎实地掌握这一核心协议。如果你想与其他开发者交流学习心得,欢迎访问云栈社区。