etcd-raft snapshot实现分析

etcd-raft snapshot实现分析

说明

snapshot,故名思议,是某个时间节点上系统状态的一个快照,保存的是此刻系统状态数据,以便于让用户可以恢复到系统任意时刻的状态。

etcd-raft中也实现了snapshot,不过其目的有所不同:主要是为了回收日志占用的存储空间(包括内存和磁盘)。

etcd-raft使用日志在集群节点之间进行状态同步:客户的所有更新首先都会被转化为更新日志,顺序追加在日志文件中,日志文件中的内容会在集群节点之间进行顺序同步以维持节点之间的状态一致,只有写入集群多数节点的日志项才被允许更改应用的状态机。

随着系统运行,日志文件一直追加会导致容量的增长。因此,需要特定的机制来回收那些无用的日志数据。

etcd-raft中的snapshot代表了应用的状态数据,而执行snapshot的动作也就是将应用状态数据持久化存储,这样,在该snapshot之前的所有日志便成为无效数据,可以删除。

数据结构

Snapshot

type ConfState struct {
    Nodes            []uint64
}

type SnapshotMetadata struct {
    ConfState ConfState 
    Index            uint64               
    Term             uint64
}

type Snapshot struct {
    Data             []byte
    Metadata SnapshotMetadata 
}

snapshot的存储

snapshot是应用的某时刻状态照相,与日志类似,snapshot也会在多个地方存储,具体说来有unstable logstorageWAL

首先,unstable log中的snapshot来自于Leader节点的SnapMsg消息,即unstable log中的snapshot是被动接收和存储的,这在我们后面的snapshot复制流程会详细描述。storage的snapshot来源有2:第一,来自于节点自身生成的snapshot,如果是这样,那么该节点的应用肯定已经包含了snapshot状态,因此,该snapshot无需在应用的状态机中进行重放,其主要目的是进行日志压缩;第二,Leader节点的SnapMsg会将snapshot复制到Follower的unstable log中,进而通知到Follower的应用,再进一步将其应用到storage。这个snapshot的主要目的是将Leader的应用状态复制到当前的Follower节点,同时相比于日志复制,它减少了数据同步的网络和IO消耗。

其次,因为unstable log中的snapshot唯一来源是Leader节点的消息同步,因此,该snapshot需要被转交给应用,由应用完成重放后再删除;而storage中的snapshot则是由应用调用storage.CreateSnapshot主动创建,会保存在storage结构中,直到再次创建snapshot时被新的锁替代。

从上面的对比可知道,unstable log和storage中存储的snapshot内容并不一致。

WAL主要存储snapshot的元信息,包括snapshot所包含的日志更新的{term,index}二元组。WAL存储元信息的目的在于,节点启动时重放日志的索引。节点启动时首先加载snapshot数据,接下来再重放该snapshot以后的更新日志即可,提高启动效率。

snapshot的数据存储方法由使用etcd-raft的应用实现,这取决于应用存储的数据类型。

关键流程

snapshot

snapshot记录的是应用的状态数据,因此,snapshot也必须由应用来进行。

以etcd示例应用为例,snapshot的触发时机被嵌入在请求的处理流程之中,具体来说,每次上层应用从raft协议核心处理层获取到日志项后,处理该日志项的过程中便插入一个是否需要进行snapshot的判断处理,如下代码来自raftexample/raft.go

case rd := <-rc.node.Ready():
   ...
   rc.maybeTriggerSnapshot()

func (rc *raftNode) maybeTriggerSnapshot() {
    if rc.appliedIndex-rc.snapshotIndex <= rc.snapCount {
        return 
    }
    // getSnapshot()是应用实现 
    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
    }

    if err := rc.raftStorage.Compact(compactIndex); err != nil {
        panic(err)
    }

    rc.snapshotIndex = rc.appliedIndex
}

