三节点 Raft KV 项目最容易让人误判的一点是:三个节点都能启动,正常写入也能成功,就以为项目已经“能用了”。
真正让项目翻车的,往往不是选举流程背得熟不熟,而是这三个边界问题:
- 重启时把未提交日志当成了已提交数据。
- 旧 Leader 在网络分区恢复后,继续用本地状态回答读请求。
- 持久化失败后,系统仍然告诉客户端“写成功了”。
下面是我在真实三节点 HTTP Raft 项目里遇到过的问题和修复方向。
翻车点 1:重启后,未提交日志变成了可见数据
Raft 日志中会存在一种条目:它已经写进了某个节点的本地日志,但还没有被多数节点确认,因此不算提交。
如果恢复逻辑过于简单,例如“启动后把本地日志全部重放到状态机”,就可能出现一个问题:
原本没有写成功的数据,节点重启后突然能读到了。
这种数据比丢数据更难排查。因为它看起来“持久化了”,实际上是半成品。
恢复时应明确区分两个位置:
快照位置:已经应用过的状态机快照
提交位置:被多数节点确认、可以安全应用的日志位置
恢复流程应该是:
- 从最新快照恢复状态机。
- 只重放快照之后、commit index 之前的已提交日志。
- 未提交日志只用于日志追赶,不进入可见状态。
判断项目是否做对,可以加一个故障测试:
1. 写入一个 key,但让它停留在未提交状态
2. 重启该节点
3. 确认该 key 不能被读到
翻车点 2:旧 Leader 在分区恢复后,仍然回答“我能读到”
Raft 的容错会有一个容易被忽略的阶段:网络分区发生后,旧 Leader 可能仍然认为自己是 Leader。
如果读路径只看“本地节点是不是 Leader”,分区期间的旧 Leader 就可能直接返回旧数据。
需要确认的不只是“本地状态”,而是:
- 多数节点是否还在当前 term 内响应。
- 本地 applied 是否已经追上 commit index。
只通过本地标志位判断可读性,不算可靠的读路径。
一个实用测试是模拟旧 Leader 分区:
1. 暂停旧 Leader 与其他节点的通信
2. 让集群选出新 Leader
3. 继续写入新数据
4. 恢复旧 Leader 的网络
5. 检查旧 Leader 恢复后是否追赶新日志,而不是继续对外服务旧数据
翻车点 3:落盘失败,却仍然对客户端说“写成功”
分布式项目里最危险的问题不是“报错”,而是“系统以为成功,其实没有落盘”。
如果 Raft 状态或状态机快照写入失败后,系统仍继续接受提案并返回成功,客户端就会收到一个错误承诺。
工程上更稳妥的做法是让故障可见:
- 持久化失败后,在节点状态中记录
persist_error。 - 后续提案不再静默确认。
- 管理后台能够看到该节点处于降级状态。
不是所有错误都要让服务立刻崩溃,但写入路径不能假装成功。
把修复变成可复现证据
这三个问题只靠“读懂了原理”很难真正避免。项目里最好有对应的故障注入测试,而不是只测正常路径。
至少应覆盖:
- Follower 崩溃重启后追平日志。
- Leader 停止后已提交数据仍可读。
- 重启后不重放未提交日志。
- 持久化失败后不再确认写入。
- 分区恢复后旧 Leader 不继续提供旧数据。
完整的测试输出和故障注入记录,我放在公开证据站:
说明:这是一个教学级、作品集级分布式 KV 项目,不是 Redis / etcd / TiKV 的生产替代品。它的价值不是“和 TiKV 比”,而是让你用真实故障场景理解 Raft 为什么需要这些边界。
这三个翻车点里,你觉得哪一个最容易藏到“项目看起来能跑”的背后?我踩过最久的是第一个:重启后把未提交日志变成了可见数据。