线性一致性(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 - 中文开源技术交流社区