Raft 协议阅读笔记
Posted 书阁琐记
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Raft 协议阅读笔记相关的知识,希望对你有一定的参考价值。
本笔记是阅读 Raft 作者博士毕业论文的过程中所做的笔记, 水平有限,说有纰漏之处, 还望指正.
前言
已有的统计数据显示, 每年大约有 2%-4% 的磁盘发生故障, 服务器宕机非常常见, 中型数据中心的每天发生数十次的网络链路故障. 在这样的背景下, 大规模分布式系统下的服务器和网络故障容忍以及数据一致性问题至关重要.
实际系统采用的一致性协议通常有以下几个典型特点:
在不出现拜占庭将军问题的情况下, 能够保证数据安全, 包括但不限于网络延时, 数据包丢失/重复/乱序.
多数节点存活且存活节点之间未发生网络分区的情况下, 全部的系统功能均可用 (fully functional).
不依赖计时来保证数据的一致性, 错误的时钟以及严重的消息延迟, 也不会造成可用性问题.
少数的慢节点不影响整体系统的性能.
所谓的拜占庭将军问题 (Byzantine Generals Problem) 指的是分布式系统中, 节点间进行消息传递的容错性问题. 该问题由 Paxos 作者 Leslie Lamport 提出 (https://zh.wikipedia.org/wiki/拜占庭将军问题).
一组拜占庭将军分别各率领军队共同围困一座城市. 有进攻和撤退两种策略, 但是将军们需要保持协同一致, 否则将会遭受重损失.
这组将军存在的问题在于, 系统中可能出现叛徒. 假设有9位将军投票, 其中1名叛徒. 8名忠诚的将军中出现 4 投进攻, 4 投撤离的情况. 这时候叛徒可能故意给4名投进攻的将领送信表示投票进攻, 给4名投撤离的将领送信表示投撤离. 这样一来在4名投进攻的将领看来, 投票结果是 5 投进攻, 从而发起进攻; 在 4 名投撤离的将军看来则是 5 投撤离. 这样各个军队的一致协同就遭到破坏.
由于将军之间需要通过信使通讯, 叛变将军可能通过伪造信件来以其他将军的身份发送假投票. 即使在保证所有将军忠诚的情况下, 也无法排除信使被敌人截杀, 甚被敌间谍替换等情况.
能够对拜占庭问题容错一直是各种系统努力的目标, 但是很遗憾, 这个问题是无解的. 在比特币中, 比特币的区块链网络在设计时提出了创新的 PoW (Proof of Work) 算法思路. 一个是限制一段时间内整个网络中出现提案的个数 (增加提案成本), 另外一个是放宽对最终一致性确认的需求, 约定好大家都确认并沿着已知最长的链进行拓宽. 系统的最终确认是概率意义上的存在. 这样, 即便有人试图恶意破坏, 也会付出很大的经济代价 (付出超过系统一半的算力). 后来的各种 PoX 系列算法, 也都是沿着这个思路进行改进, 采用经济上的惩罚来制约破坏者.
现有的多数系统的解决方案都是基于 Paxos 协议, 或者深受该协议影响. 然而, Paxos 是一个很难理解的协议, 且原始论文缺乏很多关键的实现细节, 想要理解和在实际系统中应用该协议困难重重. 其他的一致性算法还有 Viewstamped Replication 和 Zab 等, 理解成本同样很高.
Raft 作者决定提出 Raft 的初衷, 就是实现一个易于理解和实现的分布式一致性系统. 作者在提出该协议后, 在斯坦福大学进行了 Paxos 和 Raft 的对比教学实验, 最终的对比数据表明, 学生们能够很轻松的理解 Raft 协议. 作者也在 RAMCloud 开源项目中实现了该协议, 可以作为其它实现的参考.
基础 Raft 协议
这一部分介绍 Raft 协议中最基本的部分, 其它的扩展特性将在后续的部分介绍.
基础 Raft 协议的状态, rpc, 以及规则
基本概念
复制状态机 (replicated state machine)
在一致性协议中, 多个节点形成一个复制组, 这个复制组内所有节点需要确保应用值更新的顺序完全一致, 即使他们的状态机保持一致. 这个复制组还需要能够容忍节点故障, 保证已经被接受的值不会丢失. 复制状态机是实现这一要求的常见手段.
复制状态机架构示意图
典型的复制状态机实现基于复制日志(replicated log). 在上图所示的架构中:
来自客户端的命令被服务器的一致性模块接收, 被分别加到自己的日志中.
一致性模块在不同的服务器之间通讯, 对每条日志决定是否接受, 并确保他们在不同服务器上的日志顺序是一致的. 一条日志经过协商之后, 可能会被接受, 也可能会被丢弃, 被接受的日志/命令状态为
已提交的
(committed).所有 committed 状态的命令按照日志顺序被服务器状态机处理, 处理的结果返回给客户端.
对于客户端来说, 复制状态机看起来就是一个高可用的单点状态机, 而无需去分辨其中的区别.
复制状态机通常有两种实现模式: 第一种下复制组中的节点没有明显的主备, 节点协商从复制状态机读取或写下的数据; 第二种模式存在一个 leader 节点, 其它节点处于 standby, 由 leader 负责接收客户端的请求, 并向复制状态机提交更新, 其它的 standby 节点之一只有在 leader 故障时才可能被切换为新的 leader.
复制状态机的常用实现模式
Raft 协议本质上是用于管理复制状态机中的复制日志的一个算法. 其采用了第二种实现模式, 复制组中仅存在一个活跃的 leader, 接收来自客户端的请求, 打包成日志, 向其它节点分发, 并告知其它节点何时可以应用这些日志.
状态
在 Raft 的一个复制组中, 每个节点可能处于 3 种状态:
leader: 唯一能处理客户端请求, 以及负责生成以及复制 log 的节点.
candidate: 选主开始后, 有资格被选为主的节点.
follower: 最普通的状态, 只响应来自 leader 或者 candidate 的请求.
Raft 状态变迁
term
Raft 将时间且分为 term, 用连续递增的整数标记. 每一轮 term 以选主开始, 在这个 term 期间, 一个或多个 candidate 试图将自己选为 leader:
如果其中一个 candidate 成为了 leader, 它在这个 term 内就一直是主, 成为主的前提是有多数节点 (可以包括自己) 投票给这个 candidate.
如果选不出 leader 来, term 结束.
term 示意图
term 在 Raft 中是作为逻辑时钟来使用的, 表示了时间的流逝. 有些节点可能由于网络或其它原因, 感知不到一些 term 的存在. 节点间进行通信的时候会携带 term 信息. 一个节点发现自己的 term 小于其它节点的 term, 则更新自己的 term 到其它节点携带的更大的值. 如果出现此情况的节点状态为 candidate 或 leader, 则需要立刻将自己切为 follower, 因为这意味着这个节点错过了一些 term, 当前的状态不准确.
RPC (remote procedure call)
Raft 使用 RPC 进行节点间通信, 基础 rpc 协议包含 2 个 RPC:
RequestVote: candidate 选主的时候, 向其它节点请求投票;
AppendEntries: leader 向其他节点传输 log, 或维持心跳 (空数据);
选主
心跳是 Raft 触发选主的关键.
leader 定期向所有 follower 发送心跳, 来维持 leader 和 follower 之间的联系. 心跳包其实就是 AppendEntries, 当没有数据时, 使用空包. 一个 follower 超过一段时间没有收到来自 leader 的心跳, 就认为 leader 已经不活跃了, 会企图发起一次选举. 这段时间称之为选举超时 (election timeout).
开始选举时, follower 对自己的 term + 1, 状态变为 candidate, 然后并发地:
投自己一票
向其它节点发送 RequestVote rpc, 请求它们投自己一票
本轮选举的 candidate 状态一直持续, 直到发生了下面的三个事件之一:
本节点赢得了选主, 变为 leader.
获知另外一个节点变为了主.
选举超时, 没有获胜者, 这时候需要发起另外一轮选主.
一个 candidate 状态变为 leader, 意味着它赢得了绝大部分节点的投票. 节点在投票时遵循先到优先的原则. 在给定的 term, 只有最先到达的 cadidate RequestVote 请求能够赢得节点的选票. leader 在选举成功后, 会立刻发送心跳消息, 来阻止其它节点尝试发起新的选主.
在等待投票的期间, candidate 可能收到其它节点的 AppendEntries rpc. candidate 需要判别这个 rpc 是不是过时的, 判断的依据就是 term:
rpc term >= candidate term, 发送 rpc 的 leader 是合法的, 该 leader/rpc 是合法的, candidate 降级为 follower;
leader term < candidate term, 这是一个过时的 rpc, 拒绝并维持在 candidate 状态.
当多个节点同时成为 candidate, 就可能导致选票被分流, 没有任何一个节点如愿获得多数选票. 在这种情况下, 选举会超时, candidate 发起新一轮的选举. Raft 将选举超时时间进行了随机化 (在一个固定的区间内选择一个随机值作为超时时间, 通常这个区间为 [150ms, 300ms)
), 以避免活锁.
如果每个节点的选举超时时间是一个固定的周期, 那么一旦出现选票分流, 由于每一轮周期内, 所有节点的动作都几乎一样, 这个分流的情况有很大概率持续下去.
随机化选举超时时间看起来是个很简单的机制, 但却是 Raft 作者尝试过很多更复杂的机制之后得到的.
日志复制 (log replication)
一旦 leader 被选出来之后, 就开始处理客户端请求. 写/更新的处理流程如下:
leader 接收到请求之后, 形成一条 log entry;
leader 将 log entry append 到本地, 同时并发的向所有 follower 发送 AppendEntries 消息;
follower 接收到 AppendEntries 请求后, 将 log entry 追加到本地 log, 完成后 ack leader 成功;
当 leader 接收到多数节点的成功 ack (可以包括 leader 自己), 该 log 可以安全的应用到状态机, 同时回复 client 请求执行成功.
写/更新流程
log entry 示意图
在这个过程中有一些关键的细节:
如果 follower 宕机或者很慢, leader 需要无限重试, 直到所有的 follower 将日志存储下来. 这个是为了保证最终所有的 follower 都拥有全部数据.
每一条 log entry 必须携带 (term, log index) 信息, 前者是日志创建时的 term, 在 Raft 中的作用是解决日志冲突, 后者是连续递增的正整数, 用于保证日志连续. 同一个复制组内, (term, log index) 也用于保证数据的唯一性, 具有相同 (term, log index) 的 log entry 包含相同的数据.
被多数接受的日志成为 committed, Raft 的一致性模型保证进入 committed 状态的日志不会回退, 当然这个有一个前提条件, 即不出现 quorum dead.
follower 处理每一次的 AppendEntries 时都需要做一致性检查. 为了进行检查, AppendEntries 携带了上一条日志的 term 和 log index, 根据这个信息决定决绝还是接受该 AppendEntries (后面的章节会详细描述).
当 leader 发生切换时, 某些 follower 可能会包含和新 leader 不一致的数据, 这种情况下, leader 的数据是最权威的, 其它 follower 需要用 leader 的数据覆盖本地的冲突数据, 使得数据恢复到和 leader 一致的状态.
不同的数据不一致情况
安全性
选举限制
在某些一致性算法中, leader 被选举出来的时候可以没有全部的数据, 这些算法会使用很复杂的恢复机制, 来保证 leader 能够恢复全部已经 commited 的日志. Raft 认为这样的方式过于复杂, 采用了一个极为简单的机制, 确保刚选举出来的 leader 就具有全部的 commited 数据, 所有的数据流动都只有一个方向, 即从 leader 到 follower.
Raft 中有资格作为 candidate 的节点必须在本地有全部的 commited 日志. 想成为 candidate 的节点首先和复制组中的多数节点 (包括自己) 取得联系, 只有本地数据不比这个多数里的任何一个节点旧, 才有资格成为 candidate. 这个工作是通过 RequestVote rpc 来完成的.
提交之前 term 的 log entry
新 term 的 leader 选举出来后, 它需要将以前所有的未 committed 日志提交到多数节点. 有趣的是, 这个过程中达成多数的日志, 是可能丢失的. 下图给出了一个例子.
旧 term 的未 commit 日志即使达成多数也有可能被丢失
看起来这个是和 Raft 的承诺是违背的, 其实不然, 这些日志, 并没有给客户端 ack 成功, 成功与否是模棱两可的, 可以称之为 薛定谔的日志
.
但是为了避免 committed 日志回退, 依然需要引入一个新的限制, 就是 leader 只能 commit 本 term 的日志, 旧 term 的日志, 即使被多数接受, 也不能标记为 committed. 其它一些协议采用了更复杂的做法, 即将这些旧日志的 term 更新到当前 term, 当做新日志来分发.
follower 或 candidate 失联
在 Raft 中, leader 采用的是无限重试的机制, 来处理 follower 和 candidate 失败.
持久化状态和节点重启
除了日志本身, Raft 只有当前的 term 和投票信息 (记录投票给哪个节点了) 是需要持久化的, 来保证节点能够安全重启, 其它的状态都无所谓. 这两个信息是为了保证 term 不出现回退, 以及不会在同一个 term 里给不同的节点投票.
其中一个有趣的信息是 commited index, 该值记录了当前已经被 commited 的最大 log index. 如果 leader 存活着, 其它节点重启后, 立刻就能获得准确的 committed index, 否则, 新 leader 选举后, 提交一条日志就能推进 committed index 到更大的值.
记时和可用性要求
Raft 虽然不依时钟来保证协议的正确性, 但是一些参数的设置不当可能会影响协议的可用性, 通常要求:
RPC 的往返时延 RTT << 选举超时时间 << 平均故障间隔时间 MTBF
选举超时时间 < RPC 的往返时延 RTT, 会导致选举无法进行, 也无法保持心跳, 因为还未收到 ack, 就已经超时了.
平均故障间隔时间 MTBF < 选举超时时间, leader 发生故障后, 等待重新选举的时间很长.
在一个典型的系统中, RPC 往返延时大约为 0.5ms 至 20ms, MTBF 通常为数个月, 选举超时可以在 [10ms, 500ms) 的区间中选择.
leader 转移 (leader transfer)
这是 Raft 协议的一个可选扩展, 及时没有也不影响协议正常运行. 这个扩展主要是为了使 leader 的选举过程更为可控:
现实中可能会对当前的 leader 执行一些有计划的维护或管理工作, 例如机器需要重启, 或从集群中移除. 没有本扩展的话, 无主时间至少经历一个选举时间超时后, 但是如果我们能提前把 leader 角色转移给其它节点, 那么这个时间就可以有效缩短.
有些时候, 某些节点自身原因 (负载高, 部署在其它集群里), 不适合充当 leader, 这个时候我们尽量避免将这些节点选为 leader.
leader transfer 的流程如下:
leader transfer 的流程
如果在选举超时前没有完成 leader transfer 的过程, 当前 leader 主动停止转移过程, 恢复接受客户端请求. 即使当前 leader 误判也没有关系, 因为选举过程的多数原则会保证不会出现数据不一致问题.
复制组成员变更
成员变更的安全性
成员变更算法的安全性就一个关键点: 同一个 term, 不能出现两个 leader.
复制组配置直接切换是不可行的, 下面的图是一个例子. 在某个时间点, 没有完成配置切换的配置和已经完成切换的配置均认为自己满足多数条件.
直接切换配置存在风险
一种简单的复制组变更算法: 单节点变更算法
算法描述
Raft 最早实现的复制组变更算法比较复杂, 这个简单算法是后来才提出的. 该算法的核心思想就是一次只允许加减一个节点, 在前一个变更完成前, 下一次的变更会被拒绝.
一次只允许加减一个节点, 多数是始终被保证的
这个其实可以在数据上做简单证明.
加节点, 节点数从偶数 2N 变为奇数 2N+1 的情况, 变更前后多数均为 N+1.
加节点, 节点数从奇数 2N+1 变偶数 2N+2 的情况, 变更前后多数为 N+1 和 N+2.
减节点, 节点数从偶数 2N+2 变为奇数 2N+1 的情况, 变更前后多数为 N+2 和 N+1.
减节点, 节点数从奇数 2N+1 变为偶数 2N 的情况, 变更前后多数均为 N+1.
那么如果两个集合没有共同节点, 则需要 1 和 4 这两种情况需要至少 2N+2 个节点, 大于节点总数, 矛盾; 2 和 3 这两种情况需要至少 2N+3 个节点, 同样大于节点总数. 由此可以反证得到, 加减节点前后的多数必要有至少一个共同节点.
算法步骤如下:
leader 在旧配置 (Cold) 的基础上, 增加或减少一个节点, 形成新的复制组配置 (Cnew);
复制组配置作为一个新的特殊 configuration change log entry, 和正常日志一样分发给 (Cnew) 的所有节点;
新的配置一旦被写到日志中, 就直接更新 (不需要等到 commit);
如果复制组配置被 Cnew 的多数节点接受, 则可以 commit, 配置变更完成.
由于配置的更新没有等到 commit, 节点必须做好配置回退的准备, 始终使用最后一条日志里的配置. 同时, Raft 中投票和日志复制的时候都不应该检查配置是否匹配.
加节点追日志阶段
当一个新节点加入集群中, 它可能没有任何日志, 需要很长时间才能追上 leader 的日志. 如果配置变更是马上生效的, 这个过程中, 其他节点的宕机, 可能会导致在这个新加入的节点追完日志前, 整个复制组无法提交任何新的日志.
3 节点复制组新加一个节点的例子
例如上面的例子, 一个 3 节点的复制组 {S1, S2, S3}, 加入了一个新节点 S4. 复制组的多数从 2 变为 3, 如果一加进来, S3 就宕机了, 那么直到 S4 追完现有的日志, 整个复制组才能提交新的日志. 如果日志量非常大, 这个不可用时间会很长.
为了减少不可用时间, Raft 为复制组变更引入了一个新的追日志阶段, 在这个阶段里, 新加入的节点只能追日志, 没有投票权. 只有当追上其它的节点的日志时, 才发起配置变更.
新节点追日志过程
在正式发起配置变更之前, leader 可能终止这个过程, 如果新加入的这个节点宕机或者过慢, 导致很长时间都未能完成追日志的过程的话.
Raft 将追日志的过程分为若干轮, 在每一轮, leader 将本轮开始时已有的日志发送给新加入的节点. 在每一轮传输的过程中, 可能有新的日志又带来了, 所以会开始新的一轮. 直到:
新加入的节点的日志追完所有日志, leader 发起配置变更
轮数达到上限, 且最后一轮的耗时不超过选举超时, leader 发起配置变更
轮数达到上限, 且最后一轮的耗时超过选举超时, leader 放弃配置变更
移除当前 leader
leader 将自己从复制组里面移除是一个比较有趣的问题.
这个 leader 必然需要在某一个时刻 stepdown, 这个时刻必须在 Cnew commit 之后, 否则这个节点仍有可能被选举为 leader, 导致整个过程被延长.
此外, 在 commit 配置变更的时候, 被移除的 leader 不能把自己算在 quorum 里面! 这就出现了由一个不在配置组内的节点, 决定日志 commit 的怪异现象.
被移除节点造成的混乱
当一个节点从复制组里移除之后, 它不会再收到 leader 的心跳. 不幸的是, 它并不知道自己被从复制组里移除掉了, 因此, 它会尝试发起选举 (这是标准的 Raft 协议动作!). 这样当前 leader 收到更高的 term 之后, 会将自己 step down 到 follower (前面提到, 选举不需要检查配置是否匹配!). 这个节点不会被选举为 leader, 但是它会不断打断 raft 复制组正常的工作, 导致可用性降低.
最初为了解决这个问题, Raft 引入了一个新的选举阶段, Pre-Vote 阶段. 在这个节点, 节点需要其它节点的预投票, 只有收集到多数, 才能发起正式的选举过程. 这个阶段不会改变任何持久化的信息和节点状态.
然而, 这个 Pre-Vote 阶段还是遇到了其它无法解决的问题, 当然, 这个阶段在增强选举的鲁棒性方面还有其它的作用, 后面会提及.
leader 移除节点的日志未能到达到多数
在上图的例子中, S4 是 leader, 尝试移除 S1. 但是配置变更的日志在 commit 之前, S1 的选举定时器先超时了, 触发了选主. S1 在收集到 S2 和 S3 的投票之后成为新的 leader. S4 随后被强制 step down 到 follower. 在 S1 commit 一条新日志之前, S4 可能会重新发起选主夺回 leader 角色 (毕竟它的日志更新, 在选主的时候有更高的优先级). 只要 Cnew 没能达到多数, 这个过程还会反复出现. Pre-Vote 对这种 case 无能为力.
Raft 采取的方案是, 如果当前的 leader 还活着, RequestVote RPC 会被拒绝. 只有当节点丢失心跳超过最小选举超时时间, 才会处理新的 RequestVote 请求.
这个机制不影响正常的选主, 但是与之前的 leader transfer 是冲突的. 在 leader transfer 里, 选举没有等到选举超时就发起了. 这个机制很大概率会 leader transfer 失败. 当然, 在这种情况下, leader transfer 的 RequestVote 可以携带一个特殊的 flag, 表明, 该请求不受上述规则的限制, 需要马上处理.
感觉 Pre-Vote 后面描述的部分有浓浓的补丁感, 解决方案不够优雅. 整个 Raft 的一个基础就是不依赖绝对时钟, 而这里的方案确要求比较精确的最小选举时间. 在实际系统中, 遇到这个 case 的概率极小. 一个没有后续写请求的系统, 不断重新投票其实没有太大副作用, 而一旦后续的日志到来并 commit, 这里的配置变更, 成功或失败, 均会被最终定格下来. 当然, 如果 Cnew 日志能够复制到多数, 配置变更也能确定下来了.
相关 RPC
节点变更相关 rpc
更复杂的复制组变更算法: 交叉共识 (Joint-Consensus) 配置变更算法
本算法允许一次变更复制组的多个成员. 这是最早实现的算法, 作者不推荐在实际系统中采用, 因为确实比较复杂.
算法示意图
算法流程如下:
新加入的节点追日志, 全部节点追上来之后, 开始配置变更流程;
leader 一条特殊的 Cold, new 交叉变更日志, 该日志同时需要被 Cold 和 Cnew 的多数接受, 才能 commit;
leader 形成一条配置 Cnew 变更日志, 发送给 Cnew, Cnew 的多数接受即 commit, 配置变更完成.
这个算法的特殊之处, 就是存在一个交叉的时间段, Cold, Cnew 共同起作用 (发起配置变更到 Cnew commit). 在这个时间段内:
所有的日志, 都需要同时包含 Cold 和 Cnew 配置, 同时被两者的多数接受才能 commit.
如果触发选主, 两个配置中的任何一个节点都有资格成为 leader. 在 Cold, new commit 前触发选主的话, 选举可能是基于 Cold, 或者基于 Cold, new, 取决于获胜的 candidate 是否收到了 Cold, new 变更日志. Cold, new 一旦被 commit, 无论怎么选主, 选出来的 leader 一定能够看到 Cold, new 的, 可以安全的切换到 Cnew 了.
日志压缩
随着时间的推移, Raft 复制组会积累大量的日志, 如果不进行压缩, 势必会占用大量的磁盘空间, 且导致重启时加载时间过程.
但是, 日志压缩不存在一个普适的解决方案, Raft 并不能理解日志中的数据, 需要业务自行处理. Raft 中, 将压缩后的数据称之为 snapshot.
一个 Raft snapshot 需要持久化 prevIndex, prevTerm, prevConfig 三项信息, 分别代表 snapshot 压缩式最后一条日志的 index, term, 和配置. 包括 prevIndex 在内的旧日志在完成 snapshot 可以被删除.
当 leader 中不存在 follower 需要的数据时, leader 通过 InstallSnapshot rpc 向 follower 直接发送 snapshot.
InstallSnapshot RPC
客户端交互
将请求路由给 leader
当客户端刚启动, 或者 leader 切换的时候, client 可能将请求发给了 follower, follower 可以采用两种方式来处理这个请求:
proxy 方式: follower 作为代理, 向 leader 转发请求.
高效处理只读请求
只读请求值查询状态机, 并不会改变它, 理应不需要像写请求那样, 走一遍写日志的流程. 但是不经过 Raft 复制组带来一个问题, 就是处理请求的 leader 可能是一个过时 (stale) 的主, 它的状态是滞后的, 只读请求得到一个过时的结果.
leader 采取了以下步骤来保证只读请求的正确性:
如果 leader 在当前 term 下未提交过任何日志, leader 不能提供读服务. 这个是因为 committed index 信息可能会丢失, 甚至存在一些未 commit 的日志, 导致 leader 的状态机的状态是过时的. 只有第一条日志 commit 之后, 才能保证 leader 达到最新的状态. 为此, leader 在选举成功过后, 可以提交一条空的 no-op 日志, 该日志 commit 之后, 可以开始处理只读请求.
leader 在本地用变量 readIndex 记录当前的 commit index.
leader 需要确保本地的 readIndex 就是最新的, 集群中不存在更新的主, 为此, leader 向其它节点发送一轮心跳, 收集到多数 ack 之后, 就能够知道是否存在更新的主, 如果不存在, 那么 readIndex 就代表了最新的数据版本.
leader 需要等待它的状态机 apply 到 readIndex.
leader 查询状态机, 处理只读请求, ack 客户端.
实际上, 整个过程的关键就是通过一轮心跳还确定当前的 leader 是真 leader.
follower 也可以处理只读请求来分担 leader 的压力. 处理前, 需要向 leader 确认当前的 readIndex, leader 执行步骤 1-3, 步骤 4-5 在 follower 上执行.
可以通过一个 lease 的机制来优化读过程中的心跳. 一旦 leader 的心跳被多数节点确认了, 可以保证从发送心跳请求开始的一个选举超时时间内, 没有其它节点会成为主, leader 在这个时间内提供读是安全的, 可以不需要发送心跳消息的时间.
上述机制和 leader transfer 机制又是矛盾的, 因为 leader transfer 不需要等待选举超时, 为此, 对 leader transfer 做一个补充, 就是: leader 在发起 leader transfer 前主动让 lease 过期.
这个 lease 机制也有一个致命的缺陷, 就是依赖时钟的精装度, 时钟的漂移必须控制在一个给定的范围内. 但是实际上很难做到, 各种因素 (调度, 垃圾回收, 虚机迁移, 时钟同步) 都可能导致时钟漂移过大.
lease 机制
还有一种简单的方式, 那就是客户端记住复制组 ack 给自己的最大 log index, 处理请求的节点只需要保证本地数据不比 log index 更旧即可.
这种方式对多 client 写同一份数据的 case, 其实是有风险的. 其中一个 client 写下去的数据, 无法被另外一个 client 感知到, 该 client 还记录着一个较旧的 log index. 但是通过每次与复制组交互后都更新到更新 log index, 来保证 client 读到的数据版本是递增的.
选举的进一步改进
选举时间的预期
选主事件的时间线
Raft 的作者在数学上证明了, 没有选票分流的情况下, 复制组中节点的数量越多, 无主时长的期望值会越小. 简单的讲, 就是节点越多, 有节点比较靠近随机时间区间最小值的概率就越高, 这个节点会率先发起并完成选主.
固定网络延时下, 出现选票分流的概率变化
无主时间和选举超时时间的关系
作者又分析了选举超时时间的取值 (选定该值后, 随机选举超时时间取值范围为 [选举超时时间, 2 * 选举超时时间)) 对选票分流概率的影响, 结论是范围定得越大越好, 但是取值在延时的 10 倍时出现选票分流的概率已经相当小了, 此时, 平均无主时间不会超过 20 倍延时.
Pre-Vote 阶段
前面提及的 Pre-Vote 阶段可以部分解决节点被移除后仍然会打断 leader 工作的问题. 其实, Pre-Vote 作为选举的一个扩展, 更主要的作用是防止网络分区再次恢复后, 某些节点打断正常 leader 的工作.
如果没有该机制, 当出现网络分区后, 必然会有节点成为被隔离的少数, 这些节点会不断的发起一轮轮的选主过程, 但是都会失败, 导致 term 值一直往上累加. 而其它的节点会选出 leader 来, 它们的 term 保持在一个比较低的值. 当通信恢复后, leader 看到更高的 term, 将自己 step down 成 follower, 重新触发选主. 更加严重的是, 如果这少数的节点网络时好时坏, leader 会不断的被 step down, 甚至导致整个复制组无法正常工作.
Pre-Vote 阶段, candidate 只有收集到多数节点的预投票, 才能继续下面的选举流程, 发起 RequestVote. 一个节点可以给一个 candidate 的 Pre-Vote 请求 ack ok 的条件是, candidate 的日志不比自己的更旧, 且在最小选举超时时间内, 没有收到的有效的心跳消息.
实现时的一些性能优化点
并行写 leader 的磁盘
在实践中, 写 leader 本地磁盘和向其它节点 AppendEntries 是可以并行进行的. 如果 leader 的本地磁盘较慢, 其它节点的 ack 更早返回了, 且满足了多数, leader 可以直接 commit 相应的日志, 无需等到本地磁盘写完.
批量提交以及流水线化
leader 在发送数据的时候, 可以在一个 AppendEntries 里面批量提交多个日志, 来提高效率. 写磁盘同样也可以批量进行.
流水线化, 指的是 leader 无需等待前一个 AppendEntries 的 ack, 即可发送下一个 AppendEntries.
这两种方式能够一定程度上提高吞吐, 降低延时.
Raft 正确性的证明
Raft 正确性的证明是通过 TLA+ (Temporal Logic of Actions, 行为时序逻辑) 语言进行的, 该语言由 Leslie Lamport 发明, 主要用于数理逻辑计算和并发系统的正确性验证. TLA+已在航空航天, 微软, 亚马逊等实际系统中起到关键作用.
Leslie Lamport 因此项发明获得图领奖.
TLA+ 的一个入门教程: https://learntla.com/introduction/.
以上是关于Raft 协议阅读笔记的主要内容,如果未能解决你的问题,请参考以下文章