0. 简介
Etcd 是一个高可用、强一致的分布式键值(Key-Value)数据库,主要用途是共享配置和服务发现。
那Etcd是如何保证强一致性的呢?昨天面试就被问到这个问题,当时没有答上来,就很尴尬。
其实,其内部采用 Raft 算法作为分布式一致性协议,因此,Etcd 集群作为一个分布式系统"天然" 具有强一致性;而副本机制(一个 Leader,多个 Follower)又保证了其高可用性(点击进入 Etcd 官网)。
所以这里我们重点介绍一下Etcd底层运用的分布式一致性算法Raft。
1. Raft算法简介
背景
在分布式系统中,一致性算法至关重要。在所有一致性算法中,Paxos 最负盛名,但是 Paxos 算法过于复杂、实现困难,极大地制约了其应用,而分布式系统领域又亟需一种高效而易于实现的分布式一致性算法,在此背景下,Raft 算法应运而生。
Raft 算法在斯坦福 Diego Ongaro 和 John Ousterhout 于 2013 年发表的《In Search of an Understandable Consensus Algorithm》中提出。相较于 Paxos,Raft 通过逻辑分离使其更容易理解和实现,目前,已经有十多种语言的 Raft 算法实现框架,较为出名的有 etcd、Consul 。
Raft角色
根据官方文档,一个Raft集群包含若干节点,总共分为三种角色:Leader
、Follower
和Candidate
,每种角色的任务也不一样,正常情况下,一个集群只有Leader
和Follower
两种角色存在。
- Leader(领导者):负责日志的同步管理,处理来自客户端的请求,与Follower保持heartBeat的联系;
- Follower(追随者):响应 Leader 的日志同步请求,响应Candidate的邀票请求,以及把客户端请求到Follower的事务转发(重定向)给Leader;
- Candidate(候选者):负责选举投票,集群刚启动或者Leader宕机时,状态为Follower的节点将转为Candidate并发起选举,选举胜出(获得超过半数节点的投票)后,从Candidate转为Leader状态。
一致性需求的子问题拆分
为了实现一致性,Raft将一致性问题分解拆分成三个相对独立的子问题:
- 选举(Leader Election):当 Leader 宕机或者集群初创时,一个新的 Leader 需要被选举出来;
- 日志复制(Log Replication):Leader 接收来自客户端的请求并将其以日志条目的形式复制到集群中的其它节点,并且强制要求其它节点的日志和自己保持一致;
- 安全性(Safety):如果有任何的服务器节点已经应用了一个确定的日志条目到它的状态机中,那么其它服务器节点不能在同一个日志索引位置应用一个不同的指令。
下面,我们就来详细探讨一下这三个问题。
2. 选举(Leader Election)
一个应用Raft协议的集群刚启动时,所有的节点都是Follower。整个选举过程分为以下四个阶段
阶段1:所有节点都是Follower
上面提到,一个应用 Raft 协议的集群在刚启动(或 Leader 宕机)时,所有节点的状态都是 Follower,初始 Term(任期)为 0。同时启动选举定时器,每个节点的选举定时器超时时间都在 100~500 毫秒之间且并不一致(避免同时发起选举)。
阶段2:Follower转换为Candidate并发起投票
没有 Leader,Followers 无法与 Leader 保持心跳(Heart Beat),节点启动后在一个选举定时器周期内未收到心跳和投票请求,则状态转为候选者 Candidate 状态,且 Term 自增,并向集群中所有节点发送投票请求并且重置选举定时器。
注意,由于每个节点的选举定时器超时时间都在 100-500 毫秒之间,且彼此不一样,以避免所有 Follower 同时转为 Candidate 并同时发起投票请求。换言之,最先转为 Candidate 并发起投票请求的节点将具有成为 Leader 的"先发优势"。
阶段3:投票策略
节点收到投票请求后会根据以下情况决定是否接受投票请求:
- 请求节点的 Term 大于自己的 Term,且自己尚未投票给其它节点,则接受请求,把票投给它;
- 请求节点的 Term 小于自己的 Term,且自己尚未投票,则拒绝请求,将票投给自己。
阶段4:Candidate转为Leader
一轮选举过后,正常情况下,会有一个 Candidate 收到超过半数节点(N/2 + 1)的投票,它将胜出并升级为 Leader。然后定时发送心跳给其它的节点,其它节点会转为 Follower 并与 Leader 保持同步,到此,本轮选举结束。
注意:有可能一轮选举中,没有 Candidate 收到超过半数节点投票,那么将进行下一轮选举。
3. 日志复制(Log Replication)
在一个 Raft 集群中,只有 Leader 节点能够处理客户端的请求(如果客户端的请求发到了 Follower,Follower 将会把请求重定向到 Leader),客户端的每一个请求都包含一条被复制状态机执行的指令。Leader 把这条指令作为一条新的日志条目(Entry)附加到日志中去,然后并行得将附加条目发送给 Followers,让它们复制这条日志条目。
当这条日志条目被 Followers 安全复制,Leader 会将这条日志条目应用到它的状态机中,然后把执行的结果返回给客户端。如果 Follower 崩溃或者运行缓慢,再或者网络丢包,Leader 会不断得重复尝试附加日志条目(尽管已经回复了客户端)直到所有的 Follower 都最终存储了所有的日志条目,确保强一致性。
Leader选出后,就开始接收客户端的请求。Leader把请求作为日志条目(Log entries)加入到它的日志中,然后并行的向其他服务器发起 AppendEntries RPC复制日志条目。当这条日志被复制到大多数服务器上,Leader将这条日志应用到它的状态机并向客户端返回执行结果。
某些Followers可能没有成功的复制日志,Leader会无限的重试 AppendEntries RPC直到所有的Followers最终存储了所有的日志条目。
日志由有序编号(log index)的日志条目组成。每个日志条目包含它被创建时的任期号(term),和用于状态机执行的命令。如果一个日志条目被复制到大多数服务器上,就被认为可以提交(commit)了。
一些需要注意的问题
1. 为什么 Leader 向 Follower 发送的 Entry 是 AppendEntries 呢?
因为 Leader 与 Follower 的心跳是周期性的,而一个周期间 Leader 可能接收到多条客户端的请求,因此,随心跳向 Followers 发送的大概率是多个 Entry,即 AppendEntries。当然,在本例中,我们假设只有一条请求,自然也就是一个Entry了。
2. Leader 向 Followers 发送的不仅仅是追加的 Entry(AppendEntries)。
在发送追加日志条目的时候,Leader 会把新的日志条目紧接着之前条目的索引位置(prevLogIndex), Leader 任期号(Term)也包含在其中。如果 Follower 在它的日志中找不到包含相同索引位置和任期号的条目,那么它就会拒绝接收新的日志条目,因为出现这种情况说明 Follower 和 Leader 不一致。
3. 如何解决 Leader 与 Follower 不一致的问题?
在正常情况下,Leader 和 Follower 的日志保持一致,所以追加日志的一致性检查从来不会失败。然而,Leader 和 Follower 一系列崩溃的情况会使它们的日志处于不一致状态。Follower可能会丢失一些在新的 Leader 中有的日志条目,它也可能拥有一些 Leader 没有的日志条目,或者两者都发生。丢失或者多出日志条目可能会持续多个任期。
要使 Follower 的日志与 Leader 恢复一致,Leader 必须找到最后两者达成一致的地方(说白了就是回溯,找到两者最近的一致点),然后删除从那个点之后的所有日志条目,发送自己的日志给 Follower。所有的这些操作都在进行附加日志的一致性检查时完成。
Leader 为每一个 Follower 维护一个 nextIndex,它表示下一个需要发送给 Follower 的日志条目的索引地址。当一个 Leader 刚获得权力的时候,它初始化所有的 nextIndex 值,为自己的最后一条日志的 index 加 1。如果一个 Follower 的日志和 Leader 不一致,那么在下一次附加日志时一致性检查就会失败。在被 Follower 拒绝之后,Leader 就会减小该 Follower 对应的 nextIndex 值并进行重试。最终 nextIndex 会在某个位置使得 Leader 和 Follower 的日志达成一致。当这种情况发生,附加日志就会成功,这时就会把 Follower 冲突的日志条目全部删除并且加上 Leader 的日志。一旦附加日志成功,那么 Follower 的日志就会和 Leader 保持一致,并且在接下来的任期继续保持一致。
4. 安全性
Raft增加了两条限制以保证安全性:
1. 拥有最新的已提交的log entry的Follower才有资格成为Leader。
在上面投票环节也有介绍过,一个candidate必须获得集群中的多数投票,才能被选为Leader;而对于每条commit过的消息,它必须是被复制到了集群中的多数节点,也就是说成为Leader的节点,至少有1个包含了commit消息的节点给它投了票。
而在投票的过程中每个节点都会与candidate比较日志的最后index以及相应的term,如果要成为Leader,必须有更大的index或者更新的term,所以Leader上肯定有commit过的消息。
2. Leader只能推进commit index来提交当前term的已经复制到大多数服务器上的日志,旧term日志的提交要等到提交当前term的日志来间接提交(log index 小于 commit index的日志被间接提交)。
这条规则听起来很拗口,我们使用下面的例子来说明一下。
阶段(a):初始状态如(a)所示,S1作为Leader,并且日志被同步写入了S2,(term, index)为(2, 2)。
阶段(b):然后S1离线,S5被S3、S4和S5选举成为主,此时系统term=3,且写入了日志(3, 2)。
阶段(c):S5尚未日志推送的Follower就离线了,进而触发一次新的选主,而之前离线的S1经过重新上线后被选中变成Leader,此时系统term为4,此时S1会将自己的日志同步到Followers,按照上图就是将日志(2, 2)同步到了S3,而此时由于该日志已经被同步到了多数节点(S1, S2, S3),如果没有以上的限制,那么日志(2, 2)就可以被提交了;
阶段(d):假设S1又下线了,此时S5还是可能被选举为主的,S5比大多数节点(如S2/S3/S4的日志都新),然后S5会将自己的日志更新到Followers,按照(c)中,于是S2、S3中已经被提交的日志(2,2)被截断了。已提交的日志被截断,这是万万不可接受的。
为了避免以上这种情形,我们重置(c)中的策略,即不允许S1在term=4的时候提交term=2的时候的日志,得如下处理:
- (c)中S1有新的客户端消息4,然后S1作为Leader将4同步到S1、S2、S3节点,并成功提交后下线。此时在新一轮的Leader选举中,S5不可能成为新的Leader,保证了commit的消息2和4不会被覆盖。也就是说,旧term日志(2, 2)的提交要等到提交当前term的日志(4, 3)来间接提交,和(e)中的情景一样。
- c)中S1有新的消息,但是在S1将数据同步到其他节点并且commit之前下线,也就是说2和4都没commit成功,这种情况下如果S5成为了新Leader,则会出现(d)中的这种情况,2和4会被覆盖,这也是符合Raft规则的,因为2和4并未提交。
Raft就是通过以上两条限制来保证数据一致性的安全性的。