书接上文,我们在《ETCD源码分析Client端启动流程分析》中,深入源码查看ETCDClient端如何启动,如何向Server建立GRPC连接的。在本文中,我们继续探究ETCD的Client端是如何实现Watch逻辑的。
由于Watch逻辑整体还是比较复杂的,建议观看本文的小伙伴,把ETCD的V3.5版本源码打开,对比着文章查看。
ETCD源码Watch启动流程
首先对Watch流程进行分解,先介绍下ETCD客户端是如何启动Watch的。
Watch模块通过Watcher接口对外提供三个功能:
- Watch() 用于创建对某个key或者某些key的Watch流;
- RequestProgress() 用于告知客户端当前集群最新的reversion;
- Close() 用于关闭所有的Watch流。
内部有一个私有的watcher实例,其中最关键的成员就是watchGrpcStream,存储了底层的GRPC Stream,提供了GRPC流的管理;请求、响应的管理;缓冲队列的控制等等,由于其中运用了大量的Channel,加上一些数据结构的处理,代码初看起来还是比较绕的。
不废话,让我们从Watch开始,先看看整个Watch流程是怎么启动的。
func (w *watcher) Watch(ctx context.Context, key string, opts ...OpOption) WatchChan {
// 刚开始对opts做了些处理,
// 我们不care,专心看主要逻辑
// 每调用一次Watch
// 都会创建一个watchRequest,这个wr会
// 经过层层流转,最终发送到ETCD服务端
// 并在客户端服务端之间
// 创建出一条GRPC的Stream
//
// Watch可能会被多次调用,*但是*(强调一下)
// 但是以后就会共用最初创建的那条GRPC的Stream。
// Watch机制的代码做的这么复杂,也就是
// 为了处理共用一条Stream连接的问题。
wr := &watchRequest{
ctx: ctx,
createdNotify: ow.createdNotify,
key: string(ow.key),
end: string(ow.end),
rev: ow.rev,
progressNotify: ow.progressNotify,
fragment: ow.fragment,
filters: filters,
prevKV: ow.prevKV,
retc: make(chan chan WatchResponse, 1),
}
ok := false
// 上面说一般是共用GRPC的Stream连接
// 其实如果你传进来的ctx带的value不一样
// 它就不会共用连接了。
// 不过我们一般是用不上的,所以读者们可以忽略
// ,记住是共用连接就好。
// ps. 一般传进来的ctx有两种:
// 1. context.Background()
// 2. clientv3.WithRequireLeader(ctx)
// 后面还会详细介绍
ctxKey := streamKeyFromCtx(ctx)
var closeCh chan WatchResponse
for {
// 创建或重用GrpcStream
// 如果ctxKey不变的话,
// 就用streams中的缓存。
//
wgs := w.streams[ctxKey]
if wgs == nil {
wgs = w.newWatcherGrpcStream(ctx)
w.streams[ctxKey] = wgs
}
donec := wgs.donec
reqc := wgs.reqc
...
select {
// 上面代码创建出的wgs,
// 其内部创建的gorotine是核心处理逻辑
// 这里也只是把 wr 通过 reqc 通道
// 发到核心goroutine就完事了。
case reqc <- wr:
ok = true
// 如果创建的wgs被done了
//(包括关闭、异常等等情况)
// Watch()报告失败,流程终止。
case <-donec:
...
}
if ok {
select {
// wr 正确发送以后,
// 其retc会收到一个chan WatchResponse
// 而retc本身是个
// chan chan WatchResponse
// 应该怎么叫?channel的channel吧。
case ret := <-wr.retc:
// 只有走到这里,
// 才是正常结束Watch()函数
return ret
...
}
}
}
// 异常结束
close(closeCh)
return closeCh
}
通过以上分析我们就会发现整个Watch()函数就做了三件事:
- 创建或使用缓存的watchGrpcStream
- 将WatchRequest发送到watchGrpcStream
- watchGrpcStream会为WatchRequest生成 chan WatchResponse用于接收结果
并通过chan chan WatchResponse 返回给用户。
这里简单介绍一下 Watch(ctx context.Context, key string, opts …OpOption) WatchChan 这个函数支持哪些context,不同的context会让Watch产生不同的行为。
- context.WithBackground() / context.WithCancel()
最常用的ctx,设置此ctx,watch会保证底层连接创建成功
也就是会不断重试,直到连接成功或者ETCD服务端返回严重错误
或者调用ctx的cancel()方法,也可以强行结束watch- context.WithTimeout()
这个就是Timeout时间后,自动帮你调用cancel()
也就是timeout时间后,不管你的Watch流是正常还是异常
都会强行结束,一般不能用这种context- clientv3.WithRequireLeader(ctx)
在ctx中加入 RequireLeader 标志,这个标志处理的问题是:
若ETCD服务集群发生网络分区,且你的客户端Watch流正好连接少数一方的节点
那你的Watch就卡住了,不会有响应,白白浪费资源。
加入这个标志,Watch在发现自己连接的节点是少数一方节点时,尝试重新连接到其它的节点,这样就自动的处理了分区带来的不可用问题。
ETCD源码Watch监控流程
接下来,我们进入watchGrpcStream 的内部,看看它到底是如何处理连接复用问题的。
看代码之前要记住一点,watchGrpcStream做了两件事:1. 管理自己的底层GRPC连接(处理Request、Response);2. 管理共用底层连接的watcherStream。
为了简化描述,我们需要为几个关键角色起个别名
watchGrpcStream :wgs
watcherStream : ws
watchRequest : wr
watchResposne :wresp
// wgs 的主要流程就在run()内部,这是核心处理逻辑
func (w *watchGrpcStream) run() {
...
// 关闭流程会在单独介绍,此处不管
closing := make(map[*watcherStream]struct{})
defer func() {
...
}()
// 启动底层GRPC连接
if wc, closeErr = w.newWatchClient(); closeErr != nil {
return
}
var cur *pb.WatchResponse
for {
select {
// Watch() 方法内new出来的WatchRequest,
// 最终会传递到这里。
case req := <-w.reqc:
switch wreq := req.(type) {
case *watchRequest:
outc := make(chan WatchResponse, 1)
// 每个 wr 都会建立虚拟的Stream
// 因为 watcherStream 实际上在共用底层连接
// 所以我叫它叫虚拟的Stream
ws := &watcherStream{
initReq: *wreq, // wr 和 ws 是一对一关系
// 每个 ws 都会在ETCD服务端生成watchId
// 用于标识这个虚拟的Stream
id: -1,
outc: outc,
recvc: make(chan *WatchResponse),
}
ws.donec = make(chan struct{})
// 建立好一个ws,WatiGroup都会加1
// 记录建立了多少个ws
w.wg.Add(1)
// 虚拟的Stream开始提供服务
go w.serveSubstream(ws, w.resumec)
// ws 进入resuming队列,注意这个处理
// 与之对应的还有一个substreams的hash set
// 这两个数据结构都是用来存放 ws 的
// resuming和substreams就好像预备党员和党员的关系
// 初始创建的 ws 只是预备役,wgs会帮它发送 wr
// *只有底层grpc连接收到对应的 wresp 后,*
// *它才能提升到 substreams 中*
w.resuming = append(w.resuming, ws)
// 为保证wr的发送顺序(发wr收到wresp才发送下一个wr)
// 如果w.resuming不是1(说明resuming中还有没拿到wresp的wr)就不能发
if len(w.resuming) == 1 {
// wc 是 wgs 内部GRPC连接的管理者(GRPC 的client)
if err := wc.Send(ws.initReq.toPB()); err != nil {
w.lg.Debug("error when sending request", zap.Error(err))
}
}
...
}
// 底层收到了ETCD发来的一个 WatchResponse
case pbresp := <-w.respc:
// cur 专门用来支持 Fragment 功能
// 也就是ETCD服务器可能会把比较大的wresp分片发送
// cur 是用来把这些分片重新整合成完整的 wresp
if cur == nil || pbresp.Created || pbresp.Canceled {
cur = pbresp
} else if cur != nil && cur.WatchId == pbresp.WatchId {
// merge new events
...
}
switch {
case pbresp.Created:
if len(w.resuming) != 0 {
// 收到响应后,resuming队列的
// 第一个ws成功从预备役转为正式役
//(被ETCD服务端分配了一个WatchId)
if ws := w.resuming[0]; ws != nil {
w.addSubstream(pbresp, ws)
// dispatchEvent
// 会把wresp分给substreams里的 ws
// 根据 wresp 中的 WatchId 匹配
w.dispatchEvent(pbresp)
w.resuming[0] = nil
}
}
// 顺序发送下个resuming队列的ws(保证了顺序发送接收)
if ws := w.nextResume(); ws != nil {
if err := wc.Send(ws.initReq.toPB()); err != nil {
w.lg.Debug("error when sending request", zap.Error(err))
}
}
...
// 如果 wresp 既不是Created响应,
// 也不是Cancel响应,也不是Fragment响应
// ps. (wresp的响应类型分为这三种,
// Cancel响应会在关闭流程介绍)
// 那就转发给 ws 处理
default:
// 转发到 ws 中处理
ok := w.dispatchEvent(cur)
if ok {
break
}
// 客户端这里没有 ws 可以处理这个 wresp
// 那就通知ETCD服务器关闭这个虚拟的watchStream
if _, ok := cancelSet[pbresp.WatchId]; ok {
break
}
// cancelSet用来去重
cancelSet[pbresp.WatchId] = struct{}{}
// 通知服务端关闭 pbresp.WatchId
// 对应的虚拟Stream
cr := &pb.WatchRequest_CancelRequest{
CancelRequest: &pb.WatchCancelRequest{
WatchId: pbresp.WatchId,
},
}
req := &pb.WatchRequest{RequestUnion: cr}
if err := wc.Send(req); err != nil {
...
}
}
// 以下在关闭流程中会详细介绍
case err := <-w.errc:
...
case <-w.ctx.Done():
...
case ws := <-w.closingc:
...
}
}
}
这里我尽量详细的把源码的每个部分它的作用、设计注释出来。对照源码本身去看,应该能很快明白其中原理。
比较反对一些源码分析文章,抛出一大段源码,做几个寥寥注释,看的人云里雾里的那种。我觉得分析源码,要么解释详尽,要么图文并茂。当然这是题外话了。
最后,我们通过一张流程图,再总结下 wgs 的整个流程。
从上图可以看出:
- 图中三个角色都有自己内部的死循环:
- watchGrpcStream 的死循环在总览大局,既负责处理用户侧发来的WatchRequest,又负责接收watchClient发来的WatchResponse,并把它适当的转发到对应的watcherStream
- watchClient 的死循环就做一件事:接收ETCD服务端发来的WatchResponse并把它发给watchGrpcStream
- watcherStream 的死循环负责把 WatchResponse通过WatchChan发回用户侧,并维护结果缓冲队列(用户侧来不及处理的消息会被缓存在此)。
以上我们详细介绍了watchGrpcStream,接下来,我们进入watchClient、watcherStream的循环中看看。
func (w *watchGrpcStream) newWatchClient() (pb.Watch_WatchClient, error) {
// -------Start--------
close(w.resumec)
w.resumec = make(chan struct{})
w.joinSubstreams()
for _, ws := range w.substreams {
ws.id = -1
w.resuming = append(w.resuming, ws)
}
// strip out nils, if any
var resuming []*watcherStream
for _, ws := range w.resuming {
if ws != nil {
resuming = append(resuming, ws)
}
}
w.resuming = resuming
w.substreams = make(map[int64]*watcherStream)
// connect to grpc stream while accepting watcher cancelation
stopc := make(chan struct{})
donec := w.waitCancelSubstreams(stopc)
// ------- END --------
// 这里有一大段代码,开始我也没理解
// 后来梳理了关闭流程才知道原因
// 其实是跟重启有关,这里我们暂时可以不关心。
// 其实除去上面那大段复杂代码,
// watchClient 的逻辑其实很简单
// 下面这里先通过GRPC的conn开启一个 wc
wc, err := w.openWatchClient()
...
// 再启动goroutine循环,用来接收
// ETCD服务端发来的 WatchResponse
// 并转发给 watchGrpcStream
go w.serveWatchClient(wc)
return wc, nil
}
再看下 watcherStream
,这里主要看它内部的死循环的处理逻辑:
func (w *watchGrpcStream) serveSubstream(ws *watcherStream, resumec chan struct{}) {
...
// 上面代码也是在处理关闭流程
emptyWr := &WatchResponse{}
for {
curWr := emptyWr
outc := ws.outc
// outc 就是 WatchChan,就是用户侧接收的channel。
// ws.buf 是缓冲队列,这里的处理是取队头数据
if len(ws.buf) > 0 {
curWr = ws.buf[0]
} else {
outc = nil
}
// 多路复用的四种情况
// 1. outc <- *curWr outc不阻塞,那就把
// 队头的WatchResponse发出去
// 2. wr, ok := <-ws.recvc recvc有数据
// (数据是watchGrpcStream通过dispathEvent发过来的)
// 3. <-w.ctx.Done() 上下文关闭,进入关闭流程
// 4. <-resumec 这也是重启流程用的,这个分支如果执行
// ws 就从“正式党员”跌回“预备党员”
// (也就是说,从substreams移除,回到resuming)
// select语句的特性,如果多个条件都可以执行,
// 会随机选择一个执行。
select {
case outc <- *curWr:
if ws.buf[0].Err() != nil {
return
}
ws.buf[0] = nil
ws.buf = ws.buf[1:]
case wr, ok := <-ws.recvc:
if !ok {
// 关闭ws的recvc,会让处理循环退出
return
}
if wr.Created {
// 如果retc不为nil,才能进入下面代码
if ws.initReq.retc != nil {
// outc 通过retc发出去,会被Watch()本身的循环接收到
// 忘记了的可以翻到最上面看看
ws.initReq.retc <- ws.outc
// 假如没有这句代码会发生什么?
// 我们知道Watch机制有自动重试功能
// 出现错误,Watch会自动再次重试(会在关闭流程详细介绍)
// 重试会重新发送wr,收到ETCD服务端发过来Created标志的wresp
// 所以这个判断可能会进多次
// 但是对用户侧来说,重试是无感知的,
// 用户侧不希望收到多次Created的wresp
// 所以把 retc 置 nil 是必要的
// 这样下次不会进来这段代码了。
ws.initReq.retc = nil
if ws.initReq.createdNotify {
// 设置了WithCreatedNotify(),wresp会马上发出去
// 不会进入缓冲队列
ws.outc <- *wr
}
// 只有rev为0才更新进度
// 这里处理的是:
// 假如第一次创建Watch拿到当前Revision(假设是2)
// 但是Watch因为某些原因重启了,这时ETCD服务器的Revision变成100了
// 重启就丢失了2-100之间的值
// 所以:在Watch流Created的时候,
// 如果rev不为0,就不管ETCD服务器的Revision
// 用initReq.rev原来的值
if ws.initReq.rev == 0 {
nextRev = wr.Header.Revision
}
}
} else {
nextRev = wr.Header.Revision
}
// 更新下当前的Watch进度
if len(wr.Events) > 0 {
nextRev = wr.Events[len(wr.Events)-1].Kv.ModRevision + 1
}
ws.initReq.rev = nextRev
...
ws.buf = append(ws.buf, wr)
case <-w.ctx.Done():
return
case <-ws.initReq.ctx.Done():
return
case <-resumec:
resuming = true
return
}
}
}
以上,watcherStream的处理逻辑总结为以下两点:
- 把ETCD服务器的WatchResponse发出去
- 缓冲队列的维护
ETCD源码Watch关闭流程
关闭流程限于篇幅,将在下篇文章详细介绍。
参考文献
- https://etcd.io/docs/v3.5/learning/data_model/
- https://github.com/etcd-io/etcd/pull/7795/files
- https://github.com/etcd-io/etcd/pull/6647/files