本节摘要:崩溃随时会发生,持久化设计的第一步是承认它。本节给出跨过崩溃存活的三件套——重放日志(先写后改)、周期检查点(快照加日志截断)、原子写与校验和;讲清恢复流程怎么回到崩溃前的最后一致态、与第 4 章的幂等指令如何配合做到"已承诺的不重演";最后以拔电演练与"恢复后行为怪异"排错收尾。
运维频道凌晨炸出消息:飞行器主机意外重启,正在执行的转运任务停在一半。十分钟后出现两种结局——恢复流程把目标栈、航线与任务进度原样端回来,飞行器从断点继续飞;或者内存里的状态全部蒸发,恢复后的机器站在原地,像一个仪表完好却失忆的机长。决定走向哪边的,是本节的三件套。它与第 1 章的状态板、第 4 章的幂等指令互为表里:状态板定义了"什么是要保的账",幂等定义了"恢复后怎么不重演",本节补上"怎么让账活过断电"。
持久化设计从崩溃模型开始:进程可能在任意两条指令之间消失——掉电、内核杀进程、异常失控。推论有两条。其一,内存态永远不可信,可信的只有已经落盘的部分;其二,落盘操作自身可能做了一半(写了半页就断电),所以"正在写"的数据要么完整要么不存在——这就是原子写的来由:先写临时文件、刷盘、再原子改名,让"半页账"没有出场机会。校验和随行,读回时验算,损坏即弃用并回退上一份。
三件套的心脏是重放日志(Write-Ahead Log):状态修改之前,先把"要做什么"写进日志并落盘,然后才动状态。崩溃后恢复程序从日志重放,把没做完的补上。与第 4 章的幂等指令配合,重放做到恰好一次的效果:日志条目带完成标记,重放时跳过已完成的——"已承诺的不重演、没承诺的补上"。
import json, os, time, hashlib class DurableState: """可跨崩溃存活的状态:快照 + 重放日志 + 原子写。""" def __init__(self, path): self.path = path self.state = self._load_latest() # 快照 + 重放日志恢复 self._wal = open(os.path.join(path, "wal.log"), "a") def apply(self, mutation): """对外唯一的状态修改入口:先日志后状态。""" entry = {"t": time.monotonic(), "m": mutation, "done": False} self._append(entry) # 先落盘意图 self.state = self._mutate(self.state, mutation) self._mark_done(entry) # 完成标记再落盘 return self.state def checkpoint(self): """周期快照:当前态整体落盘后,截断日志。""" snap = {"state": self.state, "upto": self._wal_seq(), "sum": self._checksum(self.state)} self._atomic_write("snapshot.json", json.dumps(snap)) self._truncate_wal(upto=snap["upto"]) # 已入快照的日志可清 def _atomic_write(self, name, text): tmp = os.path.join(self.path, name + ".tmp") with open(tmp, "w", encoding="utf8") as f: f.write(text) f.flush() os.fsync(f.fileno()) # 逼落盘,防缓存欺骗 os.replace(tmp, os.path.join(self.path, name)) # 原子改名 def _load_latest(self): snap = self._read_snapshot() # 带校验和验算 state = snap["state"] if snap else {} return self._replay_wal(state) # 重放快照之后的日志
读这段实现抓两条主线。恢复路径在构造函数里:读最近有效快照(校验和不过就退上一份),重放其后的日志条目,完成标记缺的补做、有的跳过——崩溃点之后的世界被精确重建。快照与日志的配合是性能账:日志保证不丢,快照控制重放长度,截断让日志不会无限膨胀。检查点的时机按"重放代价"定拍——重放一段日志要多久,快照就该多久打一次;任务关键节点(目标变更、接管前后)强制加打。
恢复不是重放完就完事,收尾三步:一致性断言(状态板自检,字段间关系成立)、在途任务处置(崩溃时执行中的动作,按日志标记决定补做或作废,作废的走 4.3 节复飞阶梯重新上)、重新入环(感知先重估当前信念再继续执行——断电期间世界可能已经变了,旧航线先过期再续飞)。这三步做扎实,重启从灾难变成插叙。
黑匣子是持久化的只读延伸:事故之后,日志以只读方式挂载,复盘者逐帧重建决策链——哪一拍信念怎么变、哪个决策依据哪条信息、接管请求何时发出。它的价值建立在前两章的纪律上:日志不可篡改(5.1)、状态机轨迹完整(4.2)、接管有交接包(4.4)。缺任何一段,黑匣子就只能讲一个断片的故事。

拔电演练的裁判程序同样要落在代码上:
def crash_drill(build_agent, scenario, rounds=100): """拔电演练:随机时刻杀进程,全数恢复才算毕业。""" failures = [] for i in range(rounds): agent = build_agent() kill_at = random_tick(scenario) # 随机拔电时刻 agent.run(scenario, stop_at=kill_at) # 强杀后重建,从检查点与日志恢复 agent2 = build_agent() state = agent2.recover() try: assert_consistent(state) # 一致性断言 assert_no_double_execute(state) # 已承诺的不重演 assert_nothing_lost(state) # 未承诺的不丢失 except AssertionError as e: failures.append({"round": i, "at": kill_at, "err": str(e)}) return {"rounds": rounds, "failures": failures, "pass": not failures}
持久化的联调只有一条主科目:拔电演练。在任务执行的随机时刻强制杀进程(越随机越接近真实),统计重启后能否恢复到最后一致态、在途任务处置是否符合预期、恢复耗时是否在指标内。演练要覆盖刁钻时刻——快照写一半、日志标完成标一半、多状态同拍修改——这些恰是原子写与校验和的用武之地。演练通过的判据只有一条铁的:跑一百次随机拔电,一百次都回到一致态,九十九次都还不够。
恢复后重复已做过的动作是完成标记丢了——检查标记的落盘时机,"先标完成再改状态"与"先改状态再标完成"混用会出双演;恢复后少了东西是日志条目缺字段或快照早于部分关键修改——修改入口必须唯一(都走 apply),旁路直改是老病根;快照越打越慢是状态对象膨胀——该进快照的是决策态,不是全量缓存;偶发校验失败查磁盘与掉电保护——校验和报警往往在替硬件报信。
💡 关键直觉:持久化的本质是"把未来可能发生的事故,折算成现在多写的账"。日志多写一笔、快照多打一份,都是给凌晨那次断电买保险——保费按事故代价定价,不是按存储费用定价。
至此飞行器的自我记录与自我保护齐备。下一章挂上外挂设备:工具接口的挂架标准、调用的点火时序,与技能库的安全锁。