ps: 常用博客地址为 https://qtozeng.top
最近集中了解了ZAB
、Raft
及Paxos
协议的基本理论,因此想进一步深入到源代码仔细体验一致性协议如何在分布式系统中发挥作用。虽然在 MIT 6.824 课程中有简单实现Raft
协议,并基于Raft
构建了一个粗糙的 kv 存储系统。但还是想了解下工业生产级别的Raft
协议的实现内幕,故选择etcd
进行解读。etcd
是 CoreOS 基于Raft
协议使用 go 开发的分布式 kv 存储系统,可用于服务发现、共享配置及其它利用一致性保障的功能(如leader
选举及分布式锁、队列等)。这些功能ZooKeeper
不也有提供?没错。它们都可以作为其它分布式应用的独立协调服务,这通过通用的一致性元信息存储来实现。但在易用性上,etcd
可谓略胜一筹。因此,后续的一系列博客会简单对etcd
各重要组成部分的源码进行简要分析(重点在Raft
实现)。本文主要是分析etcd
的raftexample
的代码。它是etcd
官方提供的如何使用etcd
内部的Raft
协议组件来构建分布式应用的一个简单示例。
(阐述etcd-raft
的系列文章对应的etcd-raft
的版本为 3.3.11,但遗憾实际上看的master unstable
版本)etcd
内部使用Raft
协议对集群各节点的状态(数据、日志及快照等)进行同步。类似于ZooKeeper
利用ZAB
协议作为底层的可靠的事务广播协议。但etcd
对Raft
的实现有点特殊,它底层的Raft
组件库只实现了Raft
协议最核心的部分,这主要包括选主逻辑、一致性具体实现以及成员关系变化。而将诸如WAL
、snapshot
以及网络传输等模块让用户来实现,这明显增加了使用的难度,但对于应用本质上也更灵活。
本文会简单分析etcd
提供的如何其核心的Raft
协议组件来构建一个简单的高可用内存 kv 存储(其本质是一个状态机),用户可以通过 http 协议来访问应用(kv 存储系统),以对数据进行读写操作,在对日志进行读写过程中,Raft
组件库能够保证各节点数据的一致性。其对应的源码目录为/etcd-io/etcd/tree/master/contrib/raftexample
。另外,需要强调的是,本文的主题是利用Raft
协议库来构建一个简单的 kv 存储,关于Raft
协议库实现的细节不会过多阐述。若读者想继续了解此文,个人建议clone
源代码,在阅读源代码的过程中,参考本文效果可能会更好,如果有理解错误的地方,欢迎指正!
数据结构
在按raftexample/main
的示例完整解读整个流程之前,先熟悉几个重要的数据结构会有好处。此示例构建的应用为 kv 存储系统,因此,先来了解 kvstore
定义的相关字段:
// a key-value store backed by raft
type kvstore struct {
proposeC chan<- string // channel for proposing updates
mu sync.RWMutex
kvStore map[string]string // current committed key-value pairs
snapshotter *snap.Snapshotter
} // kvstore.go
关键结构成员解释如下:
proposeC
: 应用与底层Raft
核心库之间的通信channel
,当用户向应用通过 http 发送更新请求时,应用会将此请求通过channel
传递给底层的Raft
库。kvStore
: kv 结构的内存存储,即对应应用的状态机。snapshotter
: 由应用管理的快照snapshot
接口。
接下来分析一下应用封装底层Raft
核心库的结构raftNode
,应用通过与raftNode
结构进行交互来使用底层的Raft
核心协议,它封装完整的Raft
协议相关的逻辑(如WAL
及snapshot
等)。我们先列举它的相关处理逻辑,然后展示其结构内容。具体地逻辑如下:
- 将应用的更新请求传递给
Raft
核心来执行。 - 同时,将
Raft
协议已提交的日志传回给应用,以指示应用来将日志请求应用到状态机。 - 另外,它也处理由
Raft
协议相关的指令,包括选举、成员变化等。 - 处理
WAL
日志相关逻辑。 - 处理快照相关的逻辑。
- 将底层
Raft
协议的指令消息传输到集群其它节点。
// A key-value stream backed by raft
type raftNode struct {
proposeC <-chan string // proposed messages (k,v)
confChangeC <-chan raftpb.ConfChange // proposed cluster config changes
commitC chan<- *string // entries committed to log (k,v)
errorC chan<- error // errors from raft session
id int // client ID for raft session
peers []string // raft peer URLs
join bool // node is joining an existing cluster
waldir string // path to WAL directory
snapdir string // path to snapshot directory
getSnapshot func() ([]byte, error)
lastIndex uint64 // index of log at start
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 // signals when snapshotter is ready
snapCount uint64
transport *rafthttp.Transport
stopc chan struct{
} // signals proposal channel closed
httpstopc chan struct{
} // signals http server to shutdown
httpdonec chan struct{
} // signals http server shutdown complete
} // raft.go
关键结构成员解释如下:
proposeC
: 同kvStore.proposeC
通道类似,事实上,kvStore
会将用户的更新请求传递给raftNode
以使得其最终能传递给底层的Raft
协议库。confChangeC
:Raft
协议通过此channel
来传递集群配置变更的请求给应用。commitC
: 底层Raft
协议通过此channel
可以向应用传递准备提交或应用的channel
,最终kvStore
会反复从此通道中读取可以提交的日志entry
,然后正式应用到状态机。node
: 即底层Raft
协议组件,raftNode
可以通过node
提供的接口来与Raft
组件进行交互。raftStorage
:Raft
协议的状态存储组件,应用在更新kvStore
状态机时,也会更新此组件,并且通过raft.Config
传给Raft
协议。wal
: 管理WAL
日志,前文提过etcd
将日志的相关逻辑交由应用来管理。snapshotter
: 管理snapshot
文件,快照文件也是由应用来管理。transport
: 应用通过此接口与集群中其它的节点(peer
)通信,比如传输日志同步消息、快照同步消息等。网络传输也是由应用来处理。
其它的相关的数据结构不再展开,具体可以查看源代码,辅助注释理解。
关键流程
我们从main.go
中开始通过梳理一个典型的由客户端发起的状态更新请求的完整流程来理解如何利用Raft
协议库来构建应用状态机。main.go
的主要逻辑如下:
func main() {
// 解析客户端请求参数信息
...
proposeC := make(chan string)
defer close(proposeC)
confChangeC := make(chan raftpb.ConfChange)
defer close(confChangeC)
// raft provides a commit stream for the proposals from the http api
var kvs *kvstore
getSnapshot := func() ([]byte, error) {
return kvs.getSnapshot() }
commitC, errorC, snapshotterReady := newRaftNode(*id, strings.Split(*cluster, ","), *join, getSnapshot, proposeC, confChangeC)
kvs = newKVStore(<-snapshotterReady, proposeC, commitC, errorC)
// the key-value http handler will propose updates to raft
serveHttpKVAPI(kvs, *kvport, confChangeC, errorC)
} // main.go
显然,此示例的步骤较为清晰。主要包括三方面逻辑:其一,初始化raftNode
,并通过 go routine 来启动相关的逻辑,实际上,这也是初始化并启动Raft
协议组件,后面会详细相关流程。其二,初始化应用状态机,它会反复从commitC
通道中读取raftNode/Raft
传递给它的准备提交应用的日志。最后,启动 http 服务以接收客户端读写请求,并设置监听。下面会围绕这三个功能相关的逻辑进行阐述。
Raft 初始化
首先我们来理顺Raft
初始化的逻辑,这部分相对简单。
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: defaultSnapshotCount, // 只有当日志数量达到此阈值时才执行快照
stopc: make(chan struct{