etcd的raft实现之node

1.前言

在阅读本文之前希望先阅读笔者的《etcd的raft实现之log》和《etcd的raft实现之tracker&quorum》,没有前两篇文章的背景知识,本文提到的一些概念可能难以理解。当然,如果读者已经有一定的相关知识可以直接略过。

node给人第一个印象应该是raft节点功能的实现,其实并不是很准确。在笔者看来,node应该是raft的接口类,如果把raft看做一个库/包的话,那么使用这个包的接口就是node。但需要注意的是,使用raft的业务系统(比如etcd)集群的每个node都要调用raft的这个node,笔者认为这是把raft接口类定义为node原因吧。

跟前两篇文章一样,现解释几个本文的重点名词:

  1. 提议(propose):顾名思义,发起一个议程,超过半数以上的议员支持就算通过。如果从字面上理解貌似提议是一个非常复杂的事情,怎么定义议程?什么算是支持?到是如何通过到是在介绍quorum的文章中已经说明了。其实在etcd的raft实现议程就是一条日志(Entry),而这个日志能够被节点接收就算该节点支持。那提议就变得非常简单:封装成一条日志然后广播到所有节点,超过半数以上不拒绝就算提起通过。

2.分析

2.1Node

笔者既然认为node是raft的接口类,也是有一定根据,先来看看一个interface的定义:

