etcd-raft示例分析

etcd-raft示例分析

说明

etcd底层使用raft协议在集群多节点之间进行状态同步,由于etcd的raft库只实现了raft最核心的协议,其他的诸如WAL、SNAPSHOT、网络传输等都留给了使用该库的应用程序来实现,因此,对于初学者来说,该库在使用上存在一定的难度。

因此,在etcd中,作者写了个示例程序,简单展示了应用程序该如何使用etcd-raft实现应用的高可用。该示例程序位于core源码的coreos/etcd/contrib/raftexample目录中。

示例中的应用是一个非常简单的内存kv存储系统。用户通过HTTP协议访问应用进行数据的跟新和读取,应用使用etcd-raft模块将更新日志在集群中进行复制并最终将更新结果应用至状态机中,也即写入内存kv存储系统。

数据结构

kvstore

由于是示例应用,内存kv存储是一个再合适不过的选择。kvstore该结构便是抽象了应用的全部内容:

// a key-value store backed by raft 
type kvstore struct {
   proposeC    chan<- string 
   mu          sync.RWMutex
   kvStore     map[string]string 
   snapshotter *snap.Snapshotter
}

最核心的成员有:

  • proposeC:这是应用和底层raft库之间的通信接口,是一个channel,所有对应用的更新请求都会由应用通过该channel向底层raft库传递,我们在后面会详细描述
  • kvStore:内存状态机,存储应用的状态数据
  • snapshotter:应用管理snapshot的接口

raftNode

该结构是应用和底层raft核心库衔接的桥梁,通过该结构,应用可以无需过多关注底层raft细节,降低系统耦合程度。

该结构需要处理的任务包括:

  • 应用的更新传递给底层raft
  • raft已提交的请求传输给应用以更新应用的状态机
  • 处理raft组件产生的指令,如选举指令、数据复制指令、集群节点变更指令等;
  • 处理WAL日志
  • 将底层raft组件的指令通过网络传输至集群其他节点等

因此,相较于应用来说,该结构功能的实现更为复杂。核心数据结构定义如下:

// A key-value stream backed by raft 
type raftNode struct {
    proposeC    <-chan string 
    confChangeC <-chan raftpb.ConfChange 

    commitC     chan<- *string 
    errorC      chan<- error             
    id          int 
    peers       []string 
    join        bool 
    waldir      string 
    snapdir     string 
    getSnapshot func() ([]byte, error) 
    lastIndex uint64 

    confState raftpb.ConfState 
    snapshotIndex uint64 
    appliedIndex uint64 

    // raft backing for the commit/error channel 
    node raft.Node 
    raftStorage *raft.MemoryStorage 
    wal         *wal.WAL 

    snapshotter      *snap.Snapshotter 
    snapshotterReady chan *snap.Snapshotter 

    snapCount uint64 
    transport *rafthttp.Transport 
    stopc chan struct{} 

    httpstopc chan struct{} 

    httpdonec chan struct{} 
}

该结构核心的数据结构有以下几个:

  • proposeC:这是应用将客户的更新请求传递至底层raft组件的管道;
  • commitC:这是底层raft组件通知应用准备提交的指令的通道;
  • node:是对底层的raft组件的抽象,所有与底层raft组件的交互都通过该结构暴露的API来实现;
  • wal:WAL日志管理,etcd-raft将日志的管理交给应用层来处理;
  • snapshotter:同wal,etcd-raft将快照的管理也交给应用层来处理;
  • transport:应用同其他节点应用的网络传输接口,同wal,etcd-raft将集群节点之间的网络请求发送和接收也交给应用层来处理。

因此,相比于其他raft库的实现,etcd-raft的核心层实现相对轻量级,而带来的后果是应用需要处理较为复杂的与协议有关内容,带来灵活性的同时也增加了应用的复杂性。示例程序正是考虑到这种复杂性才选择抽象了raftNode来对应用屏蔽额外的复杂度。

其他

其他数据结构相对比较简单,就不在这里描述了,我们只关注核心结构及流程。

关键流程

系统启动

