分布式系统 13:Raft 持久化、快照与恢复边界
节点已经执行到日志 100,保存了一份快照,然后删除前 100 条日志。进程重启时,快照里却只有执行到 80 的状态。日志复制算法即使完全正确,也无法补回已经被本地删除、又没有进入快照的那 20 次修改。
第 12 篇 讨论了哪些日志可以提交。持久化和快照把这个结论连接到文件系统:重启时必须恢复出一组相互对应的任期、投票、日志、应用状态和进度,才能继续遵守已经作出的决定。本篇沿用前两篇的 Go 核心,接入真实文件与子进程恢复。实验使用固定成员、非拜占庭消息和确定性状态机;网络由父进程调度,文件由节点子进程写入。进程被杀死后,操作系统仍在运行。因此观察到的是进程恢复行为,不能把它当成断电、内核崩溃或磁盘控制器故障的验证。
从稳定存储前提到保存完成
Raft 的安全性依赖稳定存储。节点一旦给某个任期投票,恢复后仍须记得这张票;一旦成功确认接收日志,恢复后不能凭空失去所确认的前缀。论文 Figure 2 把 currentTerm、votedFor 和日志列为持久状态,要求相应更新在 RPC 回复之前进入稳定存储。§5.5 的崩溃恢复讨论建立在这个前提上。Raft 扩展论文,Figure 2、§5.5
核心调用 AdvancePersisted,只说明适配器声称保存已经完成。它本身没有打开文件,也没有调用同步接口。若适配器把 Write 返回当成稳定存储完成,协议事件序列中就会出现一条没有实际依据的成功确认。
本地文件写入至少有几个不同的完成位置:字节进入进程缓冲区,系统调用接受字节,文件内容同步完成,新文件名替换成功,目录项同步完成。哪一层能承受哪种故障,取决于操作系统、文件系统和存储设备。这里只使用 Go 的文件 API,逐项检查错误,不对底层设备作超出证据的承诺。Go 1.27.0 os API
快照保留的是一个已应用前缀
快照覆盖到索引 k 时,应用状态必须恰好对应执行完 k。日志末尾可能已经收到 120,提交到 100,应用到 98;这时直接把末尾索引 120 写进快照元数据,会把尚未执行的命令宣告为已经折叠进状态。
lastIncludedIndex 保存 k,lastIncludedTerm 保存原日志 k 的任期。它们组成压缩后的边界。后续索引 k+1 的 AppendEntries 仍要检查 prevLogIndex=k、prevLogTerm=term(k),即使原来的日志条目已经删除,也必须能完成这个检查。Raft 扩展论文,§7、Figure 12
快照任期与节点当前任期可以不同。一个节点在任期 8 保存了索引 100 的快照,而第 100 条日志产生于任期 6;快照应保存 (100,6),节点的 currentTerm 则仍是 8。把两者合并会同时损坏边界匹配与下一次选举。
下面以 k=2 为例。压缩后切片只剩两条记录,但 Raft 的最后索引仍是 4。
flowchart TB
S["快照边界 k=2<br/>保存 term(2)、状态与去重结果"]
C["索引 3<br/>已提交:恢复时需要重放"]
U["索引 4<br/>仅接收:恢复时不能应用"]
S -->|"prev=(2,term(2))"| C
C --> U
S -.-> B["切片偏移 0 对应全局索引 3"]
U -.-> L["lastIndex=2+len(suffix)=4"]
核心将日志访问统一换成全局索引减去快照边界。竞选时使用的末尾位置、leader 的 nextIndex 和 matchIndex、提交位置都保留全局坐标。旧 AppendEntries 若要求匹配已压缩边界之前的位置,实验核心直接拒绝,避免用负偏移或无符号下溢访问切片。
应用数据之外,还要保存恢复所需的语义状态。请求 c:1 曾执行 Add(5) 并返回 5,后来另一个请求把 x 设置成 50;快照里不仅要有 x=50,也要保留 c:1 已处理及其结果 5。否则重启后的重试会再次加 5。作者博士论文专门指出,客户端线性化所需的元数据也属于快照内容;可变成员配置还需要保存该快照索引处的配置。本实验保持成员固定。作者论文,Memory-based snapshotting
一代恢复映像
快照文件正确,不代表整个恢复过程正确。若先保存了指向 100 的提交进度,快照 100 尚未保存就崩溃,重启可能只找到截至 80 的状态与不足以补齐的日志。etcd 的历史 issue #10219 报告过这种排序问题。v3.6.0 的服务器源码要求先保存快照文件和 WAL 快照记录,再保存其他条目及 hardstate,并在释放旧 WAL 前同步。历史报告 #10219、etcd v3.6.0 保存路径
实验采用更小的存储方案:每次生成一个完整映像,把以下数据编码进同一个 current 文件。
| 数据 | 恢复时的作用 |
|---|---|
| 格式版本、节点与成员身份、generation、校验和 | 识别格式和所属节点,检测部分损坏 |
| currentTerm、votedFor | 恢复选举约束 |
| 快照边界、应用状态、请求结果缓存 | 恢复已压缩前缀 |
| 快照之后的日志后缀 | 接续复制与重放 |
| durableCommit | 限制恢复时允许应用到的位置 |
这个映像会反复重写,写入成本为 O(快照数据与日志后缀的总大小)。它方便检查不同字段是否属于同一代,适用于有限教学实验;生产存储通常需要 WAL、批量同步、增量应用与独立快照文件,必须另外设计发布和回收顺序。
durableCommit 是实验额外持久化的字段。原论文将 commitIndex 列为易失状态,节点可以通过协议重新得知提交位置。本实验要在断开协议消息的重启检查中立即恢复先前的应用进度,因此明确保存提交游标。New 保留第 12 篇的易失游标模式,NewDurable 开启这一模式;二者共享核心和状态格式。
应用前先保存 durableCommit。这样,任一次已经对外暴露的应用进度,都不超过磁盘记录的提交位置。单节点集群尤其能显示这个顺序:保存自身新条目之后,才有本地持久票据据以提交;新的提交游标还需要第二次保存。第一批写入完成不能提前放行依赖新提交结果的观察和回复。
发布文件的中断窗口
临时文件与 current 位于同一目录。适配器写入完整字节,检查短写,调用文件 Sync 并检查 Close,再执行 Rename,最后打开父目录同步。所有步骤成功后,才调用核心的保存完成接口。
flowchart TB
W["同目录临时文件<br/>完整 Write;检查短写"] --> F["文件 Sync 与 Close"]
F --> R["Rename 临时文件为 current"]
R --> D["父目录 Sync 与 Close"]
D --> A["确认这一代保存完成<br/>释放协议效果"]
W -.-> K1["切点:部分写入<br/>current 仍为旧代"]
F -.-> K2["切点:同步后、替换前<br/>临时新代尚未发布"]
R -.-> K3["切点:替换后、目录同步前<br/>未确认新代;不能推断耐断电"]
D -.-> K4["切点:已同步、尚未回复<br/>恢复可见新代,客户端仍可能重试"]
替换前失败时,旧 current 仍是恢复入口。残留临时文件不能因为 generation 更大就自动提升为正式状态;它可能只写了一半,也可能还没完成同步。
替换之后若目录同步报错,新名称可能已经可见。此时错误不能解释为“没有发生任何修改”。适配器停止该节点,不发成功回复,不继续覆盖,也不尝试反向重命名。重启读取唯一正式入口,校验完整映像并同步后再服务。最新文件若损坏,实验拒绝启动;自动选一个更旧快照继续运行可能丢失已经确认的数据。
Go 1.27.0 的 Darwin 实现会先使用 F_FULLFSYNC,只有返回 ENOTSUP 时才退回 fsync。这与“Go 在 macOS 上只调用普通 fsync”的说法不同。Apple 存档手册也区分主机缓冲刷出与设备内部缓存落介质;F_FULLFSYNC 请求设备进行更强的刷新。Go 1.27.0 Darwin 源码、Apple fsync 手册
本机源码核对与文件调用成功,仍然没有观测设备实际落介质的时刻,也没有追踪文件系统具体使用了哪个回退分支。原子名称替换与完整的断电持久性是两个不同保证。Apple rename 手册
InstallSnapshot 的三个分支
leader 已经删除了 follower 所缺的日志时,单靠重发 AppendEntries 无法补齐前缀。实验的 leader 在发现 nextIndex 落到快照覆盖范围内后发送 InstallSnapshot。快照消息使用与追加消息同样的请求编号和任期关联;退休请求的迟到回复不能修改当前复制进度。
较低任期的请求被拒绝。较高任期先触发任期更新,即使快照内容随后因过期被忽略,新的任期也需要保存后才能回复。对同一有效任期中的快照,再检查索引与本地提交位置。
flowchart TB
I["InstallSnapshot(term,k,t)<br/>校验身份与完整数据"] --> T{"RPC 任期"}
T -->|"较低"| X["拒绝"]
T -->|"较高"| H["更新 term;转 follower<br/>回复前必须持久化"]
T -->|"相同"| O{"k 不大于 commit?"}
H --> O
O -->|"是"| SK["忽略快照内容<br/>不回退应用状态"]
O -->|"否"| M{"本地存在相同 k 和 t?"}
M -->|"是"| KEEP["安装状态;保留 k 之后后缀"]
M -->|"否"| DROP["安装状态;丢弃冲突后缀"]
KEEP --> P["有状态变更时持久化<br/>恢复或保留状态后回复"]
DROP --> P
SK --> P
过期快照不能覆盖新状态。节点已经提交并应用到 100,迟到的快照只覆盖到 80,即使其内容本来合法,也不能把状态机恢复成 80 的内容。etcd/raft v3.6.0 的 restore 明确忽略索引不大于 committed 的快照。这是实现中的显式防护;原论文 Figure 13 是协议摘要,并没有列出完整的文件与应用层事务。etcd/raft v3.6.0 restore
对于包含新前缀的快照,边界匹配决定后缀能否保留。若本地也有 (k,t),Log Matching 保证该点前缀一致;后面的条目仍然可以有效,不能全部删除。保留并不等于提交:快照 k=2、本地后缀含 3,而提交只推进到 2,恢复仍不能执行 3。
若边界不匹配,合法新快照替代的是此前尚未提交的分歧。已提交前缀与合法领导者快照冲突不属于 Raft 的正常历史;不能用“安装快照”作为覆盖已提交数据的通用补救办法。
本实验安装完整状态后保留匹配后缀。etcd/raft 的匹配分支还可以只推进 commit,继续使用已有日志来应用;这是一种实现选择,不应逐行等同于这里的应用适配器。实验把快照放在一个有限消息中,也没有实现论文 Figure 13 的分块、offset、done 与传输中途恢复。
lastApplied 与应用状态共同恢复
作者勘误修正了一个容易忽略的条件:lastApplied 的持久性必须与状态机一致。持久状态机已经完成 Add(5),若应用标记没有同步保存,重启后再执行这条日志就会重复修改。反过来,标记已经保存而业务状态没有保存,则会跳过尚未生效的修改。作者博士论文勘误
对持久数据库,业务变更与应用标记需要原子提交,或具备等价的幂等恢复机制。本篇的状态机是易失内存对象,快照才保存业务状态。重启后先恢复快照,再按索引重放到 durableCommit,最后允许状态观察。恢复过程内部从较早的 k 开始,不表示服务已经对外展示了倒退状态。
sequenceDiagram
participant P as 新节点进程
participant D as current 文件
participant A as 内存状态机
participant C as 父进程观察者
P->>D: 读取并校验一代完整映像
D-->>P: snapshot(k)、suffix、durableCommit
P->>A: 恢复 k 的业务状态与去重缓存
loop k+1 到 durableCommit
P->>A: 按全局索引应用命令
end
Note over P,A: 未提交尾部不应用;未完成恢复不服务
P-->>C: READY
C->>P: 查询恢复状态
P-->>C: applied 与对应状态、缓存结果
为了让有限实验容易核对,适配器在每次命令完成时都从正式映像重建状态机,而非维护常规的增量 apply 线程。它每次需要处理快照和已提交后缀,时间成本为 O(快照状态加已提交后缀)。这里验证的是恢复函数与发布边界;没有测量吞吐量,也没有声称这种重建方式适合生产服务。
可运行实验与实际观察
代码位于 examples/distributed-systems/raft13/,继续调用同一个 raft/ 包。在仓库根目录执行:
1 | |
-scenario recovery|dedup|snapshot|cuts|errors|validation 可以选择场景,-trace 输出真实 PID、generation、索引与应用状态。父进程在专用临时目录创建文件,只终止自身启动的节点子进程。切点由子进程输出阶段消息并阻塞,父进程收到消息后才 Kill、Wait 和重启,避免用睡眠时长猜测落在哪一步。
2026-09-20 在 Darwin arm64、Go 1.27.0 上,普通运行、race 运行和 vet 均成功。恢复场景从快照 2 重放至提交位置 3,保留但不执行未提交条目 4,x 保持 50;去重场景重启后再次提交旧请求,缓存中的请求结果仍为 5,当前 x 仍是 50。
实际进程中断结果如下。保存前已有 applied=1、x=5;候选新映像为 applied=2、x=12。
| 中断或受控错误位置 | 此次重启观察 | 断言要求 |
|---|---|---|
| 部分写入、文件同步后、rename 前 | applied=1、x=5 | 未发布临时文件不得成为恢复入口 |
| rename 后、目录同步前中断 | applied=2、x=12 | 只接受完整旧代或新代;不把这次观察外推为断电保证 |
| 目录同步完成、回复前中断 | applied=2、x=12 | 已完成保存的新代必须恢复,客户端仍可能重试 |
| 注入短写、文件 Sync 或 Rename 错误 | 节点停止,无成功回复;恢复旧代 | 保存失败不能释放成功确认 |
| 注入 rename 后的目录 Sync 错误 | 节点停止,无成功回复;此次恢复新代 | 显式保留“失败但可能已生效”的边界 |
注入错误由适配器在指定调用位置返回,未制造真实磁盘 EIO。其余写入、同步、重命名、读回与进程重启都实际执行。validation 还包含独立核心输入边界检查,例如较高任期的过期快照必须保存新任期后回复;这类检查不冒充从空集群生成的完整协议历史。
负向变异分别破坏快照索引与状态的一致关系,以及快照中的去重缓存。前者被恢复校验拒绝;后者导致旧 Add 再次生效,由预期业务状态断言识别。校验和能发现部分字节损坏,却不能证明一个格式正确的快照在业务意义上正确,因而两类检查都需要保留。
完整命令、结果与边界见 本地验证记录;逐条论断和来源见 证据记录。这些有限轨迹不构成整个实现的安全性或活性证明。
练习
练习一:保存完成但回复丢失。 请求 c:1 把 x 从 0 加到 5,保存完成后进程在回复前崩溃;另一个已提交请求随后把 x 设置成 50。恢复后重试 c:1 应返回什么?只保存 x 和 lastApplied 是否足够?
答案应区分当前状态与该请求的历史结果。返回值应为 5,当前 x 应保持 50;快照需要包含请求身份与结果缓存。只记当前 x 和应用索引无法重建已经被压缩掉的每个请求结果。
练习二:相同边界与未提交后缀。 follower 的快照覆盖到 2,后缀为 (3,term=2)、(4,term=3),commit=2。收到合法快照 (3,term=2) 后,能否删除索引 4?能否立即执行索引 4?若之后收到快照 (2,term=1) 又该怎样处理?
边界匹配要求保留索引 4,但新快照只能据此把提交位置推进到 3;4 仍需后续协议确认。随后收到覆盖到 2 的旧快照,应忽略其状态内容,不能把已应用位置回退。若 RPC 带来更高任期,任期更新仍需要按持久化规则处理。
下一篇转向成员变更。此时快照中的配置不再只是固定身份检查,还要与日志后缀中的配置变化共同确定当前生效规则。
参考资料
- Raft 扩展论文,2014-05-20:Figure 2、§5.5、§7、Figure 12–13。
- 作者博士论文:Memory-based snapshotting与Updates and errata:作者仓库滚动文本,访问日期 2026-09-19/20。
- etcd/raft v3.6.0与etcd v3.6.0 服务器保存路径:用于核对实现选择与保存排序,不代表本地运行过该产品。
- Go 1.27.0 文件 API与Darwin 同步源码:对应本机工具链。
