前面的实验分别验证了复制、请求去重、分片和重配置。结课系统要回答更难的问题:这些机制放进同一个写入路径后,leader 停止、旧副本重启、迁移 worker 中途退出时,哪些状态必须一起恢复,哪些保证仍然缺失?

本篇实现一个有界文件模型。它有两个三副本 KV 组、两分片和三份配置镜像,运行一条固定故障轨迹。模型刻意不复刻 Raft、真实网络与磁盘掉电;每项观察只证明源码中写出的有限状态转换。

分布式系统(33):PBFT、恶意节点与信任边界

系统模型与不变量

键首字母小于 n 的属于 shard 0,其余属于 shard 1。初始配置是 shard 0 -> A@epoch1、shard 1 -> B@epoch1。A、B 各有三个副本,配置控制面也保存三份 JSON 镜像。

flowchart TB
  C[三份配置镜像<br/>owner + per-shard epoch] --> R[客户端路由]
  R -->|shard 0, epoch 1| A[A组三副本]
  R -->|shard 1, epoch 1| B[B组三副本]
  A --> F[(独立JSON状态)]
  B --> G[(独立JSON状态)]

数据组的写入由当前 leader 发起。至少两个 live 副本处在相同日志前缀时,命令才会追加和应用;成功响应带确认副本列表。选主规则是确定性的:多数仍存活时,从日志最长的节点中选字典序最小者,并提升任期。它演示提交、选主与追赶的职责分界,但没有实现 RequestVote、AppendEntries、随机超时或读屏障,所以不能称为 Raft。

实验检查四条不变量:

  • 成功写入必须得到至少两个副本确认;
  • 同一个 request_id 重试只返回原结果,换参数复用则拒绝;
  • 客户端请求的 epoch 必须等于该组保存的分片 epoch;
  • 迁移完成后,源组保留新 epoch 墓碑并拒绝旧请求。

配置中的 epoch 必须按分片保存。若只维护一个全局 epoch,迁移 shard 0 到 epoch 2 会让仍留在 B 的 shard 1 被错误路由为 epoch 2;它本地仍是 epoch 1,于是正常的 zebra 读取也会收到 ErrWrongGroup。本次正式运行前,实验正是通过这个失败暴露并修复了该缺陷。

写入、提交与客户端去重

一次 Put(apple, 10, client-1:1) 先验证 owner、epoch、冻结状态和请求身份,再形成日志命令。leader 只把命令交给日志长度正好衔接的 live 副本;确认数不足二时不返回成功。

sequenceDiagram
  participant C as Client
  participant L as A leader
  participant F1 as A follower 1
  participant F2 as A follower 2
  C->>L: Put(key,value,request_id,epoch)
  L->>L: 校验owner/epoch/dedupe
  L->>F1: append + apply
  L->>F2: append + apply
  Note over L,F2: 至少2份持久文件更新
  L-->>C: ok + acknowledgements

去重表保存 request_id -> (fingerprint, result, shard)。完全相同的重试直接返回 deduplicated=true;相同身份携带不同键值则返回 ErrRequestReuse。迁移快照必须同时携带 KV 和去重表,否则旧响应丢失后,客户端向新 owner 重试会再次产生业务效果。

这里的“提交”只表示一次受控进程里的多数文件更新。atomic_write 使用临时文件加 os.replace,没有 fsync,不能推出断电后文件或目录项已落盘。Python 调用也不是跨机器 RPC。

断主、换主与旧副本追赶

固定轨迹先写入 apple=10,随后停止 A 的 leader a0。A 仍有 a1/a2 两个 live 副本,确定性选主把 a1 提升到 term 2;新 leader 提交 apple=20,同一请求再发一次命中去重记录。

旧 leader 重启时不能直接恢复服务。模型从当前 leader 复制日志、KV、去重表、分片 epoch 和迁移元数据,再把 a0 加回 live 集合。正式输出中 A 的三个完整状态摘要最终相同,日志长度均为 4。

