etcd-线性一致性(linearizability)

563 阅读6分钟

线性一致性(linearizability)是强一致性的一种,它主要由如下两个特征:

  • 任何一次读都能读到某个数据的最近一次写的数据
  • 系统中的所有进程,看到的操作顺序,都与全局时钟下的顺序一致

常用的实现线性一致性的方案,如下:

  • ReadIndex:将读请求发送到Leader,Leader收到请求,记录下当前的committed index,然后向其他节点发送心跳,通过收到大多数节点的响应,来确认自己还是Leader,等待当前committed index指向的日志Entry,等到applied index > 该committed index,就读取数据响应给客户端。
  • LeaseRead:使用lease机制,保证Leader的有效性,Leader在处理读操作时无需向Follower发送心跳确认自己的Leader身份,等applied index > 该committed index后可以直接响应数据给客户端

注:分布式一致性是理论深且耗笔墨的话题,作为分布式小白只能在文末奉上相关优秀文章了,供学习回顾参考了

etcd内部也对ReadIndex及LeaseRead进行实现,具体实现过程分析从读请求发起、到raft状态机对committed index进行确认流程做了如下分析:

应用层读请求

EtcdServer线性读的入口时Range接口,以V3版本为例,接口定义如下:

https://github.com/etcd-io/etcd/blob/v3.4.9/etcdserver/v3_server.go#L89

EtcdServer收到读操作,向raft状态机获取最新committed的日志index(confirmedIndex),然后等待该index被applied,一旦该index被applied便会调用readNotifier的notify方法,唤醒挂在其上面的read操作。

func (s *EtcdServer) linearizableReadNotify(ctx context.Context) error {
	// 获取当前EtcdServer里面保存的readNotifier
	// 多个同一时间来的读操作可能会公用一个readNotifier
	s.readMu.RLock()
	nc := s.readNotifier
	s.readMu.RUnlock()


	// 通过readwaitc向EtcdServer处理线性读的loop任务发送信号
	// 如果readwaitc的信号没被成功发送出去,就会和其他读任务共用readNotifier
	select {
	case s.readwaitc <- struct{}{}:
	default:
	}

	// 等待线性读完成
	select {
	case <-nc.c:
		return nc.err
	case <-ctx.Done():
		return ctx.Err()
	case <-s.done:
		return ErrStopped
	}
}

func (s *EtcdServer) linearizableReadLoop() {
	for {
		requestId := s.reqIDGen.Next()
		leaderChangedNotifier := s.LeaderChangedNotify()
		select {
		case <-leaderChangedNotifier:
			continue
		// 等待新的读任务
		case <-s.readwaitc:
		case <-s.stopping:
			return
		}

		// 创建新的readNotifier,无法复用当前readNotifier的读任务会使用新的
		nextnr := newNotifier()
		s.readMu.Lock()
		nr := s.readNotifier
		s.readNotifier = nextnr
		s.readMu.Unlock()

                // 读取最后一次Committed的日志Log Entry index
		confirmedIndex, err := s.requestCurrentIndex(leaderChangedNotifier, requestId)
		if isStopped(err) {
			return
		}
		if err != nil {
			nr.notify(err)
			continue
		}

                // 获取当前apply的log Entry index
		// 如果raft中最后一次被committed的日志没有applied
                // 就会将committed的日志index注册到applyWait中
		// (applyWait在applyAll中完成snapshot和Entries的apply之后被调用)
		appliedIndex := s.getAppliedIndex()
		if appliedIndex < confirmedIndex {
			select {
			case <-s.applyWait.Wait(confirmedIndex):
			case <-s.stopping:
				return
			}
		}
		
		// 唤醒挂在readNotifier所有read操作
		nr.notify(nil)
	}
}

不同线性读方案在应用层实现形式基本一致,方案的差异体现在如何获取raft最新committed的日志index(confirmedIndex),上述代码片段中requestCurrentIndex是通过向raft状态机发送类型为MsgReadIndex消息,然后异步的等待raft给出响应,具体代码解读如下:

func (s *EtcdServer) requestCurrentIndex(leaderChangedNotifier <-chan struct{}, requestId uint64) (uint64, error) {
	// 向raft状态机发送ReadIndex请求
	err := s.sendReadIndex(requestId)
	if err != nil {
		return 0, err
	}

	// 通过设置超时定时器及retry定时器等待ReadIndex请求响应
	lg := s.Logger()
	errorTimer := time.NewTimer(s.Cfg.ReqTimeout())
	defer errorTimer.Stop()
	retryTimer := time.NewTimer(readIndexRetryTime)
	defer retryTimer.Stop()

	firstCommitInTermNotifier := s.FirstCommitInTermNotify()

	for {
		select {
		// raft状态机通过readStateC通道,按照ReadIndex请求的顺序回传响应
		// 拿到响应之后将Index返回给linearizableReadLoop
		// 注:每次只会有一批公用readNotifier的读操作进入linearizableReadLoop
		case rs := <-s.r.readStateC:
			requestIdBytes := uint64ToBigEndianBytes(requestId)
			gotOwnResponse := bytes.Equal(rs.RequestCtx, requestIdBytes)
			if !gotOwnResponse {
				// 由于在ReadIndex响应的时候有可能出现超时/重发,可能导致响应重复
				// 这里主要是通过raft回传回来的requestId
				// 判断响应是否是当前等待的,如果不是会忽略重新发送一次
				continue
			}
			return rs.Index, nil
                 // 其他超时、错误、重发ReadIndex请求的逻辑
		}
	}
}

Leader处理MsgReadIndex

Leader处理MsgReadIndex类型的消息流程主要在stepLeader中,处理逻辑的分析如下:

