外观
实验:分区、重试与恢复
配套 labs/distributed 包含一个固定五节点的 Raft/KV 模型。它把网络改成可控制的连通矩阵,把消息处理改成测试指定的顺序,便于复现最关键的故障路径。无需安装 Go 或外部依赖,只需 Python 3.10+。
sh
cd labs/distributed
python3 -m unittest -v
python3 raft_model.py在网站上阅读源码或下载代码。先读 README 中的假设,再读 Node 和 Cluster。测试名称说明它正在保护哪个性质。
场景一:旧 leader 被隔离
初始 A 成为 leader,复制 x=1。把集群分为 AB 与 CDE。A 在 AB 中追加 x=9,但只有两份确认,不足 3,不能返回已提交成功。C 在 CDE 中获得三票并成为新 leader,写入 x=2。网络恢复后,A 接受更高 term,自己的未提交后缀被替换,最终所有节点状态都是 2。
重点观察 log 与 state:A 的日志一度包含 9,但业务状态仍是 1。这说明追加与应用分离不是形式主义。把 submit 改成追加后直接执行,会让分区测试发现错误。
场景二:回复丢失后重试
先提交请求 ('c',1,'add','counter',3),假设成功回复没有到达客户端。重新选举,再用同一请求身份提交。日志可能再出现相同命令,但 Node.apply 查询结果表,counter 最终仍为 3。改成新 request_id,则是另一次合法加法,counter 变成 6。
把 results 从重放状态移走、仅保存在 leader 临时内存,思考切换后会怎样。快照功能若将来加入,也必须包含去重表。
场景三:旧任期日志的提交规则
先让 A 在少数分区复制一个未提交条目。恢复连接后,让持有该条目的 B 当选新 leader。B 把它复制到所有节点,但它属于旧 term,模型仍不直接提交它。B 再追加本任期 no-op,多数确认后整个前缀提交。这由 test_previous_term_not_committed_by_count_alone 验证。
这不是“所有旧日志都不允许提交”,而是不能只靠计数直接提交旧任期条目;它可以随安全提交的当前任期前缀一起提交。
场景四:重启与读取
restart 保留 term、vote、log,清空内存 KV 和易失进度,再由 leader 的提交信息触发回放。这个过程展示恢复状态的来源,但持久字段只保存在同一 Python 进程中,没有调用磁盘 fsync。
若全部节点都重启,就没有仍持有 commitIndex 的旧 leader。新 leader 不能仅因旧日志又复制到多数就执行它;需提交当前任期条目或 no-op,才重新获得安全的已提交前缀。模型用 submit(leader, None) 显式触发,不自动生成 no-op。测试 test_all_restart_needs_current_term_barrier_then_restores_dedup 还会在恢复后重试旧 add,验证去重表也由回放恢复。
隔离 leader 后,新读取也无法获得确认,因为本模型让读进入日志。生产读优化需要额外证明,不能把读操作“无副作用”误解成“无需共识相关检查”。
验证范围
十六项测试覆盖固定多数、投票与日志新旧、分区冲突恢复、重试去重、重启回放、当前任期提交限制、隔离读和旧 term 拒绝,也覆盖短 AppendEntries 的提交范围、全量重启、未知结果后提交、缓存读结果与可变对象边界。它们没有覆盖真实网络竞态、延迟 RPC 回复、随机并发、磁盘断电、快照或动态成员变更。
进入 MIT 官方实验时,应重新面对 goroutine、锁、定时器与测试网络。在锁内修改本地状态、复制请求参数后解锁发送 RPC;收到回复再检查自己是否仍在对应 term/role。旧回复覆盖新 term 状态,是常见并发 bug。官方环境测试要以所选实验年份为准,不能把此模型的测试当作替代。
进一步练习
- 增加显式消息队列,让旧 RequestVote 回复在新任期才到达。
- 用 nextIndex 代替整段前缀发送,记录重试次数。
- 添加文件持久化,并在“写文件前后、回复前后”注入崩溃。
- 设计快照,验证重试结果表也能恢复。