2014年,斯坦福大学的Diego Ongaro和John Ousterhout在USENIX ATC会议上发表了一篇题为《In Search of an Understandable Consensus Algorithm》的论文。这篇论文提出了Raft协议,声称比Paxos"更容易理解"。论文开篇直言:Paxos"非常难以理解",而Raft的设计目标就是提供"更好的可理解性"。
十年过去了,Raft确实成为了工业界的主流选择。etcd、Consul、TiKV、CockroachDB等知名系统都基于Raft构建。然而,一个讽刺的现象也随之浮现:大量开发者发现,读通Raft论文只是第一步,真正动手实现时,却陷入了一个又一个的陷阱。
MIT的6.824分布式系统课程是最早采用Raft作为实验内容的高校之一。课程的助教Jon Gjengset在2016年写了一篇《Students’ Guide to Raft》,详细记录了学生们在实现Raft时反复踩坑的问题。这篇文章后来成为了Raft社区的必读文档之一。
Figure 2不是建议,是规范
Raft论文的Figure 2是协议的核心规范,它定义了每个RPC的处理逻辑、服务器状态转换规则和关键不变量。许多实现者的第一个错误,就是把Figure 2当作"参考指南"而非"严格规范"。
一个典型的例子是选举超时重置的时机。很多实现者会在收到任何AppendEntries或RequestVote RPC时重置选举计时器——直觉上这很合理,因为这些消息表明有其他节点要么是leader,要么正在竞选leader。然而,Figure 2明确写道:
If election timeout elapses without receiving AppendEntries RPC from current leader or granting vote to candidate: convert to candidate.
关键词是"current leader"和"granting vote"。这意味着:
- 只有来自当前term的leader的
AppendEntries才应该重置计时器 - 只有投票给某个candidate时才重置计时器
- 仅仅收到一个
RequestVote请求,不应该重置计时器
为什么这个区别如此重要?考虑一个网络分区的场景:被隔离的节点会不断发起选举,term不断增加。如果其他节点每次收到它的RequestVote就重置计时器,就会导致整个集群无法稳定地选出leader。正确的做法是,只有拥有更完整日志的节点发起选举时,才不会被其他节点的选举请求干扰。
心跳不是特殊的消息
Raft论文中多次提到"heartbeat RPC",即leader定期发送空的AppendEntries消息以阻止follower发起选举。许多实现者由此产生了一个误解:心跳是一种"特殊"的消息,应该有特殊的处理逻辑。
一个常见的错误实现是,收到心跳后,follower简单地重置选举计时器并返回成功,跳过Figure 2中规定的所有检查。这是极度危险的。AppendEntries的成功响应隐含了一个承诺:follower的日志与leader的日志在prevLogIndex之前是一致的。如果leader收到这个响应后,错误地认为某个entry已经被多数节点复制,进而commit这个entry,就会导致数据不一致。
另一个相关错误是在收到心跳时,无条件地截断日志。Figure 2的规定是:
If an existing entry conflicts with a new one (same index but different term), delete the existing entry and all that follow it.
关键词是"if"——只有在存在冲突时才截断。如果follower已经拥有leader发送的所有entry,不应该截断任何内容。原因是:这可能是一个过时的AppendEntries消息,截断日志意味着"撤销"了之前可能已经告诉leader自己拥有的entry。
活跃性陷阱:Raft不保证在网络故障下的可用性
2020年11月,Cloudflare遭遇了一次长达6小时的大规模故障。根因分析显示,etcd集群在网络设备故障后无法选出稳定的leader,导致整个控制平面瘫痪。这次故障引发了业界对Raft活跃性保证的深入讨论。
Raft论文声称:
[Consensus algorithms] are fully functional (available) as long as any majority of the servers are operational and can communicate with each other and with clients.
这句话暗示:只要多数节点之间能够正常通信,Raft就应该能够正常工作。然而,在特定的网络故障模式下,原始Raft协议无法保证活跃性。
考虑一个5节点集群的场景:节点1、2、3相互连通;节点4只与节点2相连;节点5只与节点3相连。假设节点3是当前的leader:
- 节点4无法连接到leader,超时后发起选举,term增加
- 节点2收到
RequestVote,更新自己的term,迫使节点3下台 - 新的leader(可能是1、2或3之一)被选出
- 但是节点4或节点5(或两者)仍然无法连接到新leader
- 这些节点超时后继续发起新的选举,导致新leader被迫下台
这个循环会无限持续下去,系统永远无法建立稳定的leader。
PreVote和CheckQuorum
Raft的学位论文(Ongaro 2014)提出了两个解决方案:
PreVote:在正式发起选举之前,candidate先发起一个"预投票"阶段,测试自己是否能够获得多数节点的支持。一个节点只有在以下条件满足时才会给予预投票支持:
- candidate的term大于或等于自己的term
- 自己在选举超时时间内没有收到leader的心跳
PreVote解决了上述场景中的问题:节点4和节点5无法获得多数节点的预投票支持(因为节点1、2、3仍然定期收到leader的心跳),因此不会增加term,也不会干扰现有leader。
CheckQuorum:leader主动检查自己是否仍然获得多数节点的响应。如果在选举超时时间内没有收到多数节点的响应,leader主动下台。这解决了另一种场景:leader被网络分区隔离后,虽然无法提供服务,但仍会持续发送心跳,阻止新leader的选举。
PreVote和CheckQuorum必须同时使用才能完全解决活跃性问题。单独使用PreVote,被隔离的leader仍会阻止新选举;单独使用CheckQuorum,仍可能出现term不断增长导致的活锁。
线性一致读的三大陷阱
Raft是一个强一致性的共识协议,但这并不意味着所有读操作都自动获得强一致性保证。实现正确的线性一致读是Raft实现中最容易出错的领域之一。
陷阱一:直接从leader本地读取
最直观的想法是:既然所有写操作都必须经过leader,那么直接从leader读取不就可以保证一致性了吗?
问题在于:leader并不确定自己是否仍然是leader。在网络分区的情况下,可能已经有一个新的leader被选出,旧leader却毫不知情。此时从旧leader读取,可能读到过时的数据。
陷阱二:ReadIndex的正确实现
Raft论文提出了ReadIndex机制:leader在处理读请求前,先记录当前的commitIndex,然后向多数节点发送心跳以确认自己的leader身份,最后等待applyIndex追上commitIndex后再执行读操作。
这里有几个微妙的实现细节:
-
心跳确认必须在记录commitIndex之后:如果先发心跳再记录commitIndex,可能导致读取到的数据不包含刚刚commit的写入。
-
applyIndex的等待必须是异步的:如果在RPC处理线程中阻塞等待,会导致整个系统无法处理其他请求。
-
必须使用当前term的entry来确认leadership:单纯的心跳确认不足够,必须等到有当前term的entry被commit后,才能确信leadership的有效性。
陷阱三:Lease Read的时钟陷阱
为了优化读性能,许多系统实现了Lease Read:leader通过心跳获得一个"租约",在租约有效期内,可以直接本地读取,无需再次确认。
租约计算公式:lease_expire = last_heartbeat + election_timeout / clock_drift_bound
这里的关键问题是时钟漂移(clock drift)。不同服务器的时钟速率可能有微小差异,如果leader的时钟走得快,它可能认为租约仍然有效,而实际上其他节点已经可以发起新选举了。
TiKV的实现提供了一个参考:使用单调原始时钟(monotonic raw clock)而非单调时钟(monotonic clock),因为后者会受到NTP同步的影响。同时,TiKV将租约时间设置为固定的9秒,而选举超时为10秒,留出1秒的安全裕量。
2025年发表的LeaseGuard论文提出了一个创新思路:log is the lease。不需要额外的租约管理消息,commit一个entry就自动获得租约。新leader当选后,检查自己的log来确定旧leader的租约何时过期。这个设计利用了Raft的Leader Completeness属性——新leader必然拥有所有已提交的entry,因此可以精确推断旧租约的状态。
日志压缩与快照的边界条件
Raft的日志会无限增长,因此需要日志压缩机制。Raft论文第7节描述了快照(snapshot)机制,但很多实现细节没有详细说明。
consistentIndex的持久化问题
etcd曾存在一个严重bug(从2016年引入,2020年才被发现):在执行权限相关的写操作后重启节点,会导致数据不一致。
根因是consistentIndex的持久化机制。Raft使用consistentIndex来保证apply操作的幂等性:每个entry的index必须大于consistentIndex才会被apply,然后consistentIndex被更新为该index。
问题在于,consistentIndex的持久化依赖于MVCC模块的写操作,而权限操作直接写入backend存储,绕过了MVCC。这意味着:
- 执行权限写操作
consistentIndex增加,但未持久化- 节点重启
consistentIndex恢复为旧值- 权限操作被重复apply
这个bug存在了三年之久,最终通过将consistentIndex持久化与权限操作放入同一个事务来修复。
快照与恢复的原子性
当节点崩溃并恢复时,如果快照和Raft状态分别持久化,可能出现以下情况:
- 快照已经持久化
- Raft状态(包括log)尚未持久化
- 节点崩溃
恢复后,节点可能拥有最新的快照,但log中仍然包含快照覆盖范围内的entry。如果commitIndex和lastApplied未持久化,这些entry可能被重复apply。
正确的实现需要引入一个firstIndex字段,记录持久化log的第一个entry对应的"真实"index,恢复时与快照的lastIncludedIndex比较,丢弃重复的部分。
成员变更的两阶段陷阱
Raft论文描述了成员变更的"两阶段"方法,但原始论文中的描述存在一个微妙的安全漏洞。
论文建议使用joint consensus(联合共识):在变更过程中,新旧配置同时生效,任何决策都需要同时获得新旧配置的多数同意。这保证了安全性,但引入了可用性问题:在joint consensus期间,系统需要更多节点才能做出决策。
更简单的"单节点变更"方法——每次只添加或删除一个节点——也存在边界条件。考虑一个从3节点扩展到5节点的场景:
- 集群当前配置为[A, B, C]
- 添加节点D
- 在D的配置变更被commit之前,添加节点E
- 此时可能出现两个不同的majority:{A, B, C, D}和{A, B, C, E}
- 如果网络分区,可能同时出现两个leader
正确的实现需要确保:同一时间只能有一个未完成的成员变更,并且变更完成后才能开始下一个。
从etcd的bug看实现复杂度
etcd作为最成熟的Raft实现之一,仍然存在过严重的一致性bug。2020年,腾讯云团队发现了一个存在三年的数据不一致问题,其根因可以追溯到Raft实现的复杂性。
这个bug的影响范围是:所有开启鉴权的etcd3集群,在特定场景下会导致节点间数据不一致,而etcd对外表现仍可正常读写,日志无明显报错。
触发条件:
- 执行grant-permission操作
- 操作后立即重启节点
- 重启前无其他数据写入
问题根因:consistentIndex未正确持久化,导致权限操作被重复apply。
这个案例揭示了一个深层问题:Raft实现中的许多细节不是协议本身的复杂性,而是协议与存储层、权限层等子系统交互时产生的复杂性。论文中的"简单"假设——“状态机是确定性的”、“持久化是原子的”——在真实系统中往往需要大量的工程工作来保证。
正确性验证:从TLA+到Jepsen
如何确保Raft实现的正确性?业界发展出了多层次的验证方法:
TLA+规范验证
Raft的原始论文包含了一个TLA+规范,定义了协议的关键不变量。许多生产实现(如etcd、CockroachDB)都维护着自己的TLA+规范,用于验证协议变体的正确性。
TiKV团队在实现Raft优化时,发现了一个通过阅读代码难以发现的问题:在特定的并发调度下,一个节点可能长时间无法收到leader的消息,导致活锁问题。这个问题最终通过形式化验证被识别和修复。
Jepsen测试
Jepsen是分布式系统正确性测试的事实标准。它通过注入各种故障(网络分区、进程崩溃、时钟偏移)来探测系统是否违反一致性保证。
Red Hat的jgroups-raft项目在使用Jepsen测试后,发现了多个问题:
- 读操作直接本地执行,返回过时数据
- 选举安全性不变量被违反:不同节点对同一term报告不同的leader
- 成员变更操作在节点故障时挂起
这些问题的根因往往是状态更新的原子性不足。例如,leader状态和term的更新必须原子进行,否则客户端可能看到新leader关联旧term的不一致状态。
性能优化的权衡
Raft的原始设计强调简单性,而非性能。生产环境中的Raft实现通常需要大量优化,但这些优化本身也带来了新的复杂性。
批处理与流水线
TiKV采用了三种优化:
- 批处理:leader收集多个请求后一起发送给follower
- 流水线:leader不等待follower响应就发送后续log
- 并行append:leader在本地append log的同时发送给follower
这些优化显著提升了吞吐量,但也增加了正确性验证的复杂度。例如,流水线要求leader正确处理网络重排和消息丢失的情况。
异步Apply
Raft允许在entry被commit后,异步apply到状态机。这提升了并发度,但引入了新的问题:
- 客户端如何知道请求何时完成?
- 如果apply失败,如何处理?
- 重复apply如何检测?
etcd的实现中,每个客户端请求分配一个唯一的序列号,状态机维护每个客户端的最新序列号,用于去重。
实践指南
基于以上分析,实现一个正确的Raft需要注意以下原则:
严格遵循Figure 2
把Figure 2当作规范而非建议。每一个"if"、“when”、“only"都有精确的含义。不要假设可以简化任何步骤。
区分持久化状态和易失状态
Raft的持久化状态(currentTerm, votedFor, log)和易失状态(commitIndex, lastApplied, nextIndex[], matchIndex[])有不同的生命周期。崩溃恢复时,易失状态需要重新初始化,这会影响系统的正确行为。
正确处理RPC响应中的term
收到RPC响应时,首先检查其中的term:
- 如果term > currentTerm:更新term,转为follower
- 如果term < currentTerm:这是过时的响应,直接忽略
一个常见的错误是在term更新后继续处理响应,这可能导致状态不一致。
实现PreVote和CheckQuorum
如果系统需要容忍网络分区,PreVote和CheckQuorum是必须的。没有这两个机制,系统可能在特定故障模式下无法恢复。
使用形式化验证
对于关键系统,投资TLA+规范和Jepsen测试是值得的。许多bug只有在特定的故障组合下才会触发,手动测试难以覆盖。
Raft的"易于理解"是相对于Paxos而言的,它成功地将共识问题分解为了leader选举、日志复制、安全性三个相对独立的子问题。但这种分解并不意味着实现变得简单——每一个子问题的边界条件、每一个状态转换的时机、每一个RPC的语义,都蕴含着微妙的正确性约束。
论文的简洁性某种程度上掩盖了实现的复杂性。Figure 2只有一页,但它背后的每一个规则都是在大量分布式系统实践中提炼出来的。正如MIT课程经验所示:第一版实现几乎必然有bug,而大多数bug的原因都是"没有严格遵循Figure 2”。
Raft的成功在于它让共识问题变得可讨论、可分析、可验证。但可理解性不等于简单性——从理解协议到正确实现,中间仍然有一条陡峭的学习曲线。这也是为什么在生产环境中,我们倾向于使用经过大规模验证的实现(如etcd的Raft库、HashiCorp的Raft库),而非从零开始。
参考文献
- Ongaro, D., & Ousterhout, J. (2014). In Search of an Understandable Consensus Algorithm. USENIX ATC.
- Ongaro, D. (2014). Consensus: Bridging Theory and Practice. PhD Thesis, Stanford University.
- Gjengset, J. (2016). Students’ Guide to Raft. MIT 6.824 Course Materials.
- Howard, H., et al. (2015). Raft Refloated: Do We Have Consensus? ACM SIGOPS Operating Systems Review.
- Cloudflare. (2020). A Byzantine Failure in the Real World. Cloudflare Blog.
- etcd Issue #11652: Data Inconsistency Bug Fix.
- Howard, H., et al. (2020). Raft does not Guarantee Liveness in the Face of Network Faults.
- TiKV Blog. (2020). How TiKV Uses “Lease Read” to Guarantee High Performance.
- TiKV Blog. (2017). A TiKV Source Code Walkthrough – Raft Optimization.
- Bolina, J. (2024). Correctness in Distributed Systems: The Case of jgroups-raft. Red Hat Research.
- LeaseGuard Paper (2026). SIGMOD ‘26.