ZooKeeper / etcd 笔记:监听、租约和隔离机制

沿着监听中断、修订记录压缩、租约过期和选主隔离,说明协调服务为什么不能当作普通 KV 存储使用。

Engineering notesDistributed systems

ZooKeeper 和 etcd 很容易被误用,因为它们看起来都像 KV。

但它们真正提供的不是“大一点的配置表”,而是少量具有顺序、生命周期和通知机制的控制面状态。

我会用四个问题判断一个系统有没有把协调服务用对:

text
监听断开后能不能续接?
历史被压缩后能不能重建本地状态?
租约过期后旧进程还能不能写外部资源?
这里存的是控制面状态,还是数据面流量?

第一条红线:不要把协调服务当数据面

ZooKeeper / etcd 适合存:

  • 主节点身份。
  • 服务实例。
  • 配置版本。
  • 任务领取状态。
  • 工作进程会话。
  • 小规模元数据指针。

不适合存:

  • 用户大表。
  • 高频请求日志。
  • 指标流。
  • 大块二进制。
  • 消息队列式写入流。

原因不只是“它们比较慢”,而是每次写入都会进入一致性复制、落盘、修订版本推进和监听通知。把数据面流量塞进去,拖慢的是整个控制面。

协调服务应该小、稳、可解释。它的价值是给其它系统提供事实来源,不是替代业务数据库。

ZooKeeper 的强项是会话语义

ZooKeeper 的 znode 树模型很直接:

text
/
  services/
  locks/
  config/

真正有用的是节点类型:

  • 持久节点:显式删除前一直存在。
  • 临时节点:会话结束后自动删除。
  • 顺序节点:创建时带递增序号。
  • 临时顺序节点:既有会话生命周期,也带递增序号。

服务注册依赖临时节点:进程退出或会话断开后,节点消失。锁和选主依赖顺序节点:各方创建有序节点,序号最小者持有权力,其它客户端监听前一个节点。

这里的工程边界是会话。协调层不需要知道进程“物理上是否还活着”,只需要判断这个会话是否仍被承认。

etcd 的强项是修订版本时间线

etcd 更像 KV 存储,但它的关键不是键,而是修订版本。

text
rev 101: put /config/a
rev 102: put /services/x
rev 103: delete /services/y

修订版本让客户端能说清楚自己看到了哪里。监听断开后,也能从某个修订版本继续消费后续事件。

这就是 Kubernetes 控制器能够工作的基础模式:

text
在 resourceVersion R 列出当前对象
从 R + 1 开始监听
按顺序处理事件
如果监听历史已经被清理,就重新列出对象

如果没有修订版本或 resourceVersion,控制器断线后只能猜测自己是否遗漏了事件。

监听机制不是普通回调

把监听机制当作普通回调,是使用 etcd/ZooKeeper 时最常见的错误之一。

普通回调的心智模型是:

text
发生变化 -> 通知我

监听机制的真实模型更像:

text
我已经处理到修订版本 R。
请给我 R 之后的事件。
如果这段历史已经不存在,请告诉我重建状态。

所以一个可靠的监听者必须保存进度,并处理断线。

call path

监听客户端应该能够重新找回事实

客户端etcd本地缓存
  1. 1

    客户端

    etcd

    在修订版本 R 列出对象

    先拿当前事实和版本

  2. 2

    客户端

    本地缓存

    替换本地缓存

    本地状态以列出结果为准

  3. 3

    客户端

    etcd

    从 R + 1 开始监听

    按修订版本消费后续事件

  4. 4

    etcd

    客户端

    历史已被压缩

    历史不在了,必须重新列出对象

如果代码在监听断开后只是重连,不检查修订版本是否还存在,本地缓存就可能悄悄停留在旧状态。

压缩决定事件流的保留窗口

etcd 不会无限保存历史修订版本,压缩会清理旧版本。

这意味着处理过慢或长时间断线的监听者会错过保留窗口:

text
客户端最后处理:120
etcd 已压缩到:180
客户端从 121 开始监听
  -> 返回历史已压缩错误

正确动作不是继续重试,而是重新列出对象。

这点对 Kubernetes 控制器很关键。控制器不能假设自己永远在线,也不能假设监听永远连续。它必须能通过重新列出对象来重建本地状态,再从新的 resourceVersion 继续监听。

租约过期不是节点死亡的证明

租约解决的是沉默客户端的生命周期。

text
授予租约
携带租约写入键
持续续租
停顿 / 网络分区 / 崩溃
租约过期
键被删除

租约过期表示协调服务不再承认这个持有者,但不等于进程真的停止了。

旧进程可能只是发生了长时间 GC 停顿,或者网络短暂断开。恢复后它还会继续运行。如果它手里仍有旧主节点身份,事情就危险了。

所以租约通常必须配合隔离令牌。

选主若没有隔离令牌,只能保证系统内部正确

假设:

text
node-1 持有主节点租约 10
node-1 发生停顿
租约 10 过期
node-2 获得主节点租约 11
node-1 恢复运行

协调服务内部已经承认 node-2 是新主节点,但 node-1 进程仍然存活。如果它继续写外部数据库、对象存储或下游服务,而下游不检查令牌,就会出现旧主节点写入。

更稳的模式是把世代、修订版本或租约编号带到外部写入:

text
write(value, fencing_token=11)

外部系统:
  只有 token >= current_token 时才接受写入

这样旧主节点即使恢复,也会因为令牌过期而被拒绝。

选主不是终点。选出来的权力必须能被下游验证。

Kubernetes 用 etcd,是因为它能重建集群状态

Kubernetes 依赖 etcd,不是因为 etcd 像一个普通数据库,而是因为 API Server 和控制器能围绕 resourceVersion 维护有序的集群状态。

大致链路是:

text
kubectl apply
  -> API Server 校验对象
  -> 将对象写入 etcd
  -> 控制器监听变化
  -> 控制器协调实际状态

控制器的关键动作不是“收到事件就执行”,而是持续协调:

text
来自 etcd 的期望状态
  + 观察到的集群状态
  -> 下一步动作

监听可以断开,事件可以重复,本地缓存也可以丢失。只要控制器能够重新列出对象并恢复监听,就能重新回到权威状态上。

我会警惕的使用方式

看到这些设计,我会先打问号:

  • 把每个请求状态都写进 etcd。
  • 监听断线后没有保存和检查修订版本。
  • 遇到历史已压缩错误时只重试,不重新列出对象。
  • 主节点只写一个键,没有使用隔离令牌。
  • 租约 TTL 设得很短,但进程可能发生长时间 GC 停顿。
  • 把大 JSON、二进制 blob、指标流塞进协调服务。
  • 控制器的本地缓存没有重建路径。

这些问题不是 API 用法小错,而是把协调服务的边界理解错了。

协调服务应该保持克制

ZooKeeper / etcd 提供的是少量、有序、可观察且带生命周期的控制面状态。

监听机制让变化可以被追上,修订版本为断线恢复提供锚点,租约让沉默客户端可以被回收,隔离令牌让旧主节点不能继续写外部资源。把这些边界用清楚,协调服务会很稳;把它当成万能状态中心,则可能拖慢整个控制面。