Skip to content

Commit

Permalink
更新日志一致性
Browse files Browse the repository at this point in the history
  • Loading branch information
isno committed Nov 27, 2024
1 parent 9a7391e commit 03b20aa
Show file tree
Hide file tree
Showing 5 changed files with 54 additions and 67 deletions.
2 changes: 1 addition & 1 deletion assets/raft-log.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 13 additions & 1 deletion consensus/Basic-Paxos.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,19 @@ Basic Paxos 问题背景相信已经讲清楚了,那怎么解决?
图 6-4 只有一个决策节点会有单点故障
:::

为了克服单点故障问题,借鉴多数派的机制,思路是写入一半以上的节点,如果集群中有 N 个节点,客户端需要写入 W >= N/2 + 1 个节点。使用多数派的机制后最多可容忍 (N-1)/2 个节点故障。但是问题还是存在:**每个决策节点该接受几个提案呢**?
为了克服单点故障问题,借鉴多数派的机制,思路是写入一半以上的节点,如果集群中有 N 个节点,客户端需要写入 W >= N/2 + 1 个节点。使用多数派的机制后最多可容忍 (N-1)/2 个节点故障。

:::tip Quorum

Quorum(法定人数)指的是在一个分布式系统中,能够进行合法决策所需的最小节点数,也就是集群节点总数的一半加一(即 ⌈N/2⌉ + 1,其中 N 是集群节点总数)。Quorum 的大小决定了系统的容错能力,一个集群最多能容忍 ⌊N/2⌋ 个节点失效。例如:
- 3 节点集群:Quorum 为 2,允许 1 个节点失效;
- 5 节点集群:Quorum 为 3,允许 2 个节点失效。

如果失效节点超过 ⌊N/2⌋,系统无法形成 Quorum,集群就会停止提供服务以避免一致性问题。
:::


但是问题还是存在:**每个决策节点该接受几个提案呢**?

先看第一种情况:**按时序决策节点只接受它收到的第一个提案**。但考虑多个提议节点同时对一个提案进行提议,最后可能没有一个提议能够取得多数的投票,出现了平票问题(Split Votes)。如图 6-5 所示,red 和 blue 各有 2 票,没办法确定谁被选择?这也就意味着我们无法保证在一轮投票中达成共识,这违背活性(Liveness)要求。

Expand Down
6 changes: 3 additions & 3 deletions consensus/Replicated-State-Machine.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,14 @@
:::tip 复制状态机的基本原理
两个“相同的” (identical)、“确定的” (deterministic) 进程:

- 相同的:进程的代码、逻辑、以及配置完全一致,它们在设计和实现上是完全相
- 相同的:进程的代码、逻辑、以及配置完全一致,它们在设计和实现上完全相同
- 确定的:进程的行为是完全可预测的,不能有任何非确定性的逻辑,比如随机数生成或不受控制的时间依赖。

如果它们以相同的状态启动,按相同的顺序获取相同的输入。那么,最终将达到相同的状态
如果它们以相同的状态启动,按相同的顺序获取相同的输入。那么,它们一定会达到相同的状态

:::

共识算法(图中的 Consensus Module,是 Paxos 或者 Raft 算法)以广播的形式将一条日志发送到所有节点,它们就日志什么位置,记录什么(序号为 9,执行 set x=3)达成一致。也就说,每一台节点中,都有着相同顺序的日志序列。
共识算法(图中的 Consensus Module,是 Paxos 或者 Raft 算法)以广播的形式将一条条日志发送到所有节点,它们就日志什么位置,记录什么(序号为 9,执行 set x=3)达成一致。也就说,每一台节点中,都有着相同顺序的日志序列。

