
三成员 etcd 集群失去一个成员后仍能写入，失去两个成员后却不能靠“把请求发给活着的那个”恢复服务。这个限制来自提交规则，而不是客户端缺少重试。恢复服务之前，必须先区分仍有多数派的成员替换，以及已经无法组成多数派的灾难恢复。

快照恢复还会带来另一类问题：磁盘上的 Kubernetes 对象回到了过去，但节点上的进程、业务卷里的数据和客户端缓存不会一起倒退。恢复数据库只是恢复控制面的一个环节。

<!--more-->

## 多数派决定谁能推进已提交状态

etcd 用 Raft 复制变更。对包含 N 个投票成员的集群，多数派是 `floor(N/2)+1`；3 个成员需要 2 个，5 个成员需要 3 个。增加到偶数成员并不一定增加可容忍故障数，反而可能增加通信成本。

当一个成员失效，另外两个可以继续形成多数派。若失效者恰好是 leader，还需要重新选主，期间请求可能超时；“容忍一个故障”不等于“每个请求都无缝成功”。失去多数派后，存活成员不能单独确认新写入，拒绝推进状态避免了两个隔离分区各自提交冲突历史。故障语义见 [etcd failure modes](https://etcd.io/docs/v3.6/op-guide/failures/)。

```mermaid
flowchart TB
    A[原集群三个投票成员] --> B{可通信投票成员数}
    B -->|至少两个| C[可以形成多数派]
    C --> D[必要时重新选主后继续提交]
    B -->|只有一个| E[无法确认新提交]
    E --> F{其他成员能否恢复}
    F -->|能够恢复| G[恢复连接与成员 再形成多数派]
    F -->|永久丢失| H[评估快照与数据损失 执行灾难恢复]
```

网络分区时不能只数“进程活着几个”，要检查投票成员之间能否通信。磁盘慢、网络延迟和频繁选主也会让一个看似完整的集群无法稳定满足请求。

客户端请求超时还需要单独解释。v3.6.6 的 `processInternalRaftRequestOnce` 先通过 Raft 提出请求，再等待应用结果；超时分支移除等待者并返回错误，不会撤回已经提出的日志，也不等于事务回滚。因此超时请求的提交状态对客户端可能未知，多数派恢复后需要读回或使用适合业务的幂等协议确认，不能把每一次超时都标记成确定未写入。[etcd API guarantees](https://etcd.io/docs/v3.6/learning/api_guarantees/#operation-completed) 对这种不确定性有明确说明。

{{< github-file url="https://github.com/etcd-io/etcd/blob/v3.6.6/server/etcdserver/v3_server.go#L756-L799" >}}

## 读请求也有一致性条件

默认的线性一致读需要确认读取对应的已提交状态，不能把任意存活成员的本地数据直接当成最新状态。etcd v3.6.6 的 [`EtcdServer.Range`](https://github.com/etcd-io/etcd/blob/v3.6.6/server/etcdserver/v3_server.go#L104-L141)在请求不是 Serializable 时，会先调用 `linearizableReadNotify`，成功后才执行读取。

{{< github-file url="https://github.com/etcd-io/etcd/blob/v3.6.6/server/etcdserver/v3_server.go#L104-L141" >}}

Serializable 读允许从成员本地读取已知状态，可能过期，因此在失去多数派时仍可能成功。它适合能接受陈旧数据的读取场景，不能作为“集群仍具备一致读写能力”的健康证明。Watch 则从指定 revision 观察变更，它的历史可用范围还受到 compaction 限制；具体保证见 [etcd API guarantees](https://etcd.io/docs/v3.6/learning/api_guarantees/)。

Kubernetes API Server 本身还有缓存和不同请求语义，不能把直接调用 etcd 的一次读成功，推广成所有 Kubernetes API 请求都仍可用。

## 成员替换与快照恢复解决不同故障

多数派仍然正常时，优先按成员管理流程移除永久故障成员并加入替代成员。这个过程延续原集群的逻辑状态。成员数量、learner、数据同步和提升为投票成员，需要按对应版本的运维说明执行，不能复制一个旧数据目录就当作新成员。

无法恢复原多数派时，快照恢复是从选定备份重新建立集群状态。必须接受快照之后已成功写入的变更可能丢失，并防止旧集群成员重新接入形成混杂拓扑。恢复工具会重建成员和集群身份，而不是继续冒充原来的故障成员。

这两种操作不能混用。为了让一个孤立成员“马上可写”就随意重建集群，可能把原本还能恢复的多数派历史丢掉。恢复之前需要先明确幸存数据、原成员是否真的不可恢复、所选快照的时间与完整性。

快照来源也影响完整性检查。`etcdctl snapshot save` 保存的快照带有校验信息，恢复工具的 `copyAndVerifyDB` 会核验；直接复制成员的数据文件可能不带这份校验信息，还可能缺少仅在 WAL 中的最新数据，不能把跳过 hash 检查当成两种备份等价的证明。重建多成员集群时，各成员应使用同一份选定快照和一致的初始集群配置，不能把不同时刻的快照拼成一个恢复点。恢复条件见[官方灾难恢复说明](https://etcd.io/docs/v3.6/op-guide/recovery/)。

{{< github-file url="https://github.com/etcd-io/etcd/blob/v3.6.6/etcdutl/snapshot/v3_snapshot.go#L435-L478" >}}

## 快照恢复不只要关注键值，还要关注 revision

假设一个 Watch 客户端已经观察到较新的 revision，随后服务端从更旧快照恢复。客户端内存里仍可能保留快照之后的对象状态，只恢复键值数据无法自动撤销这些缓存。

etcd 的恢复工具提供 `--bump-revision` 与 `--mark-compacted`。前者把恢复后的 revision 提高到足够大的值，后者让旧历史读取和 Watch 明确失败，迫使具备正确恢复逻辑的客户端重新同步。增量需要根据快照年龄与写入速率评估，不能把实验里的固定数字当作所有环境的通用值。参数语义见 [etcd 灾难恢复说明](https://etcd.io/docs/v3.6/op-guide/recovery/)。

在 v3.6.6 的 [`Restore`](https://github.com/etcd-io/etcd/blob/v3.6.6/etcdutl/snapshot/v3_snapshot.go#L300-L335) 中，工具先恢复数据库，再在同时设置相关参数时调整 revision。`modifyLatestRevision` 不只是改显示值，还写入 scheduled compaction revision，使恢复后的服务器把对应历史视为已压缩。这个实现解释了旧 Watch 为什么必须重新建立观察，而不是继续等一个已经不属于当前历史的事件。

{{< github-file url="https://github.com/etcd-io/etcd/blob/v3.6.6/etcdutl/snapshot/v3_snapshot.go#L300-L335" >}}

## 实验一：单成员失效与失去多数派

实验通过 SSH 在 `zj` 的专用 kind 集群 `article-control` 中执行，Kubernetes v1.35.1，镜像内 etcd、etcdctl 和 etcdutl 为 3.6.6。三个控制面各有一个 stacked etcd 成员，实验只暂停自己的节点容器。

先写入实验键并保存快照，再写入另一个快照之后的键。暂停第二个控制面成员后，直接向第一个成员写入仍成功；再暂停第三个成员，写入返回：

```text
OK
Error: context deadline exceeded
two members unavailable: write timed out; commit status unknown
```

这段输出仅表示写入超时，未获得提交确认。随后对仍活着的第一个成员执行 Serializable 读，读回之前写入的 `/article/before` 和 `snapshot-value`。这说明本地旧状态仍可读取，没有证明少数派可以提交新状态。恢复两个暂停成员后，脚本检查 endpoint health，并单独读回超时请求的键，避免从超时错误推断最终数据状态。

修订后在相同版本的新建隔离集群重跑，读回结果是：

```text
after quorum recovery: timed-out key committed
```

这一次超时的写请求确实在恢复后被观察为已提交，直接说明“超时即未写入”的判断不成立。这也不意味着所有超时请求最终都会提交，具体结果仍需确认。

这组故障使用容器暂停，模拟成员无法响应；没有模拟磁盘损坏、数据不一致或跨机房网络分区。

## 实验二：在独立端口恢复旧快照

恢复实验没有替换 Kubernetes 正在使用的 etcd 数据目录。它把快照恢复到另一个目录，以单成员新集群运行，使用独立的 client 和 peer 端口 3379、3380。原 Kubernetes API Server 仍连接原集群。

恢复时提高 revision 并标记 compaction，读回结果为：

```text
restored before=snapshot-value; after-snapshot key absent
revision: 1000001272
old revision rejected after mark-compacted
snapshot contains Kubernetes Deployment storage key
old watch rejected after mark-compacted
```

自动断言确认：快照之前的实验键存在，快照之后的实验键不存在；快照中有 `/registry/deployments/control-ha/baseline` 这个 Kubernetes Deployment 的存储键。校验脚本要求旧 revision 的 Range 和 Watch 错误文本包含 `required revision has been compacted`，连接失败或其他非零退出不能算通过。

上述结果验证了快照时间边界、独立恢复、revision 调整及旧 Watch 拒绝。实验没有把恢复后的 etcd 接回 API Server，也没有重启控制器验证完整集群恢复。因此不能据此声称 Kubernetes 控制器、Informer 缓存或工作负载已经完成灾难恢复演练。

配置、运行、证据与清理说明见 [k8sdev/article-control-etcd](https://github.com/jimyag/k8sdev/tree/3cfa654905a750b1716de8c32aa42d7573220954/article-control-etcd)。先按该目录 README 的固定提交准备步骤创建独立工作目录，再运行实验。

旧历史验证明确检查 compaction 错误；仅有非零退出不能证明历史已经失效。

{{< github-file url="https://github.com/jimyag/k8sdev/blob/3cfa654905a750b1716de8c32aa42d7573220954/article-control-etcd/verify.sh#L16-L34" >}}

## 控制面元数据与业务数据要分别恢复

etcd 快照保存 Kubernetes 的控制面状态，包括对象、配置和 Secret，并不包含挂载到业务 Pod 的卷内容，也不包含容器内存。快照需要按敏感备份保护，读取权限不能比生产集群本身更宽松。

恢复旧快照后，控制器重新观察到的期望状态可能与节点上的现状不同：快照后创建的对象可能从 API 中消失，旧对象可能重新出现，卷与外部资源的实际状态却已经前进。完整恢复验收必须进一步核对 API 对象、节点运行时、存储数据和外部系统，不能只检查 etcd endpoint healthy。

真正要制定恢复方案时，备份频率决定可接受的数据丢失窗口，演练结果决定恢复时间能否达到目标。多数派保证正常运行中的一致性，快照提供历史恢复点，它们各自承担不同的可靠性责任。

