当你修改一个 Deployment 的镜像时,Deployment 控制器是如何高效的感知到期望状态发生了变化呢?

etcd Watch 机制

  • etcd v2 基于 HTTP/1.x 协议,一个 watcher 对应一个 TCP 长链接,进行定时轮询。
  • etcd v3 基于 HTTP/2 协议的多路复用等机制,实现了一个 client/TCP 连接支持多 gRPC Stream,一个 gRPC Stream 又支持多个 watcher。同时事件通知模式也从 client 轮询优化成 server 流式推送,极大降低了 server 端 socket、内存等资源。

|500

etcd 事件如何存储?保留多久?

etcd v2 的方案

  • 使用简单的环形数组+滑动窗口在内存中存储历史事件。当 key 被修改后,相关事件就会被添加到数组中来。若超过 eventQueue 的容量,则淘汰最旧的事件。v2 中容量为固定的 1000,不会占用大量 etcd 内存。
  • 缺点:保存有限的历史事件版本,不可靠。当写请求多、网络波动等,容易造成事件丢失,client 不得不触发大量的 expensive 查询操作,以获取最新的数据及版本号,才能持续监听数据。

etcd v3 的方案

  • MVCC 来保存每个 key 的历史版本。不会像 v2 一样重启后丢失数据,同时可以配置压缩策略,控制历史版本数目。

可靠的事件推送机制

  • 当通过 etcdctl 或 API 发起一个 watch key 请求的时候,etcd 的 gRPCWatchServer 收到 watch 请求后,会创建一个 serverWatchStream, 它负责接收 client 的 gRPC Stream 的 create/cancel watcher 请求(recvLoop goroutine),并将从 MVCC 模块接收的 Watch 事件转发给 client(sendLoop goroutine)。
  • 当 serverWatchStream 收到 create watcher 请求后,serverWatchStream 会调用 MVCC 模块的 WatchStream 子模块分配一个 watcher id,并将 watcher 注册到 MVCC 的 WatchableKV 模块。
  • 在 etcd 启动的时候,WatchableKV 模块会运行 syncWatchersLoop 和 syncVictimsLoop goroutine,分别负责不同场景下的事件推送。

最新事件推送机制

当执行 put hello 修改操作时,请求经过 KVServer、Raft 模块后 Apply 到状态机时,在 MVCC 的 put 事务中,它会将本次修改的后的 mvccpb.KeyValue 保存到一个 changes 数组中。

事务结束时,会将 KeyValue 转换成 Event 事件,然后回调 watchableStore.notify 函数(流程 5)。notify 会匹配出监听过此 key 并处于 synced watcherGroup 中的 watcher,同时事件中的版本号要大于等于 watcher 监听的最小版本号,才能将事件发送到此 watcher 的事件 channel 中。

serverWatchStream 的 sendLoop goroutine 监听到 channel 消息后,读出消息立即推送给 client(流程 6 和 7),至此,完成一个最新修改事件推送。

异常场景重试机制

若出现 channel buffer 满了,etcd 为了保证 Watch 事件的高可靠性,并不会丢弃它,而是将此 watcher 从 synced watcherGroup 中删除,然后将此 watcher 和事件列表保存到一个名为受害者 victim 的 watcherBatch 结构中,通过异步机制重试保证事件的可靠性。

需要注意的是,notify 操作它是在修改事务结束时同步调用的,必须是轻量级、高性能、无阻塞的,否则会严重影响集群写性能。

WatchableKV 模块的异步 goroutine syncVictimsLoop,遍历 victim watcherBatch 数据结构,尝试将堆积的事件再次推送到 watcher 的接收 channel 中。若推送失败,则再次加入到 victim watcherBatch 数据结构中等待下次重试。

若推送成功,watcher 监听的最小版本号(minRev)小于等于 server 当前版本号(currentRev),说明可能还有历史事件未推送,需加入到 unsynced watcherGroup 中,由下面介绍的历史事件推送机制,推送 minRev 到 currentRev 之间的事件。

若 watcher 的最小版本号大于 server 当前版本号,则加入到 synced watcher 集合中,进入上面介绍的最新事件通知机制。

历史事件推送机制

WatchableKV 模块的另一个 goroutine,syncWatchersLoop,正是负责 unsynced watcherGroup 中的 watcher 历史事件推送。

在历史事件推送机制中,如果监听老的版本号已经被 etcd 压缩了,client 该如何处理?

syncWatchersLoop,它会遍历处于 unsynced watcherGroup 中的每个 watcher,为了优化性能,它会选择一批 unsynced watcher 批量同步,找出这一批 unsynced watcher 中监听的最小版本号。

因 boltdb 的 key 是按版本号存储的,因此可通过指定查询的 key 范围的最小版本号作为开始区间,当前 server 最大版本号作为结束区间,遍历 boltdb 获得所有历史数据。

然后将 KeyValue 结构转换成事件,匹配出监听过事件中 key 的 watcher 后,将事件发送给对应的 watcher 事件接收 channel 即可。发送完成后,watcher 从 unsynced watcherGroup 中移除、添加到 synced watcherGroup 中,如下面的 watcher 状态转换图黑色虚线框所示。

若 watcher 监听的版本号已经小于当前 etcd server 压缩的版本号,历史变更数据就可能已丢失,因此 etcd server 会返回 ErrCompacted 错误给 client。client 收到此错误后,需重新获取数据最新版本号后,再次 Watch。你在业务开发过程中,使用 Watch API 最常见的一个错误之一就是未处理此错误。

事件匹配方案

若创建了上万个 watcher 监听 key 变化,如何快速找到监听到 watcher 呢?etcd 采用区间树的结构来存储 key,从而不止监听单 key,还可以指定监听 key 范围、key 前缀。

英文维基上,区间树和线段树并不一样。