分布式系统 21:etcd 的读、事务、Watch 与 Lease
Watch 恢复时,保存“刚收到的 revision”还不够。一笔事务在修订 2 同时改了两个键,消费者只应用第一个事件就把续传位置记成 3,重连后会永久漏掉第二个键。服务端已经完整发送,错误发生在消费端的检查点。
分布式系统 20:etcd 的 Raft 与存储路径前篇区分了 Raft 位置与 KV revision。本篇使用同一官方 etcd 3.7.1,讨论默认读、条件事务、Watch 恢复与 Lease。核心问题是:一次 API 响应究竟证明了什么,客户端需要补上哪些状态管理,才能把产品保证变成正确的业务行为。
默认读与 serializable 读
默认 KV 操作保证严格可串行化,并遵守实时时序。写已经成功完成,随后发起的读不能返回该写之前的状态,除非又发生了合法的后续修改。对于单次读,可以在调用与响应之间选择一个线性化点。与读并发的写可以排在这个点前或后,不能要求返回值包含直到响应最后一刻才完成的所有写入。API guarantees
etcd 的默认 Range 先执行 LinearizableReadNotify,再读取 KV;serializable=true 则走成员本地读路径。后者仍读取一致的本地状态,并非脏读,但可能落后于其他成员已经确认的写。名称中的 serializable 不应被理解为“与默认模式保证完全相同,只是更快”。固定版本 Range 路径
flowchart TD
R[Range 请求] --> M{serializable}
M -->|false 默认| B[确认线性化读所需进度]
B --> W[等待本成员应用满足读条件]
W --> K[读取 KV 状态]
M -->|true| L[直接读取成员本地一致状态]
L --> S[可能落后于其他成员已完成写]
K --> O[返回结果]
默认读不要求为每次请求新增一条用户日志,但需要协议确认和应用进度配合。网络分区中的成员即使能访问本地数据库,也不一定能完成默认读;选择本地读则意味着调用者愿意接受较旧状态。单成员正常运行时两种模式返回同值,只能证明这两个 API 都执行成功,无法验证分区条件下的差别。三成员故障对照留给第 22 篇。
条件事务把比较与修改放在一起
先 Get 再根据结果 Put,中间允许其他客户端写入。Txn 把比较与所选分支作为一个 etcd 内部原子操作:客户端读取键的 mod_revision=m,随后提交 mod_revision==m 条件,成功才执行新 Put。两个客户端拿到相同 m,先成功的事务会改变 mod_revision,后一个比较就不再成立。
sequenceDiagram
participant A as 客户端 A
participant E as etcd
participant B as 客户端 B
A->>E: Get,获得 mod_revision=m
B->>E: Get,获得 mod_revision=m
A->>E: Txn:若 mod=m 则 Put(first)
E-->>A: succeeded=true,新修订
B->>E: Txn:若 mod=m 则 Put(second)
E-->>B: succeeded=false,执行 Failure
version==0 可以判断键当前不存在。仅比较 value 无法区分删除后重建、修改后又改回原值等代际变化;mod_revision 使比较针对实际观察到的版本。比较为 false 仍可以执行 Failure 中的写,因此不能把所有 succeeded=false 都当成无副作用错误。Txn 协议与字段
事务原子范围止于 etcd。条件判断成功后再更新外部数据库,中间若暂停或失去资格,外部写入仍可能发生;把两个调用包进同一个业务函数不会扩大事务边界。请求超时也只说明客户端尚未获得确定结果,不能证明事务没有执行。本篇只使用非嵌套 Txn,不依赖嵌套事务的事件顺序。
Watch 游标必须代表完整处理的前缀
Watch 订阅匹配范围内的变更。start_revision 是包含式起点:从 r 开始会请求 r 的事件;没有给起点则从建立时的后续变化开始。历史能否回放取决于 compaction 保留窗口。Watch 不提供有限通知延迟,也不能代替一次线性化读来证明“当前状态已经最新”。
建立 Watch 后收到的 created=true 只表示注册成功,历史事件可能还未到达。它的 header.revision 不能直接记为消费进度,否则可能跳过尚未处理的历史。普通事件处理应依据事件 kv.mod_revision;进度通知另有保证,但必须确认它确属对应 watch 的进度响应,并且此前收到的事件已处理完毕。Watch 保证、官方 Go 客户端
一个主修订可以包含多条事件。正确的检查点语义是“到 r 为止的完整匹配变化已经应用”,而不是“曾经看到一条修订为 r 的事件”。下面的例子也是本轮真实实验使用的事务形态。
flowchart TD
T[Txn 在 r 同时写 a 与 b] --> E[完整响应含 a@r、b@r]
E --> G[全部应用后提交完整游标 r]
G --> N[重连请求 r+1]
E --> P[只应用 a@r 就中断]
P --> BAD[错误请求 r+1:漏 b]
P --> SAFE[保守请求 r:重放整个修订]
SAFE --> I[幂等更新 a 并补齐 b]
若开启 fragment,一个较大修订还可能拆成多个 WatchResponse。生产客户端应先重组到完整边界,再发布缓存与检查点;HTTP 传输分块、JSON 消息边界和逻辑修订边界是三个不同层次。官方 Go 客户端会处理相应分片,但用 Python 网关自行消费并不会自动获得那段客户端逻辑。服务端分片实现
本篇教学客户端显式 fragment=false,只处理小响应;若收到 fragment=true 或同一修订跨批次,直接报不支持并失败。created 和空响应均不推进应用游标。收到事件时检查不低于请求起点、批内有序、跨批次不倒退。这个有限客户端没有实现生产自动重连、多 watch 复用和大响应重组。
缓存与游标的持久化也需要共同设计。先保存新游标再保存缓存,崩溃后可能漏事件;先执行外部副作用再保存游标,崩溃后重放可能重复副作用。幂等赋值可以修复 KV 投影,不代表邮件发送、扣款等任意业务已经获得 exactly-once。Watch 的流内唯一事件保证不能替代业务去重。
分页 List 必须固定在同一个 R
历史不足时,客户端需要重新获取一个完整快照,再衔接变化流。对某个前缀执行第一页默认 Range,保存响应 header.revision 为 R;后续页始终显式请求 revision=R,保持同一范围与键排序。后页 header 可能反映更晚的响应状态,不能拿它覆盖已经选定的 R。
sequenceDiagram
participant C as 缓存重建器
participant E as etcd
C->>E: 第一页 limit=2,获得固定 R
E-->>C: a、b
Note over E: 并发更新 d、插入 bb、删除 e
C->>E: 第二页 revision=R
E-->>C: c、旧 d
C->>E: 第三页 revision=R
E-->>C: 旧 e、f
C->>C: 安装同一 R 的完整快照
C->>E: Watch 从 R+1
E-->>C: 补齐分页期间的修改
分页的 key 是左闭边界,range_end 是右开边界。按字节键升序时,下一页起点使用末 key 追加零字节,以排除刚处理的键并保留它的扩展键。这个操作不同于计算前缀区间上界时的“最后一个可增加字节加一”;混用会漏掉合法键。Range 协议
只要历史仍在,从 R+1 建立同范围 Watch,就能补上 List 期间以及其后的变化。这个衔接依据是固定快照与包含式事件起点,不依赖 List 和 Watch 紧贴在同一个时刻发生。多条独立范围的 Watch 则不能自动拼成跨范围原子视图;本篇始终使用单前缀、单 Watch、无过滤器。
压缩取消要求重建,而非跳过
客户端离线时,一个键可能被删除,其删除事件随后又被 compaction 清掉。若直接从压缩点之后继续 Watch,后续再没有这个键的事件,旧缓存就会永久残留它。因此 canceled/compact_revision 表示旧恢复方案失效,不能只改一个起始数字继续假装状态完整。
flowchart TD
W[按已有游标恢复 Watch] --> C{收到压缩取消吗}
C -->|否| A[应用完整事件并推进游标]
C -->|是| L[创建未发布的新快照]
L --> P[分页始终固定同一 R]
P --> X{分页中 R 被压缩吗}
X -->|是| D[废弃这一轮全部页]
D --> L
X -->|否且已读完| S[整体替换旧缓存并保存 R]
S --> N[Watch 从 R+1]
N --> C
整体替换与逐键 merge 有不同语义:新快照里没有的键也必须从旧缓存消失。图中的重试状态机是客户端设计要求;实验驱动如果在分页期间遇到 ErrCompacted,会报失败并停止,尚未实现这条自动重试分支,不把设计图当成实测覆盖。
历史回放还要求同一集群历史和未改变的范围。灾难恢复后的 revision 域、过滤器变化、缓存模式升级等,都可能需要新的基线,不能把一个旧数字当成对任何集群都有效的恢复令牌。相关已知问题的反向检索也应按版本追踪:研究记录核对了未来起点导致旧事件的修复逻辑,但没有把所有旧进度或压缩边界报告都判成 3.7.1 已修复。
Lease 的成功续租与实际删除
Lease 绑定键的生命周期。Grant 返回服务端接受的租约与 TTL;KeepAlive 的成功响应表明该次续租被服务端接受。只发出了请求、连接看起来还在、客户端本地计时器尚未归零,都不是同一种证据。响应丢失同样不能反推续租没有发生。
flowchart TD
G[Grant:服务端选定 TTL] --> K[键关联到 Lease]
K --> A[收到一次成功 KeepAlive 响应]
A --> S[停止后续续租]
S --> T[服务端到期检测]
T --> Q[撤销经过复制与应用]
Q --> D[关联键删除]
D --> W[Watch DELETE 与 Range 可观察]
D -. 不自动更新 .-> O[外部数据库或仍运行的旧线程]
从停止续租到删除可见,包含服务端计时、撤销提议和应用。实现还有检查节拍和主节点变化时的租约处理,因此不能承诺本地计时恰好走过 TTL 毫秒就一定观察到删除。分区或无多数派时尤其不能把定时器到期当成删除已经提交。固定版本 lessor
Lease 也不提供外部资源 fencing。lease ID 不是单调令牌;即使选择创建 revision 作为业务代际,也需限定同一不回退历史、资源对应关系与代际唯一性。外部资源接受了更高代际后,才能据其自身规则拒绝旧请求。外部写入对照见第 19 篇。
三组真实观察
正式源码 examples/distributed-systems/etcd21/check.py 复用第 20 篇的进程生命周期,通过真实 HTTP 网关访问官方 3.7.1。2026-09-20 运行退出 0,原始 Watch 与 KeepAlive 响应采用换行 JSON result 包装,完整响应已保存。标准库负责解码 HTTP 传输,不把 TCP 一次读取当成一条业务消息。
| 场景 | 本轮实际观察 | 验收边界 |
|---|---|---|
| 多事件修订恢复 | Txn revision=2;错误 r+1 恢复缺 /resume/b,从 r 重放恢复完整 |
真实服务端事件;部分应用中断为内存消费者模拟 |
| 完整批次后续传 | 关闭 Watch、离线写入、从完整游标+1 重建,与 Range 一致 | 主动断开客户端流,非服务端崩溃 |
| 压缩与固定页 | 旧 revision=12,压缩到14;新 R=14,limit=2 共3页 | 页间真实 update/insert/delete,固定快照与同 R 参考读相等 |
| 旧缓存替换 | 错误 merge 残留 obsolete;整体替换后续传消除残留 | 真正检出漏删,不只检查收到事件数 |
| CAS 与 Lease | 两个相同观察的CAS仅首成功;KeepAlive TTL=3;两键在revision20删除 | 同轮停止续租约3.26秒后观察到删除,不是通用时限 |
Lease 场景既收到两条 DELETE,也执行默认 Range 确认两键消失;没有主动 Revoke,也没有关闭 etcd 来模拟自然过期。读模式场景中,默认与 serializable 返回相同值,只作为单成员 API 可执行证据。
流中使用标记键作为可观察的处理边界。错误恢复分支也确实收到后续标记,再比较缓存发现缺项;没有通过“等了一会儿没收到”来证明丢事件。实验的缓存和消费进度均在内存,不验证磁盘事务式检查点、进程崩溃恢复或任意外部副作用。
从 examples/distributed-systems/ 执行:
1 | |
官方包获取与校验沿第 20 篇 README。所有临时资料、日志和运行数据均限定在仓库 .build/etcd21/,已有记录不删除。完整原始 API 与流响应、验证记录与未覆盖边界分别保存结果和核验范围。
两道练习
练习一:缓存保存到完整修订 40。重连收到 created=true、header.revision=50,随后收到事件修订 41。能否先把检查点记为 50?如果修订 41 有两个事件,收到第一个时是否能记成下一次从 42 开始?
解析:created 不能证明历史事件已经消费。检查点应仍以实际完整处理前缀为准;同修订两事件必须全部处理。如果中间中断,保守重放 41 并采用幂等投影,或者用事务式缓存与游标提交保留旧完整边界。大响应分片还需等待重组完成。
练习二:第一页在 R=70 读到 a、b,随后 d 被更新、e 被删除,第二页响应 header=72。下一页应该固定70还是改为72?如果旧缓存含已删除 z,而新快照没有 z,仅 merge 新快照是否正确?
解析:整个 List 需要固定70;后页 header 不能替换选定的历史快照。完成后用 Watch71 补齐变化。如果70中途被压缩,废弃这一轮快照重新开始。新快照必须整体替换旧视图,否则 z 的删除事件已经不在可恢复窗口时会永久残留。
参考资料
- MIT 6.5840 Spring 2026、Stanford CS244B Spring 2024,课程结构承接复制服务与论文讨论,当前 etcd API 为本系列扩展。
- etcd v3.7 API guarantees、API,访问于 2026-09-20;源码固定 v3.7.1 commit
5e7fd0de9a57db03ecc11794dc40403a734c07bb。 - #19179 压缩边界 Watch 报告、#20221 未来起点与旧事件/通知报告,作为反向核验线索,不从报告标题推定当前版本全部受影响或全部已修复。
- 标题、版本、具体论断与反向核验记录。
