如何使用 etcd raft 库构建自己的分布式 KV 存储系统(2)

67 阅读6分钟

本文是《如何使用 etcd raft 库构建自己的分布式 KV 存储系统》系列的第二篇 - raftexample 日志压缩与快照

前言

第一篇文章中我们已经学习并熟悉了 raftexample 的结构和一个写请求的处理流程,本文中我们将对 raftexample 中日志压缩和快照的处理逻辑进行解读。

日志压缩与快照

Raft Log 会在集群正常运行并处理客户端请求时不断增长,在实际应用中,我们需要一种机制来限制日志的无限制增长以避免导致可用性问题。Raft 通过快照机制来对日志进行压缩,如下图所示:

image-20240723001810207.png

在创建快照之后,Raft Log 中所有的已提交(committed)条目都被包含进一个快照中,这个快照中对最后一个已提交条目及之前的所有条目的状态机的状态进行了压缩合并,并保存了快照包含的最后一个条目的 Index 和 Term,以便在 AppendEntries RPC 进行一致性检查(日志同步)。为了支持集群成员变更,这个快照中还应该保存最新的集群配置(为了简化,图中没有标出)。

关于在如何配置快照的创建时机也是一个值得讨论的问题,一个简单的策略是在日志达到一个设置的大小阈值(字节为单位)后创建快照。

创建快照

1. 在 raftexample 中,创建快照所使用的策略是在日志条目的数量达到设置的阈值后创建快照。由 maybeTriggerSnapshot 方法执行这个策略,这个方法在每次从 raft 库接收数据(Ready)后执行。

这个方法首先计算距上一次快照之后增长的已提交日志条目的数量,如果没有达到设置的阈值(snapCount)后则直接返回,不创建快照。

if rc.appliedIndex-rc.snapshotIndex <= rc.snapCount {
    return
}

然后阻塞等待本次从 raft 库接收的所有需要提交的日志条目(rc.entriesToApply(rd.CommittedEntries))都应用到状态机(kvstore)或者服务器关闭。

// wait until all committed entries are applied (or server is closed)
if applyDoneC != nil {
    select {
    case <-applyDoneC:
    case <-rc.stopc:
        return
    }
}

之后就是正式创建快照的逻辑了:

  • getSnapshot:获取当前 kvstore 状态机保存的所有键值对的 JSON 序列化数据;

  • CreateSnapshot:创建一个快照,CreateSnapshot 会根据传入的已提交的日志索引(appliedIndex)计算出 last included index 和 last included term ,快照也会包含传入的最新的集群配置信息(confState)以及状态机的状态(data)。

    这严格遵循了论文中提到的快照需要包含的所有信息。

  • saveSnap:将创建的快照写入磁盘并将快照元数据写入 WAL 以便能从 WAL 恢复状态。

log.Printf("start snapshot [applied index: %d | last snapshot index: %d]", rc.appliedIndex, rc.snapshotIndex)
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)
}

在 MemoryStorage 的实现中,由于 CreateSnapshot 并不会对实际的 Raft Log 进行压缩,所以这里我们需要自己手动调用 Compact 方法来删除 compactIndex 之前的所有条目,否则我们只创建快照却不删除 Raft Log 会导致快照的创建失去意义。这里我们在删除条目时保留了 snapshotCatchUpEntriesN 个日志条目,这是为了让一些慢的 followers 跟上 leader 的进度。

// keep some in memory log entries for slow followers.
compactIndex := uint64(1)
// snapshotCatchUpEntriesN represents the number of log entries retained after a snapshot is triggered.
if rc.appliedIndex > snapshotCatchUpEntriesN {
    compactIndex = rc.appliedIndex - snapshotCatchUpEntriesN
}
if err := rc.raftStorage.Compact(compactIndex); err != nil {
    if err != raft.ErrCompacted {
        panic(err)
    }
} else {
    log.Printf("compacted log at index %d", compactIndex)
}

最后更新快照的进度到当前最后一个已提交(committed)的日志条目。

rc.snapshotIndex = rc.appliedIndex

2. 除了节点自身根据所使用的快照创建策略定期创建快照外,在新节点加入集群,日志条目落后太多等情况下 follower 也会从 Ready 中接收到从 leader 发送来的快照帮助进行日志同步。

判断 Ready 的 Snapshot 字段不为空后进行持久化保存,然后应用快照(ApplySnapshot)并通过 publishSnapshot 通知 kvstore 模块加载快照,这是通过向 commitC 发送一个 nil 信号实现的。