// 代码源自go.etcd.io/etcd/raft/node.go
type Node interface {
    // raft内部有两个计时:心跳和选举。raft内部没有设计定时器,计时就是由这个接口驱动的,每调用一次
    // 内部计数一次,这就是raft的计时原理。所以raft的计时粒度取决于调用者,这样的设计使得raft的
    // 适配能力更强。
    Tick()
    // 源码注释:使Node转换到候选状态并开始竞选成为leader。在后面接口实现代码中读者就会看到,这个
    // 函数的调用会触发节点发起选举,那源码注释中为什么会说“开始竞选成为leader”呢?这是因为在所有
    // 节点日志状态相同的情况下,谁先发起选举谁就大概率成为leader。
    Campaign(ctx context.Context) error
    // 这个是raft对外提供的非常核心的接口,前面关于提议的定义可知,该接口把使用者的data通过日志广播
    // 到所有节点,当然这个广播操作需要Leader执行。如果当前节点不是Leader,那么会把日志转发给Leader。
    // 需要注意:该函数虽然返回错误代码,但是返回nil不代表data已经被超过半数节点接收了。因为提议
    // 是一个异步的过程,该接口的返回值只能表示提议这个操作是否被允许,比如在没有选出Leader的情况下
    // 是无法提议的。而提议的数据被超过半数的节点接受是通过下面的Ready()获取的。
    Propose(ctx context.Context, data []byte) error
    // 与Propose功能类似,数据不是使用者的业务数据,而是与raft配置相关的数据。配置数据对于raft
    // 需要保证所有节点是相同的,所以raft也采用了提议的方法保障配置数据的一致性。
    ProposeConfChange(ctx context.Context, cc pb.ConfChange) error
    // raft本身作为一种算法的实现并没有网络传输相关的实现,这些需要使用者自己实现。这也让raft
    // 本身的适配能力更强。在以前的文章中,笔者提到了大量的“消息”,消息就是raft节点间通信的协议,
    // 所以使用者在实现网络部分会收到其他节点发来的消息,所以把其他节点发来的消息需要通过Step()函数
    // 送到raft内部处理。此处多说一句,笔者喜欢etcd实现的raft原因之一就是不实现定时器,不实现网络
    // 传输,只专注与raft算法实现本身。
    Step(ctx context.Context, msg pb.Message) error
    // 关于Ready笔者后面会有更加详细的说明,此处只举一个例子来说明Ready是个什么?在关于log文章
    // 中笔者提到过日志会从提交状态转到应用状态,这个转换过程就是通过Ready()函数实现的,使用者
    // 通过该函数获取chan然后从chan获取Ready数据,使用者再把数据应用到系统中。Ready中的数据
    // 不只有已提交的日志,还有其他内容,后面再详细说明。熟悉C++的同学可以把这个理解为回调,使用者
    // 通过Propose()提议的数据通过回调的方式在通知使用者,当然,回调的内容不只这一点点,在介绍
    // Ready类型的时候会详细说明。
    Ready() <-chan Ready
    // Advance()函数是和Ready()函数配合使用的,当使用者处理完Ready数据后调用Advance()接口
    // 告知raft,这样raft才能向chan中推新的Ready数据。也就是使用者通过<-chan Ready获取的数据
    // 处理完后必须调用Advance()接口,否则将会阻塞在<-chan Ready上,因为raft此时也通过另一个
    // chan等待Advance()的信号。
    Advance()
    
    // ProposeConfChange()提议修改配置,当这个提议产生的日志被使用者应用的时候就需要通过调用
    // 这个接口把配置应用到本地节点。常规日志被应用都修改使用者的系统状态,而配置修改日志更新的是
    // raft自己的状态。
    ApplyConfChange(cc pb.ConfChange) *pb.ConfState

    // 试图把leader转移到transferee指定的peer,当使用者需要指定某个节点为Leader时会调用这个
    // 接口
    TransferLeadership(ctx context.Context, lead, transferee uint64)

    // 在了解该接口之前,读者需要了解linearizable read的概念,就是读请求需要读到最新的已经
    // commit的数据。我们知道etcd是可以在任何节点读取数据的,如果该节点不是leader,那么数据
    // 很有可能不是最新的,会造成stale read。即便是leader也有可能网络分区,leader还自认为
    // 自己是leader,这里就会涉及到一致性模型,感兴趣的读者可以阅读笔者的《一致性模型》。
    // 书归正传,ReadIndex就是使用者获取当前集群的最大的提交索引,此时使用者只要应用的最大
    // 索引大于该值,使用者就可以实现读取是最新的数据。说白了就是读之前获取集群的最大提交索引,
    // 然后等节点应用到该索引时再把系统中对应的值返回给用户。其中rctx唯一的标识了此次读操作,
    // 索引会通过Ready()返回给使用者,获取最大提交索引是一个异步过程。
    ReadIndex(ctx context.Context, rctx []byte) error

    // 获取raft的状态,关键在于Status的定义,后面有
    Status() Status
    // 报告raft指定的节点上次发送没有成功.
    ReportUnreachable(id uint64)
    // 源码注释还是比较详细的,笔者总结为:Leader向其他节点发送的快照如果使用者发送失败了就需要通过
    // 这个接口汇报给raft。在tracker的文章中可知,节点的Progress处于快照状态是暂停的,直到节点回复
    // 快照被应用才能触发状态更新,如果此时节点异常接收快照失败,那么Leader会以为该节点的Progress
    // 一直处于暂停状态而不再向该节点发送任何日志。笔者此处有一个疑问:作为使用者只能检测到快照是否
    // 发送成功,但是快照如果还在unstable中时节点崩溃,那么是不是还会出现刚刚提到的僵局?如果还能
    // 出现,那要怎么解?
    ReportSnapshot(id uint64, status SnapshotStatus)
    
    // 停止raft,相当于close的概念。
    Stop()
}

2.2Ready

有了Node的接口定义,还要对Ready这个接口类型要有充分的认识,因为这个类型包含了非常多的信息量,理解了Ready对于理解raft非常有帮助。

