跟着etcd学习如何使用etcd-raft实现线性化读

名词解释

先解释下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
    }
}

后台合并读

readwaitcreadNotifier这两个channel由一个后台协程管理,也就是linearizableReadLoop()。在并发的情况下,多个读请求只会触发一次readwaitc的唤醒。然后多个读请求会等待同一个readNotifier,由此实现了读请求的合并等待。

linearizableReadLoop()会调用requestCurrentIndex()来获取confirmedIndexrequestCurrentIndex()负责和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
    	}
    }
}

可以看到这里还额外等待了leaderChangedNotifierfirstCommitInTermNotifier这两个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 增加了retryTimererrorTimer

可以理解成,这个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增加的retryTimererrorTimer则提供了一种保底策略,用来在最意想不到的情况下做功能降级。

下面是我的测试结果,每个测试都做了三组,图片都是相似的。测试方法:

启动三节点的etcd,一个单独的程序会每毫秒启动三个协程,分别访问三节点的etcd,并以启动那一刻为X轴,请求花费的时间为Y轴,在图上打一个点。

在测试开始后,我会kill掉leader节点,或者主动change leader节点

测试结论:

kill掉leader节点:

主动change leader节点:

图片不是很明显,放大看的话,下沉的那一条横线,左边比右边略短。

思考题

我们再回去看start函数内处理ReadStates的代码,为什么这里永远只取最后一个ReadStates呢?提示一下,也确实只有一个ReadStates哦~
原因是,在处理线性读的主函数内,也就是requestCurrentIndex函数内,首先这个函数是单点的的一个协程,也就是说不会并发执行这个函数;其次这个函数必须要把msg发送给raft函数,并等到raft把ReadIndex重新吐出来,才会返回并做下一次执行。所以整个处理线性读的流程和raft模块的交互是一对一的,所以每次也只有一个ReadStates。

由此可以尝试两个优化:

  1. 是否这一段代码可以异步化?
  2. 是否可以让raft模块同时处理多个ReadStates?
相关推荐
花晓木5 小时前
k8s etcd 数据损坏处理方式
容器·kubernetes·etcd
张声录15 小时前
【ETCD】【实操篇(十二)】分布式系统中的“王者之争”:基于ETCD的Leader选举实战
数据库·etcd
运维&陈同学5 小时前
【模块一】kubernetes容器编排进阶实战之基于velero及minio实现etcd数据备份与恢复
数据库·后端·云原生·容器·kubernetes·etcd·minio·velero
有态度的马甲5 小时前
一种基于etcd实践节点自动故障转移的思路
数据库·etcd
张声录15 小时前
【ETCD】【实操篇(十三)】ETCD Cluster体检指南:健康状态一键诊断,全方位解析!
数据库·etcd
花晓木5 小时前
k8s备份 ETCD , 使用velero工具进行备份
容器·kubernetes·etcd
张声录12 天前
【ETCD】【实操篇(三)】【ETCDCTL】如何向集群中写入数据
数据库·chrome·etcd
Likelong~2 天前
Etcd注册中心基本实现
etcd·注册中心
alden_ygq2 天前
etcd网关
服务器·数据库·etcd
张声录12 天前
【ETCD】ETCD Leader 节点写入数据流程概览
数据库·etcd