if !raft.IsEmptySnap(rd.Snapshot) {
    rc.saveSnap(rd.Snapshot)
}
// rd.HardState + rd.Entries = persistent state on all servers
rc.wal.Save(rd.HardState, rd.Entries)
if !raft.IsEmptySnap(rd.Snapshot) {
    rc.raftStorage.ApplySnapshot(rd.Snapshot)
    // Send a signal to load the snapshot. 
    // The kvstore will restore the state machine from the snapshot.
    rc.publishSnapshot(rd.Snapshot)
}

kvstore 接收到 nil 信号后会从磁盘中加载之前通过 saveSnap 保存的快照,然后 recoverFromSnapshot 会反序列化快照中保存的所有键值对的 JSON 数据并覆盖状态机(map[string]string)的存储。

for commit := range commitC {
    // signaled by raftNode.publicSnapshot rc.commitC <- nil
    if commit == nil {
        snapshot, err := s.loadSnapshot()
        if err != nil {
            log.Panic(err)
        }
        if snapshot != nil {
            log.Printf("loading snapshot at term %d and index %d", snapshot.Metadata.Term, snapshot.Metadata.Index)
            if err := s.recoverFromSnapshot(snapshot.Data); err != nil {
                log.Panic(err)
            }
        }
        continue
    }
    ...
}

从快照恢复

通过 maybeTriggerSnapshot 方法 raftexample 会在日志条目达到设置的阈值后创建一个快照并持久化保存到磁盘中,接下来我们将看看 raftexample 是如何使用磁盘中的快照进行恢复的。

1. 在启动 raft 模块时,通过 replayWAL 方法来重建 Raft Log。

  • 通过 loadSnapshot 方法来加载之前通过 saveSnap 保存到磁盘上的快照;
  • openWAL 会根据加载的快照的 Index 和 Term 来重建 WAL;
  • 通过 ApplySnapshotSetHardStateAppend 来恢复 MemoryStorage,也就是 Raft Log(MemoryStorage 是基于内存的,所以需要 WAL 帮助进行恢复)。

其中 WAL 中保存的 HardState 和 Entries 是在刚才 Ready 中接收到数据后持久化保存的。

rc.wal.Save(rd.HardState, rd.Entries)

func (rc *raftNode) replayWAL() *wal.WAL {
	log.Printf("replaying WAL of member %d", rc.id)
	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)

	// append to storage so raft starts at the right place in log
	rc.raftStorage.Append(ents)

	return w
}

在启动 Raft 模块后,raftexample 中负责和处理和 raft 库交互的 serveChannels 方法中,我们就可以通过 Snapshot 方法获取到在 replayWAL 中使用 ApplySnapshot 应用到 MemoryStorage 的快照并重建当前节点的集群配置(confState),快照索引(snapshotIndex)和已提交的日志索引(appliedIndex)信息。

snap, err := rc.raftStorage.Snapshot()
if err != nil {
    panic(err)
}

rc.confState = snap.Metadata.ConfState
rc.snapshotIndex = snap.Metadata.Index
rc.appliedIndex = snap.Metadata.Index

2. 除了 raft 模块会根据快照重建 Raft Log 外,kvstore 模块也会根据快照来恢复状态机。

同样使用 loadSnapshot 先加载快照再通过 recoverSnapshot 来反序列化 JSON 数据进行状态机恢复。

func newKVStore(snapshotter *snap.Snapshotter, proposeC chan<- string, commitC <-chan *commit, errorC <-chan error) *kvstore {
	s := &kvstore{proposeC: proposeC, kvStore: make(map[string]string), snapshotter: snapshotter}
	snapshot, err := s.loadSnapshot()
	if err != nil {
		log.Panic(err)
	}
	if snapshot != nil {
		log.Printf("loading snapshot at term %d and index %d", snapshot.Metadata.Term, snapshot.Metadata.Index)
		if err := s.recoverFromSnapshot(snapshot.Data); err != nil {
			log.Panic(err)
		}
	}
	
	go s.readCommits(commitC, errorC)
	return s
}

总结

以上就是本篇文章的所有内容了,我们从创建快照从快照恢复两个角度解析了 raftexample 中日志压缩和快照的相关处理逻辑,希望可以帮助你更好的理解如何使用 etcd raft 库构建自己的分布式 KV 存储服务。

如果哪里写错了或者有问题欢迎评论或者私聊指出,以上。

参考列表