Raft概论
欢迎来我的小站转转~~
分布式系统很大程度上解决了我们在单机系统上遇到的可靠性和扩展性问题。为了解决扩展性,引入了分片;为了解决可靠性,引入了副本。而引入的这两个机制,又带来了更多需要解决的刁钻问题。
在本篇文章中,笔者对分布式一致性算法Raft进行介绍。由于主要介绍大的结构和流程,所以称为“概论”。如果想要知道更多细节,可以下载参考资料中的内容进行更细致的研究。
本文结构:首先简要介绍共识算法可以解决那些问题;接着,对Raft算法的主要流程和数据结构进行概述;对Raft有一个整体印象后,聚焦于选主流程、日志复制中的细节和问题;最后,证明Raft算法的正确性,并描述Raft如何处理成员变更的问题。
共识算法解决的问题
简单来说,共识就是指多个节点对某个信息达成一致。
这里的“信息”可能是一个锁的持有者、集群的领导者、用户名是否注册过、银行账户的一个数值……如果多个节点对信息没有共识,就可能导致不同机器处理的结果不一致,在重要的场景下可能出现转账不一致等情况。
在下面就以简单主从复制的场景来介绍一下共识算法带来了什么样的保证吧~
简单主从复制是非常普遍的一种模式,就算是包括分片的场景下,每个分片也可以看成这样的数据流模式。在一个典型的主从复制系统中,一般都是如下图的从主节点复制到从节点。
在这样的设计中,所有更改都在主节点上进行。一旦有故障切换等情况,可能选出过时的leader,导致已经写入的记录丢失;若有从节点与主节点的网络断开,又可能认为主节点失败,将自己提升为主节点,造成有两个主节点同时出现的脑裂现象。
共识算法可以在这样的场景下保证成功写入的记录在后面都不丢失,且同一时间最多只会有一个主节点。
除了选主场景,共识算法在分布式锁、唯一性保证、时序依赖等方面都可以提供很强的保证,本篇文章主要以kv存储的场景来描述Raft算法的细节。
由于本篇文章只是概论,笔者的能力也优先,难免挂一漏万。若有任何的问题或意见,欢迎在评论区进行探讨~
Raft起源
一切从Paxos说起
**There is only one consensus protocol, and that’s Paxos”. All other approaches are just broken versions of Paxos. **
By Mike Burrows(Google Chubby作者 )
正如Mike Burrows所说,Paxos 算法可以说是分布式共识算法的代名词,包括Raft在内的其他共识算法大都是在其基础上舍弃一些保证的变种。
那为什么有了Paxos之后大家不收收心,还继续在这基础上推出各种broken version的算法呢?2014年,Raft作者Diego Ongaro在他2014年的博士论文中解释了他观察到的Paxos两个主要缺点:
难于理解
由于Paxos的作者Lamport非常喜欢各种奇奇怪怪的比喻,完整的 Paxos 算法很难让人理解。Diego Ongaro 也是等读了一些 Paxos 算法的简化解释和开始开发自己的一致性算法时,才算真正掌握了 Paxos 算法,这个过程让一名高水平的博士足足花了一年时间。
没有提供工程实现细节
Lamport 的算法描述只提供了一个框架协议和理论上的严格证明,但是没有在工程上进行太多描述。开源社区几乎没有一个被广泛认可的工程实现,很多 Paxos 算法的实现都是对它的近似。这导致真正实现的工程化协议跟论文提及的协议总有或多或少的偏差,虽然Paxos的证明非常严格,但它的工程实现可能反而更没有理论保障。
正是以上两个问题,导致分布式共识算法的应用没有更加快速地铺开。Diego Ongaro在博士论文中提出了自己的简化版分布式共识算法Raft,希望能让共识算法更易于理解,且能快速正确地进行工程化。
Raft概览
主要流程
Raft的服务端是主从结构,每个副本都维护一个日志列表和状态机。其中日志是由共识模块维护的,状态机则是客户端可见的数据。所以在日志中存在但没有复制到状态机的那部分数据,对于客户端来说都是不可见的。
在这个例子中,数据都是简单的kv类型。共识模块是Raft算法的核心,通过控制Leader选举流程、日志顺序、通知状态机执行哪些日志来保证一致性。只要各副本日志的顺序一致,状态机的内容就是一致的。一次更改操作的主要流程为:
- client发送变更信息到Leader
- 共识模块保证发送到各个副本的日志是一致的
- 每台服务器上的状态机接收共识模块的通知,从日志中获取对应操作并执行
- 返回操作结果到client
其中client一开始可能将变更请求发送Follower上,Follower会告诉client哪个节点才是Leader,并让client向Leader发送变更信息。
任期
在Raft协议中,时间线被划分为不同的任期(term),每个任期内最多只有一个Leader。在上面的一个任期中,蓝色部分表示进行选举中,绿色部分表示已经选出leader,正在提供服务。不一定每个任期都能选出leader,比如t3阶段就没有选举成功。
角色转换
在Raft中,服务器的节点按照分为三种不同的角色:Leader、Follower、Candidate。
Follower
初始化时,所有的节点都是Follower,只能被动接受Leader和Candidate的消息,不能主动发消息给两者。从Leader处接收变更消息,并返回变更结果。从Candidate处接收到竞选消息,并返回自己是否同意选举其为Leader。
Follower返回的结果会带上当前的term,以供Candidate和Leader检查自己是不是已经落后。
Candidate
如果一个节点发现太久没有收到来自Leader的心跳,则会主动提名自己为候选的Leader,并发送选举的请求。在竞选结果没有确定之前,这个节点就是Candidate节点。
对于Candidate节点来说,如果竞选成功,它就会转变为Leader节点;如果竞选失败,则退回为Follower节点。
Leader
负责接收所有的更新请求,并将更新请求转发给Follower。同时,还会不间断的向外Follower发送心跳。当Leader发现自己落后,则会转变为Follower。
数据结构
在查看Raft算法之前,我们先来了解一下各个数据结构。
日志结构
Raft中日志文件可以理解为一个单调递增的数组,数组中每个元素包含term和内容,表示一次客户端请求。其中term可以用于检查其他节点和自己的日志是否一致,内容表示请求的具体内容,一般是kv结构的值,每一个日志通过index表示它在数组中的位置。
在上图中,一共有三个term的记录。第一行的日志代表Leader中日志,下面四行则为Follower中的日志。其中索引1-7的committed状态表示一个日志已经复制到大多数节点,根据Raft的协议可以保证这个日志不会丢失,并最终复制到所有的节点上。
节点状态
Raft协议中每个节点都有一个State数据结构,用于保存节点的日志和状态等信息。为了保证节点重启后还能恢复状态,对于重要的字段都保证持久化,而其他可以通过节点间相互通信获取的信息则只需要存储在内存。
State数据结构包括:
持久化信息:
- CurrentTerm: 当前节点所在的任期,只要成为follower,就会将任期更新为leader的任期。
- VotedFor: 在当前任期内,当前节点投票给了哪个candidate
- Log[]: 日志列表,每一项包括term和内容两个信息
纯内存信息:
- CommitIndex: 表示已经复制到大多数节点的最新位置,在重新启动后可以通过节点之间互相通信重新算出最新位置。
- LastApplied: 应用到状态机的最新位置,同理,也可以通过互相通信算出来。
Leader特有的纯内存信息:
- NextIndex[]: 每一个follower最新的日志位置
- MatchIndex[]: 每个server和leader匹配的最大index, 默认为0. 处于matchIndex和nextIndex之间的日志就是哪些正在同步中的日志,是Leader已经向Follower发过去的AppendEntries消息还没有得到成功回应的日志列表
选主流程
触发条件
Raft协议通过超时时间来判断节点是否出问题:当Follower超过一段时间没有收到来自leader的请求时,就会认为现在Leader出了问题。
这时候leader可能真的出了问题,也可能只是过于繁忙或网络出了问题。无论如何,Follower都会主动转变为candidate,自增当前节点的term,并且尝试向其他节点发送请求。
投票请求
投票请求包括:
- Term: 当前(candidate)节点的任期,值为之前term+1,表明自己是下一个term的竞选者
- CandidateId: 当前(candidate)节点的id
- LastLogIndex: 当前(candidate)日志的最后一条记录的位置
- LastLogTerm: 当前(candidate)日志的最后一条记录所在的任期
投票返回结果:
- Term: 收到投票请求的节点当前任期
- VoteGranted: 对应节点是否投票给当前candidate
Candidate发送投票请求
一旦判断leader出了问题,Follower就认为应该进入下一个term,进行如下操作
- 首先更新当前term,自增,状态变为candidate
- 投票给自己,更新votedFor
- 发送投票请求给所有的server,等待过半成员同意自己成为新leader
Follower收到投票请求
Follower收到投票请求后,会进行如下操作:
- 判断candidate的term是不是更新,如果比自己term还小,则直接返回false
- 检查自己的votedFor字段,如果当前任期已经投给其他人了,则返回false。这一步保证了同一个任期内Follower只会投票给一个节点
- 到这一步,说明candidate的term是更新的,且当前节点还没有给其他人投票过,通过lastLogIndex和lastLogTerm检查candidate的日志是不是比自己更新。如果candidate更新,则返回true;否则,返回false。
- 这里判断两个日志是否更新的规则是:首先比较lastLogTerm,term越高的日志越新;如果lastLogTerm相同,则lastLogIndex越大的日志越新。
选举可能结果
投票的结果可能有以下几种情况:
竞选成功
每个节点会给新任期的第一个符合要求的candidate投票。如果candidate收到过半的节点都返回true,就认为自己赢得了竞选,转换状态到leader,并马上持续发送心跳给所有的节点以维持自己的leader地位,避免其他节点也跳出来竞选leader。
其他节点先竞选成功
如果在等待过半节点同意的过程中,收到了来自其他Server的心跳或变更信息,且这个信息中携带的term不比自己的任期小,说明已经有其他的节点赢得选举,自己再怎么等待也不可能有半数的同意。此时Candidate就转换状态为Follower,将term更新为心跳中的term,以开始接收leader发布的命令。
超时无成功节点
还有一种情况是没有一个节点成功,比如同时有三个candidate发起投票请求,没有一个candidate获得过半的支持。在这种情况下,candidate如果发现超过一段时间都没有成为leader,会自动进行下一次选举,直到自己成为leader或收到来自其他leader的心跳。
日志复制
日志状态
从发送请求到成功更新在所有节点上,一个日志可能有很多状态。Raft提供的保证是:一旦日志复制到大多数节点,那么就不会再丢失。只要不会再丢失,这个变更日志就可以在状态机中执行,从而被客户端看到。
回顾一下节点状态的数据结构,为了判断一个日志目前在什么状态,Raft引入了两个日志状态:Commited和Applied:
-
Commited:日志已经在过半的节点上持久化,则状态为committed,这时候可以认为日志不会再丢失。
-
Applied:日志已经在状态机上执行,能被客户端读取到。
Raft要求必须在前面的日志全都没问题的情况下才能处理后一个日志,所以不需要为每一个日志项都维护一个日志状态,只需要通过commitIndex记录最新的已经commited的日志,通过lastApplied记录最新的已经执行、客户端可见的日志。在commitIndex和lastApplied之前的状态必然都已经是committed和applied。
在上图的日志状态中,一共有五个节点。
对于leader来说,从索引1到7的日志都已经复制到过半节点,所以commitIndex即为7,表示前7项日志都可以安全地在状态机中执行。假设已经将前6项日志执行完成,那么leader的lastApplied即为6。
对于第一个follower来说,它相对于leader有一些延迟,最新的日志只到位置4。所以它的commitIndex可能为4,lastApplied则可能是4或者更之前的值。
复制过程
正如 Raft概览 中所描述的,client的请求都会发送到Leader中。Leader将请求追加到本地日志后,发送追加命令到Follower中,如果有Follower执行失败,则Leader会不断尝试直到成功。只要过半的Follower返回追加成功,leader就可以更新commitIndex为这个日志的索引,标记这个日志已经成功持久化。并安全的通知状态机执行对应日志,更新lastApplied,这样client就能看到这个变更。
为什么过半节点追加成功很重要嘞?回顾一下选主流程:如果一个没有追加成功的节点成为candidate,过半的节点在进行投票的时候会发现它的lastLogIndex比自己还要小,因此拒绝选它为主节点。所以一个节点能成为主节点的前提就是它是那过半的节点之一,从而保证了过半成功的日志在今后所有的选举中都能保留下来。也是由于这样的链式判断,只要两个服务器某一个index下的日志term相同,那么之前所有的日志都是一致的。
日志成功持久化之后,Leader发出的心跳就会包含更新的commitIndex和lastApplied。通过这两个字段,Follower能够知道日志已经可以安全的持久化了,就会对应更新commitId,并将旧lastApplied到新lastApplied之间的日志执行到状态机。
在Raft中,为了数据结构尽量精简,使用内容为空的变更消息作为心跳消息。所以心跳消息和变更消息的数据结构都如下一小节所示。
变更消息
变更消息只会由自认为是Leader的节点发出,至于Follower认不认这个节点为Leader则是另一回事情。如果Follower发现这个Leader比自己更落后,则不会执行对应的命令。
变更消息请求数据结构:
- Term: 当前(leader)节点的任期,通过这个字段可以让Follower判断当前节点是不是更落后
- LeaderId: 当前(leader)节点的id,这样可以让落后节点或新节点知道当前leader是谁,这样client在进行变更操作的时候,Follower就可以告诉client谁才是Leader
- PrevLogIndex: 当前日志之前一个位置的index,可以让Follower判断当前节点日志是否与其一致
- PrevLogTerm: 当前(leader)日志中最新消息的Term,可以让Follower判断当前节点是否与其一致
- Entries[]: 此次变更的消息(为了性能考量,一次可能有多个消息),如果是heartbeat,则是空内容。
- LeaderCommit: 当前(leader)节点状态机最新执行到的位置
变更信息返回:
- Term: follower当前的任期
- Success: 消息是否执行成功
示例
上图就是一种简单的日志可能情况,其中包括一台现任Leader和6台Follower (a)-(f)。探究过程可以更清楚选举和复制的细节,但也相当耗费时间。如果想尽快有个概览的话,可以直接跳过示例。为了增进大家的理解,笔者在过程中引入了魔法背景(单纯为了搞笑来的):
- 在很久很久以前的term1,节点(a) 是统领所有节点的leader,它不断向其他节点发送心跳,维持着自己的Leader地位。
- 在term1期间,魔仙堡收到了来自客户端的三个变更请求,(a)节点成功把三个请求日志复制到所有的节点ÿ