
Informer 的本地缓存会断线重连，但重连不意味着可以凭上次看到的事件继续往前推。API Server 可能已经压缩了旧历史，也可能通过 List 或 watch 返回的对象告诉你当前能保证的版本。客户端如果忽略这个边界，就会把“没有收到事件”误当作“对象没有变化”。

本文在 Kubernetes v1.35.1 上直接请求 List/Watch API，验证三件事：List 响应的 `metadata.resourceVersion` 可以作为后续 watch 的起点；压缩 etcd 并清掉旧 watch cache 后，从旧版本开始 watch 会收到 410 Expired；客户端从最后观察到的版本重连时，仍能收到后续删除事件。实验在 `zj` 的三节点 kind v0.33.0 集群 `article-next` 执行，客户端和服务端均为 v1.35.1（提交 `8fea90b45245ef5c8ba54e7ae044d3e777c22500`），请求经 `kubectl proxy` 直接打到 API Server。

<!--more-->

## resourceVersion 表示什么

`resourceVersion` 不是业务对象的版本号，也不是一个可以跨对象永久保存的时间戳。它是 API Server 返回的、用于读取和 watch 的一致性标记。List 响应和每个对象都有各自的 `resourceVersion`。用这个版本做 watch，表达的是“告诉我这个版本之后的变化”。

在 etcd 后端中，版本与 etcd revision 相关。etcd 会压缩旧 revision，API Server 的 watch cache 也只在有限历史内保留事件。客户端拿到的版本太旧，服务端就不能再保证补齐中间变化。

这里必须区分两个动作：

- List 返回当前对象集合和一个版本。
- Watch 从指定版本之后持续发送变化。

List 与 Watch 之间可能有新事件。正确做法是以 List 返回的版本作为 watch 起点；另外挑一个近似时间会漏掉这之间的变化。

