分布式系统 20:etcd 的 Raft 与存储路径
一次删除不存在键的请求,可以让 etcd 的 Raft 提交位置从 7 增长到 8,而 KV revision 仍为 4。这并不矛盾:日志记录需要按序执行的请求,修订记录实际发生的 KV 状态变化。两个计数器回答的问题不同。
分布式系统 19:ZooKeeper 一致性、锁配方与外部 fencing前篇已把协调服务的资格判断与外部资源写入分开。etcd 的同类分析还要穿过复制日志、状态机应用和本地存储三层。本文固定 etcd 3.7.1,依据官方文档、固定版本源码和真实单成员实验,解释一次写入如何变成可读状态,以及重启为何不能简单重放整份 WAL。
一个请求经过哪些位置
Raft 日志条目具有 index 和 term。index 表示日志位置,term 标识条目产生的任期;本地保存某条日志不等于已经达成提交条件。提交前缀确定哪些条目可以进入状态机,各成员随后按序 apply,成员之间的应用进度可以不同。
KV revision 则属于 MVCC 状态机。一个写事务实际修改了 KV,才产生新的主修订。内部日志项、不修改任何键的删除、租约元数据操作,都可能消耗 Raft 日志位置而不产生 KV 修订。即使两个数偶然相同,也不能互相代替。
| 位置或版本 | 所属层 | 能说明什么 |
|---|---|---|
| 本地日志 index、term | Raft 日志/WAL | 本地接受了哪个条目;可能含未提交尾部 |
| committed index | Raft 协议 | 已确定可以提交的前缀位置 |
| applied index | 成员状态机 | 本成员已经处理到的日志位置 |
| consistent_index | backend 恢复元数据 | 与数据库状态配套的应用前缀位置;须区分内存值与持久值 |
| KV revision | MVCC | 整个 KV 空间的逻辑修订 |
endpoint status 的 raftIndex 来自 CommittedIndex(),raftAppliedIndex 来自 AppliedIndex()。它们不是对 WAL 文件末尾或磁盘 consistent_index 的直接读取。固定版本 Status 实现
flowchart TD
R[客户端写请求] --> L[Raft 条目 index 与 term]
L --> W[本地 WAL 保存]
L --> Q[按 Raft 规则确认提交前缀]
Q --> A[本成员按序 apply]
A --> K{实际修改 KV 吗}
K -->|是| V[生成新 KV revision]
K -->|否| N[KV revision 保持]
A --> C[推进应用位置]
图中的分叉表示不同责任,并未规定本地保存与网络复制必须完全串行。etcd 的实现允许 leader 发送复制消息与本地保存重叠。安全性依赖持久化和 Raft 状态推进的约束,不能从一条流程箭头推导“先发消息必然不安全”,也不能把发出消息当成写入成功。
WAL、提交与成功回复
在复制层,节点可能崩溃并重启,网络可能延迟或分区;这里假设节点非拜占庭,存储按正常配置履行持久化语义。网络分区中的旧 leader 仍可能保留本地角色,却无法单凭该角色使新写入满足提交条件。持久性也不涵盖所有副本介质同时损坏、人工删库或绕过同步的配置。
3.7.1 的 Ready 处理保存 HardState 和 Entries,再按相应契约推进处理;WAL 的 Save 通过 MustSync 判断是否需要同步。一次调用并不必然对应一次新的 fsync,unsafeNoSync 等配置又会改变存储假设。本文实验使用默认正常配置。Raft Ready 处理、WAL Save
对于正常 KV 写请求,协议提交之后还需要本地 apply 执行状态机,产生结果,再唤醒请求等待者。因此“多数副本 ACK 后立刻向调用者成功返回”省略了应用这一环。与此同时,客户端超时并不证明请求没有提交;已经发生的修改可能只是回复未到达,重试策略仍需第 15 篇的请求身份与结果语义。请求提议与等待
flowchart TD
P[请求进入 Raft] --> D[复制与必要持久化约束满足]
D --> C[条目进入提交前缀]
C --> A[本地 apply 执行请求]
A --> R[产生结果并唤醒等待者]
R --> U[客户端收到成功回复]
A --> B[backend 批量提交路径]
B --> F[数据库与恢复元数据一起持久化]
backend 使用 bbolt,并允许批量提交。多个逻辑 KV 操作可以合并进一个物理数据库事务,因此不能要求每个成功 RPC 都拥有一次独立的 backend fsync。WAL 与数据库共同提供恢复材料;看见数据库物理提交滞后于内存应用,不能立刻判定已确认数据丢失。反过来,只看到某个 DB 文件存在,也不足以证明其内容和恢复位置一致。
revision、键 version 与事务子修订
同一键有三个容易混淆的字段。create_revision 表示这一轮键创建的修订;mod_revision 表示最近修改的修订;version 是该键这一轮生命周期内的版本计数。删除后重新创建会开始新的创建记录,不能把旧键的 version 当成永不回退的全局令牌。
普通 Put 即使写回相同 value,也会创建新版本。相比之下,DeleteRange 没有匹配到键时没有产生变更,主修订不增长。源码的判据是本次写事务的 changes 是否非空,而不是 RPC 方法名是否看起来属于“写”。MVCC 事务实现
一次 Txn 的分支可以修改多个不同键。它们共享一个主修订,内部再以 subrevision 区分这一事务内的变更。subrevision 不是另一套客户端请求编号,也不是每个键各自占一个 Raft 提交位置。
flowchart TD
T[一次 Txn:写 b 和 c] --> M[主修订由 3 变为 4]
M --> B[变更 b:内部坐标 4,0]
M --> C[变更 c:内部坐标 4,1]
B --> BV[b.mod_revision = 4]
C --> CV[c.mod_revision = 4]
比较结果为 false 同样不能直接翻译为“事务失败,无事发生”。etcd 会执行 Failure 分支:若其中只有读,KV 不变;若其中执行了 Put,仍会产生新修订。succeeded=false 描述选择了哪个分支,不等于 RPC 返回错误。只有所有相关分支均符合只读条件的事务,才适用对应只读快路径;不能只看到本次选中的分支是读,就断言请求一定没有经过 Raft。Txn 分支执行、只读路径
数据库为什么要同时保存 consistent_index
设数据库已经包含日志前缀到 100 的状态,下一条 101 是 Put(a,50)。恢复程序需要知道数据库已经应用到哪一项,才能决定是否再次执行 101。这里需要的不是“最近看过哪个 index”,而是与持久数据库状态一致的恢复位置。
如果先把 consistent_index=101 保存到磁盘,KV 数据仍停在 100 就崩溃,恢复会跳过 101,造成漏应用。如果 KV 修改已经持久化,但 consistent_index 仍为 100,恢复又可能重复应用 Put,导致额外的键版本和 revision。即使 value 最后仍是 50,元数据也可能已经不一致。
flowchart TD
S[准备应用条目 101] --> G[数据库变更与 CI=101 同一 backend 事务保存]
G --> OK[恢复从一致前缀继续]
S --> X[只保存 CI=101]
X --> XL[数据仍到 100:恢复跳过 101]
S --> Y[只保存数据库变更]
Y --> YL[CI仍为100:恢复可能重复应用]
这不是纯粹假想的风险。官方 2022 年 v3.5 数据不一致复盘描述了共享内存 consistent_index 被周期提交提前保存的问题。当前代码把相关推进放进 backend 写锁内,并在提交 hook 中保存索引,使数据与恢复元数据在同一个物理事务提交。历史缺陷支持这个不变量的重要性,不表示 3.7.1 仍存在同一个问题。官方复盘、提交 hook、backend 加锁与提交
正确性直觉可以按提交批次归纳:初始数据库与持久 CI 描述同一前缀;每次物理事务把这一前缀的状态变化与新 CI 一起保存,就保持该关系。恢复时只对尚未反映到数据库中的、允许应用的提交条目继续执行。这个论证还依赖底层事务原子性及正确的日志/快照选择,不能替代完整实现证明。
noop 或不取得 KV backend 写锁的请求还有相应的 CI 推进路径,并非所有日志项都必须修改 MVCC 数据。内存中已经推进的索引,也不能和较早磁盘上的 DB 拼接成一个已持久状态。apply 与恢复处理
重启不是重放整份 WAL
运行目录中的 member/wal 保存 Raft 相关持久记录,member/snap/db 是运行数据库;Raft 快照元数据、传输或接收的快照数据库文件与运行 DB 不是同一个概念。文件名中同有 snap,不代表内容和用途可互换。官方存储文件说明
flowchart TD
D[持久 DB 与配套 CI] --> R[建立状态机恢复基线]
S[适用的快照信息] --> R
W[WAL 中 Raft 状态与日志] --> L[恢复复制与提交状态]
R --> A[补应用基线之后允许应用的提交前缀]
L --> A
L --> U[未提交尾部不能直接暴露为 KV]
A --> K[恢复后的 KV 与 revision]
恢复必须考虑这些材料是否匹配。安装快照时还要切换 consistent index 所属 backend,再恢复租约和 KV 的附着关系;单独把日志文件内容依次当成用户命令执行,会把未提交尾部也错误暴露出去。
KV 历史 compaction 与 Raft 日志压缩也是不同操作。前者限制旧修订的可读范围,后者减少恢复复制状态所需的日志历史。保留哪个 Raft 快照,并不直接回答“某个旧 revision 还能不能 Range”。Watch 从哪个修订开始、历史不足时如何补偿,留到第 21 篇结合客户端 API 讨论。
单成员真实实验
实验源码为仓库 examples/distributed-systems/etcd20/check.py。Python 标准库启动已校验的官方 Darwin arm64 包,通过本次进程的 HTTP 网关访问真实 API。client 和 peer 都只绑定回环随机端口;每轮使用独立数据目录,保留两次进程日志和全部请求/响应。
2026-09-20 的最终运行退出 0,得到以下实际数值。表中 commit 来自 Status 的 raftIndex;同轮采集的 applied 在这些观察点与 commit 相同,但没有把这一巧合写成协议恒等式。
| 操作 | committed index | KV revision | 实际断言 |
|---|---|---|---|
| Put a=5 | 4→5 | 1→2 | 新键写入 |
| 再次 Put a=5 | 5→6 | 2→3 | create 不变、version 增长 |
| Txn 写 b=10、c=20 | 6→7 | 3→4 | 两键 mod_revision 都为 4 |
| 删除不存在键 | 7→8 | 4→4 | deleted=0,提交位置仍增长 |
| false compare,Failure 读 a | 8→9 | 4→4 | 返回 a=5 |
| false compare,Failure 写 a=50 | 9→10 | 4→5 | 写分支实际生效 |
| 创建无关联键 lease | 10→11 | 5→5 | 获得有效租约,修订不变 |
空库本轮观察到 revision=1;驱动并不硬编码该基线,而是检查操作前后的差值。两个无 KV 变化的操作不仅满足“index 与 revision 数值不同”,还真正观察到前者增长而后者保持。这比比较一次绝对值更能说明它们不是一一对应关系。
sequenceDiagram
participant D as Python 驱动
participant E as 单成员 etcd
D->>E: Status 前采样
D->>E: Range 前采样 revision
D->>E: 一个待验证操作
E-->>D: 实际 API 响应
D->>E: Status 后采样
D->>E: Range 后采样 revision
Note over D,E: 顺序采样,不是组合原子快照
D->>D: 检查修订差值与指定 index 增长
采样之间系统仍在运行,启动项或其他内部活动可能推进 index,不能要求每步恰好加一。本实验没有其他业务写入,也没有等待租约到期,所以 KV 修订差值可以围绕单个操作检查。HTTP 字段里的整数按字符串解析成 Python 整数,不经浮点转换。
完成七步后,驱动向自己启动的 PID 发送 SIGTERM,等待退出,再复用同一数据目录启动。最终检查所有已确认键的值、create_revision、mod_revision、version 和整体 revision 均保持。它验证的是主动 SIGTERM 终止后重启的可观察恢复,不证明优雅关闭全过程、断电安全或某个精确 WAL 同步窗口。单成员也无法检验网络分区时的多数派行为。
从 examples/distributed-systems/ 运行:
1 | |
包获取与校验见源码 README。临时目录、日志和数据全部限定在仓库 .build/etcd20/;脚本不删除旧运行目录。完整最终请求与响应、验证记录与运行边界保留原始结果及此前失败记录。
两道练习
练习一:某次事务返回 succeeded=false,Status 的 committed index 增长了,KV revision 没有变化。能否断定“事务没有经过 Raft”或“etcd 丢失了写入”?若 Failure 改成 Put 同值,修订应如何变化?
解析:两种断定都缺少依据。比较为 false 可以选择只读 Failure,但请求因另一分支包含写仍走复制路径;没有实际 KV 变更就不生成新主修订。Failure 执行 Put 同值仍是一次 KV 版本写入,会推进主修订和该键 version,不能用 value 相等推导无变化。
练习二:持久数据库已经应用到 101,磁盘 consistent_index 却是 100。重启后再次执行 101 的 Put,value 看起来正确,能否算恢复成功?若另一个实现先保存 CI 再保存数据,会有什么相反风险?
解析:仅比较 value 会漏掉重复应用导致的 revision/version 变化,也可能影响后续比较事务。相反顺序会让恢复跳过数据库中根本不存在的修改。需要数据与恢复元数据描述同一个前缀,不能靠调整两个独立写入的先后次序代替原子提交。
参考资料
-
MIT 6.5840 Spring 2026 日程与 Stanford CS244B Spring 2024 日程,承接 Raft、持久性与论文讨论结构;etcd 为本系列自行扩展的实现分析。
-
etcd 3.7.1 发布与版本资产,2026-07-23;固定实现依据。
-
etcd v3.7 Data model,访问于 2026-09-20;修订与 KV 多版本视图。
-
etcd v3.7 API guarantees,访问于 2026-09-20;持久性和 KV 一致性保证及其边界。
-
etcd 3.6 发布说明,2025;CI、单节点持久化与 defrag 的历史修复版本,不作为本轮复现结果。
-
逐条论断、固定源码定位与反向核验记录。课程结构承接系列的 Raft、持久化与状态机章节;etcd 实现分析属于自行扩展的工程部分。
