4. etcd 中的 Raft 实现:etcd-raft 库的模块划分与核心循环

etcd 并未从零实现 Raft 协议,而是将核心共识逻辑封装在独立的 etcd-raft 库中。该库是 etcd 分布式一致性引擎的基石,严格遵循 Raft 的模块化设计,对外提供清晰的 Node 接口,并通过 Propose、Step、Ready 三阶段循环驱动状态机运转。理解这一架构是掌握 etcd 压测行为与故障恢复原理的前提。

4.1 模块划分:分层解耦的共识引擎

etcd-raft 库采用分层架构,将网络传输、存储持久化与核心共识算法彻底分离。这种设计使得库本身不依赖任何特定的网络库或存储后端,便于集成与测试。

模块 职责 核心结构/文件
raft 核心共识算法:Leader 选举、日志复制、安全性保证 raft.go, log.go
rawnode 无状态封装层,将 raft 算法逻辑包装为可调用的函数集合 rawnode.go
node 有状态运行时节点,管理消息通道、定时器、应用层交互 node.go
storage 日志存储与快照存储的抽象接口 storage.go
transport 网络传输层抽象(etcd 内部使用,非库核心) transport.go
关键设计原则:etcd-raft 库是一个“无状态”的算法核心 + “有状态”的运行时封装。算法核心不维护任何定时器或网络连接,所有外部事件(消息到达、超时)都通过 Step 方法注入。

4.2 Node 接口:应用层与共识层的契约

Node 接口是 etcd-raft 库暴露给上层应用(如 etcdserver)的核心抽象。它定义了应用层如何驱动共识循环、提交日志以及获取集群状态变更。

// 简化后的 Node 接口定义
type Node interface {
    // 向 Raft 集群提交一个提议(客户端请求)
    Propose(ctx context.Context, data []byte) error
    // 向 Raft 节点注入一个外部消息(如来自其他节点的 RPC)
    Step(ctx context.Context, msg pb.Message) error
    // 阻塞等待并返回一个 Ready 结构,包含所有待处理的状态变更
    Ready() <-chan Ready
    // 通知 Raft 节点已持久化指定索引的日志
    Advance()
    // 返回当前节点的状态(角色、Term、Leader 等)
    Status() Status
    // 返回只读的集群配置变更通道
    Campaign(ctx context.Context) error
    // ... 其他方法如 ReadIndex, TransferLeadership 等
}
接口设计意图:Node 接口将 Raft 算法的内部复杂性隐藏起来,应用层只需调用 Propose 提交数据,通过 Ready 通道消费结果,并通过 Advance 告知库“我已处理完毕”,形成一个简洁的驱动闭环。

4.3 Propose / Step / Ready 循环:状态机的驱动心脏

这是 etcd-raft 库最核心的运行模式。应用层在一个独立的 goroutine 中循环执行以下三个步骤,驱动 Raft 状态机持续向前推进。

  1. Propose(提议):应用层收到客户端写请求后,调用 node.Propose()。该方法将请求数据封装为 pb.Entry,并生成一个 MsgProp 消息,通过内部通道发送给 Raft 状态机。
  2. Step(步进):Raft 状态机在内部循环中调用 node.Step() 处理所有入站消息。这些消息包括:
    • 来自 Propose 的本地提议消息。
    • 来自网络层的远程节点消息(如 MsgVote、MsgApp、MsgHeartbeat)。
    • 来自定时器的超时消息(如 MsgHup 触发选举)。
    每次 Step 调用都可能改变 Raft 的内部状态(如日志追加、Term 变更、Leader 切换)。
  3. Ready(就绪):处理完一批消息后,Raft 状态机将累积的状态变更打包成一个 Ready 结构,放入 node.Ready() 返回的 channel 中。应用层从 channel 取出 Ready 后,需要执行:
    • 持久化:将 Ready.Entries 写入磁盘 WAL。
    • 发送:将 Ready.Messages 通过网络发送给其他节点。
    • 应用:将 Ready.CommittedEntries 应用到状态机。
    • 快照:处理 Ready.Snapshot。
    处理完成后,调用 node.Advance() 通知 Raft 库可以生成下一个 Ready。