func main() {
    // 解析参数,可以忽略    
    ......

    proposeC := make(chan string)

    defer close(proposeC)

    confChangeC := make(chan raftpb.ConfChange)

    defer close(confChangeC)
    var kvs *kvstore

    getSnapshot := func() ([]byte, error) { return kvs.getSnapshot() }
    // 这里教大家怎么使用etcd-raft 
    commitC, errorC, snapshotterReady := newRaftNode(*id, strings.Split(*cluster, ","), *join, getSnapshot, proposeC, confChangeC)
    // 初始化应用:内存kv系统 
    kvs = newKVStore(<-snapshotterReady, proposeC, commitC, errorC)

    // the key-value http handler will propose updates to raft 
    // 对外提供服务 
    serveHttpKVAPI(kvs, *kvport, confChangeC, errorC)
}

因此,整个示例应用看起来只有三个主要构成部分:

  1. 初始化raft
  2. 应用初始化
  3. 应用开启对外服务大门

简单看了下代码,应用和raft组件之间又好像是通过channel进行信息传递的。我们接下来对上面三个部分分别简单描述。

raft初始化

raft 是一种在多节点组成的集群之间进行状态同步协议,因此,需要为每个节点上的应用在底层抽象出一个raft node。在示例程序中,这个创建raft node的函数是newRaftNode,在创建raft node的时候,需要指出集群其他节点ip、该节点在集群中的id以及角色等,这些是由参数决定。

我们看看newRaftNode到底做了什么:

func newRaftNode(id int,
                peers []string,
                join bool,
                getSnapshot func() ([]byte, error),
                proposeC <-chan string,
                confChangeC <-chan raftpb.ConfChange) 
    (<-chan *string, <-chan error, <-chan *snap.Snapshotter) {

    commitC := make(chan *string)
    errorC := make(chan error)

    rc := &raftNode{
            proposeC:    proposeC,
            confChangeC: confChangeC,
            commitC:     commitC,
            errorC:      errorC,
            id:          id,
            peers:       peers,
            join:        join,
            waldir:      fmt.Sprintf("raftexample-%d", id),
            snapdir:     fmt.Sprintf("raftexample-%d-snap", id),
            getSnapshot: getSnapshot,
            snapCount:   defaultSnapCount,
            stopc:       make(chan struct{}),
            httpstopc:   make(chan struct{}),
            httpdonec:   make(chan struct{}),
            snapshotterReady: make(chan *snap.Snapshotter, 1),
    }
    go rc.startRaft()
    return commitC, errorC, rc.snapshotterReady
}

纵观该函数,有几个关键点:

  • 创建waldir和一个snapdir,为以后存储WAL日志和snapshot数据;
  • 该函数给调用者返回的是三个channel(commitC、errorC和snapshotterReady),这些channel后续作用是什么?不得而知
  • 启动内部协程startRaft

其中startRaft比较关键,会启动底层一些与raft协议处理相关联的组件:

func (rc *raftNode) startRaft() {
    if !fileutil.Exist(rc.snapdir) {
        if err := os.Mkdir(rc.snapdir, 0750); err != nil {
           ......
        }
    }

    rc.snapshotter = snap.New(rc.snapdir)
    rc.snapshotterReady <- rc.snapshotter
    oldwal := wal.Exist(rc.waldir)
    rc.wal = rc.replayWAL()

    // 初始化并启动底层raft组件 
    rpeers := make([]raft.Peer, len(rc.peers))
    for i := range rpeers {
        rpeers[i] = raft.Peer{ID: uint64(i + 1)}
    }
 
    c := &raft.Config{
       ID:              uint64(rc.id),
       ElectionTick:    10,
       HeartbeatTick:   1,
       Storage:         rc.raftStorage,
       MaxSizePerMsg:   1024 * 1024,
       MaxInflightMsgs: 256,
    }


    if oldwal {
        rc.node = raft.RestartNode(c)
    } else {
        startPeers := rpeers
        if rc.join {
            startPeers = nil 
        }
        rc.node = raft.StartNode(c, startPeers)
    }

    // 启动raft网络请求传输组件 
    rc.transport = &rafthttp.Transport{
       ID:          types.ID(rc.id),
       ClusterID:   0x1000,
       Raft:        rc,
       ServerStats: stats.NewServerStats("", ""),
       LeaderStats: stats.NewLeaderStats(strconv.Itoa(rc.id)),
       ErrorC:      make(chan error),
    }


    rc.transport.Start()
    for i := range rc.peers {
        if i+1 != rc.id {
            rc.transport.AddPeer(types.ID(i+1), []string{rc.peers[i]})
        }
    }

    // 主要是启动网络监听 
    go rc.serveRaft()

    // 启动后台协程处理应用提交给raft模块的请求 
    // 和raft模块给应用的消息,后面说明 
    go rc.serveChannels()
}

