
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 indexCI三者的关系、非原子提交这一具体缺陷在源码层面的形态、HashKV 一致性检测机制的工作原理与已知局限以及社区在事后为预防、检测、恢复三类目标落地的改进动作。背景etcd v3 的持久化模型与 consistent index 的原子性要求etcd v3 的状态在磁盘上以两种形式保存WALwrite ahead log预写日志保存 etcd 状态变更的完整历史DB数据库状态某一时刻的状态快照式表示。v3.5 仍保留 v2 状态但 v2 已废弃与本事故无关。DB 需要知道自己代表历史的哪个点因此保存了一个特殊元数据字段consistent indexCI指向 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 本身还带着一个会导致进程崩溃的 bugPR #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-checketcd 启动时执行一次初始一致性检查--experimental-corrupt-check-time周期性执行一致性检查的间隔。但 postmortem 同时指出了这两个检查的缺陷它们都依赖 HashKV gRPC 方法该方法可能失败导致检查假通过多成员集群中各成员性能不同、WAL 应用进度不同比较 hash 必须在同一 revisionKV 存储的版本号上计算如果给定的 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 中InitialCorruptCheckv3.6 起为 alpha在提供服务前检查数据损坏可通过--feature-gatesInitialCorruptChecktrue打开且仅在成员已初始化非首启空成员时生效见 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 #13885v3.5 在没有足够生产采用洞察的情况下被推荐用于生产生产可用的建议部分基于内部反馈指望靠多样化使用来暴露问题把发现问题的成本转嫁给了用户。运气好的地方之所以能用功能性测试复现问题是因为一位维护者的工作站上/tmp挂载的是标准磁盘而非通常的内存文件系统——功能性测试默认把 etcd 数据放在/tmp复现完全依赖这一意外的分区配置。行动项及其在当前仓库中的落地postmortem 要求行动项直接对应教训分为三类Prevent预防同类问题例如在发布前发现数据不一致、Detect更高效地检测使用户自动知情、Mitigate缩短用户恢复时间并按优先级标注 P0v3.5 可靠性关键须回移、P1长期成功关键阻塞 v3.6、P2v3.6 的加分项。原文档的完整行动项表如下行动项类型优先级关联跟踪项状态etcd 测试能复现历史数据不一致问题PreventP0issue #14045DONEetcd 默认检测数据损坏DetectP0issue #14039DONEetcd 测试高质量、易维护和扩展PreventP1issue #13637未完成etcd apply 代码应易于理解和正确性验证PreventP1—未完成关键功能不因贡献者流失而被放弃PreventP1issue #13775DONEetcd 通过故障注入持续做合格性验证PreventP1PR #14911DONEetcd 能可靠检测数据损坏hash 具备线性读性质DetectP1—未完成etcd 检查 leader 与 follower 间传输的 snapshot 一致性DetectP1issue #13973DONEetcd 从数据不一致中恢复的流程需文档化并测试MitigateP1—未完成etcd 能即时检测并恢复数据损坏实现 Merkle rootMitigateP2issue #13839未完成当前仓库中可以找到多项行动项的直接落地证据默认损坏检测Detect/P0即上文 corrupt.go 中的 PeriodicCheck/CompactHashCheck 体系--corrupt-check-time参数化、CORRUPT 告警自动化检测不再依赖人工比对 hash故障注入持续验证Prevent/P1仓库包含独立的 robustness 测试框架通过模型驱动的流量生成、故障注入与一致性校验对集群进行随机化资格验证正是etcd 连续以故障注入做合格验证这一行动项的工程产物apply 代码的正确性守护Prevent/P1cindex_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 #135142021-01-28数据损坏报告issue #136542022-03-08数据损坏报告issue #137662022-03-25一位维护者确认损坏issue #13766 的评论2022-03-29关于损坏的声明发送至 etcd-devgooglegroups.com 与 devkubernetes.io2022-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),仅供参考