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

1,092 阅读6分钟

本文是《如何使用 etcd raft 库构建自己的分布式 KV 存储系统》系列的第一篇 - raftexample 架构与写请求处理流程

前言

raftexample 是 etcd 提供的一个使用 etcd raft 共识算法库的示例。raftexample 最终实现了一个对外提供 REST API 的分布式键值存储服务。

本文将对 raftexample 的代码进行阅读和解析,希望可以帮助读者更好的理解 etcd raft 库的使用方式,以及 raft 库的实现逻辑。

结构

raftexample 的结构非常简洁,其主要文件如下:

  • main.go: 负责 raft 模块,httpapi 模块和 kvstore 模块之间的交互方式的组织;

  • raft.go: 负责与 raft 库的交互,包括提交提案,接收需要发送的 RPC 消息并进行网络传输等;

  • httpapi.go: 负责对外提供 REST API,用户请求的入口;

  • kvstore.go: 负责持久化存储 committed 的日志条目,相当于 raft 协议中的 state machine;

一个写请求的处理流程

一个写请求会以 HTTP PUT 的形式到达 httpapi 模块中的 ServeHTTP 方法。

curl -L http://127.0.0.1:12380/key -XPUT -d value

通过 switch 对 HTTP 请求的方式进行匹配后进入 PUT 方法的处理流程:

  • 读取 HTTP 请求体中的内容(也就是 value);
  • 通过 kvstore 模块的 Propose 方法构建一个提案(添加一个键为 key,值为 value 的键值对);
  • 由于没有什么需要返回的数据,响应客户端 204 StatusNoContent;

提案是通过 raft 算法库对外提供的 Propose 方法提交给 raft 算法库的内容。

一个提案的内容可以是添加一个新的键值对,更新一个已有的键值对等。

// httpapi.go
v, err := io.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)

接下来我们进入 kvstore 模块的 Propose 方法看看提案时怎么被构建和处理的。

Propose 方法中,我们先通过 gob 将请求写入的键值对进行编码,然后将编码后的内容传递给 proposeC,一个负责将 kvstore 模块构建的提案传递给 raft 模块的 channel。

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

kvstore 构建并传递给 proposeC 的提案在 raft 模块的 serveChannels 方法进行接受和处理。

在判断 proposeC 没有被 close 后 raft 模块会通过 raft 算法库对外提供的 Propose 方法将提案提交给 raft 算法库进行处理。

// raft.go
select {
    case prop, ok := <-rc.proposeC:
    if !ok {
        rc.proposeC = nil
    } else {
        rc.node.Propose(context.TODO(), []byte(prop))
    }

提案被提交后走的就是 raft 算法的流程了,提案最终会被转发到 leader node(如果当前节点不是 leader 并且你允许 follower 将提案进行转发,由 DisableProposalForwarding 配置控制),leader 会将提案作为一个 log entry 添加到自己的 raft log 并同步给其他 follower 节点,判断为 committed 之后应用到状态机并返回结果给用户。

但是由于 etcd raft 库本身并不负责节点之间的通信,追加 raft log,应用到状态机等操作,raft 库只负责为我们准备好需要进行这些操作的数据,具体的操作都需要我们自己来做。

所以我们需要从 raft 库接收这些数据并根据数据的类型来进行对应的处理,Ready 方法返回了一个只读的 channel,我们可以通过这个 channel 接收到需要进行对应操作的数据并进行处理。

需要注意的是接收到的数据包含多个字段,例如有需要应用的快照,需要追加到 raft log 中的日志条目,需要进行网络传输的 message 等。

继续以我们的写请求为例(leader 节点),接收到对应的数据后,我们需要持久化保存快照,HardStateEntries 以应对 server crash 导致的问题(例如一个 follower 投票给多个 candidate),其中 HardStateEntries 合起来就是论文中的 Persistent state on all servers。在持久化保存之后我们就可以应用快照,追加 raft log。

由于我们当前是 leader 节点,所以 raft 库会返回给我们 MsgApp 类型的消息(对应论文中的 AppendEntries RPC),我们需要把这些消息发送给 follower 节点,这里我们通过使用 etcd 提供的 rafthttp 进行节点间通信,通过 Send 方法我们将消息发送给 follower 节点。

// raft.go
case rd := <-rc.node.Ready():
    if !raft.IsEmptySnap(rd.Snapshot) {
        rc.saveSnap(rd.Snapshot)
    }
    rc.wal.Save(rd.HardState, rd.Entries)
    if !raft.IsEmptySnap(rd.Snapshot) {
        rc.raftStorage.ApplySnapshot(rd.Snapshot)
        rc.publishSnapshot(rd.Snapshot)
    }
    rc.raftStorage.Append(rd.Entries)
    rc.transport.Send(rc.processMessages(rd.Messages))
    applyDoneC, ok := rc.publishEntries(rc.entriesToApply(rd.CommittedEntries))
    if !ok {
        rc.stop()
        return
    }
    rc.maybeTriggerSnapshot(applyDoneC)
    rc.node.Advance()

然后我们通过 publicEntries 方法将已经 committed 的 raft log 应用到状态机。正如前面所说的,在 raftexample 中 kvstore 模块担任了状态机的角色,在 publicEntries 方法中我们将需要应用到状态机的日志条目传递给 commitCcommitC 和之前的 proposeC 的作用类似,负责将 raft 模块认定已经 committed 的日志传递给 kvstore 模块来应用到状态机。

// raft.go
rc.commitC <- &commit{data, applyDoneC}

在 kvstore 模块的 readCommits 方法中,从 commitC 中读取的消息被 gob 解码然后获取到最原本的键值对,最终存储进 kvstore 模块中的一个 map 结构。

// kvstore.go
for commit := range commitC {
	...
    for _, data := range commit.data {
        var dataKv kv
        dec := gob.NewDecoder(bytes.NewBufferString(data))
        if err := dec.Decode(&dataKv); err != nil {
            log.Fatalf("raftexample: could not decode message (%v)", err)
        }
        s.mu.Lock()
        s.kvStore[dataKv.Key] = dataKv.Val
        s.mu.Unlock()
    }
    close(commit.applyDoneC)
}

回到 raft 模块,最后我们通过 Advance 方法来通知 raft 库我们已经处理完了这一次从 Ready 返回的 channel 中读取的数据并准备好处理下一个数据了。

刚刚在 leader 节点我们通过 Send 方法将 MsgApp 类型的消息发送给了 follower 节点。follower 节点的 rafthttp 会监听对应的端口接收请求并返回响应,不管是 follower 节点接收的请求,还是 leader 节点接收的响应,都会通过 Step 方法提交给 raft 库进行处理。

raftNode 实现了 rafthttp 中的 Raft 接口,Raft 接口的 Process 方法被调用以处理收到的请求内容(例如 MsgApp 消息)。

// raft.go
func (rc *raftNode) Process(ctx context.Context, m raftpb.Message) error {
	return rc.node.Step(ctx, m)
}

以上就是 raftexample 中一个写请求的完整处理流程。

总结

以上就是本篇文章的所有内容了,通过对 raftexample 结构以及一个写请求的处理流程的梳理,希望可以帮助你更好的理解如何使用 etcd raft 库构建自己的分布式 KV 存储服务。

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

参考列表