创建快照有以下几个步骤:

  1. 判断是否需要进行snapshot,该过程有代价,因此不会每次都执行;
  2. 创建snapshot,由应用实现具体的创建方法;
  3. raftStorage创建snapshot;
  4. 存储snapshot;
  5. 进行日志(raftStorage)回收(compact)

Step 1 :snapshot时机的判断的实现也各有千秋,etcd自带的示例应用采取较为简单的策略:每处理10000条日志便进行一次snapshot;

Step 2 :创建snapshot由应用实现,etcd自带的示例应用是一个内存KV存储,其snapshot的实现也比较简单:

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

Step 3 :由raft storage创建snapshot其实是创建一个上面的Snapshot结构,该结构中除了包含2创建的snapshot数据外,还有元数据信息来描述该snapshot:

func (ms *MemoryStorage) CreateSnapshot(i uint64, cs *pb.ConfState, data []byte) (pb.Snapshot, error) {
    ms.Lock()
    defer ms.Unlock()
    if i <= ms.snapshot.Metadata.Index {
        return pb.Snapshot{}, ErrSnapOutOfDate
    }

    offset := ms.ents[0].Index 
    if i > ms.lastIndex() {
        panic(...)
    }
    ms.snapshot.Metadata.Index = i 
    ms.snapshot.Metadata.Term = ms.ents[i-offset].Term 
    if cs != nil { 
        ms.snapshot.Metadata.ConfState = *cs
    }
    ms.snapshot.Data = data
    return ms.snapshot, nil
}

Step 4 :将3创建的Snapshot结构持久化存储:

func (rc *raftNode) saveSnap(snap raftpb.Snapshot) error {
    walSnap := walpb.Snapshot{
       Index: snap.Metadata.Index,
       Term:  snap.Metadata.Term,
    }
    if err := rc.wal.SaveSnapshot(walSnap); err != nil {
        return err
    }
    if err := rc.snapshotter.SaveSnap(snap); err != nil {
        return err
    }
    return rc.wal.ReleaseLockTo(snap.Metadata.Index)
}

持久化存储包括以下几个内容:

  • snapshot的索引:即当前的snapshot的起始日志项索引信息(term/index),该信息被存储在WAL日志文件所在的目录中;
  • snapshot数据:即snapshot的真正数据,其具体存储方法也由应用实现;
  • ReleaseLockTo:暂时作用不明

Step 5:回收raft storage的日志项,调用Compact方法,具体也会在下面的“日志回收”中详细描述。

snapshot复制

当Follower节点上线的时候,Leader会将该Follower上落后的日志复制过去。但是我们上面说过日志可能会被Compact。因此,这就导致了被回收的更新日志无法在Follower节点上被执行,导致节点间状态不一致。

因为日志的回收是在对当前应用进行snapshot之后进行的,被回收的日志的状态已经反映在snapshot中了,因此,一种可行的办法是:直接复制snapshot以及snapshot之后的更新日志。

etcd-raft的Leader节点维护了集群Follower节点的日志同步状态,以此作为下一次日志复制的线索。

func (r *raft) sendAppend(to uint64) {
    pr := r.prs[to]
    if pr.IsPaused() {
        return 
    }
    m := pb.Message{}
    m.To = to

    term, errt := r.raftLog.term(pr.Next - 1)
    ents, erre := r.raftLog.entries(pr.Next, r.maxMsgSize)

    // send snapshot if we failed to get term or entries 
    if errt != nil || erre != nil { 
        m.Type = pb.MsgSnap
        snapshot, err := r.raftLog.snapshot()
        if err != nil {
            if err == ErrSnapshotTemporarilyUnavailable {
                ...
                return 
            }
            panic(err)
        }

        if IsEmptySnap(snapshot) {
            panic("need non-empty snapshot")
        }
        m.Snapshot = snapshot
        sindex, sterm := snapshot.Metadata.Index, snapshot.Metadata.Term
        pr.becomeSnapshot(sindex)
    }
    ......
}