```json
{ "index": 9, "command": "set x=3" },
Expand Down
2 changes: 1 addition & 1 deletion consensus/raft-ConfChange.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
配置说明集群由哪些节点组成。例如,一个集群有三个节点(Server 1、Server 2、Server 3),该集群的配置就是 [Server1、Server2、Server3]
:::

如果把“配置”当成 Raft 中的“特殊日志”。这样一来,成员的动态变更就可以转化为“配置日志”的一致性问题。但注意的是,节点“提交”(commit)日志是异步的,不可能同时操作。这种情况下,成员变更就会出现问题。
如果把“配置”当成 Raft 中的“特殊日志”。这样一来,成员的动态变更就可以转化为“配置日志”的一致性问题。但注意的是,日志“应用”(apply)到状态机时异步的,不可能同时操作。这种情况下,成员变更就会出现问题。

举一个具体的例子,一个 Raft 集群配置为 C~old~ [Server1、Server2 和 Server3],该集群的 Quorum 为(N/2)+1 = 2。现在,我们计划增加两个节点,新集群配置为 C~new~ [Server1、Server2、Server3、Server4、Server5],该集群的 Quorum 为(N/2)+1 = 3。由于提交日志是异步的,假设 Server1,Server2 比较迟钝,还在用老配置 C~old~,而 Server3,Server4,Server5 已“提交”新配置 C~new~
- 假设 Server5 触发选举,赢得 Server3,Server4,Server5 的投票(满足 C~new~ 的 3 Quorum 要求),成为 Leader;
Expand Down
97 changes: 36 additions & 61 deletions consensus/raft-log-replication.md
Original file line number Diff line number Diff line change
@@ -1,96 +1,71 @@
# 6.4.2 日志复制

一旦集群选举出了 Leader,那么 Leader 便承担起“**将系统发生的所有变更复制到所有节点**”的职责。

在 Raft 系统中,所有的数据变化都是以日志记录的形式添加到服务节点之中。服务节点会不断的读取日志记录,并将日志记录更新到服务节点的数据中。日志记录最开始的状态是 uncommited, 更新之后状态则变为 commited。commited 意味着:日志记录不会被回滚,可以安全地应用到状态机。

图 6-17 展示了 Raft 集群中各个节点的日志情况,每个日志条目(log entry)包含了索引、任期、指令等关键信息:

前面的介绍中,笔者已经阐述过:Raft 的本质就是多个副本的日志数据达成一致的解决方案。

理解日志复制的问题之前,我们得先搞清楚 Raft 中的日志和日志项是什么。

分布式系统中有一种常见的复制状态机的抽象,就是把具有一定顺序的一系列动作抽象成一条日志(log),每个动作都是日志中的一个日志项(log entry)。我们可以把 Raft 中的日志项理解为包含以下几个关键数据的数据格式:

- **指令**: 一条由客户端请求转换成状态机需要执行的指令。
- **索引值**:日志项对应的整数索引值,用于标识日志项,是一个连续、单调递增的整数。如此,Raft 可以不用关注空洞日志,也可以通过最大日志索引定位缺失的数据。
- **任期编号**:创建这个日志项的 Leader 任期编号。
- **指令**: 表示客户端请求的具体操作内容,也就是待“状态机”(State Machine)执行的操作。
- **索引值**:日志在仓库中的索引值,单调递增。
- **任期编号**:该日志是在哪个任期(Leader 任期)中被创建的,用于解决“脑裂”和日志不一致问题。

:::center
![](../assets/raft-log.svg) <br/>
图 6-17 日志项概念
图 6-17 日志结构
:::

Raft 通过 RPC 消息将日志复制至各个 Follower 节点。用于日志复制的 RPC 被称为 AppendEntries RPC,它的结构大致如下:

## 1. 日志复制
```json
{
"term": 5, // Leader 的当前任期号
"leaderId": "leader-123",
"prevLogIndex": 8, // 前一日志的索引
"prevLogTerm": 4, // 前一日志的任期
"entries": [
{ "index": 9, "term": 5, "command": "set x=4" }, // 要复制的日志条目
],
"leaderCommit": 7// Leader 的“已提交”的索引号
}
```

Raft 是强 Leader 模型的算法,日志项只能由 Leader 复制给其他成员,这意味着日志复制是单向的,Leader 从来不会覆盖本地的日志项,即所有的日志项以 Leader 为准。
根据图 6-17 来看日志复制的过程,当 Raft 集群收到客户端的请求(set x=4)时:

当 Raft 收到客户端的指令时(x<-3):
- 如果当前节点不是 Leader,则把指令转发至 Leader;
- Leader 收到指令后先写入本地的日志仓库,然后向所有的 Follower 广播日志复制消息。
- 一旦 Leader 确认该指令充分复制(被多数派节点“Quorum”确认),该指令就会被“提交”(commit)。提交意味着,日志记录不会被回滚,可以安全地应用到状态机。
- Leader 更新 commit 水位,在随后的心跳或者日志复制请求消息中携带 commit 水位。Follower 收到心跳后更新本地的 commit 水位。
- Leader 接收请求后:
- 将指令转换成日志条目(log entry)写入本地日志仓库,此时日志条目的状态为“未提交”(uncommitted);
- 生成一条 AppendEntries RPC,将日志条目广播至所有的 Follower;
- Follower 收到 Leader 的 AppendEntries RPC 后,检查任期以及日志一致性,将新日志条目追加到本地仓库。
- 一旦 Leader 确认日志条目被充分追加(也就是达到 Quorum 要求),Leader 便将日志条目标记为“已提交”(committed),并向客户端返回执行结果。已提交的日志意味着:指令永久生效,日志不可回滚,可以安全地“应用”(apply)到状态机。

:::center
![](../assets/raft-append-entries.svg) <br/>
图 6-17 日志项概念
:::


:::tip 选择节点的数量

Raft 的日志复制需要等待多数派节点确认。节点越多,复制延迟也相应增加。所以说,以 Raft 构建的分布式系统并不是节点越多越好。如 ETCD 推荐使用 3 个节点。对高可用性要求较高,且能容忍稍高的性能开销,可增加至 5 个节点。如果超出 5 个节点,则得不偿失。
:::

通过心跳或者下一次日志复制请求消息来通知 Follower 提交(committed)日志项。这种做法可以**使协商优化成一个阶段,降低处理客户端请求一半的延迟**
Leader 向客户端返回结果,并不意味着日志复制的过程就此结束。Follower 并不知道哪些日志已经被大多数节点确认,Raft 的的方案是:Leader 在心跳或下一次日志复制中携带 leaderCommit,通知 Follower 当前已经提交的最高日志索引。这个设计的目的主要把 Quorum 确认优化成一个阶段,降低客户端请求延迟。


## 2. 实现日志的一致性
:::tip 如何选择节点的数量

当一个 Follower 新加入集群或 Leader 刚晋升时,Leader 并不清楚需要同步哪些日志给 Follower。此外,当旧的 Leader 转变为 Follower 时,可能携带上一任期(term)中仅在本地提交的日志项,而这些日志项在当前新的 Leader 上并不存在。

Raft 算法中,通过 Leader 强制 Follower 复制自己的日志项,来处理不一致的日志。具体包括两个步骤:

1. Leader 通过日志复制 RPC 的一致性检查,找到 Follower 与自己相同日志项的最大索引值。即在该索引值之前的日志,Leader 和 Follower 是一致的,之后的日志就不一致了;
2. Leader 强制将 Follower 该索引值之后的所有日志项删除,并将 Leader 该索引值之后的所有日志项同步至 Follower,以实现日志的一致。

因此,处理 Leader 与 Follower 日志不一致的关键是找出上述的最大索引值。

Raft 引入两个变量,来方便找出这一最大索引值:
Raft 的日志复制需要等待多数节点确认。节点越多,日志复制延迟也相应增加。所以说,以 Raft 构建的分布式系统并不是节点越多越好。如 ETCD,推荐使用 3 个节点,对高可用性要求较高,且能容忍稍高的性能开销,可增加至 5 个节点,如果超出 5 个节点,可能得不偿失。
:::

- **prevLogIndex**:表示 Leader 当前需要复制的日志项,前面那一个日志项的索引值。例如,下图,如果领导者需要将索引值为 8 的日志项复制到 Follower,那么 prevLogIndex 为 7
- **prevLogTerm**:表示 Leader 当前需要复制的日志项,前面一个日志项的任期编号。例如,下图,如果领导者需要将索引值为 8 的日志项复制到 Follower ,那么 prevLogTerm 为 4
实际上,上面日志复制例子中,只有 follower-1 会成功,这是因为其他节点(follower-2)的日志缺少一些日志条目。**日志的连续性相当重要,顺序不一致性的日志应用到状态机,会导致各个 follower 节点状态不一致**

follower-2 收到 AppendEntries 消息,根据 prevLogIndex、prevLogTerm 确认本地日志缺少或者冲突,它将返回失败信息:

```json
{
"term": 5,
"leaderId": "leader-123",
"prevLogIndex": 8,
"prevLogTerm": 4,
"entries": [
{ "index": 9, "term": 5, "command": "set x=42" },
],
"leaderCommit": 7
"success": false,
"term": 4,
"conflictIndex": 4, // 表示发生缺失的日志索引,Follower 的日志中最大索引为 3,所以缺失的索引是 4。
"conflictTerm": 3//缺失日志的“上一个有效日志条目”的任期号
}

