
一个 Controller 收到对象更新事件，创建了云盘，随后还没来得及把云盘 ID 写回状态就退出了。进程重启后，同一个对象再次进入队列：这次应该再创建一块云盘，还是找到第一次创建的那一块？

这类问题很难靠“正常情况下事件只来一次”解决。Controller 必须允许同一个对象被反复处理，并让重复执行继续接近期望状态。本文用真实 Kubernetes API 验证两个问题：旧版本写入为什么被拒绝，以及操作成功但没有确认时怎样避免重复副作用。

<!--more-->

## 事件告诉你要检查谁，不保证带来最终状态

已有的 [Informer、Reflector、Indexer 和 Workqueue 介绍](/posts/client-go-informer-reflector-indexer-workqueue/)解释了事件与队列之间的关系。实现调谐时，还需要区分三件事：

- 事件发生时的对象状态。
- 本次读取拿到的对象状态。
- 写入请求被 API Server 处理时的对象状态。

典型 client-go Controller 让 Informer 把对象变化同步到本地缓存，事件处理函数只把 namespace/name 放入 workqueue；worker 出队后读取当前对象并执行调谐。队列可以合并相同 key 的重复入队，所以 worker 不应假设每次执行都对应一个独立、完整的历史事件。需要推进的是对象现在的期望状态。

