前言
不了解raft论文可以去看我的博客:万字解析raft论文
背景
一直很好奇分布式是怎么个事,去年尝试了一波6.824劝退了,理由是raft状态太过复杂,涂涂改改自己都受不了自己的代码。今年重新挑战,这次我一开始就考虑用事件驱动的方式实现,本文提供了在go中实现事件驱动风格代码的思路。
这里推荐一个UP主的视频,讲的很好: 解读共识算法Raft(6)总结与拓展_哔哩哔哩_bilibili
此外推荐试试Raft Scope, 这是个很好的raft模拟器: Raft Scope
事件驱动简要模型
简而言之事件驱动就是一个大的for循环不断Poll(拉取)事件, 每获取一个事件就执行相关逻辑,更新状态信息,完成状态转移。简单的事件驱动是这样的:
poller := NewPoller()
state := State{}
for {
ev := poller.PollEvent()
switch ev {
case "...":
// do something
case "...":
// do something
}
}
Raft的状态机思路
下面是我的Raft基本骨架, 看个思路就够了:
type Raft struct {
// 对等端, 可以向它们同步信息
peers []*labrpc.ClientEnd // RPC end points of all peers
// 相当于一个wal, 能持久化数据, 能保证要么成功要么失败, 不会发生半写
persister *Persister
// 自己在peer的索引
me int
// 自己用channel做退出
reqDead chan struct{}
reqDeadOK chan struct{}
// 异步获取当前状态, 我写的代码是状态机, 最好不用锁, 不侵入状态机的状态, 通过请求打入状态机器循环
reqGetState chan struct{}
getStateChan chan GetStateInfo
// 发送命令的Channel, 以及反馈结果的管道
sendCmdChan chan SendCmdChanInfo
// 外部消息, 进入总线
messagePipeLine chan Message
// apply相关的设施
applyCh chan ApplyMsg
// 只有一个timer
timer <-chan time.Time
// 状态
state RaftState
}
func (rf *Raft) GetState() (int, bool) {
rf.reqGetState <- struct{}{}
s := <-rf.getStateChan
return s.Term, s.Isleader
}
func (rf *Raft) Kill() {
rf.reqDead <- struct{}{}
<-rf.reqDeadOK
}
type Empty struct{}
type RequestVoteRequest struct {
Term int
CandidateID int
LastLogIndex int
LastLogTerm int
}
type RequestVoteReply struct {
Term int
VoteGranted bool
}
// 心跳: AppendEntries RPCs that carry no log entries is heartbeat
type AppendEntriesRequest struct {
Term int
LeaderID int
PrevLogIndex int
PreLogTerm int
// may send more than one for efficiency
// empty for heartbeat
Entries [][]byte
LeaderCommit int
}
type AppendEntriesReply struct {
ID int
Term int
PreIndex int
Success bool
NLogsInRequest int
// 实现简化版本的快速回退
ConflictIndex int
}
// 外部调用, 会进入这里来
func (rf *Raft) RequestVoteReq(args *RequestVoteRequest, reply *Empty) {
rf.messagePipeLine <- Message{
Term: args.Term,
Msg: args,
}
}
func (rf *Raft) RequestVoteReply(args *RequestVoteReply, reply *Empty) {
rf.messagePipeLine <- Message{
Term: args.Term,
Msg: args,
}
}
func (rf *Raft) AppendEntries(args *AppendEntriesRequest, reply *Empty) {
rf.messagePipeLine <- Message{
Term: args.Term,
Msg: args,
}
}
func (rf *Raft) AppendEntriesReply(args *AppendEntriesReply, reply *Empty) {
rf.messagePipeLine <- Message{
Term: args.Term,
Msg: args,
}
}
func Make(peers []*labrpc.ClientEnd, me int,
persister *Persister, applyCh chan ApplyMsg) *Raft {
rf := &Raft{}
rf.peers = peers
rf.persister = persister
rf.me = me
rf.reqDead = make(chan struct{})
rf.reqGetState = make(chan struct{})
rf.getStateChan = make(chan GetStateInfo)
rf.messagePipeLine = make(chan Message)
rf.sendCmdChan = make(chan SendCmdChanInfo)
rf.applyCh = applyCh
rf.reqDeadOK = make(chan struct{})
go StateMachine(rf)
return rf
}
func (rf *Raft) Start(command interface{}) (idx int, term int, isLeader bool) {
c := make(chan SendCmdRespInfo)
rf.sendCmdChan <- SendCmdChanInfo{
Command: command,
Resp: c,
}
s := <-c
return s.Index, s.Term, s.IsLeader
}
func StateMachine(rf *Raft) {
// 读入磁盘的persist信息放入rf.State相关变量里面
rf.readPersist(rf.persister.ReadRaftState())
rf.timer = timer.After(time.Millisecond*50)
for {
select {
case <-rf.reqDead:
rf.Debug("dead")
rf.reqDeadOK <- struct{}{}
return
case <-rf.reqGetState:
rf.Debug("rf.reqGetState")
go func() {
rf.getStateChan <- GetStateInfo{Term: rf.state.PersistInfo.Term, Isleader: rf.state.State == "leader"}
}()
// 选举超时timer
case <-rf.timer:
switch rf.state.State {
// 如果是follower超时, 那么进入candidate状态, 并且为自己加一票
case "follower":
// 省略代码
// 说明candidate超时了, 加term继续
case "candidate":
// 省略代码
// leader超时是定时器超时, 只需要发送心跳维统治即可
case "leader":
// 省略代码
}
// 统一的外部事件总线, 从messagePipe进入
case command := <-rf.sendCmdChan:
switch rf.state.State {
case "follower", "candidate":
go func(t int) {
command.Resp <- SendCmdRespInfo{
Term: t,
Index: -1,
IsLeader: false,
}
}(rf.state.Term)
case "leader":
// 省略代码
}
case input := <-rf.messagePipeLine:
if rf.state.PersistInfo.Term < input.Term {
// 相关逻辑
}
switch val := input.Msg.(type) {
case *RequestVoteReply:
// 省略代码
case *RequestVoteRequest:
// 省略代码
case *AppendEntriesRequest:
// 省略代码
case *AppendEntriesReply:
// 省略代码
}
}
}
}
传统的做法估计就是做一个ticker当作定时器周期地time.Sleep加锁后执行对应的动作, 但是在我的事件驱动的实现中用channel实现定时器才是正确的做法。
此外, 外部的事件通过channel打入事件循环, 同样事件循环用channel将响应信息递交给请求者。比如外界调用Start尝试开始提交一个日志, 这个信息会通过sendCmdChan打入事件循环, 然后事件循环会通过对应的channel传递响应。
为什么事件驱动?
一句话, 事件驱动控制力超级强, 传统用多个goroutine担任多个角色, 例如ticker和applier看起来很美好,实际上中间加锁共享状态的代码很难写。而是事件驱动的方法很容易推进,只需要分析好状态转移就能很好地coding。更爽的是不用加锁了,岂不美哉?
为什么我把论文中的一个RPC分为了两个?
可以看到,我定义了一个Empty结构体, 且把论文里提到的RPC分成了两段RPC。例如选举:
type Empty struct{}
type RequestVoteRequest struct {
Term int
CandidateID int
LastLogIndex int
LastLogTerm int
}
type RequestVoteReply struct {
Term int
VoteGranted bool
}
理由是受Raft Scope图景的影响, 它的请求和响应是一个个独立的小球,感觉很适合这样做? 此外把它们分来能够很好地适配事件驱动的写法,并能够很简单地实现Pipeline优化, 不用等待RPC响应, 加大并发度。
所谓pipeline优化, 就是不用等待对端的响应, 能够并发地发送rpc, 如果采用这种"分割"的思路就很容易实现了。
我们知道写界面或者reactor框架中eventloop一般是不能包含长时间的阻塞的代码的, 这里也是同理, 比如在follower超时后状态变为candidate, 此时可以开多个goroutine并发向所有服务器发送`RequestVoteRequest`, 不用等待响应。后续的响应信息将由另外一个RPC打入事件循环, 再根据信息转移状态就行了。
我认为分隔它们是极佳的优化策略,且和raft scope的图景很像不是么? 后续甚至可以用udp来做优化, 少了tcp的握手流程想必能获得更大的性能提升。