介绍
原文参考Raft 实现笔记。
Raft目前是最著名的分布式共识性算法,被广泛的应用在 etcd、k8s 中。
在6.824中,Raft 作为第二个实验出现,是一系列实验的第一个,最终将实现一个容错的 KV 存储系统,类似于 Redis。
本文不对 Raft 做过多介绍,想要了解的可以阅读extended Raft paper论文。
6.824将 Raft 实现的实验分配了四个部分,分别是 2A、2B、2C 和 2D。每一部分将实现 Raft 中的一部分功能,最后实现出一个完整的 Raft 算法,如下:
- 2A:领导者选举,难度等级适中;
- 2B:日志,难度等级为难;
- 2C:持久化,难度等级为难;
- 2D:日志压缩,难度等级为难。
虽然将 2A 部分难度定为适中,但是笔者并不这样觉得,万事开头难,2A 作为第一部分,必须担起开拓的责任,给整个实验打下地基,相当于 Raft 骨架,而后面的部分则是丰富这个骨架。
由于每个实验部分都是基于上一个部分的,因此 2A 部分最为重要,这样才能推进后面的实验。
Part 2A
2A 部分需要实现领导者选举和心跳(AppendEntries RPC 请求,但无日志)。集群各节点选举出一个 leader,如果 leader 没有发生故障、网络未丢包等,则 leader 维持不变,否则将选举出新的 leader。
完成 2A 最重要的是多读多看论文上的 figure2,如下:
切记,一定要多看,遇到不会的点,一定要去看,因为上面就会有答案。
2A 可以分为两部分:leader 选举和心跳。其中选举涉及到超时选举和 RequestVote RPC 请求,而心跳涉及到定时心跳和 AppendEntries RPC 请求。
因此可讲 2A 拆分为如下四个小部分:
- 超时触发选举,即 ticker,当节点未收到心跳时触发新的选举。
- RequestVote,协调者向其它节点发送投票请求。
- 定时心跳,即 pingLoop,leader 独有,leader 向其它节点宣誓主权,不准它们发起选举。
- AppendEntries,leader 向其它节点发送心跳信息,目前不包含日志项。
结构体定义
Raft 节点由一个结构体定义,如下:
type Raft struct {
mu sync.Mutex
peers []*labrpc.ClientEnd
persister *Persister
me int // this peer's index into peers[]
dead int32 // set by Kill()
// peer
state PeerState
// 2A
currentTerm int // 当前任期
votedFor int // 给谁投过票
leaderId int // 集群 leader id
// apply channel
applyCh chan ApplyMsg
// time
electionTimer *time.Timer // 选举超时时间
pingTimer *time.Timer // 心跳超时时间
}
Raft 结构体本身就包含 mu、dead 等字段,实现 2A 我们就必须添加上需要的字段,如 state 字段用于表示当前节点的状态是 leader、follower 还是 candidate。新增的字段如下:
- state:节点状态
- currentTerm:当前节点任期
- votedFor:给谁投过票,-1 表示未投票
- leaderId:集群中的领导者 id
- applyCh:日志应用通道,暂时不用
- electionTimer:选举超时时间
- pingTimer:心跳超时时间,leader 专用
再来看看 RPC 请求中所使用的结构体:
type RequestVoteArgs struct {
Term int
CandidateId int
}
type RequestVoteReply struct {
Term int
VoteGranted bool
}
由于在 2A 中,RequestVote 只承担选举作用,不需要日志相关的字段,因此参数和回复都比较简单,这些字段的说明都可以在上面的插图中找到。 同样的,对于 AppendEntries 来说,如果只承担心跳的作用,那么字段也非常简单和明了,详细可参考插图。
type AppendEntriesArgs struct {
Term int
LeaderId int
}
type AppendEntriesReply struct {
Term int
Success bool
}
节点状态
由于节点存在 follower,candidate 和 leader 三个状态,可将这三个状态抽象为三个函数,方便在不同的状态中切换:
func (rf *Raft) becomeCandidate() {
rf.state = Candidate // 改变节点状态
rf.currentTerm++
rf.votedFor = rf.me
DPrintf("peer: %d become candidate\n", rf.me)
}
func (rf *Raft) becomeFollower(term int) {
rf.state = Follower
rf.currentTerm = term
rf.votedFor = -1
rf.electionTimer.Reset(getRandElectTimeout())
DPrintf("peer: %d become follower\n", rf.me)
}
func (rf *Raft) becomeLeader() {
rf.state = Leader
rf.leaderId = rf.me
// TODO 日志处理
// 心跳定时器
rf.pingTimer.Reset(heartbeatInterval)
go rf.pingLoop() // 开启心跳 loop
DPrintf("peer: %d become leader\n", rf.me)
}
- candidate 代表协调者,当切换为 candidate 时,除了将 state 设为 Candidate 之外,还需要增加当前任期,并且给自己投一票。
- follower 代表追随者,当切换为 follower 时,除了将 state 设为 follower 之外,还需设置当前任期为 leader 任期,votedFor 为 -1,没有给任何人投票,并且重置选举定时器。
- leader 代表领导者,当切换为 leader 时,将 state 设为 follower,重置心跳定时器,并开始心跳 loop,向其它节点发送心跳包。
此处逻辑,可以看出,心跳发送是 leader 独有的。
节点初始化
同 Make 函数新建一个 Raft 节点,并开始节点之间的选举,如下:
func Make(peers []*labrpc.ClientEnd, me int,
persister *Persister, applyCh chan ApplyMsg) *Raft {
rf := &Raft{}
rf.peers = peers
rf.persister = persister
rf.me = me // 节点 id
rf.applyCh = applyCh
// Your initialization code here (2A, 2B, 2C).
rf.currentTerm = 0
rf.votedFor = -1
rf.leaderId = -1
rf.state = Follower
rf.electionTimer = time.NewTimer(getRandElectTimeout())
rf.pingTimer = time.NewTimer(heartbeatInterval)
// initialize from state persisted before a crash
rf.readPersist(persister.ReadRaftState())
// start ticker goroutine to start elections
// 开始选举
DPrintf("peer: %d start ticker\n", rf.me)
go rf.ticker()
return rf
}
Make 函数主要对节点状态数据进行初始化,如当前任期(currentTerm)为 0,votedFor 和 leaderId 为 -1,表示空,并且节点的初始状态为 Follower,这点很重要。 随后通过 go 关键字新开 goroutine,运行 ticker 函数,该函数负责 leader 选举,后文再讲。最后返回当前节点实例。
两个 loop
2A 部分有两个 loop,即死循环,一个是 ticker,选举死循环,当一定时间内未收到心跳,则触发新的选举;另一个是 pingLoop,leader 独有,心跳死循环,当节点成为 leader 后,不断的向其它节点发送心跳 RPC。
ticker 每个节点都有,但是当节点成为 leader 后,ticker 函数其实是不工作的,如下:
func (rf *Raft) ticker() {
for rf.killed() == false {
<-rf.electionTimer.C
// 选举超时,重置定时器
rf.electionTimer.Reset(getRandElectTimeout())
rf.mu.Lock()
if rf.state == Leader {
rf.mu.Unlock()
continue
}
rf.becomeCandidate()
var votes int32 = 1 // 当前 1 票,自己投自己
me := rf.me
for peerId := range rf.peers {
if peerId == me { // 注意:此处比较仍然需要加锁
continue
}
// 新建 goroutine 发送请求
go func(peerId int) {
rf.mu.Lock()
currentTerm := rf.currentTerm
args := RequestVoteArgs{
Term: currentTerm,
CandidateId: rf.me,
}
reply := RequestVoteReply{}
DPrintf("peer: %d sendRequestVote to peer: %d\n", rf.me, peerId)
rf.mu.Unlock() // 解锁,发送 RPC 请求不能加锁
ok := rf.sendRequestVote(peerId, &args, &reply)
if !ok { // 请求失败,退出
DPrintf("peer: %d sendRequestVote fail\n", peerId)
return
}
// 注意:此处分段加锁,减少 RPC 请求等待的影响
rf.mu.Lock()
// 如果当前节点已经不是 candidate 了,则直接退出
if rf.state != Candidate {
rf.mu.Unlock() // 退出前,记得解锁
return
}
// 如果 reply 中的任期大于当前任期,则当前节点成为 follower
if reply.Term > currentTerm {
rf.becomeFollower(reply.Term)
}
if reply.VoteGranted { // 如果获得了选票
// 增加当前选票,并判断是否超过了半数
atomic.AddInt32(&votes, 1)
if int(votes) >= (len(rf.peers)+1)/2 {
DPrintf("peer: %d received %d votes, become leader\n", rf.me, votes)
rf.becomeLeader()
}
}
rf.mu.Unlock()
}(peerId)
}
rf.mu.Unlock() // 切记,必须成对出现
}
}
在代码第 7 行,如果当前节点状态变更为 leader 后,那么本次循环将会跳过,因为 leader 已经产生,则无需再次选举。 代码 12 行,如果一个节点成为 candidate 后,会给自己投上一票,因此初始票数是 1。
代码 19 行,新建 goroutine 来处理 RPC,避免锁长时间等待。
代码 37 行,这里很重要,如果当前节点已经获得了大多数投票,则已经成为了 leader,无需再处理后面的投票。
pingLoop 在节点成为 leader 后,才开始工作,如下:
func (rf *Raft) pingLoop() {
for rf.killed() == false {
// 成为 leader 后,必须立马发送一次心跳,而不是等超时后
rf.mu.Lock()
if rf.state != Leader { // 非 leader,则没有资格发送心跳,直接退出
rf.mu.Unlock()
return
}
// 向其它每个节点发送 AppendEntries RPC
leaderId := rf.leaderId
term := rf.currentTerm
for peerId := range rf.peers {
if peerId == rf.me {
continue
}
go func(peerId int) {
args := AppendEntriesArgs{
Term: term,
LeaderId: leaderId,
}
reply := AppendEntriesReply{}
ok := rf.sendAppendEntries(peerId, &args, &reply)
if !ok {
return
}
rf.mu.Lock()
// 别人的任期大
if reply.Term > term {
rf.becomeFollower(reply.Term)
}
// TODO
if reply.Success { // 证明日志同步成功
} else { // 日志同步失败,重新同步
}
rf.mu.Unlock() // 解锁
}(peerId)
}
rf.mu.Unlock()
<-rf.pingTimer.C
// 心跳超时,重置定时器
rf.pingTimer.Reset(heartbeatInterval)
}
}
pingLoop 的处理与 ticker 稍有不同,ticker 是定时器到点后才开始处理,而 pingLoop 是直接发出心跳,发完后才进行计时,这是因为成为 leader 后需要立马向其它节点发送心跳。 代码第 5 行,当节点状态不是 leader 时,不能再发送心跳,直接解锁退出。
代码第 29 行,发现别人的任期大时,直接成为 follower。
RPC
前面也提到了,2A 涉及两个 RPC 请求,即 RequestVote 和 AppendEntries,二者都是 RCP 处理器,用于处理其它节点发送的 RPC 请求。
func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
rf.mu.Lock()
defer rf.mu.Unlock()
reply.Term = rf.currentTerm
// 如果请求者的任期小于当前任期,那么直接返回 false
if args.Term < rf.currentTerm {
reply.VoteGranted = false
return
}
// 一旦发现了任期更大的请求者,则直接变成 follower
if args.Term > rf.currentTerm {
rf.becomeFollower(args.Term)
}
// 如果 votedFor 是空,或者是请求者 id,注意日志条件是 and,但是 2A 不需要
if rf.votedFor == -1 || rf.votedFor == args.CandidateId {
reply.VoteGranted = true
return
}
DPrintf("RequestVote, args: %v, reply: %v\n", args, reply)
}
RequestVote 用于处理其它节点发送的投票请求,给出回复(reply)。 代码第 6 行,如果请求者任期小于当前任期,那么拒绝投票,直接返回。
代码第 11 行,如果请求者任期大于当前任期,那么成为 follower,并进行后面的操作决定是否投票。
代码第 15 行,如果当前节点 votedFor 为 null 或者是 candidateId,那么投票。
注意:为什么 votedFor 为 null 或者是 candidateId 就投票了?原因在于如果 candidateId 是 null,那么代表当前节点未投票,状态为 follower,那么可直接投票;如果是 candidateId,那么代表已经给请求者投过票了,那么仍可以再投。 当然这只是暂时的,在后面的实验中会检查日志完整性再来决定是否投票。
func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
rf.mu.Lock()
defer rf.mu.Unlock()
reply.Term = rf.currentTerm
if args.Term < rf.currentTerm {
reply.Success = false
return
}
// 一旦发现了任期更大的请求者,则直接变成 follower
if args.Term > rf.currentTerm {
rf.becomeFollower(args.Term)
} else { // 任期相等,则刷新选举时间
rf.electionTimer.Reset(getRandElectTimeout())
}
rf.leaderId = args.LeaderId
// TODO 日志
reply.Success = true
DPrintf("AppendEntries, args: %v, reply: %v\n", args, reply)
}
AppendEntries 在 2A 部分,只负责 leader 向其它节点发送心跳,因此没有日志同步这些复杂逻辑,所以比较简单。 代码第 5 行,如果请求者的任期小于当前任期,那么心跳失败,直接返回,
代码第 10 行,如果发现请求者任期大于当前任期,那么成为 follower,否则任期相同,则重置选举时间。
代码第 15 行,设置 leaderId 并设置 reply 为心跳成功,注意此处暂时未涉及到日志同步问题,后面实验待补充。
小节
实验 2A 部分,是整个实验 2 的地基,工作主要如下:
- 三个状态,节点状态之间的转换函数。
- 两个 loop,ticker 和 pingLoop,pingLoop 是 leader 独有,ticker 在 leader 节点中也会跳过。
- 两个 RPC 请求和处理,分别用于投票和心跳。
运行测试:
go test -run 2A -race
......
Test (2A): initial election ...
... Passed -- 3.1 3 116 14386 0
Test (2A): election after network failure ...
... Passed -- 4.4 3 232 18654 0
Test (2A): multiple elections ...
... Passed -- 5.7 7 1236 103166 0
PASS
ok 6.824/raft 14.221s
完整代码见62fe2b6/src/raft/raft.go。