diff --git a/README.md b/README.md index 8675376..7b0c976 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,239 @@ # go-raft-kv --- +# 简介 +本项目是基于go语言实现的一个raft算法分布式kv数据库。 -基于go语言实现分布式kv数据库 +项目亮点如下: +支持线程与进程两种通信机制,方便测试与部署切换的同时,降低了系统耦合度 -# 框架 -每个运行main.go进程作为一个节点 +提供基于状态不变量的fuzz测试机制,增强健壮性 + +项目高度模块化,便于扩展更多功能 + +本报告主要起到工作总结,以及辅助阅读代码的作用(并贴出了一些关键部分),一些工作中遇到的具体问题、思考和收获不太方便用简洁的文字描述,放在了汇报的ppt中。 + +# 项目框架 ``` +---cmd + ---main.go 进程版的启动 ---internal ---client 客户端使用节点提供的读写功能 ---logprovider 封装了简单的日志打印,方便调试 ---nodes 分布式核心代码 - init.go 节点在main中的调用初始化,和大循环启动 + init.go 节点的初始化(包含两种版本),和大循环启动 log.go 节点存储的entry相关数据结构 node_storage.go 抽象了节点数据持久化方法,序列化后存到leveldb里 node.go 节点的相关数据结构 + random_timetake.go 控制系统中的随机时间 + real_transport.go 进程版的rpc通讯 replica.go 日志复制相关逻辑 - server_node.go 节点作为server为 client提供的功能(读写) - vote.go 选主相关逻辑 + server_node.go 节点作为server为 client提供的功能(读写) + simulate_ctx.go 测试中控制通讯消息行为 + thread_transport.go 线程版的通讯方法 + transport.go 为两种系统提供的基类通讯接口 + vote.go 选主相关逻辑 +---test 进程版的测试 +---threadTest 线程版的测试 + ---fuzz 随机测试部分 + election_test.go 选举部分 + log_replication_test.go 日志复制部分 + network_partition_test.go 网络分区部分 + restart_node_test.go 恢复测试 + server_client_test.go 客户端交互 ``` -# 环境与运行 -使用环境是wsl+ubuntu -go mod download安装依赖 -./scripts/build.sh 会在根目录下编译出main +# raft系统部分 +## 主要流程 +在init.go中每个节点会初始化,发布监听线程,然后在start函数开启主循环。主循环中每隔一段心跳时间,如果判断自己是leader,就broadcast并resetElectionTime。(init.go) +```go +func Start(node *Node, quitChan chan struct{}) { + node.Mu.Lock() + node.State = Follower // 所有节点以 Follower 状态启动 + node.Mu.Unlock() + node.ResetElectionTimer() // 启动选举超时定时器 + + go func() { + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() -# 注意 -脚本第一次运行需要权限获取 chmod +x <脚本> -如果出现tcp listen error可能是因为之前的进程没用正常退出,占用了端口 -lsof -i :9091查看pid -kill -9 杀死进程 + for { + select { + case <-quitChan: + fmt.Printf("[%s] Raft start 退出...\n", node.SelfId) + return // 退出 goroutine -## 关于测试 -test/ 测试真实rpc,通过新开进程(main)的方式创建节点(参考test/common.go中executeNodeI函数) -threadTest/ 测试线程模拟,参考threadTest/common.go中executeNodeI函数启动node + case <-ticker.C: + node.Mu.Lock() + state := node.State + node.Mu.Unlock() + + switch state { + case Follower: + // 监听心跳超时 + + case Leader: + // 发送心跳 + node.ResetElectionTimer() // leader 不主动触发选举 + node.BroadCastKV() + } + } + } + }() +} +``` +两个监听方法: +appendEntries:broadcast中遍历每个peerNode,在sendkv中进行call,实现日志复制相关逻辑。 +requestVote:每个node有个ResetElectionTimer定时器,一段时间没有reset它就会StartElection,其中遍历每个peerNode,在sendRequestVote中进行call,实现选主相关逻辑。leader会在心跳时reset(避免自己的再选举),follower则会在收到appendentries时reset。 + +```go +func (node *Node) ResetElectionTimer() { + node.MuElection.Lock() + defer node.MuElection.Unlock() + if node.ElectionTimer == nil { + node.ElectionTimer = time.NewTimer(node.RTTable.GetElectionTimeout()) + go func() { + for { + <-node.ElectionTimer.C + node.StartElection() + } + }() + } else { + node.ElectionTimer.Stop() + node.ElectionTimer.Reset(time.Duration(500+rand.Intn(500)) * time.Millisecond) + } +} +``` -## 客户端工作原理 +## 客户端工作原理(client & server_node.go) +### 客户端写 客户端每次会随机连上集群中一个节点,此时有四种情况: -a 节点认为自己是leader,直接处理请求 +a 节点认为自己是leader,直接处理请求(记录后broadcast) b 节点认为自己是follower,且有知道的leader,返回leader的id。客户端再连接这个新的id,新节点重新分析四种情况。 c 节点认为自己是follower,但不知道leader是谁,返回空的id。客户端再随机连一个节点 d 连接超时,客户端重新随机连一个节点 +```go +func (client *Client) Write(kv nodes.LogEntry) Status { + kvCall := nodes.LogEntryCall{LogE: kv, + Id: nodes.LogEntryCallId{ClientId: client.ClientId, LogId: client.NextLogId}} + client.NextLogId++ + + c := client.FindActiveNode() + var err error + + timeout := time.Second + deadline := time.Now().Add(timeout) + + for { // 根据存活节点的反馈,直到找到leader + if time.Now().After(deadline) { + return Fail + } + + var reply nodes.ServerReply + reply.Isleader = false + + callErr := client.Transport.CallWithTimeout(c, "Node.WriteKV", &kvCall, &reply) // RPC + if callErr != nil { // dial和call之间可能崩溃,重新找存活节点 + log.Error("dialing: ", zap.Error(callErr)) + client.CloseRpcClient(c) + c = client.FindActiveNode() + continue + } + + if !reply.Isleader { // 对方不是leader,根据反馈找leader + leaderId := reply.LeaderId + client.CloseRpcClient(c) + if leaderId == "" { // 这个节点不知道leader是谁,再随机找 + c = client.FindActiveNode() + } else { // dial leader + c, err = client.Transport.DialHTTPWithTimeout("tcp", "", leaderId) + if err != nil { // dial失败,重新找下一个存活节点 + c = client.FindActiveNode() + } + } + } else { // 成功 + client.CloseRpcClient(c) + return Ok + } + } +} +``` + +### 客户端读 +随机连上集群中一个节点,读它commit的kv。 + +## 重要数据持久化 +封装在了node_storage.go中,主要是setTermAndVote序列化后写入leveldb,log的写入。在这些数据变化时调用它们进行持久化,以及相应的恢复时读取。 +```go +// SetTermAndVote 原子更新 term 和 vote +func (rs *RaftStorage) SetTermAndVote(term int, candidate string) { + rs.mu.Lock() + defer rs.mu.Unlock() + if rs.isfinish { + return + } + + batch := new(leveldb.Batch) + batch.Put([]byte("current_term"), []byte(strconv.Itoa(term))) + batch.Put([]byte("voted_for"), []byte(candidate)) + + err := rs.db.Write(batch, nil) // 原子提交 + if err != nil { + log.Error("SetTermAndVote 持久化失败:", zap.Error(err)) + } +} +``` + +## 两种系统的切换 +将raft系统中,所有涉及网络的部分提取出来,抽象为dial和call方法,作为每个node的接口类transport的两个基类方法,进程版与线程版的transport派生类分别实现,使得相互之间实现隔离。 +```go +type Transport interface { + DialHTTPWithTimeout(network string, myId string, peerId string) (ClientInterface, error) + CallWithTimeout(client ClientInterface, serviceMethod string, args interface{}, reply interface{}) error +} +``` + +进程版:dial和call均为go原生rpc库的方法,加一层timeout封装(real_transport.go) +线程版:threadTransport为每个节点共用,节点初始化时把一个自己的chan注册进里面的map,然后go一个线程去监听它,收到req后去调用自己对应的函数(thread_transport.go) + +# 测试部分 +## 单元测试 +从Leader选举、日志复制、崩溃恢复、网络分区、客户端交互五个维度,对系统进行分模块的测试。测试中夹杂消息状态的细粒度模拟,尽可能在项目前中期验证代码与思路的一致性,避免大的问题。 + +## fuzz测试 +分为不同节点、系统随机时间配置测试异常的多系统随机(basic),与对单个系统注入多个随机异常的多系统随机(robust),这两个维度,以及最后综合两个维度的进一步测试(plus)。 +fuzz test不仅覆盖了单元测试的内容,也在随机的测试中发现了更多边界条件的异常,以及通过系统状态的不变量检测,确保系统在不同配置下支持长时间的运行中保持正确可用。 + +![alt text](pics/plus.png) + +![alt text](pics/robust.png) + +## bug的简单记录 +LogId0的歧义,不同接口对接日志编号出现问题 +随机选举超时相同导致的candidate卡死问题 +重要数据持久化不原子、与状态机概念混淆 +客户端缺乏消息唯一标识,导致系统重复执行 +重构系统过程中lock使用不当 +伪同步接口的异步语义陷阱 +测试和系统混合产生的bug(延迟导致的超时、退出不完全导致的异常、文件系统异常、lock不当) + +# 环境与运行 +使用环境是wsl+ubuntu +go mod download安装依赖 +./scripts/build.sh 会在根目录下编译出main(进程级的测试需要) + +# 参考资料 +In Search of an Understandable Consensus Algorithm +Consensus: Bridging Theory and Practice +Raft TLA+ Specification +全项目除了logprovider文件夹下的一些go的日志库使用参考了一篇博客的封装,其余皆为独立原创。 + +# 分工 +| 姓名 | 工作 | 贡献度 | +|--------|--------|--------| +| 李度 | raft系统设计+实现,测试设计+实现 | 75% | +| 马也驰 | raft系统设计,测试设计+实现 | 25% | + +![alt text](pics/plan.png) + diff --git a/pics/plan.png b/pics/plan.png new file mode 100644 index 0000000..8d1f54d Binary files /dev/null and b/pics/plan.png differ diff --git a/pics/plus.png b/pics/plus.png new file mode 100644 index 0000000..f61670e Binary files /dev/null and b/pics/plus.png differ diff --git a/pics/robust.png b/pics/robust.png new file mode 100644 index 0000000..8db9682 Binary files /dev/null and b/pics/robust.png differ