// 代码源自go.etcd.io/etcd/raft/node.go
type Ready struct {
    // Ready引入的第一个概念就是“软状态”,主要是节点运行状态,包括Leader是谁,自己是什么角色。
    // 关于角色的定义,笔者会在下一篇文章解释,此处只要了解软状态主要跟当前节点有关即可。该参数为
    // nil代表软状态没有变化
    *SoftState

    // Ready引入的第二个概念局势“硬状态”,主要是集群运行状态,包括届值、提交索引和Leader。如何
    // 更好理解软、硬状态?一句话,硬状态需要使用者持久化,而软状态不需要,就好像一个是内存
    // 值一个是硬盘值,这样就比较好理解了。
    pb.HardState

    // ReadState是包含索引和rctx(就是ReadIndex()函数的参数)的结构,意义是某一时刻的集群
    // 最大提交索引。至于这个时刻使用者用于实现linearizable read就是另一回事了,其中rctx就是
    // 某一时刻的唯一标识。一句话概括:这个参数就是Node.ReadIndex()的结果回调。
    ReadStates []ReadState

    // 需要存入可靠存储的日志,还记得log那片文章里面提到的unstable么,这些日志就是从哪里获取的。
    Entries []pb.Entry

    // 需要存入可靠存储的快照,它也是来自unstable
    Snapshot pb.Snapshot

    // 已经提交的日志,用于使用者应用这些日志,需要注意的是,CommittedEntries可能与Entries有
    // 重叠的日志,这是因为Leader确认一半以上的节点接收就可以提交。而节点接收到新的提交索引的消息
    // 的时候,一些日志可能还存储在unstable中。
    CommittedEntries []pb.Entry

    // Messages是需要发送给其他节点的消息,raft负责封装消息但不负责发送消息,消息发送需要使用者
    // 来实现。
    Messages []pb.Message

    // MustSync指示了硬状态和不可靠日志是否必须同步的写入磁盘还是异步写入,也就是使用者必须把
    // 数据同步到磁盘后才能调用Advance()
    MustSync bool
}

Ready()就像一个回调函数,通过<-chan Ready发送回调数据。

2.3Status

之所以解释Status这个类型,是因为在Node接口中有Status()这个函数,他返回了Status这个类型。

// 代码源自go.etcd.io/etcd/raft/status.go
type Status struct {
    // 节点ID,raft中每个节点都有一个64位无符号整型唯一ID。
    ID uint64
    // 软硬状态不需要解释了。
    pb.HardState
    SoftState
    // 应用索引,其实这个使用者自己也知道,因为Ready的回调里提交日志被应用都会有日志的索引。
    Applied  uint64
    // 如果是Leader,还有其他节点的进度。
    Progress map[uint64]tracker.Progress
    // Leader转移ID,如果正处于Leader转移期间。
    LeadTransferee uint64
}

2.2node(Node的实现)

前面介绍了Node的接口定义,按照golang不成文的规定肯定会有一个node的实现类型,来看看node的定义是怎样的:

// 代码源自go.etcd.io/etcd/raft/node.go
type node struct {
    // 用于实现Propose()接口
    propc      chan msgWithResult
    // 用于实现Step()接口
    recvc      chan pb.Message
    // 这两个chan用于实现ApplyConfChange()接口
    confc      chan pb.ConfChange
    confstatec chan pb.ConfState
    // 用于实现Ready()接口
    readyc     chan Ready
    // 用于实现Advance()接口
    advancec   chan struct{}
    // 用于实现Tick()接口,这个需要注意一下,创建node时tickc是有缓冲的,设计者的解释是当node
    // 忙的时候可能一个操作会超过tick的周期,这样会使得计时不准,有了缓冲就可以避免这个问题。
    tickc      chan struct{}
    // 在处理中避免不了各种chan操作,此时如果Stop()被调用了,相应的阻塞就应该被激活,否则可能
    // 面临死锁以后长时间退出后者永远无法退出。
    done       chan struct{}
    // 为Stop接口实现的,应该还好理解
    stop       chan struct{}
    // 一看就是为实现Status()用的,但是chan chan Status这个类型有点意思,后面分析实现函数
    // 看看如何实现的
    status     chan chan Status
    // 用来写运行日志的
    logger Logger
}

根据变量的名字和类型大概可以看出变量用来干什么的,而且基本都是chan类型,这说明接口调用与功能实现是通过chan作为媒介,必定会有一个routine负责chan的另一端。在说明node接口功能实现前,现来看看接口调用的实现:

