翻译自Eli Bendersky的系列博客,已获得原作者授权。
本文是系列文章中的第一部分,本系列文章旨在介绍Raft分布式一致性协议及其Go语言实现。文章的完整列表如下:
在这一部分,我们会大幅强化Raft的实现,做到能够实际处理客户端提交的指令,并在Raft集群中复制它们。代码结构与第一部分相同,会有一些新的结构体和函数定义,对旧代码也会有一些改动——我会对这些做简短的解释。
本部分的所有代码都在这个目录。
客户端交互
我们在序言中对客户端交互进行了简短的讨论,我强烈建议您返回去重新读一下对应章节。接下来,我们不会关注客户端如何找到领导者,相反,我们讨论的是当他已经找到领导者时,会发生什么。
首先说明一下术语。如前所述,客户端使用Raft协议来复制一系列的指令,这些指令可以视为通用状态机的输入。就我们的Raft实现而言,这些指令可以是完全任意的,我们使用Go中的空指针类型(interface{})来进行表示。在Raft的一致性历程中,一条指令会经历以下步骤:
- 首先,指令被客户端**呈递(submit)**给领导者,在一个Raft集群中,一条指令通常只会呈递给一个服务器。
- 领导者将指令**复制(replicates)**给其追随者。
- 最后,一旦领导者确认日志已经被充分复制(也就是说,集群中的多数服务器已经确认指令保存在其日志中[1]),指令会被提交(commit),并且将新提交通知给所有的客户端。
注意在呈递和提交指令之间的不对称性——在检查我们即将讨论的实现策略时,牢记这一点很重要。一条指令会呈递给单个Raft服务器,但是一段时候后多个服务器(特别是已连接/活动的同伴服务器)都会提交这个指令并通知各自的客户端。
回顾一下序言中的示意图:
状态机代表使用Raft协议进行复制的任意服务,如键值数据库。已提交的指令会改变服务的状态(如:在数据库中新增一个键值对)。
当我们在Raft ConsensusModule
上下文中讨论客户端时,通常指的是(上面说的)服务,因为这也是提交所通知的对象。换句话说,上图中的一致性模块指向服务状态机的黑色箭头就是这里所说的通知。
客户端还有另外的含义,就是该服务的客户端(比如键值数据库的用户)。服务与其客户端之间的交互是服务自身的业务,在本文中,我们只关注Raft与服务之间的交互。
译者注:作者在这里对于指令的阶段分别使用了submit和commit进行描述,在通常的翻译中,这两个词都表示提交。我个人理解,
submit
表示提交时,倾向于对象A向对象B提交某些内容,而commit
表示提交时,倾向于本地记录的确认。为了避免歧义,这里将submit
翻译为呈递
,表示服务向Raft一致性模块发送了指令;commit
仍译为提交
,表示Raft一致性模块确认本地的指令记录。在这里特别解释一下,如有更好的建议,可以联系我进行修改。
实现:提交通道
在我们的实现中,在新建ConsensusModule
时会接收一个commit channel
作为参数——CM可以使用该通道向调用方发送已提交的指令:commitChan chan<- CommitEntry
。
CommitEntry
定义如下:
/*
CommitEntry就是Raft向提交通道发送的数据。每一条提交的条目都会通知客户端,
表明指令已满足一致性,可以应用到客户端的状态机上。
*/
type CommitEntry struct {
// Command 是被提交的客户端指令
Command interface{}
// Index 是被提交的客户端指令对应的日志索引
Index int
// Term 是被提交的客户端指令对应的任期
Term int
}
使用channel是一种设计选择,但是这不是唯一的解决方法。我们也可以改用回调;在创建ConsensusModule
时调用方会注册一个回调函数,一旦我们需要提交指令,就可以执行这个回调函数。
我们很快会看到通过channel发送日志条目的代码,在此之前,我们必须讨论Raft服务器如何复制命令并决定是否提交。
Raft日志
在这个系列中,Raft日志已经被提及很多次了,但是我们还没有对此进行过多的介绍。日志就是要应用于状态机的指令的线性序列,如果需要的话,日志要能够从某个起始状态开始”重放“状态机。正常运行时,所有Raft同伴的日志的相同的。当领导者收到新指令时,会先加入自己的日志中,然后将其复制给所有的追随者。追随者将命令放在日志中并向领导者确认,后者会记录已安全复制到集群中多数服务器的最新日志索引。
Raft论文中有一些日志的示意图,类似:
每个方格是一条日志条目。方格顶部的数字是该 条目添加到日志时的任期(也就是第一部分所说的任期);方格底部是日志条目包含的键-值指令。每个日志条目都有一个线性索引[2],方格的颜色用另一种方式体现了任期。
如果上面的日志应用到一个空的键-值存储中,最终的结果就是x = 4, y = 7
。
在我们的实现中,日志条目的结构如下:
type LogEntry struct {
Command interface{}
Term int
}
每个ConsensusModule
的日志属性就是数组log []LogEntry
。客户端通常不在乎任期,但是,任期对于Raft的正确性至关重要,因此在阅读代码时请务必牢记。
呈递新指令
我们首先看一下新加的方法Submit
,客户端通过该方法呈递新指令:
/*
Submit方法会向CM呈递一条新的指令。这个函数是非阻塞的;
客户端读取构造函数中传入的commit channel,以获得新提交条目的通知。
如果当前CM是领导者返回true——表示指令被接受了。
如果返回false,客户端会寻找新的服务器呈递该指令。
*/
func (cm *ConsensusModule) Submit(command interface{}) bool {
cm.mu.Lock()
defer cm.mu.Unlock()
cm.dlog("Submit received by %v: %v", cm.state, command)
if cm.state == Leader {
cm.log = append(cm.log, LogEntry{Command: command, Term: cm.currentTerm})
cm.dlog("... log=%v", cm.log)
return true
}
return false
}
逻辑很简单,如果CM是领导者,则将新指令添加到日志中,并返回true
。否则,忽略请求并返回false
。
Q:Submit
方法返true
是否足以表明客户端已经将指令呈递到领导者?
A:很遗憾并不是。在极少数情况下,领导者可能会与其它Raft服务器之间出现网络分区,而其它服务器很快会重新选举新的领导者,但是客户端可能仍然连接在旧的领导者。客户端对于其呈递的指令应该等待一段合理的时间,检查该指令是否出现在commit channel中;如果没有的话,就表明它连接的是错误的领导者,应该连接其它领导者进行重试。
复制日志条目
我们刚刚看到呈递给领导者的指令被追加到了日志的末尾,但是这条新指令如何传给追随者呢?领导者的执行步骤在Raft论文的Figure 2
中的服务器规则
部分有详细描述(可以返回第一部分的附言查看详细内容)。我们在leaderSendHeartbeats
方法中完成该逻辑,这是一个新方法[3]:
func (cm *ConsensusModule) leaderSendHeartbeats() {
cm.mu.Lock()
savedCurrentTerm := cm.currentTerm
cm.mu.Unlock()
for _, peerId := range cm.peerIds {
go func(peerId int) {
cm.mu.Lock()
ni := cm.nextIndex[peerId]
prevLogIndex := ni - 1
prevLogTerm := -1
if prevLogIndex >= 0 {
prevLogTerm = cm.log[prevLogIndex].Term
}
entries := cm.log[ni:]
args := AppendEntriesArgs{
Term: savedCurrentTerm,
LeaderId: cm.id,
PrevLogIndex: prevLogIndex,
PrevLogTerm: prevLogTerm,
Entries: entries,
LeaderCommit: cm.commitIndex,
}
cm.mu.Unlock()
cm.dlog("sending AppendEntries to %v: ni=%d, args=%+v", peerId, ni, args)
var reply AppendEntriesReply
if err := cm.server.Call(peerId, "ConsensusModule.AppendEntries", args, &reply); err == nil {
cm.mu.Lock()
defer cm.mu.Unlock()
if reply.Term > savedCurrentTerm {
cm.dlog("term out of date in heartbeat reply")
cm.becomeFollower(reply.Term)
return
}
if cm.state == Leader && savedCurrentTerm == reply.Term {
if reply.Success {
cm.nextIndex[peerId] = ni + len(entries)
cm.matchIndex[peerId] = cm.nextIndex[peerId] - 1
cm.dlog("AppendEntries reply from %d success: nextIndex := %v, matchIndex := %v", peerId, cm.nextIndex, cm.matchIndex)
savedCommitIndex := cm.commitIndex
for i := cm.commitIndex + 1; i < len(cm.log); i++ {
if cm.log[i].Term == cm.currentTerm {
matchCount := 1
for _, peerId := range cm.peerIds {
if cm.matchIndex[peerId] >= i {
matchCount++
}
}
if matchCount*2 > len(cm.peerIds)+1 {
cm.commitIndex = i
}
}
}
if cm.commitIndex != savedCommitIndex {
cm.dlog("leader sets commitIndex := %d", cm.commitIndex)
cm.newCommitReadyChan <- struct{}{}
}
} else {
cm.nextIndex[peerId] = ni - 1
cm.dlog("AppendEntries reply from %d !success: nextIndex := %d", peerId, ni-1)
}
}
}
}(peerId)
}
}
这确实比第一部分的逻辑复杂得多,但它实际上也就是按照论文的图2所写的。关于这段代码的几点说明:
- AE请求参数的字段已经全部补齐,可以查阅论文中的图2了解各自的含义
- AE的应答中包含一个
success
字段,用于告知领导者,其请求的追随者是否得到了匹配的prevLogIndex
和prevLogTerm
。根据该字段,领导者会更新追随者对应的nextIndex
。 commitIndex
是根据已复制某天日志条目的追随者数量来更新的,如果某条索引已复制到集群中的多数服务器,commitIndex
就会修改为该索引值。
代码的这一部分对于前面讨论发客户端交互非常重要:
if cm.commitIndex != savedCommitIndex {
cm.dlog("leader sets commitIndex := %d", cm.commitIndex)
cm.newCommitReadyChan <- struct{}{}
}
newCommitReadyChan
是CM内部使用的一个通道,用来通知在commit channel
上有新条目可以发生给客户端。它是通过下面的方法起作用的,CM启动时会在goroutine运行该方法:
/*
commitChanSender负责在cm.commitChan上发送已提交的日志条目。
它会监听newCommitReadyChan的通知并检查哪些条目可以发送(给客户端)。
该方法应该在单独的后台goroutine中运行;cm.commitChan可能会有缓冲来限制客户端消费已提交指令的速度。
当newCommitReadyChan关闭时方法结束。
*/
func (cm *ConsensusModule) commitChanSender() {
for range cm.newCommitReadyChan {
// 查找需要执行哪些指令
cm.mu.Lock()
savedTerm := cm.currentTerm
savedLastApplied := cm.lastApplied
var entries []LogEntry
if cm.commitIndex > cm.lastApplied {
entries = cm.log[cm.lastApplied+1 : cm.commitIndex+1]
cm.lastApplied = cm.commitIndex
}
cm.mu.Unlock()
cm.dlog("commitChanSender entries=%v, savedLastApplied=%d", entries, savedLastApplied)
for i, entry := range entries {
cm.commitChan <- CommitEntry{
Command: entry.Command,
Index: savedLastApplied + i + 1,
Term: savedTerm,
}
}
}
cm.dlog("commitChanSender done")
}
该方法会更新lastApplied
状态变量,以了解哪些条目已发送到客户端,并保证只发送新的条目。
更新追随者的日志
我们已经讨论过领导者如何处理新日志条目,现在来查看一下追随者的代码,尤其是其中的AppendEntries
方法。
func (cm *ConsensusModule) AppendEntries(args AppendEntriesArgs, reply *AppendEntriesReply) error {
cm.mu.Lock()
defer cm.mu.Unlock()
if cm.state == Dead {
return nil
}
cm.dlog("AppendEntries: %+v", args)
// 请求中的任期大于本地任期,转换为追随者状态
if args.Term > cm.currentTerm {
cm.dlog("... term out of date in AppendEntries")
cm.becomeFollower(args.Term)
}
reply.Success = false
if args.Term == cm.currentTerm {
// 如果当前状态不是追随者,则变为追随者
if cm.state != Follower {
cm.becomeFollower(args.Term)
}
cm.electionResetEvent = time.Now()
// 以下代码为第二部分新增
// 检查本地的日志在索引PrevLogIndex处是否包含任期与PrevLogTerm匹配的记录?
// 注意在PrevLogIndex=-1的极端情况下,这里应该是true
if args.PrevLogIndex == -1 ||
(args.PrevLogIndex < len(cm.log) && args.PrevLogTerm == cm.log[args.PrevLogIndex].Term) {
reply.Success = true
// 找到插入点 —— 索引从PrevLogIndex+1开始的本地日志与RPC发送的新条目间出现任期不匹配的位置。
logInsertIndex := args.PrevLogIndex + 1
newEntriesIndex := 0
for {
if logInsertIndex >= len(cm.log) || newEntriesIndex >= len(args.Entries) {
break
}
if cm.log[logInsertIndex].Term != args.Entries[newEntriesIndex].Term {
break
}
logInsertIndex++
newEntriesIndex++
}
/*
循环结束时:
- logInsertIndex指向本地日志结尾,或者是与领导者发送日志间存在任期冲突的索引位置
- newEntriesIndex指向请求条目的结尾,或者是与本地日志存在任期冲突的索引位置
*/
if newEntriesIndex < len(args.Entries) {
cm.dlog("... inserting entries %v from index %d", args.Entries[newEntriesIndex:], logInsertIndex)
cm.log = append(cm.log[:logInsertIndex], args.Entries[newEntriesIndex:]...)
cm.dlog("... log is now: %v", cm.log)
}
// Set commit index.
if args.LeaderCommit > cm.commitIndex {
cm.commitIndex = intMin(args.LeaderCommit, len(cm.log)-1)
cm.dlog("... setting commitIndex=%d", cm.commitIndex)
cm.newCommitReadyChan <- struct{}{}
}
}
}
reply.Term = cm.currentTerm
cm.dlog("AppendEntries reply: %+v", *reply)
return nil
}
这段代码严格遵循了论文图2中的算法(AppendEntries的Received implementation
部分),而且也给出了很好的注释。
注意代码当中,如果领导者的LeaderCommit
大于自身的cm.commitIndex
时,会在cm.newCommitReadyChan
通道发送数据。这就是追随者从领导者处知道新增的日志条目可以提交的时间。
当领导者在AE请求中发送新的日志条目时,会出现以下情况:
- 追随者将新条目追加到本地日志中,并向领导者回复
success=true
- 结果就是,领导者为该追随者更新其对应的
matchIndex
。当有足够的追随者的matchIndex
指向下一索引时,领导者会更新commitIndex
并在下一次AE请求中发给所有追随者(在leaderCommit
字段中) - 当追随者收到新的
leaderCommit
,它们会意识到有新的日志条目被提交了,它们就会通过commit channel把这些指令发给其客户端。
**Q:**提交一条新指令需要多少次RPC往返?
**A:**2次。第一次请求中,领导者发送下一条日志条目给追随者,追随者进行确认。领导者处理AE应答时,可能会根据返回结果更新commit index。第二次RPC请求中,领导者会发送更新后的commit index给追随者,之后追随者会将这些日记条目标记为已提交并通过commit channel将它们发送给客户端。作为练习,请回到上面的示例代码中,找到这些步骤对应的片段。
选举安全
到目前为止,我们已经研究了为支持日志复制而添加的新代码。但是,日志也会对Raft选举产生影响。Raft论文中在5.4.1小节(选举约束)中进行了描述。除非候选人的日志与集群中多数同伴服务器一样新,否则Raft的选举程序会阻止其胜选[4]。
因此,RV请求中包含lastLogIndex
和lastLogTerm
字段。当候选人发送RV请求时,它会填入其最新日志条目的相关信息。追随者会与本身的属性进行对比,并决定该候选人是否有资格当选。
下面是最新的startElection
代码:
func (cm *ConsensusModule) startElection() {
cm.state = Candidate
cm.currentTerm += 1
savedCurrentTerm := cm.currentTerm
cm.electionResetEvent = time.Now()
cm.votedFor = cm.id
cm.dlog("becomes Candidate (currentTerm=%d); log=%v", savedCurrentTerm, cm.log)
var votesReceived int32 = 1
// Send RequestVote RPCs to all other servers concurrently.
for _, peerId := range cm.peerIds {
go func(peerId int) {
/*---------以下代码为新增--------*/
cm.mu.Lock()
savedLastLogIndex, savedLastLogTerm := cm.lastLogIndexAndTerm()
cm.mu.Unlock()
args := RequestVoteArgs{
Term: savedCurrentTerm,
CandidateId: cm.id,
LastLogIndex: savedLastLogIndex,
LastLogTerm: savedLastLogTerm,
}
/*---------以上代码为新增--------*/
cm.dlog("sending RequestVote to %d: %+v", peerId, args)
var reply RequestVoteReply
if err := cm.server.Call(peerId, "ConsensusModule.RequestVote", args, &reply); err == nil {
cm.mu.Lock()
defer cm.mu.Unlock()
cm.dlog("received RequestVoteReply %+v", reply)
if cm.state != Candidate {
cm.dlog("while waiting for reply, state = %v", cm.state)
return
}
if reply.Term > savedCurrentTerm {
cm.dlog("term out of date in RequestVoteReply")
cm.becomeFollower(reply.Term)
return
} else if reply.Term == savedCurrentTerm {
if reply.VoteGranted {
votes := int(atomic.AddInt32(&votesReceived, 1))
if votes*2 > len(cm.peerIds)+1 {
// Won the election!
cm.dlog("wins election with %d votes", votes)
cm.startLeader()
return
}
}
}
}
}(peerId)
}
// Run another election timer, in case this election is not successful.
go cm.runElectionTimer()
}
其中是lastLogIndexAndTerm
是一个新的辅助方法:
// lastLogIndexAndTerm方法返回服务器最新的日志索引及最新的日志条目对应的任期
// (如果没有日志返回-1)要求cm.mu锁定
func (cm *ConsensusModule) lastLogIndexAndTerm() (int, int) {
if len(cm.log) > 0 {
lastIndex := len(cm.log) - 1
return lastIndex, cm.log[lastIndex].Term
} else {
return -1, -1
}
}
提醒一下,我们实现中的索引是从0开始的,而不像Raft论文中是从1开始的,因此-1经常作为一个标记值。
下面是更新后的RV处理逻辑,实现了选举安全性检查:
func (cm *ConsensusModule) RequestVote(args RequestVoteArgs, reply *RequestVoteReply) error {
cm.mu.Lock()
defer cm.mu.Unlock()
if cm.state == Dead {
return nil
}
lastLogIndex, lastLogTerm := cm.lastLogIndexAndTerm()
cm.dlog("RequestVote: %+v [currentTerm=%d, votedFor=%d, log index/term=(%d, %d)]", args, cm.currentTerm, cm.votedFor, lastLogIndex, lastLogTerm)
if args.Term > cm.currentTerm {
cm.dlog("... term out of date in RequestVote")
cm.becomeFollower(args.Term)
}
// 任期相同,未投票或已投票给当前请求同伴,且候选人的日志满足安全性要求, 则返回赞成投票;
// 否则,返回反对投票。
if cm.currentTerm == args.Term &&
(cm.votedFor == -1 || cm.votedFor == args.CandidateId) &&
(args.LastLogTerm > lastLogTerm ||
(args.LastLogTerm == lastLogTerm && args.LastLogIndex >= lastLogIndex)) {
reply.VoteGranted = true
cm.votedFor = args.CandidateId
cm.electionResetEvent = time.Now()
} else {
reply.VoteGranted = false
}
reply.Term = cm.currentTerm
cm.dlog("... RequestVote reply: %+v", reply)
return nil
}
回顾”服务器失控“场景
在第一部分中,我们讨论了一个场景。在具有三台服务器的集群中,服务器B断开连接几秒钟,导致其变为候选人并且每隔150-300ms就发起一轮选举。当它重新连入集群中时,其任期要比留在集群中不知道有新一轮选举的同伴服务器高很多。
现在正好可以回顾这个场景,并考虑一下,如果连接正常的同伴服务器在此期间复制了新的日志条目,将会发生什么。
虽然B重返集群时会引发重新选举(领导者会在AE的应答中看到更高的任期而转换为追随者),但是因为它的日志不如A和C完整,所以B不可能胜选。这就是因为上一节所说的选举安全性检查。A或C会赢得新一轮的选举,因此(这次选举)对集群的破坏力相对较小。
如果您仍然担心这个不必要的影响(为什么要改选?),Ongaro的论文在Preventing disruptions when a server rejoins a cluster
一节讨论了这个确切的问题。这个问题的常用解决方案就是”预投票“,即服务器在成为候选人之前先执行一些检查。
因为这是真的非常规情形的优化,我就不在这个主题上花费太多时间。大家可以去查看论文——Raft网站提供了链接。
Q&A
结束本节之前,我们看一些在学习和实现Raft时常见的问题。如果你有其它问题,请随时给我发邮件——我会收集最常见的问题并更新文章。
Q:为什么commitIndex
和lastApplied
是分开的?我们能不能只记录因为RPC请求(或RPC响应)导致commitIndex
变化了多少,然后只将这些变化的指令发给客户端?
A:这两者分开是为了将快速操作(RPC处理)与较慢的操作(向客户端发送命令)进行解耦。考虑一下,当追随者收到AE请求,发现领导者的commitIndex
比自己大时,会发生什么?此时,它可以向commit channel发送一下日志指令。但是在channel发送数据(或执行回调函数)可能是一个潜在的阻塞操作,而我们希望尽可能快地应答RPC请求。lastApplied
就可以帮助我们将二者进行解耦,RPC方法只需要更新commitIndex
,后台的commitChanSender
goroutine会观察这些变化,并在空闲时把新提交的指令发送给客户端。
那你可能会问对于newCommitReadyChan
通道的操作是不是也存在这个问题?观察很仔细,但是通道是有缓冲的,而且由于通道两边都是我们控制的,我们可以设置一个小的缓冲区,来保证在绝大多数情况下不会阻塞。尽管如此,在极少数情况下,因为Raft代码中不可能提供无限的缓冲区,非常慢的客户端会拖延RPC请求。这未必是一件坏事,因为它会形成一种自然的背压机制。
Q:我们在领导者中需要为每个同伴都保存nextIndex
和matchIndex
吗?
A:只有matchIndex
时算法也仍然是有效的,但是在有些情况下效率会很低。考虑一个领导改变的情况,新的领导者不能假设任何关于其同伴的最新情况,所以将matchIndex
初始化为-1,因此就会尝试向每个追随者都发送整个日志。但是追随者(至少大部分)很可能拥有几乎相同的日志条目;nextIndex
帮助领导者从日志末尾开始探查追随者(所需的日志),而不必复制大量的日志。
下一步
我再一次强烈建议您研究一下代码——运行测试用例,观察输出日志。
到目前为止,我们已经有了一个基本可以使用的Raft实现,除了还没有处理持久性。这意味着我们的实现难以应对崩溃故障,即服务器崩溃并重启。
Raft算法对此做了规定,这是第三部分将讨论的内容。增加持久性会使我们能够应对更严格的测试,包括最坏情况下的服务器崩溃。
此外,第三部分会讨论这里提到的一些优化。更重要的是,如果领导者有新消息要发送给追随者时,应该更及时地发送AE请求,但是现在领导者只会在每50ms发送一次AE。这个也会在下一部分被修正。
举例来说,在规模为5的集群中,领导者期望得到2个追随者的确认回复,这样总数就是3个(2个追随者加领导者自身),也就满足了多数的要求。 ↩︎
这里需要注意,虽然Raft论文中的日志索引是从1开始的,在我们的实现中索引是从0开始的,因为这样的代码感觉更自然。这些索引对于
ConsensusModule
的客户端/用户没有任何实质性影响。 ↩︎在这里将这个方法命名为
leaderSendHeartbeats
有点不恰当,因为它不只是发送心跳。但是,因为在这一部分中,该方法每隔50ms都需要发送AE请求,所以保留了这个名字。在第三部分中我们会修正。 ↩︎这里用了一个非常简单的解释,实际情况很复杂。这里对于正确性的推理相当复杂,我建议阅读论文以获取更多详细信息。如果你是形式主义的拥趸,Ongaro的毕业论文有章节TLA+ spec of Raft来证明这些不变式的正确性。 ↩︎