应用初始化

func newKVStore(snapshotter *snap.Snapshotter, proposeC chan<- string, commitC <-chan *string, errorC <-chan error) *kvstore {
    s := &kvstore{
          proposeC: proposeC, 
          kvStore: make(map[string]string),
          snapshotter: snapshotter
        }

    s.readCommits(commitC, errorC)
    go s.readCommits(commitC, errorC)
    return s
}

示例应用是一个内存kv,这里的核心是readCommits,看参数调用是从我们前面初始化raft node时返回的commit channel中读取commit记录并将其应用到内存kv这个状态机中,不妨简单看看:

func (s *kvstore) readCommits(commitC <-chan *string, errorC <-chan error) {
    for data := range commitC {
        if data == nil {
            snapshot, err := s.snapshotter.Load()
            if err == snap.ErrNoSnapshot {
                return 
            }
            if err != nil && err != snap.ErrNoSnapshot {
                log.Panic(err)
            }
           err := s.recoverFromSnapshot(snapshot.Data)
           if err != nil {
               log.Panic(err)
           }
           continue 
        }

        // 数据反序列化 
        var dataKv kv

        dec := gob.NewDecoder(bytes.NewBufferString(*data))
        if err := dec.Decode(&dataKv); err != nil {
           ......
        }

        // commit:应用到内存状态机 
        s.mu.Lock()
        s.kvStore[dataKv.Key] = dataKv.Val
        s.mu.Unlock()
    }

    if err, ok := <-errorC; ok {
        log.Fatal(err)
    }
}

与我们所料的基本一致,只是还引入了snapshot,如果commit没有数据就需要从snapshot中去恢复,暂时还不理解这个逻辑,可能还得回到前面去看看commit channel中的消息到底是很么含义。

启动对外服务端口

示例应用对外提供的是HTTP的接口,启动对外服务的代码如下:

// serveHttpKVAPI starts a key-value server with a GET/PUT API and listens. 
func serveHttpKVAPI(kv *kvstore, port int, confChangeC chan<- raftpb.ConfChange, errorC <-chan error) {
    srv := http.Server{
       Addr: ":" + strconv.Itoa(port),
       Handler: &httpKVAPI{
                store:       kv,
                confChangeC: confChangeC,
       },
    }
    go func() {
        if err := srv.ListenAndServe(); err != nil {
            log.Fatal(err)
        }
    }()

    // exit when raft goes down 
    if err, ok := <-errorC; ok {
        log.Fatal(err)
    }
}

而客户请求处理的最终函数:

func (h *httpKVAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    key := r.RequestURI
    switch {
    case r.Method == "PUT":
        v, err := ioutil.ReadAll(r.Body)
        if err != nil {
            log.Printf("Failed to read on PUT (%v)\n", err)
            http.Error(w, "Failed on PUT", http.StatusBadRequest)
            return
        }
        h.store.Propose(key, string(v))
        w.WriteHeader(http.StatusNoContent)
    case r.Method == "GET":
        if v, ok := h.store.Lookup(key); ok {
            w.Write([]byte(v))
        } else {
            http.Error(w, "Failed to GET", http.StatusNotFound)
        }
        ...
    }
}

客户端更新请求处理

对于客户端的更新请求,首先经由HTTP协议传输至应用,也即最先被kvstore接收,它无法直接处理更新,需要先提交至raft组件在集群之间对本次提交达成一致,于是,它将这次请求通过golang的channel提交给应用的raftNode结构:

func (s *kvstore) Propose(k string, v string) {
    var buf bytes.Buffer
    if err := gob.NewEncoder(&buf).Encode(kv{k, v}); err != nil {
        log.Fatal(err)
    }
   s.proposeC <- buf.String()
}

kvstore的proposeC便是应用初始化时创建的与raftNode结构之间进行请求传输的channel,对于更新请求,kvstore将其通过proposeC直接提交给了raftNode。接下来看看raftNode是怎么处理该请求的:

func (rc *raftNode) serveChannels() {
    // send proposals over raft 
    go func() {
        for rc.proposeC != nil {
            select {
            case prop, ok := <-rc.proposeC:
                rc.node.Propose(context.TODO(), []byte(prop))
            }
           ......
        }
    }
}

