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当作"参考指南"而非"严格规范"。

一个典型的例子是选举超时重置的时机。很多实现者会在收到任何AppendEntriesRequestVote 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的leaderAppendEntries才应该重置计时器
  • 只有投票给某个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:

  1. 节点4无法连接到leader,超时后发起选举,term增加
  2. 节点2收到RequestVote,更新自己的term,迫使节点3下台
  3. 新的leader(可能是1、2或3之一)被选出
  4. 但是节点4或节点5(或两者)仍然无法连接到新leader
  5. 这些节点超时后继续发起新的选举,导致新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后再执行读操作。

这里有几个微妙的实现细节:

  1. 心跳确认必须在记录commitIndex之后:如果先发心跳再记录commitIndex,可能导致读取到的数据不包含刚刚commit的写入。

  2. applyIndex的等待必须是异步的:如果在RPC处理线程中阻塞等待,会导致整个系统无法处理其他请求。

  3. 必须使用当前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。这意味着:

  1. 执行权限写操作
  2. consistentIndex增加,但未持久化
  3. 节点重启
  4. consistentIndex恢复为旧值
  5. 权限操作被重复apply

这个bug存在了三年之久,最终通过将consistentIndex持久化与权限操作放入同一个事务来修复。

快照与恢复的原子性

当节点崩溃并恢复时,如果快照和Raft状态分别持久化,可能出现以下情况:

  1. 快照已经持久化
  2. Raft状态(包括log)尚未持久化
  3. 节点崩溃

恢复后,节点可能拥有最新的快照,但log中仍然包含快照覆盖范围内的entry。如果commitIndexlastApplied未持久化,这些entry可能被重复apply。

正确的实现需要引入一个firstIndex字段,记录持久化log的第一个entry对应的"真实"index,恢复时与快照的lastIncludedIndex比较,丢弃重复的部分。

成员变更的两阶段陷阱

Raft论文描述了成员变更的"两阶段"方法,但原始论文中的描述存在一个微妙的安全漏洞。

论文建议使用joint consensus(联合共识):在变更过程中,新旧配置同时生效,任何决策都需要同时获得新旧配置的多数同意。这保证了安全性,但引入了可用性问题:在joint consensus期间,系统需要更多节点才能做出决策。

更简单的"单节点变更"方法——每次只添加或删除一个节点——也存在边界条件。考虑一个从3节点扩展到5节点的场景:

  1. 集群当前配置为[A, B, C]
  2. 添加节点D
  3. 在D的配置变更被commit之前,添加节点E
  4. 此时可能出现两个不同的majority:{A, B, C, D}和{A, B, C, E}
  5. 如果网络分区,可能同时出现两个leader

正确的实现需要确保:同一时间只能有一个未完成的成员变更,并且变更完成后才能开始下一个。

从etcd的bug看实现复杂度

etcd作为最成熟的Raft实现之一,仍然存在过严重的一致性bug。2020年,腾讯云团队发现了一个存在三年的数据不一致问题,其根因可以追溯到Raft实现的复杂性。

这个bug的影响范围是:所有开启鉴权的etcd3集群,在特定场景下会导致节点间数据不一致,而etcd对外表现仍可正常读写,日志无明显报错。

触发条件:

  1. 执行grant-permission操作
  2. 操作后立即重启节点
  3. 重启前无其他数据写入

问题根因: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采用了三种优化:

  1. 批处理:leader收集多个请求后一起发送给follower
  2. 流水线:leader不等待follower响应就发送后续log
  3. 并行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库),而非从零开始。


参考文献

  1. Ongaro, D., & Ousterhout, J. (2014). In Search of an Understandable Consensus Algorithm. USENIX ATC.
  2. Ongaro, D. (2014). Consensus: Bridging Theory and Practice. PhD Thesis, Stanford University.
  3. Gjengset, J. (2016). Students’ Guide to Raft. MIT 6.824 Course Materials.
  4. Howard, H., et al. (2015). Raft Refloated: Do We Have Consensus? ACM SIGOPS Operating Systems Review.
  5. Cloudflare. (2020). A Byzantine Failure in the Real World. Cloudflare Blog.
  6. etcd Issue #11652: Data Inconsistency Bug Fix.
  7. Howard, H., et al. (2020). Raft does not Guarantee Liveness in the Face of Network Faults.
  8. TiKV Blog. (2020). How TiKV Uses “Lease Read” to Guarantee High Performance.
  9. TiKV Blog. (2017). A TiKV Source Code Walkthrough – Raft Optimization.
  10. Bolina, J. (2024). Correctness in Distributed Systems: The Case of jgroups-raft. Red Hat Research.
  11. LeaseGuard Paper (2026). SIGMOD ‘26.