// 代码源自go.etcd.io/etcd/raft/node.go
// Tick()接口实现
func (n *node) Tick() {
    // 向tickc发数据同时用done避免死锁,符合预期
    select {
    case n.tickc <- struct{}{}:
    case <-n.done:
    default:
        n.logger.Warningf("A tick missed to fire. Node blocks too long!")
    }
}
// Campaign()接口实现
func (n *node) Campaign(ctx context.Context) error { 
    // 封装成pb.MsgHup消息然后再处理,step()后面会详细说明
    return n.step(ctx, pb.Message{Type: pb.MsgHup}) 
}
// Propose()接口实现
func (n *node) Propose(ctx context.Context, data []byte) error {
    // 封装成pb.MsgProp消息后再处理,stepWait()后面会详细说明
    return n.stepWait(ctx, pb.Message{Type: pb.MsgProp, Entries: []pb.Entry{
   
   {Data: data}}})
}
// Step()接口实现
func (n *node) Step(ctx context.Context, m pb.Message) error {
    // 本地消息就不用处理了,至于本地消息的判断读者自己可以看看
    if IsLocalMsg(m.Type) {
        return nil
    }
    return n.step(ctx, m)
}
// ProposeConfChange()接口实现
func (n *node) ProposeConfChange(ctx context.Context, cc pb.ConfChange) error {
    // 序列化pb.ConfChange
    data, err := cc.Marshal()
    if err != nil {
        return err
    }
    // 再把序列化后的配置封装成pb.MsgProp消息再处理。
    return n.Step(ctx, pb.Message{Type: pb.MsgProp, Entries: []pb.Entry{
   
   {Type: pb.EntryConfChange, Data: data}}})
}
// Ready()接口实现,非常简单了,不需要再解释了
func (n *node) Ready() <-chan Ready { return n.readyc }

// Advance()接口实现
func (n *node) Advance() {
    // 向advancec发送数据,利用done避免死锁
    select {
    case n.advancec <- struct{}{}:
    case <-n.done:
    }
}

// ApplyConfChange()接口实现
func (n *node) ApplyConfChange(cc pb.ConfChange) *pb.ConfState {
    var cs pb.ConfState
    // 把配置调整发送到confc
    select {
    case n.confc <- cc:
    case <-n.done:
    }
    // 再通过confstatec把调整后的结果读出来
    select {
    case cs = <-n.confstatec:
    case <-n.done:
    }
    return &cs
}

// Status()接口实现
func (n *node) Status() Status {
    // 创建一个Status的chan
    c := make(chan Status)
    select {
    // 通过status把c送给node,让node通过c把Status输出
    case n.status <- c:
        // 此时再从c中把Status读出来
        return <-c
    case <-n.done:
        return Status{}
    }
}

// 实现ReportUnreachable()接口
func (n *node) ReportUnreachable(id uint64) {
    select {
    // 封装成pb.MsgUnreachable消息,然后再统一走消息处理流程
    case n.recvc <- pb.Message{Type: pb.MsgUnreachable, From: id}:
    case <-n.done:
    }
}

// 实现ReportSnapshot()接口
func (n *node) ReportSnapshot(id uint64, status SnapshotStatus) {
    rej := status == SnapshotFailure
    // 和大部分接口一样,就是封装成消息再处理
    select {
    case n.recvc <- pb.Message{Type: pb.MsgSnapStatus, From: id, Reject: rej}:
    case <-n.done:
    }
}

// 实现TransferLeadership()接口
func (n *node) TransferLeadership(ctx context.Context, lead, transferee uint64) {
    // 不解释了,还是封装消息
    select {
    // manually set 'from' and 'to', so that leader can voluntarily transfers its leadership
    case n.recvc <- pb.Message{Type: pb.MsgTransferLeader, From: transferee, To: lead}:
    case <-n.done:
    case <-ctx.Done():
    }
}

// 实现ReadIndex()接口
func (n *node) ReadIndex(ctx context.Context, rctx []byte) error {
    return n.step(ctx, pb.Message{Type: pb.MsgReadIndex, Entries: []pb.Entry{
   
   {Data: rctx}}})
}

