跟着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...](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fetcd-io%2Fetcd%2Fpull%2F12762 "https://github.com/etcd-io/etcd/pull/12762")\] 在raft层增加了`pendingReadIndexMessages`,其缓存部分读msg,并在当前节点成为Leader后重放这些读msg。 > 但是有人提出了新的问题,觉得这样可能让raft缓存了过多的message,可能让raft拖累整个etcd导致OOM,同时他提出在CockroachDB中,他们会在新的leader commit第一条消息后【重放所有的读消息】。而这个mr的作者很喜欢这种"主动的预防",所以后来又添加了12795这个PR。也就是下下个PR。 [12780](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fetcd-io%2Fetcd%2Fpull%2F12780 "https://github.com/etcd-io/etcd/pull/12780") 增加了`retryTimer`和`errorTimer` > 可以理解成,这个PR在12762的基础上提供了`retryTimer`超时重试来避免读被阻塞,同时提供了`errorTimer`来兜底,实现超时拒绝读请求做功能退避。 [12795](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fetcd-io%2Fetcd%2Fpull%2F12795 "https://github.com/etcd-io/etcd/pull/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...](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fetcd-io%2Fetcd%2Fpull%2F12762 "https://github.com/etcd-io/etcd/pull/12762")\] 在raft层增加的`pendingReadIndexMessages`已经可以很好地降低在异常情况下的切主的读请求延迟,而[12795](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fetcd-io%2Fetcd%2Fpull%2F12795 "https://github.com/etcd-io/etcd/pull/12795")增加的`firstCommitInTermNotifier`对此几乎没有效果(有没有这部分改动,延迟都一样),但在issue和PR的讨论的意料之外的是,[12795](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fetcd-io%2Fetcd%2Fpull%2F12795 "https://github.com/etcd-io/etcd/pull/12795")增加的`firstCommitInTermNotifier`对"在主动切主场景下降低读延迟"有比较明显的效果。最后,[12780](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fetcd-io%2Fetcd%2Fpull%2F12780 "https://github.com/etcd-io/etcd/pull/12780")增加的`retryTimer`和`errorTimer`则提供了一种保底策略,用来在最意想不到的情况下做功能降级。 下面是我的测试结果,每个测试都做了三组,图片都是相似的。测试方法: > 启动三节点的etcd,一个单独的程序会每毫秒启动三个协程,分别访问三节点的etcd,并以启动那一刻为X轴,请求花费的时间为Y轴,在图上打一个点。 > > 在测试开始后,我会kill掉leader节点,或者主动change leader节点 测试结论: kill掉leader节点: ![](https://file.jishuzhan.net/article/1710500158384377857/84d921a355506a807ceca16bd1718d8d.webp) 主动change leader节点: > 图片不是很明显,放大看的话,下沉的那一条横线,左边比右边略短。 ![](https://file.jishuzhan.net/article/1710500158384377857/c8cf8452e00e1e2cc36e6d2c2eadb5a5.webp) # 思考题 我们再回去看start函数内处理ReadStates的代码,为什么这里永远只取最后一个ReadStates呢?提示一下,也确实只有一个ReadStates哦\~ ![](https://file.jishuzhan.net/article/1710500158384377857/a7f648fe088bfa9db10b4f5212ee2d3c.webp) 原因是,在处理线性读的主函数内,也就是requestCurrentIndex函数内,首先这个函数是单点的的一个协程,也就是说不会并发执行这个函数;其次这个函数必须要把msg发送给raft函数,并等到raft把ReadIndex重新吐出来,才会返回并做下一次执行。所以整个处理线性读的流程和raft模块的交互是一对一的,所以每次也只有一个ReadStates。 > > 由此可以尝试两个优化: > > 1. 是否这一段代码可以异步化? > 2. 是否可以让raft模块同时处理多个ReadStates?

相关推荐
小费的部落3 天前
记 etcd 无法在docker-compose.yml启动后无法映射数据库目录的问题
数据库·docker·etcd
程序员勋勋19 天前
【GoLang】etcd初始化客户端时不会返回错误怎么办
后端·golang·etcd
JavaPub-rodert14 天前
Etcd用的是Raft算法
数据库·github·etcd·raft
喝醉的小喵14 天前
分布式环境下的主从数据同步
分布式·后端·mysql·etcd·共识算法·主从复制
hweiyu0014 天前
从JVM到分布式锁:高并发架构设计的六把密钥
jvm·redis·分布式·mysql·etcd
花千树-01019 天前
利用 Patroni + etcd + HAProxy 搭建高可用 PostgreSQL 集群
数据库·docker·postgresql·k8s·etcd
小小工匠23 天前
架构思维:如何设计一个支持海量数据存储的高扩展性架构
架构·哈希算法·raft·gossip·一致性哈希·paxos·range分片
晚风_END23 天前
kubernetes|云原生|kubeadm-1.25.7集群单master+外部etcd集群+kubeadm-init+cri-docker文件形式快速部署
云原生·kubernetes·etcd
zhuyasen24 天前
分布式锁实战:用 dlock 打造高并发系统的稳定基石
redis·分布式·etcd