`resourceVersion` 的两个特殊取值会改变整条语义，混用会得出相反结论。空字符串表示“Get State and Start at Most Recent”：先补一份当前状态（以合成 ADDED 事件形式），再从那里继续；传 `0` 表示“Start at Any”，允许从一个更老的版本起步，用于容忍版本回退的场景。只有传具体版本号才是 “Start at Exact”，也才可能因为历史被压缩而收到 410。这张对照表在官方 [API concepts](https://v1-35.docs.kubernetes.io/docs/reference/using-api/api-concepts/) 的 watch 一节。

服务端侧的分支在 v1.35.1 的 [`cacher.go:1256-1266`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiserver/pkg/storage/cacher/cacher.go#L1256-L1266)：请求带了具体版本就原样使用，没带才去 etcd 取当前版本。这解释了一个常见误判——把 URL 里的 `resourceVersion` 删掉后“不再报 410”，代价是客户端失去了“我读到哪个版本”的锚点，之后只能从服务端补发的初始状态重新对齐。

对象多到需要分页时，这个值仍然只有一个。带 `limit` 的 List 会返回 `continue`，后续分页请求续用这个 token，而 `metadata.resourceVersion` 在所有分页中保持一致（`ListMeta` 的 `ResourceVersion` 与 `Continue` 字段注释，[`types.go:66-92`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/types.go#L66-L92)）。所以拿第一页的版本去 watch 是正确的，不需要等最后一页。反过来说，如果分页中途失败重来，必须从第一页重新开始，用中间某一页的 `continue` 拼不出一致快照。

`continue` 本身有寿命。那段注释写明：服务端配置变化或者过了几分钟之后，续接这个 token 可能失败。Reflector 对分页有明确的取舍，同一段代码里能看到三档（`reflector.go:606-641`）：调用方指定了 `WatchListPageSize` 就用它；上一次拿到过分页结果就保持默认分页；带具体 `ResourceVersion`、又没显式要求分页时，把 `PageSize` 设为 0 关掉分页，强制走 watch cache。注释给出的理由是避免大量客户端在集群升级时同时打到 etcd——带版本的 List 语义是“至少一样新”，所以从 watch cache 拿全量不会回退到更旧的快照。

分页真失败时也不是让它冒到上层。`pager.ListWithAlloc` 在续接分页遇到 Expired 时会自己退回全量 List（`pager.New` 把 `FullListIfExpired` 默认设为 true，[`pager.go:58-68`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/client-go/tools/pager/pager.go#L58-L68)）；Reflector 再兜一层：`isExpiredError` 或 `isTooLargeResourceVersionError` 命中就置位 `isLastSyncResourceVersionUnavailable`，立刻用空版本重试一次。

这两个错误不是一回事。`isExpiredError` 判的是 410 Gone / ResourceExpired，意思是版本太旧、历史被压缩；`isTooLargeResourceVersionError` 判的是请求的最小版本大于服务端当前最大版本，通常出现在客户端缓存的版本来自另一个 API Server、或 watch cache 还没跟上时。两者都要退回空版本重试，但根因一个在过去、一个在未来，排障时看到的现象也相反。所以这里一共有三层恢复，“收到 410 就重新 List”这个说法会漏掉两次。

```mermaid
sequenceDiagram
    participant C as 客户端
    participant A as API Server
    participant E as etcd
    C->>A: List pods
    A->>E: 读取当前集合
    E-->>A: 对象和 revision
    A-->>C: items + metadata.resourceVersion=rv1
    Note over C,A: List 与 watch 之间可能已经发生对象变化
    C->>A: Watch pods?resourceVersion=rv1
    A-->>C: rv1 之后的 ADDED / MODIFIED / DELETED
    alt rv1 仍可从 watch cache 或底层历史补出
        A-->>C: 连续事件
    else 版本已过期
        A-->>C: ERROR Status，code=410，reason=Expired
        C->>A: 重新 List，取得新的 rv
    end
```

图中的 410 可能以 HTTP 错误返回，也可能在 watch 流中表现为一个 `ERROR` 事件。客户端库需要同时处理这两种形式。

## 一次 List 和连续 Watch

实验先创建 `before` Pod，再通过 `kubectl proxy` 请求 namespace 的 Pod 列表。记录到的版本是 `1273`。随后打开：

```text
/api/v1/namespaces/article-watch-consistency/pods
  ?watch=1&resourceVersion=1273&timeoutSeconds=30
```

再创建 `after` Pod，watch 流收到的第一个对象事件是：

```text
ADDED after resourceVersion=1274
```

List 版本既不是 Pod 的版本，也不是服务端“下一个事件”的编号，作用只是作为 watch 的起点。`after` 创建后先得到 1274，之后 kubelet 更新状态，又可能出现新的 MODIFIED 事件。

持久化事件的顺序由服务端保证，但客户端不能把“收到 ADDED”当作容器已经 Ready。对象创建、调度、kubelet 状态更新和 Ready 是不同层次的事件。

## 从最后版本重连仍然能收到删除

第一次 watch 收到 `ADDED` 后，客户端记录最后观察到的对象版本 1274。断线后，它用 1274 重新发起 watch，然后删除 `after`。重连的流里先收到了后续 MODIFIED，最后收到 `DELETED`。

这次能重连成功，是因为服务端仍保留 1274 之后的完整历史；重连成功不能证明这个版本会一直可用。可用边界由服务端历史决定，客户端保存的数字本身没有保证。

```text
startResourceVersion: 1274
MODIFIED after 1275
...
DELETED  after 1286
```

实验中还记录了多个状态更新。不能只挑最后的 DELETED 写成“删除准时到达”；真正能确认的是事件类型和对象的 UID。对于一个会被控制器替换的工作负载，删除旧 Pod 后还可能创建同名或不同名的新 Pod，名称本身不足以区分对象。

## 什么情况下会得到 410 Expired

最初的实验直接把 `resourceVersion=1` 传给 watch，结果并没有立刻得到 410。这是实验设计的问题：一个单节点新集群的 watch cache 仍覆盖那个版本，或者 API Server 可以从更底层补出请求，不能把“很小的数字”直接等同于“历史已过期”。

第二轮实验先读取当前 revision，执行 etcd compaction，再重启 kube-apiserver 让旧 watch cache 失效，随后从压缩前的 revision 发起 watch。响应中的第一个事件是：

```json
{
  "type": "ERROR",
  "object": {
    "kind": "Status",
    "status": "Failure",
    "message": "too old resource version: 1162 (1163)",
    "reason": "Expired",
    "code": 410
  }
}
```

这是服务端给出的完整判断条件，客户端无需自行推断。etcd 压缩、watch cache 被清掉以及请求版本之间形成了一个可复现的“已过期”场景。要让“很小的版本号”变成“历史已过期”，必须真的让旧历史不可用：只改请求参数、不动服务端状态，复现不出 410。

压缩 etcd 并重启 API Server 只用于隔离实验。生产环境不应把这种操作当成常规排障手段。真实集群中，API Server 的 watch cache、etcd 压缩策略、请求范围和对象数量共同决定可保留的历史窗口。

## client-go Reflector 为什么要重新 List

client-go 的 Reflector 把 List 和 Watch 封装成 `ListAndWatch`。v1.35.1 的 [`reflector.go`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/client-go/tools/cache/reflector.go#L684-L700) 在 List 成功后调用 `syncWith` 把结果写入 store，再用 `setLastSyncResourceVersion` 记下这次版本，随后以该版本发起 watch。退避和重试分成两层：watch 流里收到 `Expired` 只是让 `handleWatch` 返回错误，`watch` 里的 `case isExpiredError` 只记一条 V(4) 日志（`reflector.go:561-565`），真正的循环在 `RunWithContext` 里由 `wait.BackoffUntil` 驱动（`reflector.go:365-373`）。

重连时不会立刻丢弃上次版本。下一次 List 仍带 `lastSyncResourceVersion`，因为带版本的 List 语义是“至少和数据一样新”，从 watch cache 拿到的全量结果不会比客户端已有的更旧。只有这次 List 也失败了，才轮到上面那层空版本兜底（`relistResourceVersion` 的取值规则在 `reflector.go:1014-1030`）。所以“重新 List”通常仍以旧版本为起点，退回空版本是最后手段，不是每次 Expired 都发生。

这带来两个结果：

1. Informer 最终会恢复，但恢复可能需要重新 List 全部对象，不能承诺零延迟和无额外 API 请求。
2. 重新 List 后，缓存中不再出现的对象会产生删除语义；这与收到一个正常 DELETED 事件并不完全一样。v1.35.1 的 [`delta_fifo.go`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/client-go/tools/cache/delta_fifo.go#L590-L600) 在 `Replace` 时给新列表中已不存在的 key 排入 `DeletedFinalStateUnknown{k, deletedObj}`，消费者要先从 key 取出对象标识，再决定清除哪个缓存或触发哪个 reconcile。`controller.go` 里的 `DeletionHandlingMetaNamespaceKeyFunc` 就是为这类对象解包 key 用的。

共享 Informer 会让多个控制器复用同一条 List/Watch 和本地缓存。一个控制器处理慢，不应改变其他消费者看到的事件基础；但每个控制器自己的 workqueue 仍需要保证 key 的串行处理和失败重试。

## Watch 只能保证什么

| 观察 | 能说明什么 | 不能说明什么 |
| --- | --- | --- |
| List 返回 `rv1` | 当前集合和读取版本 | 之后所有对象都不会变化 |
| Watch 从 `rv1` 开始 | 服务端可以从此版本追加事件 | 本地处理一定及时完成 |
| 收到 ADDED | 服务端观察到一个新建对象 | 容器已启动或 Ready |
| 收到 DELETED | 服务端发布了删除事件 | 节点上的进程已经结束 |
| 收到 410 | 请求版本超出可补历史 | etcd 或其他资源一定损坏 |
| 重新 List | 缓存重新对齐到新集合 | 所有控制器业务逻辑已经重放完 |

控制器的职责不是把事件当作最终状态，而是用事件触发一次 reconcile，在 reconcile 中读取当前对象和依赖对象，做出可重复的收敛动作。[Controller 调谐文章](/posts/kubernetes-controller-reconcile/)的调谐实验已经说明，队列事件只是“要检查谁”，不是“最终状态已经到齐”。

## 用这些信号排查缓存问题

1. 读取 HTTP List 响应中的 `metadata.resourceVersion`；`kubectl get -o json` 默认可能不显示这个字段。
2. 检查 watch URL 是否使用了这个版本，而不是硬编码 `1` 或上次的猜测值。
3. 读到 `Expired` 或 `too old resource version` 时，确认客户端是否重新 List，而不是继续无限重连。
4. 重新 List 后检查缓存对象的 UID，不要只看 name。
5. 把 API 事件、控制器队列和业务处理结果分开记录；收到 DELETED 不代表外部系统已经完成清理。

`resourceVersion` 的读取语义（`ResourceVersionMatch`、书签、`resourceVersion=0` 等）以 [API concepts](https://v1-35.docs.kubernetes.io/docs/reference/using-api/api-concepts/) 为准；`kubectl get -o json` 隐藏字段这一点与响应体本身无关。Informer、Reflector、Indexer 和 Workqueue 的分工见[已有的 client-go 文章](/posts/client-go-informer-reflector-indexer-workqueue/)。

实验目录和运行方法见 [k8sdev/article-watch-consistency](https://github.com/jimyag/k8sdev/tree/917db9bc548a27980afe404592b862cafea22db4/article-watch-consistency)。本次验证了压缩后的 410、从 List 版本 watch 和断线后重连收到删除；没有验证大规模对象量、分页 List、watch bookmarks 或 API Server 长时间中断时的全部重试行为。