从接口实现上看,基本的就是各种chan的操作,每个接口都有一个专用的chan,还有一部分接口调用被转换成了消息,让node当做一种消息处理。这种实现方式是因为node的所有功能都通过一个run() 的routine实现的,routine间的交互golang推荐用chan,node基本算是把chan用到极致了。在开始run()函数前,现把上面挖的坑填上,那就是step()和stepWait()函数,其实node实现了一系列的step()函数。

// 代码源自go.etcd.io/etcd/raft/node.go
// step()和stepWait()都是调用stepWithWaitOption()函数,唯一差别就是是否等待标记,这个这两个
// 函数名字上也能看出来
func (n *node) step(ctx context.Context, m pb.Message) error {
    return n.stepWithWaitOption(ctx, m, false)
}
func (n *node) stepWait(ctx context.Context, m pb.Message) error {
    return n.stepWithWaitOption(ctx, m, true)
}
// stepWithWaitOption才是核心
func (n *node) stepWithWaitOption(ctx context.Context, m pb.Message, wait bool) error {
    // 所有的非pb.MsgProp消息通过recvc送给node处理,此时是否wait根本不关心,因为通过recvc
    // 提交给node处理的消息可以理解为没有返回值的调用。
    if m.Type != pb.MsgProp {
    select {
    case n.recvc <- m:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    case <-n.done:
        return ErrStopped
    }
  }
    
    // 只有pb.MsgProp的消息才会通过propc送给node处理,而且送到chan的类型是msgWithResult,
    // msgWithResult从名字可以看出来是消息+结果的组合,而结果是chan error类型的变量,需要
    // node通过这个chan把error返回,所以通过propc提交给node处理的消息可以理解为error返回值
    // 的调用
    ch := n.propc
    pm := msgWithResult{m: m}
    // 此处用到了wait,这个参数就是为了告诉函数调用者是否关心返回值,这个很像一个有返回值的函数
    // 但是调用者可以在乎返回值,也可以忽视返回值。如果在乎返回值,那么就要创建一个chan来获取返
    // 回的错误。这里有个细节需要注意,result的这个chan的缓冲量为1,为什么?请看下面的代码。
    if wait {
        pm.result = make(chan error, 1)
    }
    
    // 向node提交请求
    select {
    case ch <- pm:
        // 如果不在乎返回值直接返回
        if !wait {
            return nil
        }
    case <-ctx.Done():
        return ctx.Err()
    case <-n.done:
        return ErrStopped
    }
    
    // 获取返回值 
    select {
    // 这里就是为什么result的有缓冲的原因,如果因为ctx.Done而退出,那么node就会因为result没有
    // 缓冲而阻塞致死
    case err := <-pm.result:
        if err != nil {
            return err
        }
    case <-ctx.Done():
        return ctx.Err()
    case <-n.done:
        return ErrStopped
    }
    return nil
}

搞了这么复杂,用一个函数调用不可以么,非要用chan搞得这么“高大上”?理论上讲,用函数实现是可以,无非是需要用sync.Mutex来实现必要的同步。等我们分析完run()函数后,在总结章节解释设计者为什么选择了这种方案。

现在就开启node最内核之旅,做好解析一个比较大的函数源码的心理准备吧~

