etcd v3.5 数据不一致事故复盘:consistent index 非原子写入的根因、触发条件与检测机制
【免费下载链接】etcdDistributed reliable key-value store for the most critical data of a distributed system项目地址: https://gitcode.com/GitHub_Trending/et/etcd
本文基于 etcd 官方在仓库中保留的事故复盘文档(postmortem),完整还原 v3.5.0 版本"已提交事务丢失"类数据不一致事故的根因链:一次为了简化管理 consistent index 的重构,使其与数据变更的落盘不再具备原子性,叠加进程在高负载下的崩溃,导致部分集群成员跳过了已提交的 WAL 条目。读完本文,你将理解 etcd 持久化体系中 WAL、DB 与 consistent index(CI)三者的关系、"非原子提交"这一具体缺陷在源码层面的形态、HashKV 一致性检测机制的工作原理与已知局限,以及社区在事后为"预防、检测、恢复"三类目标落地的改进动作。
背景:etcd v3 的持久化模型与 consistent index 的原子性要求
etcd v3 的状态在磁盘上以两种形式保存:
- WAL(write ahead log,预写日志):保存 etcd 状态变更的完整历史;
- DB(数据库状态):某一时刻的状态快照式表示。
v3.5 仍保留 v2 状态,但 v2 已废弃,与本事故无关。
DB 需要知道自己"代表历史的哪个点",因此保存了一个特殊元数据字段consistent index(CI),指向 DB 已经应用到 WAL 的最后一条条目。当 etcd 更新数据库状态时,它会重放 WAL 条目并把 CI 更新到新条目。postmortem 强调,这一操作必须满足原子提交(atomic commit)语义:部分失败意味着 DB 与 WAL 不再匹配——如果只有 CI 被更新而数据未应用,重启后这些条目会被跳过;如果只有数据被应用而 CI 未更新,条目会被执行两次。
对于 etcd 这样的分布式系统,这一要求尤为关键:集群有多个成员,每个成员各自把 WAL 条目应用到自己的 DB。**系统的正确性依赖这样一个假设——每个成员重放 WAL 条目后都会到达同一状态。**一旦某个成员因 CI 与数据的落盘顺序出现裂缝,这个假设就被打破。
从当前仓库源码可以印证这一持久化模型:CI 作为 KV 元数据存储在 backend 中,由server/storage/schema提供读写接口;一致性索引的内存缓存与落盘逻辑集中在 consistentIndex 实现,其UnsafeSave(tx backend.UnsafeReadWriter)方法负责把内存中的 CI 写入底层存储,注释明确"必须在持有 tx 锁的情况下调用"。
根因:consistent index 与数据变更的落盘被拆成两步
为了简化 consistent index 的管理,etcd 在 v3.5 引入了 backend hooks 机制(对应上游 PR #12855),目标是确保 CI 总能被更新:在事务提交时自动触发 CI 的落盘。postmortem 给出的实现方式是:
- 在应用 WAL 条目之前,先把内存中的 consistent index 更新为新值;
- 事务提交过程中,数据库 hook 读取该内存中的 CI 值并写入数据库。
问题在于:内存中的 consistent index 是共享的,除了串行化执行的 WAL apply 流程之外,还可能存在其他"在途"(in-flight)事务。postmortem 给出的关键时序场景是:
- etcd server 启动一个 apply 工作流,先把内存中新的 consistent index 值设置好;
- 一个周期性的事务提交(periodic commit)恰好被触发,它执行 backend hook,把第 1 步中 apply 工作流设置的 CI 值提前保存到了数据库;
- etcd server 完成 apply 工作流,保存新的数据变更,并再次保存同一个 CI 值。
**在第 2 步和第 3 步之间存在一个极小的时间窗口:CI 已经落盘增加,但对应的 WAL 条目还没有被应用到数据库。**原子性在这里被打破——CI 与数据变更本应"同生同死"地落盘,实际上却被拆成了两次独立的持久化。
当前仓库保留了这一 hook 机制的代码形态,可以精确定位缺陷发生的位置:
- BackendHooks 实现了
OnPreCommitUnsafe(tx backend.UnsafeReadWriter)钩子,其中第一步就是bh.indexer.UnsafeSave(tx),把当前内存 CI 值写入本次提交的事务; - 该钩子会被后端的批量提交路径周期性触发(见 batch_tx.go 中提交前调用 OnPreCommitUnsafe),也就是说:任何一次普通事务提交都可能顺带把"尚未应用完成"的 CI 值带下去——这正是 postmortem 所述第 2 步的源码对应物。
修复后的架构把 CI 的持久化与应用流程重新绑定。从源码结构看,当前实现引入了两级索引:
- apply 流程中,当确认要应用某个条目时,先调用
SetConsistentApplyingIndex(e.GetIndex(), e.GetTerm())记录"正在应用"的索引(见 apply 主循环,其中s.consistIndex.SetConsistentApplyingIndex只在e.GetIndex() > index时设置); - 真正代表"已应用完成"的 CI 推进,被推迟到 apply 事务内部的 post-lock hook 中执行:getTxPostLockInsideApplyHook 在事务提交路径结束时,仅当
applyingIdx大于当前已持久化 CI 时才把applyingIndex提升为正式 CI。这样,CI 的推进与应用同一条 WAL 条目落在同一个 backend 事务中,恢复了原子性。 - cindex.go 中的 TODO 注释还保留了这段演进的历史:作者计划移除
OnPreCommitUnsafe并评估把e.Index/e.Term直接存入 CI 后由 apply hook 持久化的方案,说明该问题在后续维护中仍被持续审视。
此外,测试代码也固化了对这一不变式的守护:cindex_test.go 中包含对 CI 回退的断言("Should refuse to decrease cindex"),防止 CI 被意外调低。
触发条件:崩溃恰好落在非原子窗口内
根因只是一个"窗口",要真正造成数据不一致还需要一个触发器。postmortem 指出:如果 etcd 在"CI 已保存、apply 工作流未完成"这个窗口内崩溃,就会造成数据不一致。重启恢复时,etcd 会依据落盘的 CI 判断哪些条目已执行,从而跳过那个未完成 apply 工作流中的变更——而它们实际上从未被应用到 DB。
复盘文档记录了真实触发场景的特征:
- 报告的触发方式都是etcd 在高请求负载下崩溃;
- etcd v3.5.0 本身还带着一个会导致进程崩溃的 bug(PR #13505),在 v3.5.1 修复;
- 除该 bug 外,所有报告都描述 etcd 处于高内存压力下,时不时发生 OOM(内存耗尽)导致的进程死亡。
官方复现方法是:在高压力下运行 etcd,然后随机使用 SIGKILL 信号杀掉其中一个成员(不可恢复的立即进程死亡)。SIGKILL 排除了进程自行清理、刷盘的机会,最贴近 OOM-kill 的真实形态。
从源码结构看,这类"崩溃后恢复"路径正是 CI 语义发挥作用的地方:重启时 etcd 从 backend 读出 CI,只重放e.GetIndex() > CI的 WAL 条目(apply 循环中的判定逻辑清晰体现了这一过滤条件)。当 CI 被提前落盘时,这条判定就会把"其实没应用"的条目误判为"已应用"而跳过——事故的数据丢失正是由此发生。
检测手段:HashKV 一致性检查及其局限
postmortem 对检测部分给出了一个残酷的结论:单成员集群完全无法检测该问题——当时没有任何机制或工具能验证 DB 状态与 WAL 是否匹配。
多成员集群中则有所不同:崩溃成员会缺失未完成 apply 工作流的变更,其 DB 状态与集群其余成员不同,从而通过 HashKV 调用返回不同的 hash。etcd 提供了自动检测机制,postmortem 时代通过两个参数启用:
--experimental-initial-corrupt-check:etcd 启动时执行一次初始一致性检查;--experimental-corrupt-check-time:周期性执行一致性检查的间隔。
但 postmortem 同时指出了这两个检查的缺陷:
- 它们都依赖 HashKV gRPC 方法,该方法可能失败,导致检查"假通过";
- 多成员集群中,各成员性能不同、WAL 应用进度不同,比较 hash 必须在同一 revision(KV 存储的版本号)上计算;如果给定的 revision 在某个成员上不可用(非常慢的成员,或损坏已导致 revision 分叉),比较就无法进行;
- 因此对于该事故,损坏检查只在 etcd 崩溃后刚重启时可靠。
当前仓库的检测实现远比 postmortem 时期完善,核心代码在 corrupt.go,包含三类检查:
- InitialCheck:在服务任何 peer/client 流量之前,取本地
HashByRev(0)的 hash,向所有 peer 请求同一 revision 的 hash 进行比对;对ErrFutureRev(慢成员)、ErrCompacted(本地落后)、集群 ID 不匹配等情况分别输出不同的告警日志,只有"同一 compact revision 下 hash 不同"才判定为数据不一致; - PeriodicCheck:周期性地在本地两次取 hash,同时向各 peer 拉取 hash,校验"follower 的 revision/compact revision 不得大于 leader"、"同 compact revision 时 hash 必须一致"等不变式,发现不匹配即触发 CORRUPT 告警(
triggerCorruptAlarm通过 raft 请求激活AlarmType_CORRUPT报警); - CompactHashCheck:利用"compaction 在各成员间协调于同一 revision 执行、每个被压缩的 revision 都会保存 hash 一段时间"这一事实,由 leader 与各 peer 比对同一 compact revision 的 hash,并采用法定人数(quorum)判定——只有当持相同 hash 的成员达到
memberCnt/2 + 1时才确定少数派为损坏成员;无法确定多数时以 memberID 0 报警,表示整个集群受影响。
成员间的 hash 交换走 peer 端点的 HTTP 接口 PeerHashKVPath = "/members/hashkv",并校验X-Etcd-Cluster-ID头防止跨集群误请求。
参数层面,以当前仓库为准:
- 周期性检查间隔由
--corrupt-check-time控制,定义于 embed 配置(fs.DurationVar(&cfg.CorruptCheckTime, "corrupt-check-time", ...),默认0s即不检查),帮助文本输出于 help.go; - 启动时的初始检查则通过 feature gate 启用:etcd_features.go 中
InitialCorruptCheck(v3.6 起为 alpha,"在提供服务前检查数据损坏"),可通过--feature-gates=InitialCorruptCheck=true打开,且仅在成员已初始化(非首启空成员)时生效(见 embed/etcd.go 中memberInitialized && ...Enabled(features.InitialCorruptCheck)的判定)。这与 postmortem 中提到的--experimental-initial-corrupt-check是同一能力的演进版本; - 此外 corrupt_test.go 用 fakeHasher 覆盖了"无 peer、取 hash 失败、hash 不匹配、compact revision 分叉"等检测路径,保证检测逻辑本身被持续测试。
影响
postmortem 明确:没有发现用户报告生产环境中的数据损坏案例——触发该问题需要频繁崩溃。但问题严重到足以促使社区发布公开声明,主要影响在于用户对 etcd 可靠性的信任损失。
经验教训
复盘文档从三个角度总结了教训,全部保留如下:
做得好的
- 多位维护者能在不同时区接力工作,随时有人跟进问题的复现与修复;
- 在修复主数据不一致问题的过程中,发现了多个其他可能引起数据损坏的边缘情况(上游 issue #13514、#13922、#13937)。
做得不好的
- 没有任何用户开启数据损坏检测,因为该功能自 v3.3 起一直是实验特性;所有报告的案例都靠人工发现,几乎无法复现;
- etcd 本有专门设计用于发现此类问题的功能性测试,但这些测试无人维护、不稳定(flaky),且缺少关键场景;
- v3.5 发布时的合格性验证(qualification)不如以往彻底:老维护者执行的"人工验证流程"已无人知晓或执行;
- etcd 的 apply 代码复杂到修复这次数据不一致耗时近两周、多次尝试;修复本身复杂到需要为它专门开发自动验证工具(上游 PR #13885);
- v3.5 在没有足够生产采用洞察的情况下被推荐用于生产;"生产可用"的建议部分基于内部反馈,指望靠多样化使用来暴露问题,把发现问题的成本转嫁给了用户。
运气好的地方
- 之所以能用功能性测试复现问题,是因为一位维护者的工作站上
/tmp挂载的是标准磁盘而非通常的内存文件系统——功能性测试默认把 etcd 数据放在/tmp,复现完全依赖这一"意外的分区配置"。
行动项及其在当前仓库中的落地
postmortem 要求行动项直接对应教训,分为三类:Prevent(预防同类问题,例如在发布前发现数据不一致)、Detect(更高效地检测,使用户自动知情)、Mitigate(缩短用户恢复时间);并按优先级标注 P0(v3.5 可靠性关键,须回移)、P1(长期成功关键,阻塞 v3.6)、P2(v3.6 的加分项)。原文档的完整行动项表如下:
| 行动项 | 类型 | 优先级 | 关联跟踪项 | 状态 |
|---|---|---|---|---|
| etcd 测试能复现历史数据不一致问题 | Prevent | P0 | issue #14045 | DONE |
| etcd 默认检测数据损坏 | Detect | P0 | issue #14039 | DONE |
| etcd 测试高质量、易维护和扩展 | Prevent | P1 | issue #13637 | 未完成 |
| etcd apply 代码应易于理解和正确性验证 | Prevent | P1 | — | 未完成 |
| 关键功能不因贡献者流失而被放弃 | Prevent | P1 | issue #13775 | DONE |
| etcd 通过故障注入持续做合格性验证 | Prevent | P1 | PR #14911 | DONE |
| etcd 能可靠检测数据损坏(hash 具备线性读性质) | Detect | P1 | — | 未完成 |
| etcd 检查 leader 与 follower 间传输的 snapshot 一致性 | Detect | P1 | issue #13973 | DONE |
| etcd 从数据不一致中恢复的流程需文档化并测试 | Mitigate | P1 | — | 未完成 |
| etcd 能即时检测并恢复数据损坏(实现 Merkle root) | Mitigate | P2 | issue #13839 | 未完成 |
当前仓库中可以找到多项行动项的直接落地证据:
- 默认损坏检测(Detect/P0):即上文 corrupt.go 中的 PeriodicCheck/CompactHashCheck 体系,
--corrupt-check-time参数化、CORRUPT 告警自动化,检测不再依赖人工比对 hash; - 故障注入持续验证(Prevent/P1):仓库包含独立的 robustness 测试框架,通过模型驱动的流量生成、故障注入与一致性校验对集群进行随机化资格验证,正是"etcd 连续以故障注入做合格验证"这一行动项的工程产物;
- apply 代码的正确性守护(Prevent/P1):cindex_test.go 中针对 CI 不可回退的强断言、bootstrap_test.go 中对 snapshot 恢复路径下 CI 持久化顺序的测试(snapshot 恢复必须先
SetBackend再 recover lessor,否则旧的 CI 会被OnPreCommitUnsafe落盘覆盖新值——server.go 中的对应注释原样记录了这一边缘情况),都是"修复必须复杂、需要自动验证"这一教训的直接产物。
时间线
postmortem 记录的完整事件时间线(日期与事件均照录原文档):
| 日期 | 事件 |
|---|---|
| 2021-05-08 | 导致数据损坏的 PR #12855 被合并 |
| 2021-06-16 | 携带数据损坏的 v3.5.0 发布 |
| 2021-12-01 | 数据损坏报告(issue #13514) |
| 2021-01-28 | 数据损坏报告(issue #13654) |
| 2022-03-08 | 数据损坏报告(issue #13766) |
| 2022-03-25 | 一位维护者确认损坏(issue #13766 的评论) |
| 2022-03-29 | 关于损坏的声明发送至 etcd-dev@googlegroups.com 与 dev@kubernetes.io |
| 2022-04-24 | 携带修复的 v3.5.3 发布 |
结语
这起事故的教训可以浓缩为一句话:**在一个多副本系统中,"元数据指针"与"它所指向的数据"如果不同步落盘,指针就可能在崩溃恢复时撒谎。**etcd 的修复方案本质上是把 CI 的持久化重新绑定到 apply 事务本身(applyingIndex两阶段 + txPostLockInsideApplyHook),并用 HashKV 检测体系、feature gate 化的启动检查与 robustness 故障注入框架补上"检测—预防"两道防线。对于运行 etcd 的用户,实操要点是:升级时至少避开 v3.5.0 至 v3.5.2 区间,并为集群配置周期性损坏检查(--corrupt-check-time),让检测从"实验特性"变成默认防御层。
【免费下载链接】etcdDistributed reliable key-value store for the most critical data of a distributed system项目地址: https://gitcode.com/GitHub_Trending/et/etcd
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考