bigywater.tech
微服务 · 42

etcd watch:从某个 revision 开始监听不丢事件

服务发现靠 watch 感知变化,断线之后怎么保证不丢事件?历史被压缩掉怎么办?一次事务的多个修改会怎么到达?watcher 多了延迟会怎样?这篇用 etcd 3.7.2 的流式 watch 逐项量出来。

50 对 0
断线重连时,从“现在”开始监听与从最后 revision + 1 开始监听,丢掉的事件数(断开期间写了 50 个 key)
compact_revision = 313
从被压缩掉的 revision 263 watch 时,etcd 立刻取消该 watch 并告知的 compact revision
14.40 ms
200 个 watcher 时事件送达延迟的 p50(1 个 watcher 是 6.58 ms;含 HTTP 网关与 Python 调度的开销)

从 revision 重连

实测 · 本机执行输出
  重连方式                                    | 断开前收到 | 重连后收到 | 整个过程没收到的 key | 重复收到的 key
  ----------------------------------------+-------+-------+--------------+----------
  (a) 不带 start_revision(从现在开始)            | 10    | 1     | 50 / 61      | 0        
  (b) start_revision = 最后看到的 revision + 1 | 10    | 51    | 0 / 61       | 0        
  从“现在”开始监听时,断开的 2 秒里写入的 50 个事件永远丢了;带上最后看到的 revision + 1 重连,事件一个不丢、也没有重复

带上最后看到的 revision + 1,事件不丢不重。

被压缩的历史

实测 · 本机执行输出
  start_revision                | 结果                                                   | 收到的事件数 | 事件的 revision 范围
  ------------------------------+------------------------------------------------------+--------+----------------
  (a) 旧 revision(263)           | 被拒:canceled=True,cancel_reason=,compact_revision=313 | 0      | -              
  (b) 等于 compact revision(313)  | 创建成功                                                 | 11     | 313…323        
  (c) compact revision + 1(314) | 创建成功                                                 | 10     | 314…323        
  当前 revision 是 323,compact 到了 313。比 compact revision 小的历史取不到,etcd 创建 watch 后立刻把它取消(canceled = true)并带上 compact_revision;客户端遇到这种错误必须重新 list(拿到当前全量和 header.revision),再从 header.revision + 1 开始 watch,中间被压缩掉的变化只能靠 list 的结果补上

start_revision 小于 compact revision 时 watch 被立刻取消,必须重新 list 再 watch。

原子性

实测 · 本机执行输出
  watch 响应序号 | 事件数 | 事件(类型:key@revision)                                   | 各事件的 prev_kv 旧值
  -----------+-----+-------------------------------------------------------+----------------
  1          | 3   | PUT:/tx/a0@324 PUT:/tx/a1@324 PUT:/tx/a2@324          | - - -          
  2          | 3   | DELETE:/tx/a0@325 DELETE:/tx/a1@325 DELETE:/tx/a2@325 | 1 1 1          
  3          | 1   | PUT:/tx/z@326                                         | -              
  4          | 1   | PUT:/tx/z@327                                         | old            
  一次 Txn 提交的 3 个 put 在同一个 revision 里,在同一个 watch 响应里一起到达;删除前缀产生一个 DELETE 事件对应一个 key,也在同一个 revision;prev_kv 让 watcher 在 PUT(第二次写 /tx/z)和 DELETE 事件里拿到修改前的值;第一次写 /tx/z 没有旧值,所以是 “-”

一次提交的多个修改在同一个 revision 和同一个响应里到达,prev_kv 给出旧值。

规模

实测 · 本机执行输出
  watcher 数 | 收到的事件总数 | 延迟 p50   | p99      | 最大      
  ----------+---------+----------+----------+---------
  1         | 30      | 6.58 ms  | 9.23 ms  | 9.40 ms 
  50        | 1500    | 7.69 ms  | 17.97 ms | 18.63 ms
  200       | 6000    | 14.40 ms | 22.11 ms | 22.83 ms

watcher 数增加,延迟缓慢增长,事件不少。

分步演示

etcd watch
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / Go / nginx / Envoy / etcd / Consul / Nacos / OpenTelemetry / Prometheus(按各篇注明用到哪些及版本);所有服务都是本机自己起的进程,端口 9150 以上,只监听 127.0.0.1,不连任何外部服务,不碰本机已有的实例;故障用 kill -9 进程、让连接断开或在代码里注入延迟与错误来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。etcd 3.7.2(Homebrew,单节点自己起的进程);通过 HTTP/JSON 网关的流式 /v3/watch(响应是 chunked 的,逐行 JSON)操作,client 是 Python 线程;每次运行前清空数据目录,所以 revision 数值每次一致;延迟包含 HTTP 网关和 Python 线程的开销;我最初的 Watch.close() 会和读线程死锁,改成先 shutdown socket;cancel_reason 在这个版本的响应里是空串,取消是靠 canceled 和 compact_revision 判断的
    • 没有验证:watch 的 progress_notify 与 fragment 参数;大量 watcher 共享一条 gRPC 连接(clientv3)时的行为;watch 在 leader 切换、集群成员变更时的重连语义;watcher 追赶历史事件时(“unsynced”)的吞吐;1000 个以上 watcher
    京ICP备2026067502号-1京公网安备11010502063487号