最近终于给silly实现了etcd driver
。
下一步就要基于etcd
来实现我去年提到的分布式框架了。
但在此之前,我需要知道etcd
的边界在哪里,他是如何保证一致的,是否有幻读等问题。
由于etcd
采用了raft
共识算法,所以我需要了解raft
的一些基本概念。
最近懒得阅读英文文章,因此在学习raft
之前,我先查了一大堆中文资源,最后我都不太满意。
最终只能去啃CONSENSUS: BRIDGING THEORY AND PRACTICE, 于是就有本文。
CONSENSUS: BRIDGING THEORY AND PRACTICE中几乎介绍了需要实现raft
中的方方面面,但我并没有看完,因为我只是想了解raft
算法的边界。
因此本文只是按我的理解,从leader选举
、日志复制
和安全性
三个方面来描述一下raft
算法,其中我会着重描述安全性
。
因为我查到的所有中文资料中,对安全性
的描述,我最不满意。
raft
使用心跳机来触发leader选举
,选举机制如下:
进程状态分为follower
、candidate
和leader
三种。
所有服务器启动时都默认是follower
状态。
当follower
接收到leader
或candidate
进程的有效RPC
请求时,会保持在follower
状态。
leader
会定时向所有follower
发送心跳包
(没有日志条目数据的AppendEntries RPCs
即为心跳包)来保持他的权威性。
如果follower
在election timeout
时间内没有收到心跳包
,那么follower
就会发起一次选举,来选择新的leader
。
整个选举流程如下:
- 一个
folllower
增加它的当前term
(任期)并转换为candidate
状态。 candidate
给自己投票,并向所有其他服务器发送RequestVote RPCs
。candidate
一直会保持在candidate
状态,直到以下三种情况之一发生:- 得到了
超过一半
的服务器的投票,此时candidate
会转换为leader
状态。 - 另一个
candidate
赢得了选举,此时candidate
会转换为follower
状态。 - 选举超时,此时
candidate
会增加term
并重新发起选举。
- 得到了
每个服务器在给定的term
(任期)内按照先到先得
(安全性
部分会增加一些额外的限制)的原则, 只能投出一票。
超过一半
原则会保证在一个特定的term
内,只有一个candidate
会胜出成为leader
。
一旦一个candidate
胜出选举,他就会成为leader
,并向所有服务器发送心跳包
,来宣布他的权威性,并阻止新的选择。
在等待投票的过程中,一个candidate
可能会收到其他服务器S发送AppendEntries RPC
消息来声明服务器S(自己)成为leader
, candidate
就会检查服务器S发送的term
是大于等于自己的term
。
如果是,那么candidate
就会转换为follower
状态,否则就拒绝这个RPC
请求,然后继续保持在candidate
状态。
可以看到整个选举过程,本质上就是使用paxos算法就term
的值达成一致的过程。
正如paxos一样,raft
的选举过程也会产生活锁
, 即永远都无法选出leader
。
raft
为了解决这一问题,引入了随机化选举超时时间
,这样就最大话限度地避免活锁
。
再来看看日志复制
是怎么进行的,自此raft
就和paxos没有任何关系了。
raft
的日志复制
是基于quorum来保证可靠性的。
一旦一个leader
被选出,他就会开始接受客户端的请求。
每个客户端请求包含了一个由复制状态机执行的command
。
复制状态机可以按日志写入顺序,从前往后执行日志中记录的command
,以得到最终的结果。
例如:复制状态机按(set x=1),(set x=5)
的顺序执行完command
, 那么x
的最终值就是5。这也意味着只要集群中各节点中日志一致,各节点中关于某个变量的值都是一致的。
leader
先将这个command
作为一条新的日志(log entry
)追加(append)
到自己的日志中。然后对其他服务器并行发出AppendEntries RPCs
,要求他们也将这条日志追加到自己的日志中。
当有超过半数的服务器都成功的将这条日志追加到自己的日志中,那么leader
就会将这个日志条目应用到自己的状态机
中(这就是大名鼎鼎的quorum
)。
此时这条日志就处于committed
状态, leader
已经可以将状态机
中的结果返回给客户端了。
在将结果返回给客户端之后,leader
会不停的使用AppendEntries RPCs
来重试直到所有followers
最终都保存了所有的日志。
需要强调的是,这个committed
状态是针对leader
来说的,当一条日志处于commited
状态,就意味着这条日志产生的结果可能已经被返回给客户端了,因此不能丢失。这一点对于后面理解安全性
部分很重要。
raft
保证所有处于commited
的日志都会持久化,并被所有可用的状态机被执行。
整个log的组织方式如下图所示,每条日志(log entry
)都会有一个惟一id
, 这个id
由term
和log index
组成,其中term
就是产生本条日志的,当期leader
的term
值。
当一条日志处于commited
状态时,leader
会将这条日志之前的所有日志都commit
, 包括前任(整个集群中所有之前的)leader
所产生的日志。
leader
会始终记录当前已经处于committed
中日志最大log index(max_log_index)
,这个索引会通过下一次AppendEntries RPCs
(包括心跳)发送给followers
。
当一个follower
知道一条日志处于committed
状态时,他就会顺序的将这条日志之前的所有日志都应用到本地复制状态机。
这条规则对于一致性没有任何帮助,只是为了防止当leader
宕机时,follower
成为leader
后,follower
能够快速的将自己的状态机和leader
的状态机保持一致。
raft
的日志机制会使得不同服务器间的日志保持了高度一致,这不仅会简化系统行为,而且会使结果更加可预测,也是确保安全性
的重要组成部分。
raft
日志有如下属性:
- 如果在两个服务器中的日志有相同的
log index
和term
,那么这两个日志包含相同的command
。 - 如果在两个服务器中的日志有相同的
log index
和term
, 那么这两个服务器中,这两条日志之前的所有日志,都是相同的。
第一个属性来源于这样的一个事实:leader
在一个term
内,对于给定的log index
最多只能产生一条日志。
第二个属性则被AppendEntries
中执行的一致性检查来保证。当发送一个AppendEntries RPCs
时,leader
会将紧接着本次新增日志(可能有多条)的上一条日志的log index
和term
,发送给所有follower
,如果follower
发现自己不存在同样的日志,则他会拒绝这条新增日志。
举个例子,当leader
发送了一个(last_term=2, last_log_index=1, entries=((term=2, log_index=2, command="x=1"),(term=2, log_index=3, command="x=2")))
的AppendEntries RPCs
时,follower
会检查自己的日志列表,如果发现自己的日志列表中不存在(term=2, log_index=1)
的日志时,follower
就会拒绝这个RPC
请求。
正常情况下,raft
的leader
和follower
之间的日志列表是一致的,但是当leader
和follower
之间产生一系列的crash
之后,日志列表可能就会变得不一致。
下图展示了一些leader
和follower
之间的日志列表不一致的情况, 一个follower
可能会丢失已经存在于leader
日志列表中的日志,也可能会拥有不存在于leader
日志列表中的日志。
在raft
中,leader
会通过强制所有follower
来复制自己的日志列表来保证一致性。
当leader
向follower
中的AppendEntries
被拒绝时,leader
会不停的向历史日志回退,直到找到第一个和follower
相同的日志(term=T,log_index=L)
,然后使follower
删除日志(term=T,log_index=L)
之后的所有日志,并复制从leader
中日志(term=T,log_index=L)
之后的所有日志。
leader
会通过找到和被复制的follower
中最新一条相同的日志,然他开始往后复制,直到复制完leader
中所有的日志,至此leader
和follower
的日志列表就保持了一致。
上面的日志机制只保证了可以leader
和follower
之间的日志列表保持一致,但是并不能保证已经处于committed
的日志不会丢失。
下面来看看安全性
是怎么做到这一点的。
raft
规定,如果一个caididate
没有包含所有已经处于committed
的日志,那它就不可能在选举
中胜出。
一个candidate
为了被选举成功,必须要联系超过半数的服务器,这意味着,所有已经处于committed
的日志至少会存在于被联系的一台服务器之中(同样基于quorum
)。
这里需要重申一下,这个处于committed
的日志仅仅指,他被前任leader
已经应用到状态机
并将结果返回给了客户端的日志。
并不是leader
通过AppendEntries RPCs
同步过的,已经处于committed
中日志最大log index(max_log_index)
。
这一点需要思考一下才能得出结论,也是所有中文资料最模糊的地方,也是很关键的地方。
我们来看一个反例:
假如所有的follower
都维护了一个committed index
, 这个committed index
来源于每次leader
发送的AppendEntries RPCs
中的最大的已提交日志的log index
。
整个集群中日志内容如下, S1
为leader
, 他已经将(term=1, log_index=2)
复制到了大多数服务器中,因此S1
已经将(term=1, log_index=2)
标记为committed
,并且为客户端返回了结果。
S1 (term=1, log_index=1, command="x=1"),(term=1, log_index=2, command="x=2")
S2 (term=1, log_index=1, command="x=1"),(term=1, log_index=2, command="x=2")
S3 (term=1, log_index=1, command="x=1"),(term=1, log_index=2, command="x=2")
S4 (term=1, log_index=1, command="x=1"),
S5 (term=1, log_index=1, command="x=1"),
根据规则,本次提交的log_index
将会通过下次AppendEntries RPCs
同步给所有follower
。
因此S2,S3,S4,S5
服务中,此时leader
已提交的最大的id
为(term=1,log_index=1)
。
如果此时S1 crash
, S5
当前持续的最大日志id
为(term=1,log_index=1)
,是大于等于S2,S3,S4
的最大日志id
的,因此S5
有资格成为leader
。
这种情况下已经处于committed
状态下的日志(term=1, log_index=2, command="x=2")
就会丢失。
因此,如果一个caididate
没有包含所有已经处于committed
的日志,那它就不可能在选举
中胜出只是一个必要条件,并不是用来实现的充分条件。
事实上,当一个candidate
发出一个RequestVote RPC
时,他会带上自己日志列表中最新日志的(term,log_index)
, 而集群中别的服务器收到RequestVote RPC
时,会拿这个(term,log_index)
和自己的最新日志(term,log_index)
进行比较,如果发现自己的日志比candidate
的日志新(通过比较(term,log_index)
二元组来确定),那么就会拒绝这个RequestVote RPC
。
由于RequestVote RPC
会发给半数以上的服务器,因此当candidate
胜出时,他必然会已经包含了所有已经处于committed
的日志,不然他就收不到超过半数的投票。
之所以上面说是一个必要条件,是因为candidate
也有可能包含超过已经处理committed
的之外的日志,这些日志是否需要恢复会产生一个更微妙的问题。
这就需要再次增加一个限制条件来达成一致性,leader
只能通过当前日志是否已经在集群中被复制超过半数
来判断是否应该提交自己的当期日志,而不能用来判断是不是应该提交前任的日志。
下图展示了为何leader
只能主动提交自己当期的日志。
假如在图(c)
中,S1
过度到term=4
,并且继续提交(term=2,log_index=2)
,此时(term=2,log_index=2)
在整个集群中的复制数量已经超过半数,因此S1
可以将这条日志标记为committed
。
然后S1
生成了一条新的日志(term=4,log_index=3)
, 在还没有来得及复制给follower
之前crash
掉了,此时S5
以term=3
的情况成为leader
, 由于他本地最新的日志为(term=3,log_index=2)
, 因此他可以胜出获得超过半数的投票。
此时他会强制所有follower
来与自己保持一致,会覆盖掉已经处理committed
状态下的日志(term=2,log_index=2)
, 这违反了raft
的保证。
这里其实还隐含了一些更微妙的问题。在刚开始阅读原文时我没有注意到。
从图(c)之后,假设S1
接受了(term=4, log_index=3)
的请求并开始复制,他会先复制(term=2, log_index=2)
,然后再复制(term=4, log_index=3)
。
如果在集群中机器超过半数都已经复制到了(term=4, log_index=2)
, 但是在复制(term=4, log_index=3)
时,S1
再次crash
了。
虽然(term=2, log_index=2)
在集群中的复制数量已经超过了半数,但是由于(term=4, log_index=3)
还没有处于committed
。
因此(term=2, log_index=2)
还不能被标记为committed
, 因此(term=2, log_index=2)
是可以被别的leader
覆盖掉的。
至此,raft
的leader选举
、日志复制
和安全性
三个部分算法总算描述完了。
博主加个 rss 吧,想订阅
https://blog.gotocoding.com/feed 或 https://blog.gotocoding.com/atom 都可以