简介
本文章中的分布式key-value存储系统基于国外公开课 MIT 6.824 的Lab2-3实现。国外公开课 MIT 6.824 是一门深入研究分布式系统的课程,其实验中采用了 Go 语言 来实现关键功能模块。这门课程的 Lab 2-3 指导学生构建一个高可用的 key-value 存储系统。本篇文章基于课程提供的接口和代码,结合我的实践经验,解析了实现过程中的关键细节和代码。
在学习分布式系统的过程中,推荐大家参考 MIT 6.824 和 《数据密集型应用系统设计》(DDIA) ,它们对理解分布式系统的核心原理与实际应用有很大的帮助。
需求分析
构建一个高可用的分布式 key-value 存储系统,需要满足以下功能需求:
-
Key-Value 存储:
- 提供基本的
put和get接口。 put用于存储键值对。get用于查询特定键的值。- 系统需要对客户端请求做出实时响应。
- 提供基本的
-
分布式特性:
- 使用 Raft 共识算法 确保数据的一致性和高可用性,即使在节点故障时也能正常运行。
系统实现
系统架构
系统采用 Raft 论文中描述的架构模型,主要组件如下:
-
Client:
- 客户端发送
put和get请求。
- 客户端发送
-
Server:
- 服务器维护一个日志列表(Log),记录客户端的每次操作。
- 日志通过 Consensus Module(共识模块) 保持一致。
- 一旦日志提交,系统会将操作应用到 State Machine(状态机) 中。
- 最终将结果返回给客户端。
-
State Machine:
- 作为实际的存储结构,负责保存和处理 key-value 数据。
- 作为实际的存储结构,负责保存和处理 key-value 数据。
Raft实现:
Raft 是一种易于理解且实现的共识算法,感兴趣的同学可以阅读Raft算法的论文《In Search of an Understandable Consensus Algorithm (Extended Version)》进行更深入的学习,其核心机制包括:
- 心跳同步: 定期向其他节点发送心跳,维持主从状态。
- 超时选举: 主节点故障时,通过随机超时触发选举,选举出新的主节点。
- 日志复制: 确保每个节点的日志序列一致。
- 状态机应用: 日志在提交后,会将其操作结果同步到状态机。
Raft需要实现心跳同步,超时选举等机制,我们这里直接使用一个go协程进行后台处理。具体来讲,通过select和Timer实现超时时进行对应的心跳或者选举逻辑,注意为了防止多个follower同时变成candidate从而导致同票或者都得不到半数以上的票,electionTimer每次设置的都是随机值。
func (rf *Raft) ticker() {
for rf.killed() == false {
select {
case <-rf.electionTimer.C:
//实现超时选举逻辑
case <-rf.heartbeatTimer.C:
//实现发送心跳逻辑
}
}
}
日志在commit后需要作用到State Machine中,这里也是用一个go协程后台进行处理。使用条件变量等待出现commit的日志编号大于当前已经应用到State Machine中的编号的情况出现就可以开始进行apply操作。对于将请求的内容发送给Server进行操作,go语言提供了非常灵活的线程间通信的通道channel,我们可以直接在通道中传递请求的内容。
func (rf *Raft) applier() {
for rf.killed() == false {
rf.mu.Lock()
for rf.lastApplied >= rf.commitIndex {
rf.applyCond.Wait()
}
//实现获取未apply的Log逻辑
for _, entry := range entries {
rf.applyCh <- ApplyMsg{
CommandValid: true,
Command: entry.Command,
CommandIndex: entry.Index,
CommandTerm: entry.Term,
}
}
rf.mu.Lock()
if commitIndex > rf.lastApplied {
rf.lastApplied = commitIndex
}
rf.mu.Unlock()
}
}
Start函数提供一个供Server调用的接口,当Server收到Client发送的命令后,将该命令通过Start函数传递给Raft层。
func (rf *Raft) Start(command interface{}) (int, int, bool) {
rf.mu.Lock()
defer rf.mu.Unlock()
index := -1
term := -1
isLeader := true
if rf.state != Leader {
isLeader = false
return index, term, isLeader
}
//将命令加入日志并发送心跳同步
return newLog.Index, newLog.Term, isLeader
}
Server实现
核心功能
- 与客户端交互,提供
Get和PutAppend操作接口。 - 维护已处理的请求列表,防止重复处理过期请求。
- 接收 Raft 层日志的应用结果,并更新状态机。
Server主要提供两个函数Get和PutAppend供Client进行远程调用,需要注意由于网络或者其他原因会导致Server收到一些已经过期的请求,这个时候需要维护一下已经处理了的请求列表,碰到过时的请求直接返回即可。只有PutAppend需要进行过期请求的判断,这里可以复用一下对Raft层发起调用的代码,为了可读性考虑,推荐把发起调用的代码重写一个函数,我这里直接图方便调用了Get函数。
func (kv *KVServer) Get(args *OpRequest, reply *OpResponse) {
index, _, isLeader := kv.rf.Start(*args)
if !isLeader {
reply.Err = ErrWrongLeader
return
}
//实现创建channel逻辑
select {
case result := <-ch:
reply.Value, reply.Err = result.Value, result.Err
case <-time.After(ExecuteTimeout):
reply.Err = ErrTimeOut
}
//实现删除channel逻辑
}
func (kv *KVServer) PutAppend(args *OpRequest, reply *OpResponse) {
kv.mu.RLock()
if kv.checkOutDateRequest(args.ClientId, args.CommandId) {
lastResponse := kv.lastOperations[args.ClientId].LastResponse
reply.Err, reply.Value = lastResponse.Err, lastResponse.Value
kv.mu.RUnlock()
return
}
kv.mu.RUnlock()
kv.Get(args, reply)
}
Server也拥有一个协程,用于处理Raft层通过通道返回的apply请求。Server收到请求后,需要根据操作类型对key-value进行对应的操作,完成后通过通道通知主线程可以给Client返回结果。
func (kv *KVServer) applier() {
for !kv.killed() {
message := <-kv.applyCh
if message.CommandValid {
kv.mu.Lock()
if message.CommandIndex <= kv.lastApplied {
kv.mu.Unlock()
continue
}
kv.lastApplied = message.CommandIndex
reply := new(OpResponse)
args := message.Command.(OpRequest)
if args.Type != OpGet && kv.checkOutDateRequest(args.ClientId, args.CommandId) {
reply = kv.lastOperations[args.ClientId].LastResponse
} else {
switch args.Type {
case OpGet:
if value, ok := kv.memoryKV[args.Key]; ok {
reply.Value, reply.Err = value, OK
} else {
reply.Value, reply.Err = "", ErrNoKey
}
case OpPut:
kv.memoryKV[args.Key] = args.Value
reply.Value, reply.Err = "", OK
case OpAppend:
kv.memoryKV[args.Key] += args.Value
reply.Value, reply.Err = "", OK
}
if args.Type != OpGet {
kv.lastOperations[args.ClientId] = LastOp{args.CommandId, reply}
}
}
//实现通知主线程的逻辑
kv.mu.Unlock()
}
}
}
Client实现:
Client实现比较简单,由于Raft算法都是从主节点进行写入和读取,所以Client需要使用一个循环请求的过程,直到请求的节点是主节点,才能进行远程调用。
func (ck *Clerk) Get(key string) string {
//实现封装请求逻辑
for {
reply := new(OpResponse)
if !ck.servers[ck.leaderId].Call("KVServer.Get", args, reply) || reply.Err == ErrWrongLeader || reply.Err == ErrTimeOut {
ck.leaderId = (ck.leaderId + 1) % int64(len(ck.servers))
continue
}
ck.commandId++
return reply.Value
}
}
func (ck *Clerk) PutAppend(key string, value string, op string) {
var opType OpType
if op == "Put" {
opType = OpPut
} else {
opType = OpAppend
}
//和Get函数差不多,进行循环请求直到请求到主节点
}
总结
以上实现构建了一个分布式、高可用的 key-value 存储系统,涵盖了 Raft 算法的核心机制以及 Server 和 Client 的交互逻辑。在实现过程中,通过合理利用 Go 语言的并发特性(如 Goroutines 和 Channels),大大简化了分布式系统的设计复杂度。
学习和实践分布式系统时,建议参考 Raft 论文及 MIT 6.824 提供的实验指导,通过代码实践和深入理解原理来夯实基础,为后续的分布式系统设计和优化提供坚实的技术支撑。