共识算法就是保证一个集群的多台机器协同工作,在遇到请求时,数据能够保持一致。即使遇到机器宕机,整个系统仍然能够对外保持服务的可用性。
对于一个分布式系统而言,可扩展性 是非常重要的,因此 共识算法 需要具备 在 集群 中 不停服务增减机器 的能力。
在学术理论界,分布式一致性算法的代表还是 Paxos,但是 理解起来很费劲。因此 Paxos 的可理解性 & 工程落地性的门槛很高。
斯坦福学者 花了很多时间理解 Paxos,于是他们研究出来 Raft。相比Paxos, Raft最大的特性就是 易于理解。
Raft算法 为了 达到 易于理解的目标,主要做了 两方面的事情:
问题分解
把 共识算法 分解为 三个子问题: 领导选举
、日志复制
、安全性
转态简化
对算法做出一些限制,减少 状态数量 和 可能产生的变动。
是一台服务器(节点实例)只在 三个状态 之间进行切换,并且服务器之间的通信 仅通过 两类 RPC来完成。
这是的 Raft 相对于其他 算法 非常的简洁。
Raft算法 在 功能 和 性能 方面 完全可以和 其他公式算法 等同,因此Raft出现以后,越来越多的 软件 在 实现
共识算法 时 都会优选选择 Raft算法。
这是一个理解 共识算法 非常重要的 高度抽象的 一个 实现模型。
相同的 初始状态 + 相同的 输入 = 相同的 结束状态
在Raft中, Leader将客户端的 请求 封装到一个个 log entry
中, 将这些 log entry 复制到 所有的 Follower节点,
然后 大家 按照相同的 顺序 应用 log entry, 根据 复制状态机理论, 大家的 结束状态 肯定就是 一致的。
可以说 使用 共识算法 就是为了 实现 复制状态机,从而 始终保持 分布式中 各节点 的状态一致, 进而 实现 高可用。
在任何时刻,每个节点 都处于 Leader、Follower、Candidate 这三个状态之一,
Raft要求系统在 任意时刻 最多只有一个Leader,正常工作 期间 只有Leader和Followers。
相比Paxos,这一点就极大简化了 算法的实现, 因为 Raft只需考虑 状态的切换, 而不用像Paxos那样 考虑状态之间的 共存 和 互相影响。
Raft算法将时间分为一个个的任期(term),每个任期 Term 都有自己的编号 TermId
,该编号全局唯一且单调递增。
每个 任期 的开始 都是 领导选举。
举个例子,参考下图,Term N 选举成功,Term N+1 和 Term N+2 选举失败,Term N+3 重新选举成功。
Raft算法中的服务器 节点使用 RPC
进行通信, 并且 Raft中 只有 两种
主要的 RPC:
通信过程中的 几个状态变化:
小于
其他服务器的,过期了
, 会立即回到 follower 状态。拒收
这个 请求。Raft内部有一种 心跳机制, 如果存在 Leader, 那么他就会 周期性地 向所有的 Follower 发送心跳,
依次来维持自己的 领导地位。
如果 某个 Follower 一段时间 没有收到心跳,那么他就会 任务 Leader 出问题了,然后就 进行选举。
随机选举超时时间
(一般为 150~300ms)后,增加 自己的任期号, 开始新一轮的 投票。随机选举超时时间
内 没有选出 Leader 就会进入 下一轮的 选举。type RequestVoteRequest struct {
term int // 自己的当前任期号
candidate int // 自己的ID
lastLoginIndex int // 自己最后一个日志号
lastLogTerm int // 自己最后一个日志的任期
}
type RequestVoteResponse struct {
term int // 自己的当前任期号
voteGranted bool // 自己是否给这个candidate投票
}
对于 没有成为 candidate的Follower 节点,对于同一任期, 会按照 先来先得 的原则 投出自己一票,
即 节点A 请求 节点B 投票, 如果 节点B通过自己的判断 投给了 节点A, 那么节点B 就不能再投给其他的节点了。
这也是为了 奖励那些 率先 发现 Leader 出问题的 Follower,让他们有更大的概率 成为下一届的 Leader。这就是所谓的 新发优势。
至于 为什么 请求投票 的 RPC Request 中有 日志的信息, 这个 放到 安全性的子问题 中 进行说明。
Leader 被选举出来后, 开始为 客户端 提供服务, 那 客户端 是怎么知道 哪个节点是 Leader?
Leader 收到 客户端 的请求, 会将该 请求 以 指令的形式 追加一条 新的条目 到 日志中。
每个 日志 中 需要具有 3 个信息:
生成日志之后,Leader 并行地 发送 AppendEntries RPC 给 众多 Follower,
让他们复制 该条目,当 该条目 超过半数
的Follower 复制后,
Leader就可以 在本地 执行 该指令,并把结果 返回给客户端。
我们把 本地执行指令 称作 提交。
注意:日志复制 超过半数 就 百分百提交哪?
当然不是,因为 Follower复制完成,到 通知Leader,再到 Leader完成提交, 是需要时间的,
这个时间内 Leader 如果宕机了,就 无法进行 提交了。
在日志复制过程中, Leader 和 Follower 随时都有 宕机 或 缓慢 的可能,
Raft 必须要在 有宕机的情况下 继续支持 日志复制,并且保证每个副本的 日志顺序一致。
一致性检查
生效,保证 Follower 能按顺序 恢复 崩溃后的 缺失的 日志。一致性检查:
Leader在每一个发往 Follower 的追加条目的RPC中,会放入前一个日志条目的索引位置
和任期号
,如果 Follower 在他的日志中 找不到前一个 日志,那么他就会拒绝
此日志, Leader收到 Follower 的 拒绝后, 会发送前一个
日志条目,从而 逐渐向前 定位到 Follower 第一个缺失的 日志。
强制 Follower 复制他的日志
来解决 不一致的 问题。type AppendEntriesRequest struct {
term int // 自己的当前任期号
leaderId bool // 用户告诉 Follower 我是 leader
entries []byte // 当前 日志体
// 以下两个属性 用来 一致性检查的
prevLogIndex int // 前一个日志的 日志号
prevLogTerm int // 前一个日志的 任期号
// Follower根据该字段,就可以把 自己 复制未提交的 日志 设为 已提交 状态
// 对于那些 追赶Leader日志进度 的 Follower来说,leaderCommit 大于自己最后一个日志,这时它的所有日志都是可以提交的。
leaderCommit int // leader的已提交 日志号
}
type AppendEntriesResponse struct {
term int // 自己的当前任期号
success bool // 是否同意追加该条目, 如果 包含前一个日志 则为 true
领导选举 和 日志复制 这两个子问题,实际上 已经涵盖了 共识算法 的全过程,
但是,这两点 还不能保证 每一个状态机 会按照相同的 顺序 执行相同的 命令。
这里重点 强调一下 “顺序”, 因为 日志中的 命令应用到 状态机的 顺序 是一定不能 颠倒的。
但是 很多公式算法 为了 提高效率,会允许 日志 乱序复制到 Follower 节点上,如下图,
造成 非常多的 边界情况 需要处理。
Raft是一个非常追求 易理解的 共识算法,所以 Raft为了 简化设计,避免对这些 边界情况的 复杂处理,
在日志复制阶段,就保证了 日志是 有序 且 无空洞的。
日志复制阶段 对于 日志顺序 的保证 是基于 Leader 正常工作,如果 Leader 出现宕机,
他的后几个日志的 状态 就有可能 出现不正常,这时, 新Leader 是否 具备这些 不正常的 日志,
以及 怎么处理这些 日志,就显得 尤为重要 了。
这也是 Raft 为数不多的几个 需要进行 特殊处理的 边界情况。
所以,安全性 这个 子问题 主要目的就是: 定义几个 规则来 完善Raft算法,使得在各种 边界情况 都不出错。即 安全性 是 领导选举 和 日志复制 的 附加规则,即补丁 规则
。
我们讨论 分 4种 情况:
如果一个 Follower 落后 Leader 几条日志, 但没有 漏一整个 任期,
那么他在下次选举中 是有可能 当选 新Leader 的, 因此 他在当选新Leader后 就永远也无法 补上之前缺失的那部分日志了,
从而导致 状态机 之间的 不一致。
所以 需要对 领导的选举 增加一个限制: 被选举出来的Leader一定包含之前 各个任期 的所有 被提交 的日志条目。
实现 该限制的 机制原理 是 通过 请求投票 的 RPC Request
type RequestVoteRequest struct {
term int // 自己的当前任期号
candidate int // 自己的ID
lastLoginIndex int // 自己最后一个日志号
lastLogTerm int // 自己最后一个日志的任期
}
“新” 在这里的定义:
- 两份日志,如果 任期号 不同,那么 任期号 大的 日志 更 “新”;
- 两份日志,如果 任期号 相同,那么 日志号 大的 日志 更 “新”;
如果 请求投票的 候选节点A, 向 候选节点B 发出 请求投票 的日志,
候选节点B 通过比对 他和 候选节点A 谁更 新, 如果发现 候选节点A 还没有自己新,
候选节点B 会果断拒绝 候选节点A 的拉票,拒绝给 候选节点A 投票。
上述行为 进而保证了 上面 增加的 限制。
一旦 当前任期的 某个日志条目 已经存储到 过半 的服务器节点上 时, Leader 就知道 该日志条目 可以被 提交 了。
单点提交
目前讨论的 提交 都是一个 单点状态(只leader节点),而非 集群状态:
Leader 收到 超过半数节点 的复制成功反馈后 就可以 提交。但这时候 对 Follower 而言 节点虽然 复制到了 日志,
但还没有进行 提交, 因此 提交 这个状态并没有 构成 大多数的。
Follower 是怎么知道 自己何时可以 提交 ?
日志复制 中说过 追加日志 AppendEntries RPC 有 leaderCommit
这么一个参数,
通过这个参数 Follower 就可以知道Leader提交了哪个日志,
进而 Follower 自己也可以 提交 这个日志。
那么按照上面的说法, 当前日志的 最快提交 也得 等到 下个日志 的发送时才行,
其实不然,心跳机制 也是 追加日志 AppendEntries RPC,只不过 没有 日志体,
但 仍然可以 传递 leaderCommit 这个参数,从而 告知 Follower 当前日志 是否可以 提交。
集群提交
的 概念,实际上 对于多少系统需求而言 Leader单点提交 后 就返回 客户端,已经是安全的了,
并没有 等待 集群提交 的必要。
Raft 永远不会 通过 计算 副本的数量(已复制日志的节点数) 的方式来 提交 **前任期** 的 日志。
Raft 只有自己任期内的 日志 才会通过 计算 副本的数量(已复制日志的节点数) 的方式 来提交。
那么 新leader 复制的 老日志 何时 提交 哪? 等新leader在他的任期内 产生一个新日志,
在这个 新日志 提交时, 老日志 也就可以 提交了,这样 老日志 就不会被 覆盖 了。
注意:这里是 新日志 的 提交 而非 复制。
Follower 和 Candidate 宕机后的 处理方式比其 Leader 较为 简单,
并且 两者的处理方式 是相同的, 如果宕机了,那么 后序发给他们的 RequestVote 和 AppendEntries RPC 都会失败,
Raft 通过 无限的重试
来处理 这种失败, 如果 宕机的 节点 重启了,那么 RPC 就会成功的 完成。
如果 一个节点 收到一个 RPC, 且完成相应操作,但是 没来及 响应给 Leader 完成信息 就宕机了,
那么 Leader 还会 再发送一次 和上次同样的 RPC。
raft算法 整体 不依赖 客观时间, 也就是说, 哪怕因为 网络 or 其他因素,
造成 后发的RPC先到, 也不会 影响 raft的正确性。
只要整个 系统 满足 下面的 时间要求, Raft就可以选举出 并 维持一个稳定的 leader:
广播时间(一次RPC网络来回时间) << 选举超时时间 << 平均故障时间
如果 广播时间 大于 选举超时时间,那么 将永远选不出 Leader。
广播时间 和 平均故障时间 是由 系统的硬件 决定的, 而 选举超时时间 则可以 手动设定,
Raft的RPC需要将 接受的信息落盘,用时取决于存储技术,所以 一般 广播时间为 0.5ms ~ 20ms,
因此 选举超时时间 可能需要在 10ms ~ 500ms 之间。
服务器的平均故障间隔时间 都在 几个月 甚至更长 的时间。
在实际生产过程中,很多情况下 都需要对 集群的配置进行调整,
比如:集群扩容、对故障机器进行下线、调整机器的高可用。
这时需要对 Raft的 配置文件进行改变,改变的过程 可能会影响Raft的正常运行,
当然 我们可以把 集群停掉 在执行变更,但这 就要 停止对外的服务,并且有 手动操作的 风险。
所以Raft设计了 集群成员变更 的功能,来 自动、无需停止服务的 集群成员变更。
对于 分布式系统而言,是不可能 在所有机器上 同一时间 完成变更,这点是分布式 天然的限制
。
所以变更 必然会持续一段时间,这就 造成 集群成员变更 最大的难点——脑裂问题。
即 同一期任内 出现 两个 Leader 节点。
- 脑裂问题:
3节点 扩展到 5节点 集群的 举例说明说明,一开始 有 S1、2、3 三个节点,
运行一段时间后,加入 S4、5 节点,由于 配置的改变需要一段时间,
假设 S3 率先使用 新配置,此时S1、2是老配置,S3、4、5 是新配置。
此时leader宕机,开始进行选举:
S1 自己一票,加上S2投自己一票,由于老配置,所以 2/3 就满足大多数票,
所以S1当选为 Leader。
与此同时,S3自已一票,S4、5 投自己两票,由于新配置,所以3/5就满足大多数票,
所以S3也可以当选为Leader。
此时,同一个任期,有两个 Leader, 出现了 脑裂问题。
为了解决 脑裂问题, Raft的新配置的使用 采用一种 两阶段 的方法。
联合一致
状态,借用脑裂问题的 例子说明:
如果S3要当选为 leader,那么 在老配置节点中,
要 至少有 两个节点 投票自己,即S1、2 都投票给自己,满足 旧配置 的 大多数。
在 新配置中,要 至少有 三个节点 投票给自己,即S3自己一票,S4、5也投自己,
满足 新配置 的 大多数。