Propose / Step / Ready 核心循环 应用层 (etcdserver) 接收客户端请求 Propose() Raft 状态机 处理 MsgProp Step() 处理所有消息 网络消息 (MsgVote, MsgApp...) 定时器事件 (MsgHup 选举超时) 生成 Ready 结构 打包状态变更 Ready() 持久化 WAL 发送消息 Advance() 通知处理完成 应用 Committed Entries 到状态机

4.4 软状态与硬状态:Raft 节点的双重视图

etcd-raft 库将 Raft 节点的状态明确区分为 软状态 (SoftState) 和 硬状态 (HardState),这一区分直接指导了持久化策略与故障恢复逻辑。

状态类型 包含字段 持久化要求 恢复方式
软状态 (SoftState) Lead (当前 Leader 节点 ID)
RaftState (节点角色: Follower/Candidate/Leader)
无需持久化 重启后通过选举或心跳重新获取
硬状态 (HardState) Term (当前任期号)
Vote (当前任期投给谁)
Commit (已提交的最高日志索引)
必须持久化到 WAL 重启后从 WAL 中恢复,确保不重复投票、不丢失已提交日志
压测中的关键影响:在压测场景下,频繁的 Leader 选举和网络分区会导致 HardState 的 Term 和 Vote 字段快速变化。如果 WAL 写入延迟过高(如磁盘 I/O 瓶颈),可能导致节点无法及时持久化硬状态,从而在崩溃恢复后出现“双 Leader”或“日志不一致”等严重问题。这是压测中需要重点监控的指标。

4.5 Ready 结构:应用层与共识层的“数据快递”

Ready 结构是 etcd-raft 库与应用层之间传递状态变更的唯一载体。理解其每个字段的含义,是正确实现应用层逻辑的基础。

// 简化后的 Ready 结构定义
type Ready struct {
    // 需要持久化到 WAL 的日志条目
    Entries []pb.Entry
    // 需要持久化的硬状态(Term, Vote, Commit)
    HardState pb.HardState
    // 需要持久化的快照
    Snapshot pb.Snapshot
    // 需要发送给其他节点的消息
    Messages []pb.Message
    // 已提交且可以应用到状态机的日志条目
    CommittedEntries []pb.Entry
    // 是否必须同步持久化(如快照写入)
    MustSync bool
}
处理顺序要求:应用层在处理 Ready 时,必须严格按照“先持久化,后发送,再应用”的顺序执行。具体来说:先调用 Storage.Save(HardState, Entries, Snapshot) 写入 WAL,再调用 Transport.Send(Messages) 发送网络消息,最后调用 StateMachine.Apply(CommittedEntries) 应用到状态机。违反此顺序可能导致数据丢失或状态不一致。
Ready 结构处理流程与持久化边界 Ready 结构 • Entries • HardState • Snapshot • Messages • CommittedEntries • MustSync 持久化到 WAL (Entries, HardState, Snapshot) 网络发送 Messages 应用到状态机 持久化边界 WAL 写入完成后 节点即使崩溃 重启后仍可恢复 未发送的消息 会由 Raft 协议 重试机制保证送达

4.6 总结:etcd-raft 库的设计哲学

  • 无状态核心 + 有状态外壳:算法逻辑与运行时环境解耦,便于测试和移植。
  • 消息驱动:所有状态变更都通过 Step 方法以消息形式注入,包括本地提议和远程 RPC。
  • 显式状态传递:通过 Ready 结构明确告知应用层需要做什么,应用层通过 Advance 确认完成,形成背压控制。
  • 软硬状态分离:硬状态必须持久化以保证崩溃恢复的安全性,软状态允许丢失后重新学习。
压测启示:在压测环境中,Ready 结构的处理速度直接决定了 etcd 的吞吐量。如果应用层处理 Ready 过慢(如 WAL 写入成为瓶颈),会导致 Raft 状态机无法生成新的 Ready,从而阻塞 Propose 调用。监控 etcd_disk_wal_fsync_duration_seconds 和 etcd_server_proposals_failed_total 指标,可以快速定位此类问题。