// 代码源自go.etcd.io/etcd/raft/node.go
// 该函数需要raft参数,因为node是raft的接口类,所以需要把接口调用最终转换为raft的调用
func (n *node) run(r *raft) {
    var propc chan msgWithResult
    var readyc chan Ready
    var advancec chan struct{}
    var prevLastUnstablei, prevLastUnstablet uint64
    var havePrevLastUnstablei bool
    var prevSnapi uint64
    var applyingToI uint64
    var rd Ready

    // 初始状态不知道谁是leader,需要通过Ready获取
    lead := None
    // 因为run()函数是一个串行函数,所以每次循环都可以用当前状态和上一次状态做比较,如果有任何变化
    // 都可以通过Ready输出给使用者,用局部变量记录上一次状态是一个非常简单有效的方法。此处需要剧透
    // 一些下一篇文章的一些内容:类型raft(就是变量r的类型)是raft的最核心实现,但是raft对象需要
    // node的run函数驱动,所以raft本身也不需要关心线程安全的问题,因为他的唯一使用者就是node,而
    // node又设计成了单routine形式。
    prevSoftSt := r.softState()
    prevHardSt := emptyState

    for {
        // 前面的介绍知道Ready()和Advance()是一个串行的工作,使用者调用Ready()从node获取数据
        // 处理完后再调用Advance()通知node处理完成准备处理下一轮Ready的数据。在同一时刻只能处理
        // 一个chan的,所以advancec和reaydc不可能同时非nil。读者需要注意:advancec != nil
        // 的时候,是使用者处理Ready数据的时候,这时候node的run()函数还是可以处理其他chan的
        // 请求的,这是一种并行的方法,毕竟使用者把日志持久化是一个比较耗时的事情。
        if advancec != nil {
            readyc = nil
        } else {
            // 从传入的参数可以看出来,Ready数据通过上一次的软、硬状态计算这两个状态的变化,其他
            // 的数据都是来源于raft。关于newReady()下面有函数注释。
            rd = newReady(r, prevSoftSt, prevHardSt)
            // 如果有任何更新就要通过readyc把数据输出。其实Ready的设计挺好的,通过一个数据结构
            // 实现了多种数据的回调。
            if rd.containsUpdates() {
                readyc = n.readyc
            } else {
                readyc = nil
            }
        }

        // leader发生了变化,因为初始化lead=None,所以这个变化也可能是刚刚获取leader ID。
        if lead != r.lead {
            // 都是一些日志的东西,核心内容就是知道leader是谁后就可以通过propc读取数据,否则
            // 不处理。这也说明propc里的数据都需要通过leader发起提议才能处理的,前面总结了
            // proc里的数据都是需要有返回值的,这两点一结合,可以总结出:通过proc输入给node
            // 处理的数据都是需要同步到整个集群的(自己不是leader就把数据转发给leader实现),
            // 而这类的数据处理必须有返回值告知用户是否成功,其他的数据通过recvc输入到node,
            // 这类数据只需要节点自己处理,不是集群性的,此时再看这两个chan的命名感觉很深刻了。
            if r.hasLeader() {
                if lead == None {
                    r.logger.Infof("raft.node: %x elected leader %x at term %d", r.id, r.lead, r.Term)
                } else {
                    r.logger.Infof("raft.node: %x changed leader from %x to %x at term %d", r.id, lead, r.lead, r.Term)
                }
                propc = n.propc
            } else {
                r.logger.Infof("raft.node: %x lost leader %x at term %d", r.id, lead, r.Term)
                propc = nil
            }
            lead = r.lead
        }

        select {
        // 处理proc中的数据,当然是通过raft处理的,然后把raft的返回值在返回给调用者
        case pm := <-propc:
            m := pm.m
            m.From = r.id
            err := r.Step(m)
            if pm.result != nil {
                pm.result <- err
                close(pm.result)
            }
        // 处理recvc中的数据
        case m := <-n.recvc:
            // 相比于propc中的数据处理,多了一些判断,来看看这些判断的目的是什么?
            // 1.要么是是合法节节点发来的,这个挺好理解的;
            // 2.关键的是第二点,要么是非响应消息,为什么处理非法节点发来的非响应消息呢?这个笔者
            // 也比较困惑~
            if pr := r.prs.Progress[m.From]; pr != nil || !IsResponseMsg(m.Type) {
                r.Step(m)
            }
        // 配置修改处理
        case cc := <-n.confc:
            // 如果NodeID是None,就变成了获取节点信息的操作~这我相信是后加的功能,放在这里毫无
            // 违和感
            if cc.NodeID == None {
                select {
                case n.confstatec <- pb.ConfState{
                    Nodes:    r.prs.VoterNodes(),
                    Learners: r.prs.LearnerNodes()}:
                case <-n.done:
                }
                break
            }
            // 根据配置修改的类型做不同的处理,但实际的处理都是交给raft实现的
            switch cc.Type {
            // 添加节点
            case pb.ConfChangeAddNode:
                r.addNode(cc.NodeID)
            // 添加learner
            case pb.ConfChangeAddLearnerNode:
                r.addLearner(cc.NodeID)
            // 删除节点
            case pb.ConfChangeRemoveNode:
                // 如果删除的节点是自己,那么就把propc设置为空,避免下一循环接收任何数据
                if cc.NodeID == r.id {
                    propc = nil
                }
                r.removeNode(cc.NodeID)
            case pb.ConfChangeUpdateNode:
            default:
                panic("unexpected conf type")
            }
            // 返回配置状态
            select {
            case n.confstatec <- pb.ConfState{
                Nodes:    r.prs.VoterNodes(),
                Learners: r.prs.LearnerNodes()}:
            case <-n.done:
            }
        // 时钟tick处理
        case <-n.tickc:
            r.tick()
        // Ready输出给使用者后的处理,也就是使用者在调用Advance()之前,此处可以在使用者
        // 处理Ready数据的同时node在做下面的处理,算是一种并行吧。
        case readyc <- rd:
            // 此时的软状态已经成为了上一次软状态
            if rd.SoftState != nil {
                prevSoftSt = rd.SoftState
            }
            // 如果有不可靠日志输出,那么就要记录最后一个不可靠日志的索引和届,在使用者调用Advance()
            // 用来调整log的可靠索引值,下面的代码会有涉及。           
            if len(rd.Entries) > 0 {
                prevLastUnstablei = rd.Entries[len(rd.Entries)-1].Index
                prevLastUnstablet = rd.Entries[len(rd.Entries)-1].Term
                havePrevLastUnstablei = true
            }
            // 如果有硬状态,就要把这个硬状态设置为上一次硬状态
            if !IsEmptyHardState(rd.HardState) {
                prevHardSt = rd.HardState
            }
            // 如果有快照,把快照的索引记为上一次快照的日志
            if !IsEmptySnap(rd.Snapshot) {
                prevSnapi = rd.Snapshot.Metadata.Index
            }
            // 这个处理就是计算Ready里提交日志的最后一个索引,提交日志被使用者应用完调用Advance()
            // 时需要调整log的应用索引。
            if index := rd.appliedCursor(); index != 0 {
                applyingToI = index
            }
            r.msgs = nil
            r.readStates = nil
            // 此处需要解释一下,在raft中统计了未提交日志的总量(单位是字节),用来避免大量日志
            // 造成的大量内存开销,在超过一定量的情况下就不允许提议了。有的读者会问Leader更新
            // 提交索引后才需要减少未提交日志的总量,为什么在这里调用呢?这个思路是对的,Leader
            // 通过quorum获得了最新的提交索引后广播给其他节点,但是读者需要知道,这只是告诉所有
            // 节点日志可以提交到这个索引,但是具体提交动作是需要各自的节点自己完成的。通过Ready
            // 就是把日志提交给了使用者,那自然就在此处把这部分提交日志的大小从未提交总量中减去。
            r.reduceUncommittedSize(rd.CommittedEntries)
            advancec = n.advancec
        // 使用者处理完Ready数据后,调用了Advance()
        case <-advancec:
            // 更新log的应用索引值,这个值就在上面的case中获得的。
            if applyingToI != 0 {
                r.raftLog.appliedTo(applyingToI)
                applyingToI = 0
            }
            // 更新可靠索引值,这个值也是在上面的case中获得的。
            if havePrevLastUnstablei {
                r.raftLog.stableTo(prevLastUnstablei, prevLastUnstablet)
                havePrevLastUnstablei = false
            }
            // 更新快照索引,这个值同样是在上面的case中获得的。
            r.raftLog.stableSnapTo(prevSnapi)
            advancec = nil
        // 获取状态
        case c := <-n.status:
            c <- getStatus(r)
        // 关闭node
        case <-n.stop:
            close(n.done)
            return
        }
    }
}

