名词解释
先解释下etcd中提供的LinearizableRead是什么意思。线性化(Linearizable)是介于严格一致性和顺序一致性之间的一种一致性级别。Linearizable假设所有的操作都有会被排序,并且确保后一个操作可以看到前一个操作的结果,同时要求所有的server都能达成这个共识。
举个更加具体的例子,如果我们有一个client,三个server;client先write(x, 9),再read(x),一定会得到结果9,不管write和read操作被发送到了哪个server上。
而这种线性一致,正是依赖raft的ReadIndex
去实现的。etcd调用ReadIndex()
时,会带上一个唯一ID,然后等待node.Ready()
返回并与这个唯一id匹配上,然后才可以读数据。这个过程中又做了一些优化。我们看看etcd的具体实现:
RaftNode:etcd对raft模块的封装
状态机:etcd-raft上层的应用
前台两个channel
前台的同步逻辑非常简单,其会调用LinearizableReadNotify
并等待这个函数返回后再读数据。可以看到重点是往readwaitc
发信号,再等待readNotifier
的信号。
go
func (s *EtcdServer) LinearizableReadNotify(ctx context.Context) error {
return s.linearizableReadNotify(ctx)
}
func (s *EtcdServer) linearizableReadNotify(ctx context.Context) error {
s.readMu.RLock()
nc := s.readNotifier
s.readMu.RUnlock()
// 往readwaitc发信号
// 通知readwaitc有读请求进来;readwaitc容量只有1,所以在并发的情况下,其他的协程会命中default分支,这样可以避免重复提醒,也相当于将多个读合并成一个读
select {
case s.readwaitc <- struct{}{}:
default:
}
// 等待readNotifier的信号
select {
case <-nc.c:
return nc.err
case <-ctx.Done():
return ctx.Err()
case <-s.done:
return errors.ErrStopped
}
}
后台合并读
readwaitc
和readNotifier
这两个channel由一个后台协程管理,也就是linearizableReadLoop()
。在并发的情况下,多个读请求只会触发一次readwaitc
的唤醒。然后多个读请求会等待同一个readNotifier
,由此实现了读请求的合并等待。
linearizableReadLoop()
会调用requestCurrentIndex()
来获取confirmedIndex
(requestCurrentIndex()
负责和raft模块交互,这个函数会运行比较久),然后判断是否appliedIndex < confirmedIndex
,如果是则给readNotifier
发信号,如果不是则再来一次。
总体来说,这个函数并不复杂,其主要工作就是合并读,以及判断appliedIndex < confirmedIndex
。
go
// 这个函数在后台异步执行
func (s *EtcdServer) linearizableReadLoop() {
for {
requestId := s.reqIDGen.Next() // requestId 会单调递增且唯一,这个id和raft 的entry index、请求id都没关系,是一个独立的用于linearizable Read的id
leaderChangedNotifier := s.leaderChanged.Receive()
select {
case <-leaderChangedNotifier:
continue // 仅仅更新requestId
case <-s.readwaitc: // 接收到了linearizable Read的信号
case <-s.stopping:
return
}
// 替换readNotifier,相当于将许多的读请求做了分批
// 一批读请求对应一个readNotifier
nextnr := newNotifier()
s.readMu.Lock()
nr := s.readNotifier
s.readNotifier = nextnr
s.readMu.Unlock()
// 下面这个函数是重点
confirmedIndex, err := s.requestCurrentIndex(leaderChangedNotifier, requestId)
// ...忽略错误处理
// confirmedIndex 是这个读请求的Index,
// 要等本地的appliedIndex大于等于confirmedIndex后在读,才能保障线性读
appliedIndex := s.getAppliedIndex()
if appliedIndex < confirmedIndex {
select {
case <-s.applyWait.Wait(confirmedIndex):
case <-s.stopping:
return
}
}
nr.notify(nil)
}
}
等待读msg的index
具体而言,requestCurrentIndex()
调用了node.ReadIndex()
发送了一个唯一的ReqID,然后等待readStateC
中吐出来和ReadState.RequestCtx
等于刚刚发送的唯一的ReqID(readStateC
是啥稍后讲),然后返回这次ReadStats.Index
作为ReadIndex,也就是上面函数的confirmedIndex
。
简单总结一下,requestCurrentIndex()
使用raft模块的ReadIndex()
功能发送了一个唯一ID,然后等待raft模块再把相同的唯一ID吐出来,并返回这个ReadState.Index
作为confirmdIndex
go
func (s *EtcdServer) requestCurrentIndex(leaderChangedNotifier <-chan struct{}, requestId uint64) (uint64, error) {
// sendReadIndex 将 requestId 作为 rctx 发送给了raftNode.ReadIndex()
// 核心代码只有一行:
// err := s.r.ReadIndex(ctx, uint64ToBigEndianBytes(requestIndex))
err := s.sendReadIndex(requestId)
if err != nil {
return 0, err
}
errorTimer := time.NewTimer(s.Cfg.ReqTimeout())
defer errorTimer.Stop()
retryTimer := time.NewTimer(readIndexRetryTime)
defer retryTimer.Stop()
firstCommitInTermNotifier := s.firstCommitInTerm.Receive()
for {
select {
// 正常情况
case rs := <-s.r.readStateC:
if !bytes.Equal(rs.RequestCtx, uint64ToBigEndianBytes(requestId)) {
// 省略日志代码
continue
}
return rs.Index, nil
...其他代码忽略,见下一章
}
}
readStateC
在主流程func (r *raftNode) start(rh *raftReadyHandler)
中接收Ready.ReadStates
:
go
func (r *raftNode) start(rh *raftReadyHandler) {
...
case rd := <-r.Ready():
...
// 处理线性化读
if len(rd.ReadStates) != 0 {
select {
case r.readStateC <- rd.ReadStates[len(rd.ReadStates)-1]:
case <-time.After(internalTimeout):
r.lg.Warn("timed out sending read state", zap.Duration("timeout", internalTimeout))
case <-r.stopped:
return
}
}
...
异常处理
我们单独看一下这个for循环,这个for循环的第一个case是处理正常情况的,而其他的case都是处理非正常情况的
go
func (s *EtcdServer) requestCurrentIndex(leaderChangedNotifier <-chan struct{}, requestId uint64) (uint64, error) {
...
for {
select {
// 正常情况
case rs := <-s.r.readStateC:
...
// 当leader发生改变了,放弃本次的所有读请求
case <-leaderChangedNotifier:
return 0, errors.ErrLeaderChanged
// 当新leader被选举出来后,但其Term内的第一条日志还没提交前,向新的leader发起线性读,此时读请求可能会卡死,直到触发超时。
// 时机:当新leader被选举出来后,但其Term内的第一条日志还没提交前,向新的leader发起线性读
// 附加条件:此时没有任何其他的写请求来让新leader提交其Term内的第一条日志。
// 则新leader会拒绝所有的读请求,而此时这里并不能感知到这个情况的发生
case <-firstCommitInTermNotifier:
firstCommitInTermNotifier = s.firstCommitInTerm.Receive()
err := s.sendReadIndex(requestId)
if err != nil {
return 0, err
}
retryTimer.Reset(readIndexRetryTime)
continue
// 超时与退出处理
case <-retryTimer.C:
s.sendReadIndex(requestId) // 再试一次
retryTimer.Reset(readIndexRetryTime)
continue
case <-errorTimer.C:
return 0, errors.ErrTimeout
case <-s.stopping:
return 0, errors.ErrStopped
}
}
}
可以看到这里还额外等待了leaderChangedNotifier
和firstCommitInTermNotifier
这两个channel。leaderChangedNotifier
的处理比较粗暴,如果发生这种事情,就拒绝掉现在所有的读请求。而firstCommitInTermNotifier
的处理就比较微妙了。我找到了当时增加这些代码的讨论:
这是问题的开始:github.com/etcd-io/etc...
[12762][github.com/etcd-io/etc...] 在raft层增加了pendingReadIndexMessages
,其缓存部分读msg,并在当前节点成为Leader后重放这些读msg。
但是有人提出了新的问题,觉得这样可能让raft缓存了过多的message,可能让raft拖累整个etcd导致OOM,同时他提出在CockroachDB中,他们会在新的leader commit第一条消息后【重放所有的读消息】。而这个mr的作者很喜欢这种"主动的预防",所以后来又添加了12795这个PR。也就是下下个PR。
12780 增加了retryTimer
和errorTimer
可以理解成,这个PR在12762的基础上提供了
retryTimer
超时重试来避免读被阻塞,同时提供了errorTimer
来兜底,实现超时拒绝读请求做功能退避。
12795增加了firstCommitInTermNotifier
来触发【重放当前的读消息】
首先需要解释下在raft中一个很细节的规则:在raft中,新的leader不能commit非自己任期内的Entry,只能通过commit自己任期内的Entry来间接commit非自己任期内的Entry。因此有些raft的实现会在leader当选后,主动发一条空的Entry来把之前的Entry给commit掉。而在etcd-raft中,leader会识别到这个空的Entry,并触发
firstCommitInTermNotifier
。
在我的实际测试中,[12762][github.com/etcd-io/etc...] 在raft层增加的pendingReadIndexMessages
已经可以很好地降低在异常情况下的切主的读请求延迟,而12795增加的firstCommitInTermNotifier
对此几乎没有效果(有没有这部分改动,延迟都一样),但在issue和PR的讨论的意料之外的是,12795增加的firstCommitInTermNotifier
对"在主动切主场景下降低读延迟"有比较明显的效果。最后,12780增加的retryTimer
和errorTimer
则提供了一种保底策略,用来在最意想不到的情况下做功能降级。
下面是我的测试结果,每个测试都做了三组,图片都是相似的。测试方法:
启动三节点的etcd,一个单独的程序会每毫秒启动三个协程,分别访问三节点的etcd,并以启动那一刻为X轴,请求花费的时间为Y轴,在图上打一个点。
在测试开始后,我会kill掉leader节点,或者主动change leader节点
测试结论:
kill掉leader节点:
主动change leader节点:
图片不是很明显,放大看的话,下沉的那一条横线,左边比右边略短。
思考题
我们再回去看start函数内处理ReadStates的代码,为什么这里永远只取最后一个ReadStates呢?提示一下,也确实只有一个ReadStates哦~
原因是,在处理线性读的主函数内,也就是requestCurrentIndex函数内,首先这个函数是单点的的一个协程,也就是说不会并发执行这个函数;其次这个函数必须要把msg发送给raft函数,并等到raft把ReadIndex重新吐出来,才会返回并做下一次执行。所以整个处理线性读的流程和raft模块的交互是一对一的,所以每次也只有一个ReadStates。
由此可以尝试两个优化:
- 是否这一段代码可以异步化?
- 是否可以让raft模块同时处理多个ReadStates?