func stepLeader(r *raft, m pb.Message) error {
	switch m.Type {
        // other case is inessential
	case pb.MsgReadIndex:
		// 如果集群中只有一个节点Leader,就直接发送响应回去
		// responseToReadIndexReq会分两种情况发送消息:
		// (1)消息来自于其他节点,将消息回传到其他节点(IsSingleton时不会出现这类情况)
		// (2)消息来自于自身应用层,将响应追加到raft状态机的readStates中,最后通过Ready结构传递出去
		if r.prs.IsSingleton() {
			if resp := r.responseToReadIndexReq(m, r.raftLog.committed); resp.To != None {
				r.send(resp)
			}
			return nil
		}

		// 如果Leader刚刚上任,在任期内还没提交任何Entry,那么就把MsgReadIndex消息挂起
		// 挂起的消息会在Leader收到MsgAppResp消息,且有Entry被提交的时候进行处理,处理逻辑: sendMsgReadIndexResponse(r, m)
		if !r.committedEntryInCurrentTerm() {
			r.pendingReadIndexMessages = append(r.pendingReadIndexMessages, m)
			return nil
		}

		// 根据线性读实现的不同方案,执行不同的ReadIndex处理逻辑
		sendMsgReadIndexResponse(r, m)

		return nil
	}

	// other code is inessential
	return nil
}

在stepLeader中,处理MsgReadIndex的真正入口逻辑在sendMsgReadIndexResponse中,其他根据线性读不同实现方案:LeaseRead和ReadIndex分别做流程处理,具体如下:

func sendMsgReadIndexResponse(r *raft, m pb.Message) {
	switch r.readOnly.option {
        // 采用比较安全的ReadIndex方案
	case ReadOnlySafe:
		// 将当前ReadIndex的Ack请求追加到ReadIndex ack计数队列里面
		r.readOnly.addRequest(r.raftLog.committed, m)
		// 当前节点对该ReadIndex请求进行ack
		r.readOnly.recvAck(r.id, m.Entries[0].Data)
		// 通过广播Heartbeat携带ReadIndex请求ctx,广播ReadIndex请求到Follower,请求Follower对当前Committed index进行ack
		r.bcastHeartbeatWithCtx(m.Entries[0].Data)

        // 采用LeaseRead方案,只需要进入Leader的状态机就认为当前节点还是Leader,不考虑网络出现隔离问题
	case ReadOnlyLeaseBased:
		if resp := r.responseToReadIndexReq(m, r.raftLog.committed); resp.To != None {
			r.send(resp)
		}
	}
}

注:如果采用LeaseRead方案,sendMsgReadIndexResponse中会将readState追加到Leader的readStates队列中,等待readStates内容随Ready传递出去,将Committed传递到应用层。

如果是ReadIndex方案则需要进行一次广播HeartBeat,Follower收到后如果确认接收方来自Leader,会将ctx回传回来,完成该Follower对ctx(Leader通过ctx找ReadIndex相关上下文)的ack操作,具体逻辑如下:

func (r *raft) handleHeartbeat(m pb.Message) {
	r.raftLog.commitTo(m.Commit)
	r.send(pb.Message{To: m.From, Type: pb.MsgHeartbeatResp, Context: m.Context})
}

Leader收到MsgHeartbeatResp响应之后,如果响应里面携带了ctx,就会确认对应的ctx是否被集群中quorum节点确认了,如果收到quorum确认就会向ReadIndex请求及之前Pendding的所有ReadIndex请求发送响应,具体逻辑如下:

// 判断Context对应的ReadIndex请求是否得到集群quorum节点的确认
if r.prs.Voters.VoteResult(r.readOnly.recvAck(m.From, m.Context)) != quorum.VoteWon {
	return nil
}

// 检索Context对应的ReadIndex请求,及其之前投递的ReadIndex请求
// 如果Context对应的Committed index被quorum个节点确认,那么其之前的Committed index也一定得到确认了
rss := r.readOnly.advance(m)
for _, rs := range rss {
	// 分别向ReadIndex请求发送响应,可能响应会发送到当前EtcdServer层,也肯能会发送到其他Follower
	if resp := r.responseToReadIndexReq(rs.req, rs.index); resp.To != None {
	        r.send(resp)
	}
}

Follower处理MsgReadIndex

Follower状态机在收到ReadIndex消息之后,会将消息转发给Leader,由Leader统一处理类型为MsgReadIndex的消息,具体流程在stepFollower中如下:

func stepFollower(r *raft, m pb.Message) error {
	switch m.Type {
	// other case ...
  // 处理ReadIndex请求,将Readindex请求转发给Leader
	case pb.MsgReadIndex:
		if r.lead == None {
			r.logger.Infof("%x no leader at term %d; dropping index reading msg", r.id, r.Term)
			return nil
		}
		m.To = r.lead
		r.send(m)
	// 收到ReadIndex响应之后,将响应追加到readState中,通过readStateC发送给requestCurrentIndex
	case pb.MsgReadIndexResp:
		if len(m.Entries) != 1 {
			r.logger.Errorf("%x invalid format of MsgReadIndexResp from %x, entries count: %d", r.id, m.From, len(m.Entries))
			return nil
		}
		r.readStates = append(r.readStates, ReadState{Index: m.Index, RequestCtx: m.Entries[0].Data})
	}
	return nil
}

其他优秀文章推荐

【1】金融级分布式架构:SOFAJRaft 线性一致读实现剖析 | SOFAJRaft 实现原理

【2】共识、线性一致性与顺序一致性 - SegmentFault 思否

【3】从微信朋友圈的评论可见性,谈因果一致性在分布式系统中的应用 - osc_km8z9zfx的个人空间 - OSCHINA - 中文开源技术交流社区

【4】线性一致性和 Raft | PingCAP