// 一下是run()函数中引用到的函数
// 创建新的Ready数据,这个函数其实非常重要,因为笔者认为Ready是etcd的raft实现的精髓之一。
func newReady(r *raft, prevSoftSt *SoftState, prevHardSt pb.HardState) Ready {
    // Entries和CommittedEntries这两个值在介绍log的文章中已经说明了,分别代表不可靠日志和
    // 提交日志。不可靠日志需要使用者将其存储可靠存储,提交日志需要使用者应用到系统中。Message
    // 是封装好需要通过网络发送都其他节点的消息,这个笔者会在raft实现的文章做详细说明。此处需要
    // 读者体会一下Ready(准备好的)这个类型的用意,意思就是raft已经准备好需要使用者处理的数据,
    // 这个类型的命名充分的体现了它存在的目的。
    rd := Ready{
        Entries:          r.raftLog.unstableEntries(),
        CommittedEntries: r.raftLog.nextEnts(),
        Messages:         r.msgs,
    }
    // 软状态有变化就要输出,软状态是通过raft对象获取的。
    if softSt := r.softState(); !softSt.equal(prevSoftSt) {
        rd.SoftState = softSt
    }
    // 硬状态有变化就要输出,硬状态是通过raft对象获取的。
    if hardSt := r.hardState(); !isHardStateEqual(hardSt, prevHardSt) {
        rd.HardState = hardSt
    }
    // 如果有新快照需要存储,那就通过Ready输出给使用者
    if r.raftLog.unstable.snapshot != nil {
        rd.Snapshot = *r.raftLog.unstable.snapshot
    }
    // 如果有ReadIndex()的结果,那就通过Ready输出。
    if len(r.readStates) != 0 {
        rd.ReadStates = r.readStates
    }
    // 设置必须同步标记,见下面有函数的解析,其实在etcd中并没有用Ready.MustSync标记,
    // 而是用了MustSync()函数。
    rd.MustSync = MustSync(r.hardState(), prevHardSt, len(rd.Entries))
    return rd
}
// 设置是否必须同步
func MustSync(st, prevst pb.HardState, entsnum int) bool {
    // 有不可靠日志、leader更换以及换届选举都需要设置同步标记,也就是说当有不可靠日志或者
    // 新一轮选举发生时必须等到这些数据同步到可靠存储后才能继续执行,这还算是比较好理解,毕竟
    // 这些状态是全局性的,需要leader统计超过半数可靠可靠以后确认为可靠的数据。如果此时采用
    // 异步实现,就会出现不一致的可能性。
    return entsnum != 0 || st.Vote != prevst.Vote || st.Term != prevst.Term
}

