存储与数据库课后作业--实现一个分布式key-value存储系统 | 豆包MarsCode AI刷题

202 阅读5分钟

简介

本文章中的分布式key-value存储系统基于国外公开课 MIT 6.824 的Lab2-3实现。国外公开课 MIT 6.824 是一门深入研究分布式系统的课程,其实验中采用了 Go 语言 来实现关键功能模块。这门课程的 Lab 2-3 指导学生构建一个高可用的 key-value 存储系统。本篇文章基于课程提供的接口和代码,结合我的实践经验,解析了实现过程中的关键细节和代码。

在学习分布式系统的过程中,推荐大家参考 MIT 6.824《数据密集型应用系统设计》(DDIA) ,它们对理解分布式系统的核心原理与实际应用有很大的帮助。

需求分析

构建一个高可用的分布式 key-value 存储系统,需要满足以下功能需求:

  1. Key-Value 存储:

    • 提供基本的 putget 接口。
    • put 用于存储键值对。
    • get 用于查询特定键的值。
    • 系统需要对客户端请求做出实时响应。
  2. 分布式特性:

    • 使用 Raft 共识算法 确保数据的一致性和高可用性,即使在节点故障时也能正常运行。

系统实现

系统架构
系统采用 Raft 论文中描述的架构模型,主要组件如下:

  1. Client:

    • 客户端发送 putget 请求。
  2. Server:

    • 服务器维护一个日志列表(Log),记录客户端的每次操作。
    • 日志通过 Consensus Module(共识模块) 保持一致。
    • 一旦日志提交,系统会将操作应用到 State Machine(状态机) 中。
    • 最终将结果返回给客户端。
  3. State Machine:

    • 作为实际的存储结构,负责保存和处理 key-value 数据。 image.png

Raft实现:
Raft 是一种易于理解且实现的共识算法,感兴趣的同学可以阅读Raft算法的论文《In Search of an Understandable Consensus Algorithm (Extended Version)》进行更深入的学习,其核心机制包括:

  • 心跳同步: 定期向其他节点发送心跳,维持主从状态。
  • 超时选举: 主节点故障时,通过随机超时触发选举,选举出新的主节点。
  • 日志复制: 确保每个节点的日志序列一致。
  • 状态机应用: 日志在提交后,会将其操作结果同步到状态机。

Raft需要实现心跳同步,超时选举等机制,我们这里直接使用一个go协程进行后台处理。具体来讲,通过selectTimer实现超时时进行对应的心跳或者选举逻辑,注意为了防止多个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实现
核心功能

  • 与客户端交互,提供 GetPutAppend 操作接口。
  • 维护已处理的请求列表,防止重复处理过期请求。
  • 接收 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 提供的实验指导,通过代码实践和深入理解原理来夯实基础,为后续的分布式系统设计和优化提供坚实的技术支撑。