对于一个Follower,如果需要同步给它的日志已经被回收了,那就直接发送Snapshot消息(MsgSnap)给该Follower。

Follower节点收到Leader的MsgSnap消息后,主要调用下面的处理函数:

func (r *raft) handleSnapshot(m pb.Message) {
    sindex, sterm := m.Snapshot.Metadata.Index, m.Snapshot.Metadata.Term 
    if r.restore(m.Snapshot) {
        r.send(pb.Message{To: m.From, Type: pb.MsgAppResp, Index: r.raftLog.lastIndex()})
    } else {
        r.send(pb.Message{To: m.From, Type: pb.MsgAppResp, Index: r.raftLog.committed})
    }
}

// restore recovers the state machine from a snapshot.  
// It restores the log and the configuration of state machine. 
func (r *raft) restore(s pb.Snapshot) bool {
    if s.Metadata.Index <= r.raftLog.committed {
        return false
    }
    // 如果本地的raft log已经包含了Snapshot的日志项 
    // 那直接提交即可,什么都不用做 
    if r.raftLog.matchTerm(s.Metadata.Index, s.Metadata.Term) {
        r.raftLog.commitTo(s.Metadata.Index)
        return false
    }

    // 恢复内存日志项(其实主要是unstable)为空,并记录下snapshot信息 
    // 根据Snapshot当时的集群节点配置重构集群拓扑 
    r.raftLog.restore(s)
    r.prs = make(map[uint64]*Progress)
    for _, n := range s.Metadata.ConfState.Nodes {
        match, next := uint64(0), r.raftLog.lastIndex()+1 
        if n == r.id {
            match = next - 1 
        }
        r.setProgress(n, match, next)
    }
    return true
}

对于Follower节点:

  • 如果本地日志已经包含了snapshot中的日志,那么就什么都不用做了,直接提交即可;
  • 否则,将内存中的日志项清空,并将Leader发过来的snapshot存储在内存日志结构中(参见raftLog.restore())。同时,snapshot记录了彼时刻的集群配置信息,既然要恢复成snapshot时的状态,也必须得按照该集群配置去重构本地的节点拓扑。

与节点自身主动进行的snapshot过程所不同的是,Follower节点被动接受的Leader复制的snapshot后,需要将该snapshot更新至当前节点的应用状态机。

Follower节点接收并存储了Leader复制而来的snapshot后,更新应用状态的大致的过程是:Follower节点的raft内部状态机会将unstable log中的snapshot信息放在Ready结构中,应用通过Ready()接口获取到snapshot信息,然后在内存中重放:

func newReady(r *raft, prevSoftSt *SoftState, prevHardSt pb.HardState) Ready {
    rd := Ready{
           Entries:          r.raftLog.unstableEntries(),
           CommittedEntries: r.raftLog.nextEnts(),
           Messages:         r.msgs,
    }

    // snapshot保存至ready 
    if r.raftLog.unstable.snapshot != nil {
        rd.Snapshot = *r.raftLog.unstable.snapshot 
    }
}

func (n *node) run(r *raft) {
    for {
        if advancec != nil {
            readyc = nil
       } else {
            rd = newReady(r, prevSoftSt, prevHardSt)
       }
       ...
    }
}

以etcd-raft自带的示例应用说明应用如何重放snapshot:

func (rc *raftNode) serveChannels() {
    // event loop on raft state machine updates 
    for {
        select {
        case <-ticker.C:
            rc.node.Tick()
        case rd := <-rc.node.Ready():
            rc.wal.Save(rd.HardState, rd.Entries)
            // 处理snapshot 
            // 1. 保存snapshot 
            // 2. ApplySnapshot 
            // 3. publishSnapshot 
            if !raft.IsEmptySnap(rd.Snapshot) {
                rc.saveSnap(rd.Snapshot)
                rc.raftStorage.ApplySnapshot(rd.Snapshot)
                rc.publishSnapshot(rd.Snapshot)
            }
        ...
        }
    }
}

