从 Raft 到 Etcd:一致性协议的工程视角
Raft 论文的好处是它把一致性讲得非常干净:leader 选举、日志复制、安全性,三块拼起来就是一个可证明正确的协议。但当你打开 Etcd 的源码,会发现论文里那些"显然"的部分,在工程实现中占据了不到三成的代码量。剩下的七成,全在处理论文里一句话带过的异常路径。
raft 库的边界:它不做 IO
这是理解 go.etcd.io/raft 的第一个关键点,也是最容易困惑的地方。raft 库是一个纯状态机:你通过 Node.Propose() 喂进去提案,通过 Node.Ready() 拿到一个 Ready 结构体,里面装着"你现在应该持久化哪些日志、应该给谁发什么消息、哪些日志已经 commit 可以 apply"。
所有的磁盘写入、网络发送、apply 到业务状态机,都由调用方(etcdserver)自己完成,完成后再调 Advance() 告诉 raft 库"我处理完了,可以给我下一个 Ready"。
// etcdserver/raft.go 的主循环骨架(简化)
for {
select {
case rd := <-r.Ready():
// 1. 先持久化 HardState 和 Entries(顺序不能反)
r.raftLog.Save(rd.HardState, rd.Entries)
// 2. 发送消息
r.send(rd.Messages)
// 3. apply 已提交的日志
r.applyAll(rd.CommittedEntries)
// 4. 通知 raft 可以继续
r.Advance()
}
}
这里第 1 步和第 3 步的顺序是正确性要求而不是性能优化:如果在持久化之前就 apply,进程崩溃重启后会出现"状态机已经变了但日志丢了"的不一致。
选举:论文里的随机超时,工程里的三重保险
论文说"每个 follower 在 150–300ms 之间随机选一个超时时间"。Etcd 的实现除了这个随机化,还加了几个东西:
- PreVote:一个网络分区的节点会不断自增 term,等它重新连上集群时会用高 term 强制打断当前 leader。PreVote 让节点在真正发起选举前先探测"我有没有可能赢",避免这种破坏。
- CheckQuorum:leader 定期检查自己是否还能联系到多数派,不能就主动降级为 follower。这解决了 leader 被分区后仍然认为自己是 leader 的问题。
- Leadership Transfer:主动移交 leader 身份,用于成员变更和优雅下线。这个在论文里完全没有。
PreVote 和 CheckQuorum 必须一起开。只开 CheckQuorum 会让被分区的 leader 反复降级又反复参选,反而加剧抖动。
日志复制:一个被误解的优化
很多人以为 leader 会给每个 follower 维护一个发送窗口做流控。Etcd 确实有 MaxInflightMsgs,但它控制的是未确认的 AppendEntries 数量,不是字节数。这意味着如果单条 entry 很大(比如一次大的事务),inflight 限制就形同虚设。
所以 etcd 在更上层做了 --max-request-bytes(默认 1.5MB)的硬限制。这也是为什么"etcd 不适合存大 value"不是一句经验之谈,而是协议实现层面的直接约束。
线性一致读:ReadIndex 与 LeaseRead
Raft 论文第 6.4 节提到读操作也要走日志才能保证线性一致,但那样代价太高。Etcd 用了两个优化:
- ReadIndex:leader 记录下当前的 commit index,然后向多数派发一轮心跳确认自己还是 leader,等到 apply index 追上 commit index 后再返回读结果。省掉了写日志。
- LeaseRead:更进一步,leader 认为自己在一个租约期内必然是 leader(因为 follower 的选举超时更长),连心跳都省了。
LeaseRead 快,但它依赖时钟假设。如果 leader 发生长时间 STW GC 或者虚拟机被暂停,租约可能实际已过期而 leader 不自知,此时会读到陈旧数据。Etcd 默认开启 LeaseRead,这是一个用正确性换性能的显式取舍——生产环境如果对线性一致读有强要求,值得重新评估这个默认值。
读源码的建议路径
不要从 etcdserver/server.go 开始,那里全是胶水代码。推荐顺序:
raft/raft.go里的stepFollower/stepCandidate/stepLeader三个函数——协议的全部状态转移都在这里,且和论文一一对应。raft/log_unstable.go和raft/tracker.go——理解日志管理和 per-follower 进度。raft/node.go的run循环——理解 Ready 机制。- 最后才回到 etcdserver,看它怎么驱动这个状态机。
配合 raft/testutil 里的网络模拟器写几个测试,比单纯读代码有效得多。你可以人为制造分区、丢包、延迟,然后观察 term 和 commit index 的变化——这比任何博客讲得都清楚。