worker 处理时再次发生更新，或一次失败进入重试，同一个 key 都可能再次被处理。通常成功后调用 `Forget` 清除重试计数，失败时通过 `AddRateLimited` 安排重试，并用 `Done` 结束本次队列处理。这些 API 管理处理机会与退避，不会替业务逻辑撤销已经创建的外部资源。调用模式见 [client-go controller 示例](https://github.com/kubernetes/client-go/blob/v0.35.1/examples/workqueue/main.go)。

读取和写入之间，用户与其他控制器仍可能修改对象。即使直接 GET API Server，也存在这个并发窗口；所以失败后应重新读取、重新计算差异，不能无限重放旧对象。

Kubernetes 用 `metadata.resourceVersion` 检查部分更新操作的并发条件。它是 API 使用的版本标识，不应当解析成业务序号或时间戳。相关约定见 [Kubernetes API Concepts](https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions)。

## 队列怎样既合并事件，又保留处理期间的新变化

client-go 的 [workqueue](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/client-go/util/workqueue/queue.go#L227)用两个集合记录不同事实：`dirty` 表示这个 key 有待处理的更新，`processing` 表示某个 worker 正在处理它。重复 `Add` 一个已经 dirty 的 key，不会增加多份队列项；`Get` 取出 key 时，把它加入 processing，同时从 dirty 移除。

真正容易漏掉的是处理期间再次发生更新。此时 `Add` 会重新标记 dirty，但发现 key 仍在 processing，暂不入队；原 worker 调用 `Done` 时，才把这个 dirty key 再次放回队列。这样，同一队列不会让两个 worker 同时处理同一个 key，也不会因为它正在处理就丢弃新的处理需求。

```mermaid
flowchart TB
    A[Get 取出 key] --> B[加入 processing 并清除 dirty]
    B --> C[worker 读取当前对象并处理]
    C --> D{期间再次 Add?}
    D -->|是| E[标记 dirty 暂不入队]
    D -->|否| F[保持非 dirty]
    E --> G[Done 移除 processing]
    F --> G
    G --> H{key 是否 dirty?}
    H -->|是| I[重新入队 等待再次处理]
    H -->|否| J[本次处理结束]
```

这提供的是同一队列内的串行处理和更新合并，不是业务操作恰好执行一次。controller 重启后会重新观察对象；其他 controller 也可能同时操作外部系统。`Forget` 只清理限速队列的重试历史，不能代替 `Done`，更不能撤销已经成功的外部调用。

因此，下面的实验特意分别验证旧版本写入冲突和重复执行的幂等结果。它们没有运行完整的 Informer/workqueue；队列状态机来自上述版本源码，实验用于验证调谐函数必须面对的并发与重复执行条件。

## 实验一：读到 one，不代表还能写回 one

实验通过 SSH 在 Linux 主机 `zj` 上运行，使用 kind v0.33.0、Kubernetes v1.35.1，namespace 为 `dd-reconcile`。所有操作都作用于实验 ConfigMap，不创建真实云资源。

脚本执行了四个动作：

1. 创建 `desired`，令 `data.value=one`，保存当时读取到的完整对象。
2. 使用另一个请求把值改成 `two`。
3. 在旧对象上把值改成 `stale-write`，保留旧 `resourceVersion`，执行 `kubectl replace`。
4. 再次读取 API 中的值。

实际结果是旧写入被拒绝，服务端仍然保留 `two`：

```text
cached_value: one
current_value: two
Error from server (Conflict): ... the object has been modified;
please apply your changes to the latest version and try again
```

这里的 `cached_value` 是脚本保存的旧快照，故意制造并发窗口，用来验证 API 冲突检测；它不是对 client-go Informer 缓存传播延迟的测量。

遇到 Conflict 后，正确动作取决于修改意图。如果想“把计数加一”，需要重新读取最新计数再计算；如果想“将某个字段设置成指定值”，需要确认最新对象是否仍满足操作前提。直接把旧对象的 `resourceVersion` 换成新的，再提交旧内容，可能把其他人的有效修改覆盖掉。

## 实验二：操作成功后没有确认，再执行会发生什么

为了模拟外部副作用，实验创建另一个 ConfigMap，名称为 `effect-<desired UID>`。它代表“为这个对象创建的资源”，不是 Controller 的状态字段。

第一次调谐读取最新的 `desired`，成功创建 effect，然后故意不写确认标记。这对应“外部操作成功，Controller 在状态持久化前退出”的窗口。第二次和第三次再次处理同一个对象：创建请求得到 AlreadyExists，随后把已有 effect 的数据调整为当前期望值。

实测记录如下：

| 调谐次数 | 创建新 effect | 写入确认标记 | effect 中的值 |
| --- | --- | --- | --- |
| 第一次 | 是 | 否，模拟操作后中断 | `two` |
| 第二次 | 否，发现对象已存在 | 是 | `two` |
| 第三次 | 否，复用已有对象 | 是 | `two` |

三次处理结束后只有一个 effect。重复调用没有变成三份资源，也没有用最早事件对应的 `one` 覆盖最新期望值。

这里验证的是一个明确的设计：副作用有稳定身份，重试能够找到它。实验使用 ConfigMap 的唯一名称保证原子创建；它不证明任意云厂商 API 都有同样的语义。对真实云盘、IP 或账户，需要检查对方是否提供幂等键、查询标签或其他可靠的关联方式，并保留资源归属校验。

## 为什么名称最好包含 UID

同一个 namespace 中，名为 `desired` 的对象可以被删除后重新创建。名称相同，UID 已经不同。若外部资源只用 namespace/name 关联，新对象可能错误接管旧对象遗留的资源。

实验把 UID 放进 effect 名称，使这两个生命周期能够区分。真实控制器还需要决定删除时怎样清理、失败时怎样恢复，以及哪些资源允许显式接管。Finalizer 可以阻止对象在清理责任完成前消失，但清理逻辑本身仍然必须允许重试。

## 缓存、冲突和幂等各解决一个问题

缓存减少 API 读取压力，代价是读取可能落后于刚完成的写入。冲突检测防止部分陈旧更新悄悄覆盖新版本。幂等设计则处理“同一操作再次执行”的后果。这三者不能互相替代。

例如，绕过缓存重新 GET 可以减少读到旧状态的机会，却无法消除“云盘创建成功，状态写入失败”的窗口；给更新加重试可以处理 Conflict，却可能在每次重试时再创建一块云盘。

检查 Controller 时，可以沿着一次调谐逐个确认：读取的对象是否仍存在、UID 是否匹配、期望值是否已改变、关联资源是否已创建、写入是否产生冲突。如果已经达到期望状态，就应该停止产生新的副作用。没有变化时反复写状态，还可能制造额外更新事件和 API 请求。

实验代码与使用说明见 [k8sdev/deep-dive-reconcile](https://github.com/jimyag/k8sdev/tree/8f9bfbbd44ac061702310f332ee949fcdf0a213d/deep-dive-reconcile)。