总结

node是彻头彻尾raft的接口类,它提供了各种接口函数和接口类型,把使用者的请求转换为raft的请求。设计者把node设计成为一个带routine的类型,笔者猜测如下:

  1. 所有的请求都是通过chan实现的,这样就不用考虑同步的问题,避免使用了锁,使得代码简化了很多。
  2. 通过node.Ready()获得的数据,使用者处理数据的同时node可以处理其他chan的请求,这样可以实现并发,直到使用者调用了Advance()为止。

本章引入了Ready这个类型,让我们对raft的日志处理逻辑有了更新的认识:

  1. 日志是一批一批的被使用者存储和应用的,这样的设计效率比较高;
  2. 存储日志和接收日志是同步进行的,接收的日志会被缓存在内存中等待进入下一轮的Ready;
  3. 存储、接收日志的同时,提交索引也会被更新,提交索引是Leader指导集群节点把日志通过Ready.CommittedEntries提交给使用者的上限索引值,他可能覆盖了一部分unstable的日志。因为node是一个单routine的对象,通过Ready输出的不可靠日志、提交日志以及硬状态中的提交索引值都是同一时间点的状态,只要使用者处理Ready数据设计的足够可靠,那么即便提交日志中有一部分是在unstable中也是没问题的。正因为raft处理逻辑的复杂性,用单routine的设计方法反而让raft的代码实现起来比较简单,否则就需要为各种加锁问题而头疼。

猜你喜欢

转载自blog.csdn.net/weixin_42663840/article/details/101039942