分布式系统(34):复制 KV、分片迁移与故障恢复
前面的实验分别验证了复制、请求去重、分片和重配置。结课系统要回答更难的问题:这些机制放进同一个写入路径后,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 | |
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。
工程结论
复制、配置与迁移各自保存不同事实。复制组回答某条命令是否由多数副本接受;配置记录当前路由;迁移收据证明某个有身份的阶段已经发生。恢复路径必须从这些持久事实重建,不能依据超时、退出码或调用方记忆猜测。
下一篇把范围从“实现一个有界系统”转到“验证一个有界研究问题”:先写可证伪的假设和基线,再决定实验能支持多强的结论。
参考资料
- Ongaro and Ousterhout, 2014, In Search of an Understandable Consensus Algorithm:复制、选主与安全性边界。
- MIT 6.5840, 2026, Lab 5: Sharded Key/Value Service:分片归属、迁移与控制器恢复问题。
- Chang et al., 2006, Bigtable:tablet assignment 与恢复。
- Herlihy and Wing, 1990, Linearizability:历史模型与实时顺序。
- Gray, 1981, The Transaction Concept:持久日志、提交与恢复。
- etcd v3.7, Failure modes:多数可用性与 leader 故障。