看到了,serveChannels中启动了一个单独的协程专门处理应用的更新请求,而请求的最终归宿是被提交到了最底层的raft组件,通过其对外暴露的Propose接口,我们不再作更详细的探究,会在其他的文章中再去描述。需要注意的是,serveChannels本身也是在raftNode结构初始化时创建的单独协程中运行。

raft指令处理

我们前面说过,客户端的请求最终进入了raft组件,经过变换最终还是会通过Ready()暴露给应用,由应用负责写日志,发送给集群Follower节点等。这里我们来看看应用是如何一步步的处理raft的指令。

同样是在serveChannels中,

func (rc *raftNode) serveChannels() {
    // event loop on raft state machine updates 
    for {
        select {
        case <-ticker.C:
            rc.node.Tick()

        // 1. 通过Ready()获取到raft指令 
        case rd := <-rc.node.Ready():
            // 2. 写WAL日志 
            rc.wal.Save(rd.HardState, rd.Entries)
            if !raft.IsEmptySnap(rd.Snapshot) {
                rc.saveSnap(rd.Snapshot)
                rc.raftStorage.ApplySnapshot(rd.Snapshot)
                rc.publishSnapshot(rd.Snapshot)
            }
            // 3. 这是干什么? 
            rc.raftStorage.Append(rd.Entries)
            // 4. 发送给某个Follower 
            rc.transport.Send(rd.Messages)
            // 5. 将已经commit的日志提交到应用状态机 
            ok := rc.publishEntries(rc.entriesToApply(rd.CommittedEntries))
            if !ok {
                rc.stop()
                return
            }
            rc.maybeTriggerSnapshot()
            rc.node.Advance()
        case err := <-rc.transport.ErrorC:
            rc.writeError(err)
            return
        case <-rc.stopc:
            rc.stop()
            return
        }
    }
}

已提交请求应用至状态机

当底层raft组件判定请求在集群多数节点已经完成状态复制后,raft会将其视为可commit,raft组件会将一个commit消息通过commit channel返回给应用(kvstore),应用在收到该commit消息后,将其应用至应用状态机,即存储在内存的kv map之中。该部分同样是在函数raftNode.serveChannels()中:

func (rc *raftNode) serveChannels() {
    // event loop on raft state machine updates 
    for {
        select {
        case <-ticker.C:
            rc.node.Tick()

        // rc.node.Ready()从raft组件中取出已经完成日志写入的请求, 
        case rd := <-rc.node.Ready():
            rc.wal.Save(rd.HardState, rd.Entries)
            if !raft.IsEmptySnap(rd.Snapshot) {
                rc.saveSnap(rd.Snapshot)
                rc.raftStorage.ApplySnapshot(rd.Snapshot)
                rc.publishSnapshot(rd.Snapshot)
            }
            rc.raftStorage.Append(rd.Entries)
            rc.transport.Send(rd.Messages)

            // 通过该函数将请求应用至状态机中 
            ok := rc.publishEntries(rc.entriesToApply(rd.CommittedEntries))
            if !ok {
                rc.stop()
                return
            }
            rc.maybeTriggerSnapshot()

            // 完成状态机应用后,通过该方法通知底层raft组件 
            // 状态机应用完毕,raft组件可以准备下一次Ready消息了 
            rc.node.Advance()
        case err := <-rc.transport.ErrorC:
            rc.writeError(err)
            return
        case <-rc.stopc:
            rc.stop()
            return
        }
    }
}

回顾应用初始化逻辑,它启动了一个后台协程执行函数readCommits,这个该函数会不断地从commitC中接收消息并将消息解码得到客户端原始发起的kv请求,更新至kv map中。

日志管理

etcd-raft example应用中使用了etcd提供的通用日志库来进行日志管理,本文主要关注应用程序如何使用日志,关于日志的实现我们在后面再去详细描述。

日志追加

正常的更新流程是将该更新请求先以日志项形式追加至日志文件中,避免更新丢失,另外raft协议也使用日志在集群之间进行状态同步。在我们前面的关键流程分析中有如下:

