Raft
系统模型
Raft算法所运行的系统模型为:
- 服务器可能宕机、停止运行,过段时间会恢复,但不存在拜占庭式故障,即节点的行为是非恶意的, 不会恶意篡改数据
- 消息可能丢失、延迟、乱序和重复;可能有网络分区,并在一段时间后恢复 Raft和Multi-Paxos一样是基于领导者的共识算法
基本概念
Raft算法中的服务器在任意时间只能处于以下三种状态之一:
- 领导者(Leader),领导者负责处理所有客户端请求和日志复制;同一时刻最多只能有一个正常工作的领导者
- 跟随者(Follower),跟随者完全被动地处理请求,不主动发送rpc请求,只响应收到的rpc请求,服务器在大 多数情况下处于此状态
- 候选者(Candidate),候选者用来选举出新的领导者,候选者是处于领导者和跟随者之间的暂时状态
Raft算法选出领导者意味着进入一个新的任期Term,实际上任期就是一个逻辑时间,Term在算法启动时初始 值为0、单调递增且永不重复 Term一般分为两部分:选举过程和正常运行
有些任期内可能没有选举出领导者,如Term3,这时会立即进入下一个任期,再次尝试选举出一个领导者 每台服务器需要维护一个currentTerm变量,表示服务器当前已知的最新任期号,currentTerm必须持久化 存储,便于服务器在宕机重启后能知道最新的任期 Raft算法中服务器之间通信主要通过两个rpc调用实现,一个是RequestVote Rpc,用于领导者选举;一个是 AppendEntries Rpc,被领导者用于复制日志和发送心跳
领导者选举
每个节点在启动时都是跟随者状态,跟随者只能被动地接受领导者或候选者的Rpc请求;所以领导者如果想保持 状态,则必须向集群中其他节点周期性的发送心跳包,即空的AppendEntries消息;如果一个跟随者节点在选举 超时时间内没有收到任何任期更大的Rpc请求,则认为集群中没有领导者,开始新的一轮选举
当一个节点开始竞选领导者时,其流程如下:
- 节点转为候选人状态
- 自己的currentTerm变量+1,表示进入一个新的任期
- 给自己投票
- 并行想集群中其他节点发送 RequestVote消息索要选票 如果没有收到指定节点的响应,则候选者节点会反复尝试,只有以下三种情况才会更新状态
- 获得超过半数的选票,该节点成为领导者节点
- 收到领导者的AppendEntries心跳,说明集群中已有领导者,该节点转为跟随者
- 选举超时,该节点开始重新选举,回到下图中第二步
选举过程中需要保证共识算法的安全性和活性 安全性是指一个Term内只会有一个领导者被选举出来,需要保证:
- 每个节点在一个任期内只能投一次票,投给第一个满足条件的RequestVote请求,然后拒绝其他 候选者的请求;节点保存一个投票信息变量votedFor,表示当前Term选票投给了哪个候选者,votedFor 需要持久化存储
- 只有获得超过半数的选票才能成为领导者 活性是指确保系统最终能选出一个领导者;原则上节点可以无限重复平分选票,假设多个候选者选举同一 时间开始,然后平分选票,又同一时间超时,同一时间再次选举,如此循环;类似活锁问题,同样可以使 用解决活锁的方法来解决,即节点的超时时间选择随机时间,打破同时选举造成的冲突
Raft领导者选举的伪代码如下:
type Raft struct {
mu sync.Mutex
// 服务器当前状态
state int
// 服务器当前已知的最新任期
currentTerm int
// 当前任期内投票的候选者
votedFor int
// 心跳时间
heartbeatTime time.Time
}
type RequestVoteArgs struct {
// 候选者任期
Term int
// 候选者Id
CandidateId int
}
type RequestVoteReply struct {
// 处理请求节点的任期号,用于候选者更新自己的任期
// (如果候选者收到比自己更大的任期响应,则更新自己的任期)
Term int
// 投票为true,否则为false
VoteGranted bool
}
func (r *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
rf.mu.Lock()
defer rf.mu.Unlock()
reply.Term = rf.currentTerm
reply.VoteGranted = false
if args.Term < rf.currentTerm {
return
}
// 如果收到来自更大任期的请求,则更新自身的currentTerm,转为跟随者
if args.Term > rf.currentTerm {
rf.currentTerm = args.Term
rf.state = Follower
rf.votedFor = -1
}
if rf.votedFor == -1 || rf.votedFor == args.CandidateId {
rf.votedFor = args.CandidateId
reply.VotedGranted = true
rf.heartbeatTime = time.Now()
}
return
}
日志复制
Raft算法中的日志格式,每个节点存储自己的日志副本log[],日志中每个日志条目Log Entry包含以下内容:
- 索引。表示该日志条目在整个日志中的位置
- 任期号。首次被领导者创建时的任期
- 命令。应用于状态机的命令
type LogEntry struct {
Index int
Term int
Command interface{}
}
type Raft struct {
...
// 状态机日志
log []LogEntry
...
}
Raft算法通过索引和任期号唯一标识一条日志记录 如果一条日志被存储在超过半数的节点上,则认为该记录已提交(committed)
日志必须持久化存储,一个节点必须先将日志条目安全写到磁盘中,才能向系统中其他节点发送请求或回复请求 如下图所示,第1-7条日志 已提交,而第8条日志尚未提交(未超过半数)
Raft算法正常运行时,日志复制的流程如下:
- 客户端向领导者发送命令,希望该命令被所有节点状态机执行
- 领导者将该命令追加到自己的日志中,确保日志持久化存储
- 领导者并行向其他节点发送AppendEntries消息,等待响应
- 如收到半数以上节点的响应,则认为新的日志记录已经提交;领导者将命令应用到自己的状态机,然后向客户 端响应。此外,一旦领导者提交了一个日志记录,将在后续的AppendEntries消息中通过LeaderCommit参数通知 Follower,该参数代表领导者已提交的最大日志索引,跟随者也会把自身小于LeaderCommit的日志全部提交
- 如果跟随者宕机会请求超时,日志复制请求没有被成功响应,领导者会反复尝试发送AppendEntries消息
- 性能优化:领导者不必等待全部跟随者响应,只需要超过半数跟随者成功响应
Raft维持了两个特性:
- 如果两个节点的日志在相同索引位置上的任期号相同,则认为它们具有一样的命令,且从日志开头到这个索引 位置之间的日志也完全相同
- 如果给定的记录已提交,那么所有前面的日志也已提交 Raft算法的日志必须连续地提交,不允许出现日志空洞
一致性检查:每个AppendEntries消息请求包含新日志条目之前一个日志条目的索引prevLogIndex和任期prevLogTerm 跟随者收到请求后,会检查自己最后一条日志的索引和任期号是否与请求消息中的prevLogIndex、prevLogTerm匹配, 如果匹配则接受该记录,否则拒绝(保证日志是连续提交的)
跟随者追加日志的逻辑伪代码:
type AppendEntriesArgs struct {
Term int
LeaderId int
PrevLogIndex int
PrevLogTerm int
// 需要复制的日志条目,用于发送心跳消息时Entries为空
Entries []LogEntry
// 领导者已提交的最大的日志索引,用于跟随者提交
LeaderCommit int
}
type AppendEntriesReply struct {
Term int
Success bool
}
func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
rf.mu.Lock()
defer rf.mu.Unlock()
reply.Term = rf.currentTerm
reply.Success = false
if args.Term < rf.currentTerm {
return
}
if args.Term > rf.currentTerm {
reply.Term = args.Term
rf.currentTerm = args.Term
}
// 主要为了重置选举超时时间
rf.setState(Follower)
// 日志一致性检查
lastLogIndex := len(rf.log) - 1
if args.PrevLogIndex > rf.log[lastLogIndex].Index || rf.log[args.PrevLogIndex].Term != args.PrevLogTerm {
return
}
reply.Success = true
// 需要处理重复的rpc请求
// 比较日志条目的任期,确认是否能够安全的追加日志
// 否则会导致重复应用命令
index := args.PrevLogIndex
for i, entry := range args.Entries {
index++
if index < len(rf.log) {
if rf.log[index].Term == entry.Term {
continue
}
rf.log = rf.log[:index]
}
rf.log = append(rf.log, args.Entries[i:]...)
break
}
if rf.commitIndex < args.LeaderCommit {
lastLogIndex = rf.log[len(rf.log) - 1].Index
if args.LeaderCommit > lastLogIndex {
rf.commitIndex = lastLogIndex
} else {
rf.commitIndex = args.LeaderCommit
}
// 将命令应用到自己的状态机,不同的应用有不同的实现
rf.apply()
}
// 保险起见,再重置一次选举超时时间
rf.setState(Follower)
return
}
领导者更替
Raft算法在新领导者上任时,不会立即执行任何清理操作,会在正常运行期间执行清理操作;原因是 一个新领导者刚上任时,往往意味着有机器发生了故障或网络分区,此时没有办法立即清理它们的日 志,在机器恢复之前,必须保证系统正常运行 Raft算法假定领导者的日志始终是对的 领导者也可能在完成日志复制之前出现故障,没有复制也没有提交的日志在一段时间内会堆积起来,从 而造成相当混乱的情况 S4和S5是任期2、3、4的领导者,但它们没有复制自己的日志记录就崩溃了,S1、S2、S3轮流成为 任期5、6、7的领导者,但无法与S4、S5通信以执行日志清理 索引1到3之间的日志条目可以确认是已提交的,因为它们已处于多数派的节点中,因此必须确保留下 它们。而其他的日志都是未提交的,没有状态机应用其命令,也没有响应客户端,所以不管保留还是 丢弃都无关紧要
无论领导者如何变更,已提交的日志都必须保留在这些领导者的日志中
- 领导者不会覆盖日志中已提交的记录
- 只有领导者的日志条目才能被提交,并且在应用到状态机之前,日志必须先被提交 Raft算法通过比较日志,在选举期间,选择最有可能包含所有已提交日志的节点作为领导者,即日志最新 且最完整的节点
- 候选者在RequestVote消息中包含自己日志中的最后一条日志条目的索引和任期,记为lastIndex 和lastTerm
- 收到投票请求的服务器V将比较谁的日志更完整,如果服务器V的任期比候选者C的任期新,或者两者 任期相同但服务器V上的日志比候选者C上的日志更完整,那么V将拒绝投票 优化后的RequestVote伪代码如下:
type RequestVoteArgs struct {
Term int
CandidateId int
// 候选者的最后一条日志的索引
LastLogIndex int
// 候选者的最后一条日志的任期
LastLogTerm int
}
type RequestVoteReply struct {
Term int
VoteGranted bool
}
func (rf *Raft) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
rf.mu.Lock()
defer rf.mu.Unlock()
reply.Term = rf.currentTerm
reply.VoteGranted = false
if args.Term < rf.currentTerm {
return
}
// 如果收到来自更大任期的请求,则更新自身的currentTerm,转为跟随者
if args.Term > rf.currentTerm {
rf.currentTerm = args.Term
rf.state = Follower
rf.votedFor = -1
}
if rf.votedFor == -1 || rf.votedFor == args.CandidateId {
// 增强选举限制
lastIndex := len(rf.log) - 1
if rf.log[lastLogIndex].Term > args.LastLogTerm || (rf.log[lastLogIndex].Term == args.LastLogTerm && rf.log[lastLogIndex].Index > args.LastLogIndex) {
return
}
rf.votedFor = args,Candidate
reply.VotedGranted = true
rf.heartbeatTime = time.Now()
}
return
}
延迟提交
任期为2的日志条目一开始只写在服务器S1和S2两个节点上,由于网络分区的原因,任期3的领导者 S5并不知道这些记录,S5创建了自己任期的三条记录后未复制就宕机了。之后任期4的领导者S1被 选出,领导者S1试图与其他服务器的日志进行匹配,因此S1复制了任期2的日志到S3
虽然此时索引为3、任期为2的日志已经复制到多数派节点,但这条日志是不安全的,不能提交
因为领导者S1可能在提交后立即宕机,然后S5恢复重新发起选举,由于S5的日志比S2、S3、S4都要 新且长,所以S5可以成为任期5的领导者;一旦S5成为领导者,那么它将复制自己第3条到第5条日志 条目,这会覆盖S2和S3上索引为3的日志——这不符合已提交日志不能被修改的要求
日志仅是复制到多数派,Raft算法不能立即认为该日志可以提交;之后某个节点可能覆盖该日志,重新执 行某些命令,这是违反状态机安全性的;这也是Raft算法为什么要延迟提交的原因
Raft算法要求领导者想要提交一条日志,必须满足:
- 日志必须存储在超过半数的节点上
- 领导者必须看到超过半数的节点上存储着至少一条自己任期内的日志 上述的例子中,索引为3且任期为2的日志条目被复制到多数派节点时,仍然不能提交该日志,必须等到 当前任期4的日志条目也被存储在多数派节点上,此时索引3、4的日志才可以提交
此时,服务器S5便无法赢得选举了,因为S1、S2、S3的日志更新
领导者只能提交自己任期的日志,从而间接提交之前任期的日志
S1将索引2且任期2的日志复制到大多数,由于领导者当前任期为4,所以不能提交任期为2的日志; 如果领导者S1将任期为4的日志复制到多数派节点后,那么领导者就可以提交日志了,此时索引为 2且任期为2的日志也一起被提交
这里产生了一个问题,虽然增加了延迟提交的约束,系统不会重复提交了,但如果一直没有新的 客户端请求进来,那么索引为2且任期为2的日志就一直不能被提交了?系统会阻塞 例如该日志的命令是set("k","1"),客户端使用Get("k")查询k的值,由于该日志还未被提交,且线 性一致性不允许返回旧的值,那么该请求会被阻塞
为了解决该问题,Raft算法引入了no-op空日志,其只有索引和任期信息,命令信息为空;该类日志 不会改变状态机的输入和输出,只是为了驱动算法的运行
领导者在选举成功后,不管是否有客户端的请求,立即向自己本地追加一条no-op日志,并将其 复制到其他节点。no-op是当前任期的日志,多数派达成后立即提交,携带之前任期的延迟待提交 日志。
清理不一致日志
系统中通常有两种不一致的日志 :缺失的条目 和 多出来的条目;领导者必须使跟随者与自己的日志 保持一致,对于缺失的条目,Raft会发送AppendEntries消息补齐;对于多出来的条目,Raft会删除 领导者为每个跟随者保存变量nextIndex[],用来表示要发送给该跟随者的下一条日志条目的索引;对 于跟随者i来说,领导者的nextIndex[i]的初始值为 领导者最后一条日志索引 + 1
领导者可以通过nextIndex[]来修复日志,如果AppendEntries消息发现日志一致性检查失败,领导者 递减对应跟随者的nextIndex[i]值并重试
对于跟随者1,属于缺失条目,其完整流程为:
- nextIndex[1]初始化为 10 + 1 = 11,带上前一个日志条目索引10且任期6作一致性校验;检查发现跟 随者1索引10处没有日志,校验失败
- 领导者递减nextIndex[1]的值,知道发现等于5时,带上前一个日志条目索引为4且任期4一致性校验 成功;领导者会发送日志,将跟随者1从5到10的日志补齐 对于跟随者2,属于多出来的条目
- nextIndex[2]初始化也是11,一直递减做一致性校验;直到nextIndex[2]为4时,才校验成功
- 删除跟随者2从索引4到之后的所有日志
处理旧领导者
旧领导者有时并不会马上消失,例如网络分区后恢复、领导者宕机后拉起;此时旧领导者并不知道新的领导 者已经被选举出来 任期就是用来发现过期领导者或候选者的 每个rpc请求都包含发送方的任期 如果接收方发现自身已知的任期更大,则拒绝该rpc请求,将自身已知的最大任期返回给发送方;发送方接收 响应后,转为跟随者并更新任期
由于新领导者的选举会更新多数派节点的任期,所以旧领导者时不能提交新日志的(无法满足多数派)
客户端协议
Raft算法要求必须由领导者来处理客户端请求,如果客户端不知道领导者是谁,它会先和任意一台服务器通信 如果通信的服务器不是领导者,其会响应告诉客户端领导者的地址 领导者将命令写入本地并复制到其他节点、提交和执行命令到状态机后,才会响应客户端 这里有个和Multi-Paxos类似的问题:同一个命令可能被执行多次——领导者可能在执行命令之后响应客户端之 前宕机,此时客户端会重新寻找新领导者并重试该请求,同一个命令就会被执行两次,这是不可接受的 解决办法同Multi-Paxos相同,客户端发送的请求命令会附带一个唯一id,服务器将id持久化存储;领导者在接 受请求之前,先检查id是否存在,如果存在,则忽略重复命令,直接返回响应
实现线性一致性
一种实现线性一致性读的方法是,领导者将读请求当做写请求处理,运行一次完整的Raft实例;但需要复制日志 并将日志写入多数派节点持久化存储,这将造成额外的性能开销
Raft算法可以通过在领导者增加一个变量readIndex来优化一致性读的实现,流程如下:
- 领导者在其任期刚开始时,为了知道哪些日志是已提交的,其会提交一个no-op空日志;一旦no-op日志被成功 提交,说明领导者的commitIndex至少和多数派节点一样大
- 领导者将其commitIndex赋值给readIndex
- 领导者收到读请求后,向所有节点发送心跳;若收到多数派响应,此时readIndex是集群中所有节点已知的 最大已提交索引
- 领导者等待其状态机引用命令,至少执行到日志索引为readIndex处
- 领导者执行读请求,返回客户端
为了提高系统吞吐量,可以让跟随者帮助处理读请求,转移领导者负载 跟随者向领导者发送请求询问最新的readIndex,领导者执行上述1-3步骤,获取最新的readIndex;跟随者执行 4-5步骤,将提交日志应用到状态机,之后可处理读请求
另一种优化方法是,领导者使用心跳机制来维护一个租约,心跳开始时间为start,一旦其心跳被多数派节点确认, 领导者租约延长至start + electionTimeout/clockDriftBound;在租约时间内,领导者可以安全回复只读消息, 不需要额外发送消息 clockDriftBound:时钟漂移界限,时钟同步行为
配置变更
替换故障机器或修改集群节点数量,需要通过一些机制来安全、自动、不停机地变更系统配置 不能直接从旧配置切换到新配置,可能会导致出现矛盾的多数派;必须使用两段式提交协议
第一阶段:领导者收到new的配置变更后,先写入一条new+old的日志,配置变更立即生效;领导者通过AppendEntries 将消息复制到跟随者上,收到该消息的跟随者更新该配置;当old+new日志被复制到多数派节点后,领导者提交 该日志 old+new日志已提交保证了后续的领导者一定保存了该日志,领导者选举必须获取旧配置和新配置节点的多数派投票
第二阶段:领导者写入一条new日志,并通过AppendEntries复制到跟随者上,跟随者收到new日志后立即更新配置, new日志复制到多数派节点后,领导者提交该日志
细节:配置变更过程中,新旧配置的节点都可能成为领导者,如果当前领导者不再new配置中,一旦new日志被提交, 则旧领导者必须下台
Pre-Vote阶段:在任何节点发起选举之前,先发出Pre-Vote请求询问整个系统,如果超过半数的节点同意Pre-Vote 请求,则发起请求的节点才能真正发起新的选举 一个节点同意Pre-Vote请求的条件:
- 参数中的任期更大,或任期相同但日志索引更大
- 至少一次选举超时时间内没有收到领导者心跳 其解决了网络分区导致的跟随者任期不断增大,导致选举成功的问题
极端情况下的活性问题
如果有一条网络连接不可靠,那么当前领导者会不断被迫下台,导致系统实质上毫无进展 如下图所示的4节点Raft集群中有一个节点和其他三个的网络连接不太稳定,假设其能发送消息但收不到其他节点的 消息;其一直收不到心跳消息,就会转为候选者,不断自增任期发起新的选举; 该节点发送更大任期的RequestVote请求会导致当前领导者下台并重新选举,系统无法正常工作
之前提到的Pre-Vote阶段可以解决该问题,分布式存储etcd中实现了Pre-Vote;etcd将候选者细分为预候选者和 候选者,前者发送Pre-Vote时的状态,不会增加自己的任期;后者是发送RequestVote时的状态,会增加自己的 任期;状态转移图如下所示
只增加Pre-Vote阶段还可能存在一种极端情况导致Raft算法失去活性 下图中是一个5节点集群,故障发生前4是领导者,节点5宕机了,同时节点4只和节点2保持了连接,节点1、2、3 互相保持连接; 这种情况下,节点1、3无法收到心跳,会发起Pre-Vote请求,但由于节点2能收到领导者4的心跳,因此节点2不会 给其投票;节点1、3无法达成多数派 该集群无非选举出新领导者,且当前领导者4的日志复制请求也无法达成多数派,整个集群无非正常工作 5节点的集群正常可以容纳2节点故障,但增加Pre-Vote阶段后反而不能容忍了 因此需要增加一种机制来让领导者主动下台
领导者的AppendEntries请求没有收到超过半数节点响应时,主动下台
etcd把这种优化叫做CheckQuorum CheckQuorum确保如果当前领导者无法连接到多数派节点,其会主动下台,选出新的领导者 Pre-Vote确保一旦领导者当选,整个系统将是稳定的,领导者不会被迫下台
日志压缩
Raft算法的日志在正常运行期间会不断增长,会导致可用性问题:要么磁盘空间被耗尽,要么花费太长时间才能启动 新节点 一般思路是:日志中许多信息随着时间的推移会变成过时的,可以丢弃这些过时的日志;例如一个命令是x=2,如果在 之后有一条命令x=3,那么可以认为x=2这条命令已经过时了 日志压缩之后得到快照snapshot,快照也需要写入持久化存储;每个服务器会独立地创建自己的快照,快照中只包括 已提交的日志
无论何种压缩方法,都有几个核心共同点:
- 不将压缩任务集中在领导者上,每个服务器独立地压缩其已提交的日志
- Raft算法要保存最后一条被丢弃的日志条目的索引和任期,用于AppendEntries消息进行一致性检查
- 领导者承担两个新的责任:如果服务器重启了,则需要将最新的快照加载到状态机后再接收日志;需要向较慢的跟随 者发送一致的状态快照,使其跟上系统的最新状态