一致性协议raft详解(三):raft中的消息类型
前言
有关一致性协议的资料网上有很多,当然错误也有很多。笔者在学习的过程中走了不少弯路。现在回过头来看,最好的学习资料就是Leslie Lamport和Diego Ongaro的数篇论文、Ongaro
在youtube上发的三个视频讲解,以及何登成的ppt。
本系列文章是只是笔者在学习一致性协议过程中的摘抄和总结,有疏漏之处敬请谅解,欢迎讨论。
raft 节点
Raft算法中服务器有三种角色
- Follower
- Candidate
- Leader
每个服务器上都会存储的持久状态:
- currentTerm: 当前节点所能看到的最大的term值, 初始化为0并单调递增
- votedFor: 当前term里将票投给对象的candidateId, 如果尚未投票则为空(我实现时置为-1)
- log[]: 日志条目(每条日志条目包含命令和任期), 会按顺序作用于状态机, 第一个索引Index为1
每个服务器上都会存储的易失状态:
commitIndex
: 当前服务器已知已提交的最高的日志条目的索引(每次选举之后leader将其初始为0,单调递增)(这个代表了整个raft集群的最后一个index,根据figure8,这个参数有可能因为其他节点而被改变)- 所谓的CommitIndex,就是已经达成多数派,可以应用的最新日志位置
lastApplied
: 当前服务器已经被应用到状态机的最高的日志条目的索引(初始值为0,单调递增)(这个参数代表了自己这个节点目前到底持久化了多少日志)
上面两个index只是索引,可能会有空挡,比如某个log entry没有commit上
在状态为Leader的服务器上会额外存储的易失状态:
nextIndex[]
: 针对每个其他节点, 下一个需要发送的日志条目的索引, 初始化为leader最后一个日志索引+1matchIndex[]
: 针对每个其他节点, 当前所知的和Leader匹配的最高日志索引, 初始化为0并单调递增
Raft中RPC的种类
RequestVote
candidate节点请求其他节点投票给自己
请求参数:
term
: 当前candidate节点的term值candidateId
: 当前candidate节点的编号lastLogIndex
: 当前candidate节点最后一个日志的索引lastLogTerm
: 当前candidate节点最后一个日志的term值
返回值:
term
: 接受投票节点的term值, 主要用来更新当前candidate节点的term值voteGranted
: 是否给该申请节点投票
一个节点(无论当前是什么状态)在接收到RequestVote(term, candidateId, lastLogIndex, lastLogTerm)
消息时, 其会做如下判断:
- 如果参数携带的
term < currentTerm
, 则返回currentTerm并拒绝投票请求: (currentTerm, false), 并保持当前节点状态不变 - 如果当前term voteFor=null,做以下检查:
- 如果参数携带的
term > currentTerm
- leader会stepdown,并且提升term,然后重新选主(这点可以通过Leader Stickiness进行优化)
- follower会拒绝leader的请求,提升term,然后重新选主
- 经过以上的过程之后,节点仍需要将request lastLogIndex和自己的最后一条日志的index进行比较(leader就是最后一条日志(比如lastapplied或者最后一个log的index),follower就是commitIndex),确保candidate节点的日志至少和自己一样新,才可以同意
RequestVote RPC
- 如果参数携带的
term = currentTerm
,直接判断candidate的日志是否至少和自己一样新,如果是则同意RequestVote RPC
- 如果参数携带的
leader选举成功后
领导人:
- 一旦成为领导人:发送空的附加日志 RPC(心跳)给其他所有的服务器;在一定的空余时间之后不停的重复发送,以阻止follower超时(5.2 节)
- 如果接收到来自客户端的请求:附加条目到本地日志中,在条目被应用到状态机后响应客户端(5.3 节)
- 如果对于一个follower,如果leader发现自己的最后日志条目的索引值大于等于 nextIndex,那么:发送从 nextIndex 开始的所有日志条目:
- 如果成功:更新相应follower的 nextIndex 和 matchIndex
- 如果因为日志不一致而失败,减少 nextIndex 重试
- 如果存在一个满足
N > commitIndex
的 N,并且大多数的matchIndex[i] ≥ N
成立,并且log[N].term == currentTerm
成立,那么令 commitIndex 等于这个 N (5.3 和 5.4 节) (figure 8
),这样的话,leader就可以把漏下的日志补上- 之所以这么做,是因为在新的leader选举的过程中,老的leader是可以继续生效的,那么也就导致新的leader可能确实了一部分老leader最后commit的日志,或者network partition了,某个节点的term很大,导致其一定是主,但是这个主上有很多漏掉的leader
AppendEntries
leader节点使用该消息向其他节点同步日志, 或者发送空消息作为心跳包以维持leader的统治地位
请求参数
- term: 当前leader节点的term值
- leaderId: 当前leader节点的编号(注:follower根据领导者id把客户端的请求重定向到领导者,比如有时客户端把请求发给了follower而不是leader)
- prevLogIndex: 当前发送的日志的前面一个日志的索引
- prevLogTerm: 当前发送的日志的前面一个日志的term值 (这个和上一个作用是follower日志有效性检查)
- entries[]: 需要各个节点存储的日志条目(用作心跳包时为空, 可能会出于效率发送超过一个日志条目)
- leaderCommit: 当前leader节点最高的被提交的日志的索引(就是leader节点的commitIndex)
返回值
- term: 接收日志节点的term值, 主要用来更新当前leader节点的term值
- success: 如果接收日志节点的log[]结构中prevLogIndex索引处含有日志并且该日志的term等于prevLogTerm则返回true, 否则返回false
一个节点(无论当前是什么状态)接收到AppendEntries(term, leaderId, prevLogIndex, prevLogTerm, entries[], leaderCommit)
消息时, 其会做如下判断(条件从上往下依次判断):
- 如果参数携带的term < currentTerm, 则返回当前term并返回: (currentTerm, false), 并保持当前节点状态不变
- 如果参数携带的term >= currentTerm, 则设置currentTerm = term, voteFor = leaderId, 转换当前节点为Follower状态, 重置随机定时器, 进入下一步判断:
- 如果当前节点log[]结构中
prevLogIndex
索引处不含有日志, 则返回(currentTerm, false) - 如果当前节点log[]结构中
prevLogIndex
索引处含有日志但该日志的term不等于prevLogTerm, 则返回(currentTerm, false) - 如果当前节点log[]结构中
prevLogIndex
索引处含有日志并且该日志的term等于prevLogTerm, 则执行存储日志, 然后应用日志到状态机并返回(currentTerm, true) - 以上三点说明了,log在一个节点上是顺序append的 (日志提交的顺序:先append再apply)
- 如果当前节点log[]结构中
存储日志(日志同步过程)
- Leader上为每个节点维护NextIndex、MatchIndex,NextIndex表示待发往该节点的Entry index,MatchIndex表示该节点已匹配的Entry index,同时每个节点维护CommitIndex表示当前已提交的Entry index。转为Leader后会将所有节点的NextIndex置为自己最后一条日志index+1,MatchIndex全置0,同时将自身CommitIndex置0。
- Leader节点不断将user_data转为Entry追加到日志文件末尾,Entry包含index、term和user_data,其中index在日志文件中从1开始顺序分配,term为Leader当前的term。
- Leader通过AppendEntry RPC将Entry同步到Followers,Follower收到后校验该Entry之前的日志是否已匹配。如匹配则直接写入Entry,返回成功;否则删除不匹配的日志,返回失败。校验是通过在AppendEntry RPC中携带待写入Entry的前一条entry信息完成。
- 当Follower返回成功时,leader更新对应节点的NextIndex和MatchIndex,继续发送后续的Entry。如果MatchIndex更新后,大多数节点的MatchIndex已大于CommitIndex,则更新CommitIndex。Follower返回失败时回退NextIndex继续发送,直到Follower返回成功。
- Leader每次AppendEntry RPC中会携带当前最新的LeaderCommitIndex,Follower写入成功时会将自身CommitIndex更新为Min(LastLogIndex,LeaderCommitIndex)。
leader会将commit index置为0 --> 大部分follower将commitindex推进之后 --> leader才会推进自己的commit index --> leader代表整个系统推进commit index
InstallSnapshot RPC
该rpc主要用于leader将集群的快照同步给其他节点。这里主要讲一下快照的机制:
本节主要参考文章条分缕析 Raft 算法(续):日志压缩和性能优化
log过多就需要做快照,最初设计 LogCabin
的时候没有考虑日志压缩,因此代码上假定了如果 entry i 在日志中,那么 entry 1 到 i - 1 也一定在日志中。有了日志压缩,这就不再成立了,前面的 entry 可能已经被丢弃了。
和配置变化不同,不同的系统有不同的日志压缩方式,取决于你的性能考量,以及基于硬盘还是基于内存。日志压缩的大部分责任都落在状态机上。
不同的压缩方法有几个核心的共同点:
- 不将压缩决定集中在 Leader 上,每个服务器独立地压缩其已提交的日志。这就避免了 Leader 将日志传递给已有该日志的 Follower,同时也增强了模块化,减少交互,将整个系统的复杂性最小化。(对于非常小的状态机,基于 Leader 的日志压缩也许更好。)
- 将之前的 log 的维护责任从 Raft 转移到状态机。Raft 要保存最后被丢弃的记录的index和term,用于 AppendEntries RPC一致性检查。同时,也需要保存最新的配置信息:成员变更失败需要回退配置,最近的配置必须保存。
- 一旦丢弃了前面部分的日志,状态机就承担两个新的责任:
- 如果服务器重启了,需要将最新的快照加载到状态机后再接受 log;此外,
- 需要向较慢的 follower(日志远落后于 Leader)发送一致的状态镜像。(
InstallSnapshot RPC
)
memory-based 状态机的快照的大部分工作是序列化内存中的数据结构。
快照的并发性
创建一个快照需要耗费很长时间,包括序列化和写入磁盘。**因此,序列化和写快照都要与常规操作并发进行,避免服务不可用。**copy-on-write 技术允许进行新的更新而不影响写快照。有两个方法来实现:
- 状态机可以用不可变的(immutable)数据结构来实现。因为状态机命令不会 in-place 的方式来修改状态(通常使用追加的方式),快照任务可以引用之前状态的并把状态一致地写入到快照。
- 另外,也可以使用操作系统的 copy-on-write。例如,在 Linux 上可以使用 fork 来复制父进程的整个地址空间,然后子进程就可以把状态机的状态写出并退出,整个过程中父进程都可以持续地提供服务。LogCabin中当前使用的就是这种方法。
快照实现以及何时做快照
服务器需要决定什么时候做快照。太过频繁地做快照,将会浪费磁盘带宽和其他资源;太不频繁地做快照,则有存储空间耗尽的风险,并且重启服务需要更长的重放日志时间。
**一个简单的策略是设置一个阈值,当日志大小超过阈值则做快照。**然而,这会导致对于小型状态机时有着不必要的大日志。
一个更好的方法是引入快照大小和日志大小的对比,如果日志超过快照好几倍,可能就需要做快照。但是在做快照之前计算快照的大小是困难并且繁重的,会引入额外负担。所以使用前一个快照的大小是比较合理的行为,一旦日志大小超过之前的快照的大小乘以扩展因子(expansion factor),服务器就做快照。
这个扩展因子权衡空间和带宽利用率。例如,扩展因子为 4 的话会有 20% 的带宽用于快照(每1byte 的快照写入有对应的 4bytes 的 log 写入)和大约 6 倍的硬盘空间使用(旧的快照+日志+新的快照)。
快照仍然会导致 CPU 和磁盘的占用率突发,可以增加额外的磁盘来减轻该现象。
**同时,可以通过调度使得做快照对客户端请求没有影响。**服务器需要协调保证在某一时刻集群只有小部分成员集同时在做快照。由于 Raft 是多数派成员构成的 commit,所以这样就不会影响请求的提交了。当 Leader 想做快照的时候,首先要先下台,让其他服务器选出另一个 Leader 接替工作。如果这个方法充分地可行,就可能消除快照的并发,服务器在快照期间其实是不可用的(这可能会造成集群的容错能力降低的问题)。这是一个令人兴奋的提升集群性能并降低实现机制的机会。(这里其实可以通过实现指定服务器做快照来优化,braft 里就有提到这点。)
快照实现
根据log的实现方式不同(分为memory-based和disk-based),快照也有不同的实现方式
disk-based
对于几十或上百 GB 的状态机,需要使用磁盘作为主要存储。对于每一条记录,当其被提交并应用到状态机后,其实就可以被丢弃了,因为磁盘已经持久化存储了,可以理解为每条日志就做了一个快照。
Disk-based 状态机的主要问题是,磁盘会导致性能不佳。在没有写缓冲的情况下,每应用一条命了都需要进行一次或多次随机磁盘写入,这会限制系统的整体吞吐量。
Disk-based 状态机仍然需要支持向日志落后的 Follower 提供最新的快照,而写快照也要继续提供服务,所以仍然需要 copy-on-write 技术以在一定期间内保持一个一致地快照传输。幸运的是,磁盘总是被划分为逻辑块,因此在状态机中实现应该是直接的。基于磁盘的状态机也可以依靠操作系统的支持,例如 Linux 的 LVM
也可以用来创建快照。或者是使用系统的COW支持,Linux的fork,或者是ZFS的Snapshot等。
memory-based
memory-based日志主要有Log-structured File System 或 LSM tree方式做快照