func (rc *raftNode) serveChannels() {
    ......
    for {
        select {
        case <-ticker.C:
           rc.node.Tick()

        // 正常更新请求,第一步先追加日志 
        case rd := <-rc.node.Ready():
            rc.wal.Save(rd.HardState, rd.Entries)
        ......
    }
    ......
}

日志重放

在应用程序启动时,第一步便是进行日志重放,构建内存状态机。

func (rc *raftNode) replayWAL() *wal.WAL {
    snapshot := rc.loadSnapshot()
    w := rc.openWAL(snapshot)
    _, st, ents, err := w.ReadAll()
    if err != nil {
        log.Fatalf("raftexample: failed to read WAL (%v)", err)
    }
    rc.raftStorage = raft.NewMemoryStorage()
    if snapshot != nil {
        rc.raftStorage.ApplySnapshot(*snapshot)
    }

    rc.raftStorage.SetHardState(st)
    rc.raftStorage.Append(ents)
    if len(ents) > 0 {
        rc.lastIndex = ents[len(ents)-1].Index
    } else {
        rc.commitC <- nil 
    }
    return w
}

因为日志又总是和snapshot搅和在一起的,因此,构建内存状态机必须是Snapshot + 日志一起。

最关键的问题是:由于糅合了snapshot,我们需要明确需要重放哪些日志。在重放日志时,应用程序首先会load最新的snapshot,这个在下面的snapshot管理中会描述。然后根据这个snapshot的日志index在WAL目录下查找该index之后的日志,接下来只需要回放这些日志即可。

日志压缩

日志在追加的过程中可能会一直增长,因此,需要通过一种机制来抑制这种增长,标准做法是snapshot:即将内存当前状态进行压缩成为snapshot存储在文件中,然后,该snapshot之前的日志便可以全部丢弃。

etcd-raft example中如何生成snapshot我们在下面会描述。

在示例应用中好像没有实现日志压缩功能。

snapshot管理

snapshot本质上是应用状态的一份拷贝,snapshot就是对内存当前状态进行照相保存在磁盘上。snapshot的主要目的是回收日志文件,随着系统运行,raft使用的更新日志文件会越来越大,使用snapshot,对某时刻系统照相后,那么当前系统的状态便会被永久记录,则此刻之前的更新日志便可被回收了。如果接下来系统重启,只需要将该时刻的snapshot加载并重放该snapshot以后的更新日志即可重构系统奔溃之前的状态。

snapshot创建时机

因为snapshot创建是有代价的,因此,这个频率不能太高,在示例应用中,每更新10000条日志才会进行一次snapshot创建。

func (rc *raftNode) maybeTriggerSnapshot() {
    // 如果更新数量不足,不创建snapshot 
    if rc.appliedIndex-rc.snapshotIndex <= rc.snapCount {
        return 
    }

    // 生成内存状态机此刻状态 
    data, err := rc.getSnapshot()
    if err != nil {
        log.Panic(err)
    }
    snap, err := rc.raftStorage.CreateSnapshot(rc.appliedIndex, &rc.confState, data)
    if err != nil {
        panic(err)
    }
    if err := rc.saveSnap(snap); err != nil {
        panic(err)
    }
    compactIndex := uint64(1)
    if rc.appliedIndex > snapshotCatchUpEntriesN {
        compactIndex = rc.appliedIndex - snapshotCatchUpEntriesN
    }
    // 这里将raft状态机中的内存日志进行阶段至compactIndex 
    if err := rc.raftStorage.Compact(compactIndex); err != nil {
        panic(err)
    }

    // 记录本次snapshot的位置信息 
    rc.snapshotIndex = rc.appliedIndex
}

需要说明的是:snapshot只能针对那些已经被应用到状态机的提交。换言之,那些还未被应用到状态机的更新日志是不能被回收的。raftNode结构中的appliedIndex记录了当前最新被应用到状态机的更新日志编号,而snapshotIndex记录了当前已经snapshot的日志编号。

在示例应用中,每次进行snapshot后会将snapshot保存在磁盘中,其中包括两方面数据:

  1. snapshot数据:即当前应用内存状态的实际数据,一般被存储在当前的快照目录中;
  2. snapshot索引数据:即当前快照的index信息,这个信息对WAL至关重要,这个index决定了日志压缩的时候哪些可以被回收,也决定了日志重放的时候哪些可以被略过。snapshot索引数据被存放在日志目录下。示例应用好像没有实现日志compact功能。

snapshot序列化

etcd-raft 示例应用的内存状态机的snapshot方法如下:

func (s *kvstore) getSnapshot() ([]byte, error) {
    s.mu.Lock()
    defer s.mu.Unlock()
    return json.Marshal(s.kvStore)
}

非常简单,只是将所有的kv记录json序列化即可。

snapshot加载

func (s *kvstore) recoverFromSnapshot(snapshot []byte) error {
    var store map[string]string 
    if err := json.Unmarshal(snapshot, &store); err != nil {
        return err
    }

    s.mu.Lock()
    s.kvStore = store

    s.mu.Unlock()
    return nil 
}

snapshot的加载和序列化是逆向过程,原理也很简单。

示例应用的snapshot的加载也使用了etcd自身提供的snapshot管理组件,其特点是加载过程中会优先选择最新的snapshot,只有当前snapshot被破坏了才会选择更旧一点的snapshot。

节点变更

在etcd-raft示例应用中,一个新应用节点上线的时候,有两种可能的角色:

  • 加入已有集群,成为一个全新的Follower;
  • 作为一个集群的Leader

在示例应用中,分别定义了两种不同的模式来启动新应用。

启动方式一

raftexample --id 1 --cluster http://127.0.0.1:12379,http://127.0.0.1:22379,http://127.0.0.1:32379 --port 12380 

raftexample --id 2 --cluster http://127.0.0.1:12379,http://127.0.0.1:22379,http://127.0.0.1:32379 --port 22380 

raftexample --id 3 --cluster http://127.0.0.1:12379,http://127.0.0.1:22379,http://127.0.0.1:32379 --port 32380 

上面的方式启动raftexample应用时,对于每个节点来说,它知道这个集群共有三个节点,而且启动时都会发起一次选主过程,但是否被其他节点所接受这得另说。

根据测试观察的现象:启动示例应用时一旦指定cluster参数,那么应用中的raft协议模块会定期向集群中其他节点发送health check消息以确定对方是否存活,这和我们以前所理解的不太一样,在以前的实现模型中,所有的消息都是单向流动,只有Leader才会检查Follower是否活着,Follower一定时间内没有收到Leader的心跳消息才会发起一次新的选主过程。不确定为什么etcd-raft要这么做?这样岂不是只会增加连接的数量而实际并没什么卵用?

启动方式二

还可以通过以下方式将一个新节点加入集群:

curl -L http://127.0.0.1:12380/4 -XPOST -d http://127.0.0.1:42379 
raftexample --id 4 --cluster http://127.0.0.1:12379,http://127.0.0.1:22379,http://127.0.0.1:32379,http://127.0.0.1:42379 --port 42380 --join

不过好像没有看出来这种方式和方式一有什么区别,除了步骤多了一个之外。

仔细阅读了下源代码发现,其主要区别在于启动RaftNode的时候,如果是join,就不会传递集群其他peers信息给raft组件,否则就会传递。但是是否传递该参数对raft组件的行为有什么影响目前还不太清楚,有待接下来继续分析raft组件了。

总结

通过上面示例分析,我们了解到,如果应用程序需要使用etcd-raft实现一个分布式系统,就必须要在该library的基础上增加如下子系统:

  • WAL: 即日志系统,应用程序需要负责日志的append和load;
  • Snapshot: 负责状态机的定期快照和WAL日志的回收;
  • 应用状态机:实现自己的应用逻辑
  • raft协议消息的网络收发

而etcd-raft库只是实现了raft协议的核心部分,包括:

  • 选主
  • 多节点一致性语义实现
  • 节点变更

etcd-raft和应用之间是通过channel进行消息的通信,而消息的结构也是由raft库定义好。具体来说,应用通过raft库提供的Ready()接口获取到消息传输管道,并从该管道接收raft库发出的各种指令(Message),最后再通过Advance()通知raft库命令处理结果。应用处理指令的典型流程是:

  1. 将指令写入WAL日志
  2. 将指令写入raft组件内存中(为什么要做这个?)
  3. 将消息中指定的已经commit的日志进行提交,也即:应用到应用状态机中
  4. 调用Advance接口,应该是通知raft当前命令执行完成,可以继续提供下一条指令了。

因此,raft模块所需要完成的工作就相对比较简单了:

  1. 为应用准备好需要执行的指令,这些指令是根据raft协议而定义的
  2. 应用在执行完成指令后通知raft,raft根据该指令的执行结果(例如,该指令是否已经在多数节点上完成执行)决定是否向前推进commit index,并且,raft会继续向应用准备下一条指令

需要注意的一点是:所有的客户端请求都是直接发往应用的。应用需要将这些请求先提交给raft组件以保证在集群多数节点之间完成数据同步。应用提交的过程其实就是调用raft模块的Propose()接口。

编辑于 2017-09-26

文章被以下专栏收录