RAFT 实现难点——论文没告诉你的那些事
论文描述的是"晴天"下的规则。代码要处理的是任意时刻的崩溃、丢包、延迟和分区——任意组合,同时发生。
核心矛盾
论文说的是理想流程,实现要处理的是意外。
两者的差距就是 RAFT 实现难的地方。
六类典型边界情况
1. 老村长回来了(过期 Leader)
场景:Leader A 因网络断开被判定失联,B 当选新 Leader。A 的网络恢复后,它还以为自己是 Leader,继续发指令。
难点:A 和 B 同时发指令,记录本乱套。
正确处理:每条消息带 Term 编号。A 收到 B 的消息,发现 B 的 Term 比自己大,必须立刻承认下台,降回 Follower。
漏实现的后果:两个 Leader 同时写入,数据不一致。
2. 盖章盖到一半,Leader 挂了(上一任的未提交记录)
场景:Leader 把第88条同步给了 A、B,但还没通知 C、D 就挂了。新 Leader 上任,第88条在部分节点上存在。
难点:新 Leader 能不能直接把第88条算作已提交?
论文里最微妙的规定:
新 Leader 不能直接提交上一任 Term 的记录,必须先提交一条自己 Term 的新记录,才能间接让旧记录生效。
为什么?防止一种极端场景:旧记录在新 Leader 上提交,但实际上之前已经被另一个 Leader 覆盖过。
漏实现的后果:已"提交"的记录后来被覆盖,违反 Leader Completeness 铁律。
3. 幽灵消息(过期 RPC)
场景:Candidate C 发出拉票消息,但网络延迟,消息绕了一大圈才到。这期间 B 已当选,Term 从 2 变成了 3。C 的消息带的还是 Term 2。
难点:过期消息干扰已稳定的新任期。
正确处理:收到 Term 比自己小的消息,直接无视,不做任何处理,不回复。
漏实现的后果:旧消息触发不该发生的状态转换,选举结果被破坏。
4. 投票记录不能丢(持久化顺序)
场景:Follower 投票给 B 后,立刻崩溃重启。重启后它还记得自己投过吗?
难点:如果不记得,同一轮可能投两票,破坏"每人每轮只投一票"的保证。
正确处理:投票记录必须在回复投票消息之前写入磁盘(持久化)。
顺序至关重要:
❌ 错误:先发回复 → 再写盘 → 崩溃 → 重启后不记得投过 ✅ 正确:先写盘 → 再发回复 → 崩溃 → 重启后知道投过同样需要持久化的状态:当前 Term、投票给谁、已接受的 Log。
漏实现的后果:同一轮出现两个合法 Leader。
5. 网络分区(脑裂)
场景:集群被切成两半,东边 3 人(含 Leader)、西边 2 人。西边等了一会儿,选出了新 Leader。大雪过去,两边重新联通。
难点:两个 Leader 在分区期间各自写了不同的记录,恢复后谁的算数?
正确处理:
- 西边 2 人凑不够多数(5人村需要3票),实际上什么都没提交
- 东边 3 人可以正常提交,Term 更大
- 恢复联通后,西边 Leader 收到东边更大的 Term,立刻降为 Follower,回滚自己多写的未提交记录
漏实现的后果:分区期间西边写入的记录被当作已提交,恢复后数据冲突。
6. 并发与竞态(共享状态)
场景:同时收到心跳、拉票请求、客户端请求,多个 goroutine(或线程)同时修改 Term、Log、投票状态。
难点:状态更新不是原子的,并发修改导致不一致。
典型竞态:
- 检查 Term 和修改 Term 之间插入了另一个操作
- 发送 RPC 期间状态已经改变,回调处理旧结果
正确处理:对共享状态加锁,且锁的粒度要合适——太粗会死锁,太细会漏保护。
必须持久化的三样东西
| 状态 | 原因 |
|---|---|
| 当前 Term | 重启后不能用旧 Term 参与投票,否则破坏选举唯一性 |
| 投给了谁(votedFor) | 防止同一轮投两票 |
| Log 条目 | 已接受的记录重启后不能丢 |
其余状态(commitIndex、角色等)重启后可以重新推导,不需要持久化。
实现难度总结
| 意外类型 | 核心难点 |
|---|---|
| 消息丢失 | 重试幂等性,流水号去重 |
| 消息乱序/延迟 | Term 编号过滤过期消息 |
| 崩溃重启 | 持久化顺序(先写盘再回复) |
| 网络分区/脑裂 | 分区恢复后未提交记录的回滚 |
| 并发 | 共享状态加锁,RPC 回调检查状态是否过期 |
| Leader 交接 | 新 Leader 不能直接提交上一任的记录 |
每一条单独看都不难。它们可以任意组合同时发生——这才是 RAFT 实现难的地方。
一句话
论文给了你规则,实现要你在规则的每一个缝隙里,堵住所有可能出错的时机。
配合《RAFT 论文——村委会版》一起读效果最佳