```
这之后,Leader 根据上述信息找到一个与 Follower 日志匹配的最大索引(也就是 6),重新开始日志复制过程,逐步恢复与 Follower 的一致性。

:::center
![](../assets/raft-log-fix.svg) <br/>
图 6-19 领导者处理不一致日志
:::

Leader 处理不一致的具体过程分析如下:

1. Leader 通过日志复制 RPC 消息,发送当前自己最新日志项给 Follower,该消息的 prevLogIndex 为 7,prevLogTerm 为 4。
2. 由于 Follower 在其日志中,无法找到索引值为 7,任期编号为 4 的日志项,即 Follower 的日志和 Leader 的不一致,故 Follower 会拒绝接收新的日志项,返回失败。
3. 此时,Follower 在其日志中,找到了索引值为 6,任期编号为 3 的日志项,故 Follower 返回成功。
4. Leader 收到 Follower 成功返回后,知道在索引值为 6 的位置之前的所有日志项,均与自己的相同。于是通过日志复制 RPC ,复制并覆盖索引值为 6 之后的日志项,以达到 Follower 的日志与 Leader 的日志一致。

:::center
![](../assets/raft-log-fix-action.svg) <br/>
图 6-20 Leader 处理不一致日志过程
:::

从上面的步骤看到,Leader 通过日志复制 RPC 消息的一致性检查,比较 index 和 term,从而找到 Follower 节点上与自己相同日志项的最大索引值,然后复制并更新该索引值之后的日志项,实现各个节点日志自动趋于一致。

0 comments on commit 03b20aa

Please sign in to comment.