从上面的流程可以看到,对于日志中的snapshot,应用需要:

  1. 存储snapshot至WAL日志;
  2. 将snapshot存储在storage中,主要是函数ApplySnapshot;
  3. publishSnapshot将snapshot的消息通知给snapshot回放模块,etcd-raft的示例kv存储应用有一个后台协程会定期接收该消息,加载、回放最新的snapshot。在示例应用中,回放snapshot其实就是将snapshot中的数据反序列化为内存的kv。

日志回收

etcd-raft中的日志项会存储在三个地方:unstable log、raft storage以及WAL。其中前两者是内存存储,WAL则是使用磁盘文件存储日志项。

先来聊聊raft storage。它在内存中维护了那些已经被写入WAL但是未compact的日志项,同时还记录了最近一次的snapshot信息。节点每次完成snapshot后,便可以回收该snapshot之前的所有日志项,以释放日志项占用的内存。

func (ms *MemoryStorage) Compact(compactIndex uint64) error {
    ms.Lock()
    defer ms.Unlock()

    offset := ms.ents[0].Index 
    if compactIndex <= offset {
        return ErrCompacted
    }

    if compactIndex > ms.lastIndex() {
        panic()
    }

    i := compactIndex - offset
    ents := make([]pb.Entry, 1, 1+uint64(len(ms.ents))-i)
    ents[0].Index = ms.ents[i].Index 
    ents[0].Term = ms.ents[i].Term 
    ents = append(ents, ms.ents[i+1:]...)
    ms.ents = ents

    return nil
}

unstable log中的日志项来源主要有二:于Leader节点,日志项是来自客户端的更新请求而形成的日志;于Follower节点,日志项源自Leader节点的复制。

无论是Leader还是Follower,unstable log中的日志项最终都会被应用获取到并进行一系列处理(如写入WAL、存储至storage、发送到其他Follower等),处理完成后,这些日志项可能就会变得不再有效,可以被回收。因此,应用在处理完成Ready内的日志项时,最终会调用一个Advance()方法:

func (n *node) Advance() {
    select {
    case n.advancec <- struct{}{}:
    case <-n.done:
    }
}

Advance()只是通知内部协程,内部携程收到这个通知后:

func (n *node) run(r *raft) {
    for {
        select {
        case <-advancec:
            if prevHardSt.Commit != 0 {
                r.raftLog.appliedTo(prevHardSt.Commit)
            }
            if havePrevLastUnstablei {
                r.raftLog.stableTo(prevLastUnstablei, 
                                   prevLastUnstablet)
                havePrevLastUnstablei = false
            }
            r.raftLog.stableSnapTo(prevSnapi)
            advancec = nil
        }
        ...
    }
}

advancec收到消息就意味着unstable log的上一次更新日志({}prevLastUnstablet, prevLastUnstablei}之前的所有日志)必然已经被写入WAL日志和storage内存了,因此,unstable log中这部分日志也就没有存在的必要了,stableTo便是回收这部分日志项。

func (u *unstable) stableTo(i, t uint64) {
    gt, ok := u.maybeTerm(i)
    if !ok {
       return
    }
    // if i < offset, term is matched with the snapshot 
    // only update the unstable entries if term is matched with 
    // an unstable entry. 
    if gt == t && i >= u.offset {
        u.entries = u.entries[i+1-u.offset:]
        u.offset = i + 1 
        u.shrinkEntriesArray()
    }
}

同样的,unstable log中保存的snapshot必然也被持久化存储甚至更新至应用了,无需再保存了,调用stableSnapTo来删除即可:

func (u *unstable) stableSnapTo(i uint64) {
    if u.snapshot != nil && u.snapshot.Metadata.Index == i {
        u.snapshot = nil
    }
}

WAL的垃圾日志回收暂时未发现。

发布于 2017-10-03

文章被以下专栏收录