lab 2 raft
- 本节作为实现ft KV store的基础部分,实现raft状态机复制协议,lab3基于lab2的raft模块,构建KV service,lab4基于上述构建shared KV service
- 一般来说,容错通过复制集实现状态的复制,保证在少数节点故障的场景下服务依旧可用,挑战是数据的一致性
- Raft控制一个服务的状态复制,保证故障后的一致性,保证所有operator log按照相同的顺序在所有复制集节点上执行,保证所有节点对log的内容达成一致性共识。当故障节点重新上线时,raft采用一定的策略保证它慢慢到达最新的一致性状态,Raft依赖主节点同步log,当没有主节点时,raft不会同步log,而是开始新一轮的选举
- 本节实现Raft为模块独立,每个Raft实例之间通过rpc调用来维持复制状态机,log entries with index numbers,每条entry写来index,最终达成一致的情况下commit,此时raft模块会将结果返回上层服务来执行(仅允许rpc调用,不允许共享变量,不允许依赖共享存储文件等等)
- 主要参考raft paper来做实现,同时参考reference
- 更深入理解一致性,可以参考paxos、chubby、Paxos Made Live、Spanner、Zookeeper、Harp…
- 本节会实现raft论文中大部分操作,但不是全部,包括持久化日志,重启,但不会实现集群身份转变和快照以及日志压缩功能,section 6 and 7
lock advice
- rule 1: 多个go程同时访问的数据结构需要加锁保护,go自带的race检测可以很好的发现这个问题
- rule 2: 类似事务的概念,一连串修改如果要保证同时生效而不是部分可见,需要一起锁住,mu保护两个状态,无论要使用哪个变量都需要获取该锁
rf.mu.Lock()
rf.currentTerm += 1
rf.state = Candidate
rf.mu.Unlock()
- rule 3:当某个go程的一系列读写操作中间被别人修改会出错时,整个部分都需要加锁临界区保护,同时注意currentTerm变量其他go程使用需要同一的锁做保护,某些rpc handle更是需要全过程加锁保护
rf.mu.Lock()
if args.Term > rf.currentTerm {
rf.currentTerm = args.Term
}
rf.mu.Unlock()
- rule 4:加锁的临界区不应该有耗时的阻塞操作,比如读阻塞信道,写信道,等待定时器,sleep,或者发送同步rpc。原因有二,第一是减少了并发度,此时其他go程可以继续持锁干活;第二是防止死锁,peer rpc互相持锁发送rpc依赖对方的锁,会造成死锁;rpc场景如果确实需要持锁,那么可以另起其他go程去做rpc操作,主go程持锁做其他事,之间通过信道通信
- rule 5:小心释放锁重新获得锁之后的状态,一般这么做是为了提高性能,防止等待,但是需要确保重新获取锁之后,状态和之前释放锁的状态是一致的;下述例子,有两个错误,第一rf.currentTerm需要显式传入go程,copy一份,否则go程执行的时候该变量已经被修改了;第二,go程重新获取锁后,需要检查rf.currentTerm是否还和传入的参数一致,一致才能继续操作,不一致则说明条件已经不满足
rf.mu.Lock()
rf.currentTerm += 1
rf.state = Candidate
for <each peer> {
go func() {
rf.mu.Lock()
args.Term = rf.currentTerm
rf.mu.Unlock()
Call("Raft.RequestVote", &args, ...)
// handle the reply...
} ()
}
rf.mu.Unlock()
- 总体来说,满足上述条件的代码开发颇具挑战,具体来说决定临界区何时开始何时结束,哪一系列操作需要加锁是困难,同时并发调试也是不容易的,一个更具有实际意义的方法是,首先假设没有并发,也就不需要锁保护,但是为了发送rpc不阻塞,仍旧需要创建go程,那么可以在每个rpc handlers执行全过程加锁保护,go程开始执行最初获得锁,执行结束在释放锁,完全避免了并发,也就可以满足上述rule 1 2 3 5. 这样就避免从大量代码系列中寻找临界区的麻烦,rule 4依旧是个麻烦,所以下一步就是找到阻塞的位置,增加代码在阻塞之前释放锁,在阻塞之后得到锁后,小心检查此时的状态是否和阻塞之前一致,按照上述的步骤操作较为容易。上述方案的弊端显而易见,就是性能差一些,很多不需要锁的地方也被加锁保护了,换句话说,某一个Raft peer内部全部是串行的,无法发挥多核cpu的特性
Raft Structure Advice
- raft示例主要处理外部事件,包括AppendEntry RequestVote Rpcs,Rpc replies and start calls,后台周期性任务,包括心跳和选举,实现上述所有功能的数据结构有很多,本文给出一些提示
- 每个raft实例的状态(log、current index)这些需要都需要在事件到达过程或者reply过程中并发的进行读写访问,go官方文档针对这种模式提供两种机制,共享数据+锁,信道通信,实践证明前者实现起来更直观一些
- 一些周期性的任务,比如主节点定时发送心跳,备节点在指定时间内没有收到主节点心跳,发起选举,这些周期性任务最好每个都独立的启动go程去执行,而不是用一个后台go程处理所有的事件
- 处理心跳超时重新发起选举是一个棘手问题,最简单的做法,结构中记录上一次主节点心跳的时间戳,超时go程周期性检查该变量是否超时,建议使用time.Sleep()参数使用一个小的常数,而不是使用time.Ticker 或者time.Timer,要想用对这两个需要一些技巧
- 另外一个单独的go程来提交log entry到applyChan,务必保证是一个独立go程,因为发送信道会导致阻塞,另外务必是一个单线程模式,防止order乱序,建议增加ommitIndex后使用使用sync.Cond来c唤醒applyLog GO程
- 每个RPC请求的接收和发送返回需要独立Go程,原因有二,第一某一个不可达的peer不会阻塞大部分的回复过程,第二使得心跳和超时选举可以不被阻塞一致正常进行;简单的实现rpc reply过程使用同一个go程,而不是通过信道发送消息
- 注意,网络会延迟rpc请求的发送和返回,当并发发送rpc请求时,发送顺序和返回都是乱序的,rpc handler需要忽略那些old term的消息;主节点尤为要注意,处理消息返回时,要注意term假设依旧没变,同时需要注意处理并发rpc的返回时,修改leader的状态
Students’ Guide to Raft
代码结构
- 主要开发集中在raft.go
- Make接口,创建一个Raft peer,传入所有peer网络标识符,index,
- Start(command)通知raft系统执行append command到replica log,start应当立即返回,发送ApplyMsg消息将新的commit log entry发送到applyChan
- Raft peers之间通信通过labrpc,基于GO原生rpc库,使用channel代替网络socket,raft.go中包括一些实例rpc,比如sendRequestVode,RequestVote
- labrpc中会对rpc做一些错误注入,延迟,乱序以及删除来代表网络异常,不允许修改labrpc库
to be continue…
Part 2A
Part 2B
Part 2C
reference
来源:CSDN
作者:WhateverYoung
链接:https://blog.csdn.net/yangfeng2014/article/details/104114862