MIT 6.824 Lab2A Raft之領導者選舉
實驗準備
- 實驗程式碼:
git://g.csail.mit.edu/6.824-golabs-2021/src/raft
- 如何測試:
go test -run 2A -race
- 相關論文:Raft Extended Section 5.2
- 實驗指導:6.824 Lab 2: Raft (mit.edu)
實驗目標
實現Raft演算法中Leader Election(RequestVote RPC
)和Heartbeats(AppendEntries RPC
)。確保只有一個Leader被選中,且若無錯誤該Leader會一直唯一存在,當該Leader下線或發生其他錯誤導致發出的資料無法被成功接收,則會產生新的Leader來替代。
一些提示
- 參考論文的Figure 2實現相應的結構體和函式。
- 通過
Make()
建立一個後臺goroutine,用於一段時間(election timeout
)沒收到其他節點的訊息時,通過RequestVote RPC
發起選舉。 - 儘量保證不同節點的
election timeout
不會讓他們在同一時間發起選舉,避免所有節點只為自己投票,可以通過設定隨機的election timeout
來實現。 - 測試要求Hearbeats頻率每秒不高於10次。
- 測試要求New Leader在Old Leader下線後5秒內出現,考慮到一次換屆多輪選舉的情況(提示3的情況),election timeout應當足夠短。
- 論文中對於
election timeout
設定在150ms - 300ms之間,前提是Heartbeat頻率遠遠超過150ms一次。由於提示4的限制,實驗中election timeout
應該更大。 - 推薦使用
time.Sleep()
而不是time.Timer
或time.Ticker
來實現定期或延遲行為。 - 不要忘記實現
GetState()
。 - 使用
rf.killed()
判斷測試是否關閉了該節點。 - RPC相關結構欄位都應使用大寫字母開頭,這和Go語言的語法有關。
Raft簡介
日誌被理解為來自客戶端的請求序列,在一個叢集中,有唯一的一個節點用於接收客戶端請求,稱為"Leader Node",為了保證資料的安全性,"Leader Node"的日誌應該複製給若干個節點用於備份,稱為"Follower Node"。"Follower Node"的日誌需要和"Leader Node"保持一致,Raft就是一種為了管理日誌複製而產生的一致性演算法。
領導者選舉
Raft叢集通常有奇數個節點,設為N,叢集允許N/2個節點失效,在正常情況下,叢集有1個Leader和N-1個Follower組成,當Leader失效時,會產生除了Leader和Follower外的第三種身份:Candidate。
Follower在election timeout後,身份轉換為Candidate,在獲取(N-1)/2個其他節點的選票後,身份轉換為Leader。
主要結構
首先是Raft結構,具體的屬性在論文的Figure 2中已經給出,此外還需要額外的兩個屬性。
role
:當前節點的身份。lastRecv
:上一次收到其他節點訊息的時間。
被註釋的欄位在Part A中可以忽略,且在本小節中,為了方便理解,請先忽略currentTerm欄位。
type Raft struct {
mu sync.Mutex
peers []*labrpc.ClientEnd // 叢集中所有的節點
// persister *Persister
me int // 當前節點在peers中的索引
dead int32 // 標記當前節點是否存活
lastRecv time.Time
role Role
currentTerm int
votedFor int
// log []LogEntry
// commitIndex int
// lastApplied int
// nextIndex []int
// matchIndex []int
}
超時選舉
如果當前節點不是Leader,且超過election timeout未收到其他節點的訊息,則發起選舉。
此處設定election timeout在150ms - 300ms之間。
func (rf *Raft) electionTimeout() time.Duration {
return time.Duration(150 + rand.Int31n(150)) * time.Millisecond
}
發起選舉後,身份轉換為Candidate,並通過RequestVote RPC獲取其餘節點的選票。在獲取(N-1)/2個其他節點的選票後,身份轉換為Leader。成為Leader後,需要立即向其餘節點發送心跳,宣告自己的存在。
程式碼中的註釋即論文Figure 2中的邏輯。
func (rf *Raft) elect() {
for !rf.killed() {
if rf.role == Leader || time.Since(rf.lastRecv) < rf.electionTimeout() {
return
}
/* On conversion to candidate, start election. */
rf.role = Candidate
/* Vote for self. */
rf.voteFor = rf.me
voteCount := 1
/* Reset election timer. */
rf.lastRecv = time.Now()
/* Send RequestVote RPCs to all other servers. */
for i, peer := range rf.peers {
if i == rf.me {
continue
}
reply := RequestVoteReply{}
peer.Call("Raft.RequestVote", &RequestVoteArgs{
CandidateId: rf.me,
}, &reply)
if reply.VoteGranted {
voteCount++
}
}
/* If votes received from majority of servers: become leader. */
if voteCount > len(rf.peers)/2 {
rf.role = Leader
rf.votedFor = -1
rf.heartbeat()
}
time.Sleep(10 * time.Millisecond)
}
}
Explain 1:如何理解
rf.role == Leader
?Follower和Candidate都可以參加選舉,Candidate可以參加的原因在於,選出一個Leader可能不止一輪選舉,假設非常不幸,所有節點都在同一時刻發起選舉,他們都把自己的選票投給了自己,那麼本輪選舉將無法選出Leader。
這時候將開啟第二輪選舉,因此不能限制只有Follower可以參與選舉。
傳送心跳
不攜帶日誌的日誌複製即心跳,Leader通過心跳重新整理其餘節點的election timeout。Hint 4限制了心跳頻率在每秒10次,因此這裡讓心跳一次後休眠100ms。
func (rf *Raft) heartbeatInterval() {
return 100 * time.Millisecond
}
func (rf *Raft) heartbeat() {
for !rf.killed() {
if rf.role != Leader {
return
}
for i, peer := range rf.peers {
if i == rf.me {
continue
}
reply := AppendEntriesReply{}
peer.Call("Raft.AppendEntries", &AppendEntriesArgs{}, &reply)
}
}
time.Sleep(rf.heartbeatInterval())
}
RPC全稱Remote Procedure Call,即遠端過程呼叫,通俗的講就是呼叫其他節點上的函式。例如
peer.Call("Raft.AppendEntries", &args, &reply)
,就是呼叫了對應節點的AppendEntries函式,引數是args,返回值儲存在reply中。heartbeat是主動,AppendEntries是被動。elect是主動,RequestVote是被動。
RequestVote
Candidate通過遠端呼叫RequestVote,向其他節點索要選票。論文的Figure 2中也給出了RequestVoteArgs和RequestVoteReply的定義。在Part A中不需要關注LastLogIndex和LastLogTerm兩個欄位。同樣的,為了方便理解,請先忽略Term的概念。
type RequestVoteArgs struct {
Term int
CandidateId int
// LastLogIndex int
// LastLogTerm int
}
type RequestVoteReply struct {
Term int
VoteGranted bool
}
在一輪投票中,每個節點只有一張選票,Candidate會投給自己,而Follower會投給第一個向他索要選票的Candidate。
程式碼中的註釋即論文Figure 2中的邏輯。
RequestVote中需要重新整理election timeout,一次換屆多輪選舉的情況。
func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
reply.VoteGranted = false
rf.lastRecv = time.Now()
/* If votedFor is null or candidateId, grant vote. */
if rf.votedFor == -1 || rf.votedFor == args.CandidateId {
rf.votedFor = args.CandidateId
reply.VoteGranted = true
}
}
Explain 2:如何理解
rf.votedFor == args.CandidateId
?邏輯上來說這個條件是沒必要的,去掉這個條件依舊能通過所有測試。我猜測這個條件是為了防止回覆的網路包丟失,傳送方重傳,因此需要接收方再次投出選票。
AppendEntries
Leader通過AppendEntries遠端呼叫,重新整理其他節點的election timeout,保證在自己存活期間,不會有其他節點發起選舉。論文的Figure 2中也給出了AppendEntriesArgs和AppendEntriesReply的定義。
被註釋的欄位在Part A中不需要關注,同樣的,為了方便理解,請先忽略Term欄位。
type AppendEntriesArgs struct {
Term int
// LeaderId int
// PrevLogIndex int
// PrevLogTerm int
// Entries []LogEntry
// LeaderCommit int
}
type AppendEntriesReply struct {
Term int
Success bool
}
Hint 2中,收到其他節點的訊息,就重新整理election timeout。因此RequestVote和AppendEntries中都需要更新rf.lastRecv
。
func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
reply.Success = true
rf.lastRecv = time.Now()
}
唯一的Leader
競選成功的條件為voteCount > len(rf.peers)/2
且每個節點只有一張選票,這保證了最多隻有一個節點達到競選成功條件,保證了Leader的唯一性。
現在考慮唯一的Leader因為某些網路問題導致Leader的心跳無法發出,那麼剩餘的N-1個節點將會選出新的Leader,剩餘的N-1個節點可以繼續提供正常的服務。那麼如果Old Leader的網路問題因為某些原因恢復了,整個叢集將同時出現兩個Leader,這樣整個叢集的日誌的一致性就不能保證。
引入任期
Term(任期)解決了可能出現多個Leader的問題。
Term是一個單調遞增的整型值,所有節點的Term應保持一致,Term的自增只發生在從Follower到Candidate的轉換中,即只有選舉的時候,Term才會自增1。
再次考慮上面的問題,當Old Leader恢復後,由於剩餘的N-1個節點又經歷了至少一次選舉,因此剩餘的N-1個節點包括New Leader的Term都大於Old Leader的Term,Raft演算法規定,任意節點感知到Term更高的節點,將轉換為Follower;任意節點感知到Term更低的節點,將忽略對方的訊息,並告知對方自己的Term。
這樣,當Old Leader收到New Leader的更高Term的心跳時,會將自己的身份轉換為Follower,保證了Leader的唯一性。
什麼是感知到其他節點?
AppendEntries或RequestVote兩個RPC的請求或回覆中都包含Term,Old Leader感知到New Leader有兩種途徑。
- 收到New Leader的心跳,發現AppendEntriesArgs.Term更高。
- 向New Leader或其餘節點發送心跳,發現AppendEntriesReply.Term更高。
實驗總結
上面的圖片就是本文多次提及的論文的Figure 2,我用綠色的線框選了Part A需要實現的部分。
引入Term後的程式碼本文就不再給出了,參照圖片補充剩餘的實現就可以,需要注意的是,本文為了簡潔程式碼,省略了資料同步問題,-race
可以暴露出你程式碼的data race問題,記得為臨界資源上鎖。
最後,為了證明我不是在亂寫,附上我的測試結果。