基础概述
etcd 的 Watch 机制是 API Server Watch 机制的基础。etcd 的 Watch 机制可以让 client 以增量的方式同步获取到 etcd 中变更的数据。在 etcd 中,版本号是 etcd 的逻辑时钟,client watch 的时候,指定监听某个版本号之后的数据变更后,etcd 就会不断地把该版本号之后的历史变更事件推送给 client,client 根据事件同步变更本地的数据,避免了全量同步。
v3 && v2
-
连接协议
etcd v3 使用了基于 HTTP/2 的 gRPC 协议。基于 HTTP/2 的多路复用实现了一个 TCP 连接支持多 gRPC Stream(一个 gRPC Stream 又支持创建多个 watcher)。基于 HTTP/2 的服务端推送机制,将事件通知模式从 client 轮询优化成了服务端主动推送,降低了服务端的连接数、内存等资源。clientv3 库对上述连接进行了封装,并且当 watch 连接的节点故障,clientv3 库还支持自动重连到健康节点,并使用之前已接收的最大版本号创建新的 watcher,避免旧事件回放。对于开发者来说,只需要使用 watch 机制抽象出来的三个简单的接口:Watch、Close、RequestProgress 即可。
etcd v2 watch 机制实现中,使用的是 HTTP/1.x 协议,实现简单,兼容性好,每个 watch 对应一个 TCP 连接。client 通过 HTTP/1.1 协议连接定时轮询 server,获取最新的数据变化事件。但是,当 watcher 的数量变多之后,即使集群空载,大量轮询也会造成一定的 QPS,服务端会消耗大量的 socket、内存等资源,导致 etcd 的扩展性、稳定性无法满足 kubernetes 的业务场景。
-
历史记录保存
etcd v3 对 key-value 的历史记录保存,依靠了 MVCC 机制。MVCC 机制将历史版本持久化到磁盘中,避免了历史版本的丢失。
etcd v2 使用滑动窗口的机制,将最近的历史版本保存到内存中。它使用一个简单的环形数组来存储历史事件版本,当 key 被修改之后,相关事件就会被添加到数组中来,如果超过了 eventQueue 的容量,则会淘汰最旧的事件。在 etcd v2 中,eventQueue 的容量是固定的 1000,也就是说它最多只会保存 1000 条事件记录,从而避免占用大量 etcd 内存导致 OOM。但是,如果写请求较多,很容易导致事件丢失,client 不得不触发大量的 expensive 查询操作,以获取最新的数据和版本号,才能持续监听数据。
具体原理
整体架构
整个 watch 请求的流程如图所示,
-
客户端发起 Watch 请求。
客户端通过 client 库使用 gRPC 协议发起一个 Watch 请求,指定它希望监视的键(key)。这步骤其实包含两步:一个是创建 gRPC 连接,一个是创建 watcher。
-
gRPC Watch Server 接收请求。
etcd 服务器端的 gRPC Watch Server 接收到 Watch 请求后,首先为该 gRPC 连接创建一个 serverWatchStream,用于管理客户端创建/取消 watcher 的请求,也用于发送键值对变更事件。
serverWatchStream 相当于对一条 gRPC 连接的抽象,一个 gRPC 连接可以创建多个 watcher,因此 serverWatchStream 要负责管理基于这条 gRPC 连接的 watcher。watcher 发送键值对变更事件则通过 serverWatchStream,也就是通过共享 gRPC 连接。
-
serverWatchStream 等待请求。
serverWatchStream 会运行一个 recvLoop 循环,等待来自客户端流的进一步请求,比如创建/取消 watcher 的请求。
-
处理 create watcher 请求。
当 serverWatchStream 收到一个 create watcher 的请求后,调用 MVCC 模块的 WatchStream 子模块创建 watcher 实例,并分配一个 watcher id,同时将 watcher 注册到 MVCC 的 WatchableKV 模块。
-
键值对变化事件推送。
etcd 将 watcher 按场景分为三类:
-
synced watcher,此类 watcher 表示监听的数据都已经同步完毕,在等待新的变更。比如,创建的 watcher 未指定版本号(默认为 0),或者指定的版本号大于 etcd server 当前最新的版本号(currentRev)。
-
unsynced watcher,此类 watcher 表示监听的数据还未同步完成,落后于当前最新数据变更。比如,创建的 watcher 指定版本号小于 etcd server 当前最新版本号。
-
victim watcher,此类 watcher 主要表示处于异常情况的 watcher,比如网络波动导致 serverWatchStream 缓冲区已满无法继续发送的变更事件的。处于 victim 集合中的 watcher,是 slow watcher。
其中,
- synced 和 unsynced watcher 都被保存到对应的 watcherGroup 中,分别是 synced watcherGroup 和 unsynced watcherGroup。
- victim watcher 则会被保存到 watcherBatch 中。
WatchableKV 模块用于监视键值对的变化。当 etcd 启动时,WatchableKV 模块会启动 syncWatchersLoop 和 syncVictimsLoop 两个 goroutine。
- syncWatchersLoop:负责 unsynced watcherGroup 中 watcher 历史事件推送。
- syncVictimsLoop:负责 victim watcherBatch 中 watcher 的事件推送。
-
最新事件推送
当发生 put 请求之后,put 请求所产生的最新变更事件如何推送给客户端,比如上图的 put 请求。
-
put 请求经过 KVServer、Raft 模块处理后,会由 Apply 模块进行处理,进而进入 MVCC 模块进行处理。
-
MVCC 模块会将本次修改后的 mvccpb.KeyValue 保存到一个 changes 数组中。
-
在完成更新之后,MVCC 会将 KeyValue 转换为 Event 事件,然后回调 WatchableKV 的 notify 函数。
-
notify 函数会从 synced watcherGroup 中快速找出监听此 key 的 watcher。如果事件中的版本号大于等于 watcher 监听的最小版本号则将事件发送给 watcher 中的事件 channel 中。
-
serverWatchStream 的 sendLoop goroutine 监听到该 watcher 中的 channel 中有消息,则会读取消息并立即推送给 client,这样就完成了一个最新修改事件的推送(etcd v3.4.9 中接收 watch 事件的 channel 容量默认为 1024)。
历史事件推送
上文提到 WatchableKV 模块的 syncWatchersLoop 负责 unsynced watcherGroup 中的 watcher 历史事件的推送。过程如下,
-
syncWatchersLoop 会遍历处于 unsynced watcherGroup 中的 watcher。出于性能优化考虑,它会选择一批 unsynced watcher,然后找出这一批 unsynced watcher 监听的最小版本号。
如果 watcher 监听的版本号已经小于当前 etcd server 压缩的版本号,历史变更数据已经丢失,此时 etcd server 会返回 ErrCompacted 错误。client 收到此错误之后,可以考虑重新获取数据最新版本号,再次 watch,而往往在开发过程中未处理该错误。
-
之后,以这个最小版本号为开始区间,当前 server 最大版本号作为结束区间,遍历 boltdb 获取所有历史数据。
-
然后依次将 KeyValue 转换为事件,并从 unsynced watcherGroup 中找到监听该 key 的 watcher,将事件发送给对应 watcher 中的事件 channel。
-
发送完成后,将该 watcher 从 unsynced watcherGroup 中移除,添加到 synced watcherGroup 中。
异常场景推送
上文提到接收 watch 事件的 channel 容量默认为 1024。如果 channel 满了,etcd 为了保证 watch 事件的可靠性,同时考虑到 notify 函数是被同步调用的,需要确保轻量级、高性能和无阻塞的。
- 首先 notify 会将此 watcher 从 synced watchGroup 中删除,并将该 watcher 和事件列表(待推送的事件)保存到 victim 的 watcherBatch 结构中。
- 之后 syncVictimsLoop 会遍历 victim 的 watcherBatch,尝试将堆积的事件再次推送到 watcher 的接收 channel 中。
- 如果推送失败,则再次加入到 victim watchBatch 中等待下次重试。
- 如果推送成功,但是 watcher 监听的最小版本小于等于 etcd server 当前版本号,则说明还有历史事件未推送,则会将该 watcher 加入到 unsynced watcherGroup 中。由 syncWatchersLoop 推送 minRev 到 currentRev 之间的事件。假如,watcher 的最小版本号大于 etcd server 当前版本号,则加入到 synced watcherGroup 中,之后采用「最新事件推送」方式进行推送。
高效的事件匹配
上述过程中,会发现都需要从 synced watcherGroup 或 unsynced watcherGroup 中找到监听该 key 的 watcher。
由于 watcher 监听的 key 可能是单个 key,也可能是 key 范围、key 前缀。为此,etcd 使用 map(监听单个 key 的情况) 和区间树(监听 key 返回、key 前缀)两种数据结构来保存 key 和 watcher 的映射关系,用于快速查找监听某个 key 的 watcher 情况。
-
针对创建 watcher 的情况。
etcd 会将 watcher 监听的 key 范围插入到上面的区间树中,区间的值保存了监听同样 key 范围的 watcher 集合(watcherSet)。
-
针对查找 watcher 的情况。
-
首先从 map 中查找是否有 watcher 监听了单个 key。
-
之后继续从区间树找出与 key 相交的所有区间,然后获取监听的 watcher 集合。区间树支持快速查找一个 key 是否存在在某个区间内,事件复杂度为 O(LogN)。
举个例子,如果某个 watcher 监听了以 /c 和 /d 为前缀的所有 key,当 key 为 /cdemo 的值发生变化后。除从 map 中找到监听的 key 为 /cdemo 的 watcher 之外,还会从区间树中找到其他 watcher,比如上述监听了 key 以 /c 和 /d 为前缀的 watcher。
-