结合论文笔记阅读更佳:
https://zhuanlan.zhihu.com/p/514512060
1. 脑裂(Split-Brain)
在之前的课程中介绍了几个具备容错特性(fault-tolerant)的多副本系统(replication system)。但是它们存在一个共性:需要一个单节点来决定,在多个副本中,谁是主(Primary)。这就有**单点故障(Single Point of Failure)**问题。
MapReduce(单 Coordinator)、GFS(单 Master)、VMware FT(Test-and-Set 服务)
系统容错的关键点,转移到了这个单点上。使用单点的原因是,需要避免脑裂(Split-Brain)。
80 年代之前在构建多副本系统时,需要排除脑裂的可能有两种技术:
- 钞能力构建一个不可能出现故障的网络。
- 运维人工解决。
2. 过半票决(Majority Vote)
后来在构建能自动恢复,同时又避免脑裂的多副本系统时,人们发现,关键点在于过半票决(Majority Vote):奇数个服务器下,在任何时候为了完成任何操作,你必须凑够所有机器中过半的节点来批准相应的操作。
如果系统有 2 F + 1 2F + 1 2F+1 个服务器,那么系统最多可以接受 F F F个服务器出现故障。
Raft 就依赖这个特性。此外,以投票选 Leader 为例,此次投票的多数节点中,一定有与上次投票的多数节点重合的节点,因此新的Leader必然知道旧Leader使用的任期号和日志操作。这是Raft能正确运行的一个重要因素。
Raft 论文图 9:如果 S1 (任期 T 的领导者)提交了一条新的日志在它的任期里,然后 S5 在之后的任期 U 里被选举为领导人,然后至少会有一个机器,如 S3,既拥有来自 S1 的日志,也给 S5 投票了。
半票决这种思想的支持下诞生了两个系统,一个叫做Paxos,Raft论文对这个系统做了很多的讨论;另一个叫做ViewStamped Replication(VSR)。
尽管Paxos的知名度高得多,Raft从设计上来说,与VSR更接近。他们仅仅是在15年前,也就是他们发明的15年之后,才开始走到最前线,被大量的大规模分布式系统所使用。
3. Raft 整体服务逻辑
Raft会以**库(Library)**的形式存在于服务中,每个服务的副本将会由两部分组成:应用程序代码和Raft库。应用程序代码接收RPC或者其他客户端请求;不同节点的Raft库之间相互合作,来维护多副本之间的操作同步。
以 Lab 3:Key-Value数据库为例。Key-Value数据库在上层,对应的状态就是Key-Value Table。下层就是 Raft 服务。外部还有使用此 Key-Value数据库服务的客户端,多副本机制对客户端是透明的。
Key-Value数据库需要对Raft层进行函数调用,来传递自己的状态和Raft反馈的信息。Raft会记录操作的日志。
- 客户端请求 Put 一个
(k,v)
,要更新进Key-Value数据库,最后接收数据库是否成功的响应,似乎和普通 C/S 交互没有区别,即透明性。 - 实际上在集群中,客户端请求交给 Leader 处理,Leader 上层将操作交给 raft 层,请求把这个操作提交到多副本的日志(Log)中,并在完成时通知(这里也就是 Lab 2b 中
ApplyMsg
的处理逻辑)。 - Raft节点之间相互交互,直到过半的Raft节点将这个新的操作加入到它们的日志中(即已被提交)。
- Leader Raft 知道已提交后,上发通知数据库。数据库更新后响应客户端。
4. Raft 节点交互时序
- 客户端请求被 Leader Raft S1 添加到日志后,发送 AppendEntries RPC 到 Follower S2 S3。
- 若 Leader 接收到 S2 响应,则已经过半响应,日志已提交,不需要继续等待 S3 响应。则 Leader 执行更新,然后响应客户端。
- 此时可能才收到 S3 响应。
- Leader 在下一次 AppendEntries RPC(可能是心跳)告知 S2、S3 日志已提交,S2、S3 执行更新。
5. Log 的作用
-
对操作进行排序。复制状态机需要保证所有操作执行顺序完全一致才能保证最终状态一致。
-
Follower 缓存操作命令。Follower 需要一个地方缓存接收到但还不能执行(因为未提交)的命令,因为可能之后要被丢弃。Log 就是缓存这些临时操作。
-
Leader 缓存操作命令。Leader 有时需要向故障过的 Follower 重传丢失的 Log。
-
故障重启恢复。节点故障重启可以从 Log 中恢复故障前的状态,例如从头执行一遍。引入快照后就是从快照开始恢复。
但是重启后不会立即从 Log 恢复,因为不知道哪些是已提交的,需要等待 Leader 的 AppendEntries RPC。
如果 Leader 和 Follower 执行命令速度差异大,可能需要额外的通信告知 Leader 此时 Follower 的执行进度,用于调节 Leader 速度。
6. 应用层接口
这里具体到 Lab 2b 中的代码实现了。
假设我们的应用程序是一个key-value数据库,下面一层是Raft层。这两层之间主要有两个接口:
Start(command)
:key-value层用来转发客户端请求的接口。key-value层说:我接到了这个 command,请把它存在Log中,并在committed之后告诉我。返回此命令在 log 中的index
、任期term
,注意是立即返回不用等待命令提交,提交是applyCh
的工作。applyCh
:一个 channel,用于 Raft 通知key-value层已提交命令(ApplyMsg
消息,包括command
和index
),Raft 层写入,应用层读出。读出后执行此命令到状态机,Leader 将结果返回给客户端。
7. Leader 选举
-
为什么要有 Leader?
有了一个公认的Leader,一个请求只通过一轮消息就获得过半服务器的认可。对于无Leader的系统(例如Paxos),通常需要一轮消息来确认一个临时的Leader,之后第二轮消息才能确认请求。所以,使用一个Leader可以提升系统性能至2倍。同时,有一个Leader可以更好的理解Raft系统是如何工作的。
-
Leader 如何选举?
这里的选举细节直接看 Raft 论文中的实现吧。
-
每个任期必然最多只有一个Leader。
-
选举定时器(Election Timer)
-
瓜分选票 → 随机选举超时时间
超时时间应大于(几次)Leader心跳间隔,不同节点超时时间差应大于一次 RPC 传播时间。但是不能太长,会影响性能。
-
学生提问:如果单向的网络出现故障,Leader可以发出心跳,但是又不能收到任何客户端请求?发出的心跳被送达了,因为它的出方向网络是正常的,那么它的心跳会抑制其他服务器开始一次新的选举。但是它的入方向网络是故障的,这会阻止它接收或者执行任何客户端请求。这个场景是Raft并没有考虑的众多极端的网络故障场景之一。可以通过一个双向的心跳来解决这里的问题,Leader 一段时间没收到 Follower 心跳,自行卸任。
8. Leader 宕机
对应论文5.3.4、5.3.5、5.4.2部分。建议直接看论文笔记。
-
此处对应论文5.3.4 不一致的情况
学生提问:槽位10和11的请求必然执行成功了吗?Raft必须认为它们已经被commit了,这里我们不能排除Leader已经返回响应给客户端的可能性,只要这种可能性存在,我们就不能将槽位10和槽位11的Log丢弃,因为客户端可能已经知道了这个请求被执行了。所以我们必须假设这些请求被commit了。
-
此处对应论文5.4.1 选举约束
这里由于 follower 不会给 log 比自己旧的candidate 投票,所以 s1 不会上任。
-
此处对应论文5.3.5 不一致的恢复
若 s3 上任,新日志任期为 6,AppendEntries RPC还包含了
prevLogIndex
字段和prevLogTerm
字段。Leader为每个Follower维护了
nextIndex
,在Leader刚刚当选,nextIndex
的初始值是从新任Leader的最后一条日志开始,Follower若发现前一份日志不匹配,会返回 false,Leader减小nextIndex
后重新发送。 -
此处对应论文5.3.5 快速恢复优化
如果一个Follower关机并错过了1000条Log条目,Leader重启之后,需要每次通过一条RPC来回退
nextIndex
来遍历1000条Follower错过的Log记录。论文的5.3结尾处说了,让Follower返回足够的信息给Leader,这样Leader可以以任期(Term)为单位来回退,而不用每次只回退一条Log条目。
可以让Follower在回复拒绝Leader的AppendEntries消息中,携带3个额外的信息,来加速日志的恢复。
- XTerm:这个是Follower中与Leader冲突的Log对应的任期号。在之前有介绍Leader会在prevLogTerm中带上本地Log记录中,前一条Log的任期号。如果Follower在对应位置的任期号不匹配,它会拒绝Leader的AppendEntries消息,并将自己的任期号放在XTerm中。如果Follower在对应位置没有Log,那么这里会返回 -1。
- XIndex:这个是Follower中,对应任期号为XTerm的第一条Log条目的槽位号。
- XLen:表示空白的Log槽位数。
这里视频从三种情况进行举例,不细说了。
学生提问:可以使用类似二分查找的方法进一步加速吗?可以,这里只是加工了一下论文模糊的说法,只是实现的其中一种方法。Lab 2 测试也许需要一定的性能优化才能通过。
9. 持久化(Persistence)
从Raft论文的图2的左上角看到,有些数据被标记为持久化的(Persistent),有些信息被标记为非持久化的(Volatile)。
仅有三个数据是需要持久化存储的。它们分别是Log
、currentTerm
、votedFor
。服务器重启加入到Raft集群之前,它必须要检查并确保这些数据有效的存储在它的磁盘上。currentTerm
和votedFor
都是用来确保每个任期只有最多一个Leader。
这些数据需要在每次你修改它们的时候存储起来。可以通过一些批量操作来提升性能。例如,只在服务器回复一个RPC或者发送一个RPC时,服务器才进行持久化存储,这样可以节省一些持久化存储的操作。synchronous disk updates 优化磁盘写入性能是一个出现在所有系统中的常见的问题,也必然出现在Raft中。
如果有大量的客户端请求,或许你应该同时接收它们,但是先不返回。等大量的请求累积之后,一次性持久化存储(比如)100个Log,之后再发送AppendEntries。
学生提问:写 RAFT 代码时,怎么做来确保它们已经储存在磁盘?write函数返回时,并不能确保数据存在磁盘上在UNIX上。你调用了write,将一些数据写入之后,你需要调用fsync。在大部分系统上,fsync可以确保在返回时,所有之前写入的数据已经安全的存储在磁盘的介质上了。fsync是一个代价很高的调用,一般尽量减少使用频率。
如果Leader收到了一个客户端请求,在发送AppendEntries RPC给Followers之前,必须要先持久化存储在本地。在回复AppendEntries 消息之前,Followers也需要持久化存储这些Log条目到本地,因为它们最终也要commit这个请求,它们不能因为重启而忘记这个请求。
为什么可以安全的丢弃lastApplied
?Leader可以通过检查自己的Log和发送给Followers的AppendEntries的结果,来发现哪些内容已经commit了。
重启之后,应用程序可以通过重复执行每一条Log来完全从头构建自己的状态。这是一种简单且优雅的方法,但是很明显会很慢。这将会引出我们的下一个话题:Log compaction和Snapshot。
10. 日志快照(Log Snapshot)
Log压缩和快照(Log compaction and snapshots)在Lab3b中出现的较多。
快照背后的思想是,要求应用程序将其状态(state)的拷贝作为一种特殊的Log条目存储下来。对于大多数的应用程序来说,应用程序的状态远小于Log的大小。Log可能包含大量的重复的记录(例如对于X的重复赋值)。
如果Raft要求应用程序做一个快照,Raft会从Log中选取一个与快照对应的点,然后要求应用程序在那个点的位置做一个快照。如果我们有一个点的快照,那么我们可以安全的将那个点之前的Log丢弃。在key-value数据库的例子中快照本质上就是key-value表单。我们还需要为快照标注Log的槽位号,在这个图里面,这个快照对应的正好是槽位3
Raft要求应用程序做快照,得到快照之后将其存储在磁盘中,同时持久化存储快照之后的Log,并丢弃快照之前的Log。
尽管Raft在管理快照,快照的内容实际上是应用程序的属性。Raft并不理解快照中有什么,只有应用程序知道,因为快照里面都是应用程序相关的信息。
Leader可以丢弃Follower需要的Log,解决方法是(一个新的消息类型)InstallSnapshot RPC。
Leader回退nextIndex
时,在某个点,它已经到了自己Log的起点。这时,Leader会将自己的快照发给Follower,之后立即通过AppendEntries将后面的Log发给Follower。
这里明显的增加了的复杂度。因为这里需要Raft组件之间的协同,这里还有点违反模块性,因为这里需要组件之间有一些特殊的协商。例如,当Follower收到了InstallSnapshot,这个消息是被Raft收到的,但是Raft实际需要应用程序能吸纳这个快照。所以它们现在需要更多的交互了。
学生提问:快照的创建是否依赖应用程序?Robert教授:肯定依赖。快照生成函数是应用程序的一部分,如果是一个key-value数据库,那么快照生成就是这个数据库的一部分。Raft会通过某种方式调用到应用程序,通知应用程序生成快照,因为只有应用程序自己才知道自己的状态(进而能生成快照)。而通过快照反向生成应用程序状态的函数,同样也是依赖应用程序的。但是这里又有点纠缠不清,因为每个快照又必须与某个Log槽位号对应。
学生提问:如果RPC消息乱序该怎么处理?Robert教授:是在说Raft论文图13的规则6吗?这里的问题是,你们会在Lab3遇到这个问题,因为RPC系统不是完全的可靠和有序,RPC可以乱序的到达,甚至不到达。你或许发了一个RPC,但是收不到回复,并认为这个消息丢失了,但是消息实际上送达了,实际上是回复丢失了。所有这些都可能发生,包括发生在InstallSnapshot RPC中。Leader几乎肯定会并发发出大量RPC,其中包含了AppendEntries和InstallSnapshot,因此,Follower有可能受到一条很久以前的InstallSnapshot消息。因此,Follower必须要小心应对InstallSnapshot消息。我认为,如果Follower收到了一条InstallSnapshot消息,但是这条消息看起来完全是冗余的,这条InstallSnapshot消息包含的信息比当前Follower的信息还要老,这时,Follower该如何做?Raft论文图13的规则6有相应的说明。我认为正常的响应是,Follower可以忽略明显旧的快照。其实我(Robert教授)看不懂那条规则6。
11. 线性一致(Linearizability)
线性一致(Linearizability)或者说强一致(Strong consistency)。一个服务是线性一致的,那么它表现的就像只有一个服务器,并且服务器没有故障,这个服务器每次执行一个客户端请求,并且没什么奇怪的是事情发生。
这是分布式一致性的一个概念,可以自己查查。
大概意思就是并发的读写请求都有一个顺序,读在写之后,且读的一定是最近写入的值,不会发生脏读幻读。