stateDiagram-v2
  [*] --> LeaderA0
  LeaderA0 --> Stopped: stop a0
  Stopped --> LeaderA1: a1/a2形成多数并选主
  LeaderA1 --> Updated: 提交apple=20
  Updated --> CaughtUp: a0复制当前leader完整状态
  CaughtUp --> [*]

复制完整状态是一条教学捷径。真实共识实现要处理日志冲突、快照边界、持久任期、投票和落后副本渐进追赶;本模型没有覆盖这些路径。

迁移不是复制日志的别名

复制解决一个组内“同一命令是否由多数保存”;迁移解决两个组间“谁有资格服务某个分片”。配置控制面把 owner 和每分片 epoch 绑定在一起,数据面保存分片内容与请求身份。分片归属变更也不是复制组成员变更:本实验从未改变 A、B 各自的三个成员。

实验把 shard 0 从 A@1 迁到 B@2:

flowchart LR
  P[plan<br/>配置记录migration] --> F[freeze<br/>A拒绝shard 0新请求]
  F --> S[snapshot<br/>KV + dedupe]
  S --> I[install<br/>B多数保存epoch 2]
  I --> C[cutover<br/>配置改为B@2]
  C --> T[tombstone<br/>A删除数据并留epoch 2]
  T --> D[done]

freeze 先于快照,阻止复制期间源分片继续变化。快照只挑出 shard 0 的键和值及其去重记录。install 用 action_id=install:move-shard-0-epoch-2 做控制动作去重;同一动作、同一摘要的重试成功返回,身份相同而内容不同则拒绝。

cutover 只修改 shard 0 的 owner 和 epoch。shard 1 仍保持 epoch 1,因此 zebra=7 在迁移后继续可读。源组最后执行 tombstone,删除 shard 0 的 KV 与去重项,同时永久保存 tombstones[0]=2。旧客户端随后向 A@1 写 apple=99,得到 ErrStaleConfig。

这个协议没有跨组原子提交。A 冻结后到配置切换前可能暂时没有可用的正常路由;模型用停止服务换取单 owner 安全。目标安装后已持有 epoch 2 状态,但正常客户端仍只能从控制面取得路由。未授权直连、多个控制器竞争以及目标激活的独立屏障都没有模拟。

install 已完成而 worker 退出

最容易误判的窗口位于目标 install 与控制面记录之间。迁移子进程把快照提交到 B 后立即调用 os._exit(73),控制面仍显示 phase=frozen。这不代表 install 没发生。

sequenceDiagram
  participant W1 as Worker PID 1
  participant A as Source A
  participant B as Destination B
  participant C as Controller files
  W1->>A: export frozen snapshot
  W1->>B: install(action_id, digest)
  B-->>W1: committed
  W1--xW1: os._exit(73)
  Note over C: phase仍为frozen
  participant W2 as Worker PID 2
  W2->>B: retry same install
  B-->>W2: deduplicated
  W2->>C: installed -> cutover -> done
  W2->>A: tombstone epoch 2

父进程确认首个 worker 退出码为 73,再用同一状态目录启动恢复 worker。第二个进程读取持久文件,重复 install 命中已有收据,只产生一份 install: receipt,然后完成 cutover 与 tombstone。正式观察中 resume_exit=0、最终 phase 为 done。

恢复依赖的是持久动作身份,不是“父进程记得上次走到哪”。不过本实验只注入一个退出点;freeze 后、cutover 后、tombstone 后的崩溃,以及控制面自身故障,都没有运行。

顺序历史检查的证明范围

实验把每个客户端操作按完成次序记录为公开历史。检查器从空 map 开始:成功 Put 更新参考值,成功 Get 必须等于当时的参考值;明确失败的旧 epoch 请求不改变状态。10 个成功操作的投影得到 apple=30、zebra=7,检查结果为 valid=true。

随后复制历史并把最后一次 Get(apple) 从 30 篡改为 20。检查器在 step 9 返回 bad read。这个负例证明检查器不是无条件打印 PASS。

它仍不是通用线性一致性检查器。历史没有并发调用区间、pending 操作、跨客户端实时边或未知响应;检查过程也不搜索所有可能的串行化次序。它只验证固定顺序工作负载是否符合一个简单 KV 规范。

实际运行

1
2
3
4
5
6
mkdir -p examples/distributed-systems/.build/capstone34/tmp
export TMPDIR="$PWD/examples/distributed-systems/.build/capstone34/tmp"
export TMP="$TMPDIR" TEMP="$TMPDIR" PYTHONDONTWRITEBYTECODE=1
python3 -B examples/distributed-systems/capstone34/check.py \
--state-dir examples/distributed-systems/.build/capstone34/run-final-3 \
--output examples/distributed-systems/.build/capstone34/observations-final.json

Python 3.12.3 正式运行退出 0;迁移 worker 首次退出 73,恢复进程退出 0。最终 apple 由 B 持有且值为 30,zebra 仍为 7;A 的 shard 0 墓碑为 epoch 2,旧 epoch 写被拒绝。A、B 各组三副本分别只有一个完整状态摘要,目标 install 收据数为 1。--help 退出 0,缺少必需的 --state-dir 退出 2。

结构化输出见观察结果,来源与运行证据见实验证据,验证边界见验证说明。

未覆盖故障表

未覆盖项 为什么重要 当前不能声称什么
真实网络延迟、丢包、重排和分区 会产生任意消息交错与未知响应 不能声称通过网络故障测试
Raft 选举、日志冲突与快照 当前选主与追赶是确定性整状态复制 不能声称实现了共识协议
配置 leader 故障和多控制器并发 三份配置由一个进程同步更新 不能声称控制面高可用或竞争安全
每个迁移持久边界的崩溃矩阵 当前只测 install 后退出 不能推广到任意指令点恢复
目标 STAGED 与 ACTIVATE 分离 install 后目标已具备服务 epoch 不能声称有独立激活栅栏
fsync、断电、撕裂写和磁盘损坏 os.replace 不等于介质持久化 不能声称断电不丢数据
并发客户端与通用历史搜索 只有单线程完整操作 不能声称已验证线性一致性
跨分片事务 两个分片独立服务 不能声称跨分片原子性
去重记录回收 请求身份永久保留 不能声称空间有界
拜占庭节点、认证与授权 摘要只检测内容,不证明来源 不能声称抵抗恶意节点
负载、尾延迟与容量 没有真实 RPC 或并发压力 不能给出性能结论

两个推演练习

为什么 tombstone 不能只删数据,不保存 epoch?

旧客户端可能长期缓存 A@epoch1。若 A 删除数据后忘记自己曾服务过 shard 0,重启或错误初始化可能再次把旧请求当作合法请求。保存 epoch 2 墓碑后,任何 epoch <= 2 的旧写都能稳定返回 ErrStaleConfig;它仍不替代认证和新配置获取。

install 成功、控制面尚未记录时,客户端重试迁移会发生什么?

恢复 worker 从控制面看到 frozen,于是再次导出同一快照并重发相同 action_id。B 比较已有收据摘要,相同则返回去重成功,不重复覆盖;若同一动作身份携带不同摘要,应返回冲突错误。随后控制面才能推进 installed 和 cutover。

工程结论

复制、配置与迁移各自保存不同事实。复制组回答某条命令是否由多数副本接受;配置记录当前路由;迁移收据证明某个有身份的阶段已经发生。恢复路径必须从这些持久事实重建,不能依据超时、退出码或调用方记忆猜测。

下一篇把范围从“实现一个有界系统”转到“验证一个有界研究问题”:先写可证伪的假设和基线,再决定实验能支持多强的结论。

参考资料