# 6.5840 Lab 3: Raft

## 简介

这是构建容错 key/value 存储系统系列实验中的第一个。本实验中你将实现 Raft，一种复制状态机协议。下一个实验你将在 Raft 之上构建 key/value 服务。之后你将对服务进行 "shard"（分片），在多个复制状态机上获得更高性能。

复制服务通过在多台副本服务器上存储完整状态（即数据）副本来实现容错。复制使服务在部分服务器发生故障（崩溃或网络中断、不稳定）时仍能继续运行。难点在于故障可能导致各副本持有不同的数据副本。

Raft 将客户端请求组织成称为 **log**（日志）的序列，并保证所有副本服务器看到相同的日志。每个副本按日志顺序执行客户端请求，并应用到其本地服务状态副本。由于所有存活副本看到相同的日志内容，它们以相同顺序执行相同请求，从而保持相同的服务状态。若某服务器失败后恢复，Raft 会负责使其日志跟上。只要至少**多数**（majority）服务器存活且能相互通信，Raft 就会持续运行。若不存在这样的多数，Raft 不会取得进展，但一旦多数能再次通信就会从中断处继续。

本实验中你将把 Raft 实现为带有关联方法的 Go 对象类型，作为更大服务中的一个模块使用。一组 Raft 实例通过 RPC 相互通信以维护复制日志。你的 Raft 接口将支持无限长的、带编号的命令序列，也称为 **log entries**。条目用 *index* 编号。给定 index 的日志条目最终会被 **committed**。届时你的 Raft 应将该日志条目交给上层服务执行。

你应遵循 [Raft 扩展论文](../papers/raft-extended-cn.md) 的设计，尤其注意 **Figure 2**。你将实现论文中的大部分内容，包括保存持久状态并在节点失败重启后读取。不需要实现集群成员变更（第 6 节）。

本实验分**四个部分**提交。你必须在各自截止日前提交对应部分。

---

## 起步

若已完成 Lab 1，你已有实验源码。若没有，可在 Lab 1 说明中查看通过 git 获取源码的方法。

我们提供了骨架代码 `src/raft1/raft.go`，以及一组用于驱动实现并用于批改的测试，测试在 `src/raft1/raft_test.go`。

批改时我们会在**不带** `-race` 标志下运行测试。但你自己**应用 `-race` 测试**。

运行以下命令即可开始。别忘了 `git pull` 获取最新代码。

```bash
$ cd ~/6.5840
$ git pull
...
$ cd src
$ make raft1
go build -race -o main/raft1d main/raft1d.go
cd raft1 && go test -v -race  
=== RUN   TestInitialElection3A
Test (3A): initial election (reliable network)...
Fatal: expected one leader, got none
        /Users/rtm/824-process-raft/src/raft1/test.go:151
        /Users/rtm/824-process-raft/src/raft1/raft_test.go:36
info: wrote visualization to /var/folders/x_/vk0xmxwn1sj91m89wsn5b1yh0000gr/T/porcupine-2242138501.html
--- FAIL: TestInitialElection3A (5.51s)
...
$
```

---

## 代码结构

在 `raft1/raft.go` 中补充代码实现 Raft。该文件中有骨架代码以及发送、接收 RPC 的示例。

你的实现必须支持下列接口，测试程序以及（最终）你的 key/value 服务器会使用。更多细节见 `raft.go` 和 `raftapi/raftapi.go` 中的注释。

```go
// create a new Raft server instance:
rf := Make(peers, me, persister, applyCh)

// start agreement on a new log entry:
rf.Start(command interface{}) (index, term, isleader)

// ask a Raft for its current term, and whether it thinks it is leader
rf.GetState() (term, isLeader)

// each time a new entry is committed to the log, each Raft peer
// should send an ApplyMsg to the service (or tester).
type ApplyMsg
```

服务通过调用 `Make(peers, me, …)` 创建 Raft 节点。**peers** 是 Raft 节点（含本节点）的网络标识数组，用于 RPC。**me** 是本节点在 peers 数组中的 index。**Start(command)** 请求 Raft 开始将命令追加到复制日志的流程。**Start()** 应立即返回，不等待日志追加完成。服务期望你的实现在每条新提交的日志条目时向 **Make()** 的 **applyCh** 参数发送一条 **ApplyMsg**。

`raft.go` 中有发送 RPC（`sendRequestVote()`）和处理传入 RPC（`RequestVote()`）的示例代码。你的 Raft 节点应使用 labrpc Go 包（源码在 `src/labrpc`）交换 RPC。测试可以指示 labrpc 延迟、重排或丢弃 RPC 以模拟各种网络故障。可以临时修改 labrpc，但须确保你的 Raft 在原始 labrpc 下能工作，因为我们会用其测试和批改。Raft 实例之间只能通过 RPC 交互；例如不允许通过共享 Go 变量或文件通信。

后续实验建立在本实验之上，因此留足时间写出可靠代码很重要。

---

## Part 3A: Leader Election

实现 Raft 的 leader 选举与心跳（不含日志条目的 AppendEntries RPC）。Part 3A 的目标是选出一个 leader、在无故障时保持该 leader、以及当旧 leader 失败或与之相关的包丢失时由新 leader 接管。在 `src` 目录下运行 `make RUN="-run 3A" raft1` 测试 3A 代码。

* 遵循论文 **Figure 2**。当前阶段关注发送和接收 RequestVote RPC、与选举相关的 Rules for Servers，以及 leader 选举相关的 State。
* 在 `raft.go` 的 Raft 结构体中加入 Figure 2 中与 leader 选举相关的状态。
* 填写 **RequestVoteArgs** 和 **RequestVoteReply** 结构体。修改 **Make()**，创建一个后台 goroutine，在一段时间未收到其他节点消息时定期发起 leader 选举、发送 RequestVote RPC。实现 **RequestVote()** RPC 处理函数，使服务器能相互投票。
* 为实现心跳，定义 **AppendEntries** RPC 结构体（可能暂时不需要所有参数），并让 leader 定期发送。实现 **AppendEntries** RPC 处理函数。
* 测试要求 leader 发送心跳 RPC **每秒不超过十次**。
* 测试要求你的 Raft 在旧 leader 失败后**五秒内**选出新 leader（若多数节点仍能通信）。
* 论文 5.2 节提到选举超时在 150 到 300 毫秒范围。该范围仅在 leader 发送心跳远高于每 150 毫秒一次（例如每 10 毫秒）时合理。因测试限制为每秒十次心跳，你须使用**大于**论文 150–300 毫秒的选举超时，但不宜过大，否则可能无法在五秒内选出 leader。
* 可使用 Go 的 **rand**。
* 需要编写定期或延迟执行动作的代码。最简单的方式是创建一个带循环的 goroutine 并调用 **time.Sleep()**；参见 **Make()** 中为此创建的 `ticker()` goroutine。**不要使用 Go 的 time.Timer 或 time.Ticker**，它们难以正确使用。
* 若测试难以通过，请再次阅读论文 Figure 2；leader 选举的完整逻辑分布在图的多个部分。
* 别忘了实现 **GetState()**。
* Go RPC 只发送**首字母大写**的 struct 字段。子结构字段名也须大写（例如数组中日志记录的字段）。**labgob** 包会对此给出警告；不要忽略。
* 本实验最具挑战的部分可能是调试。调试建议见 [Guidance](./2.%20Lab%20Guidance-cn.md) 页。
* 若测试失败，测试程序会生成一个可视化时间线文件，标出事件、网络分区、崩溃的服务器和执行的检查。参见[可视化示例](https://pdos.csail.mit.edu/6.824/labs/raft-tester.html)。你也可以添加自己的标注，例如 `tester.Annotate("Server 0", "short description", "details")`。

提交 Part 3A 前请确保通过 3A 测试，看到类似输出：

```bash
$ make RUN="-run 3A" raft1
go build -race -o main/raft1d main/raft1d.go
cd raft1 && go test -v -race -run 3A 
=== RUN   TestInitialElection3A
Test (3A): initial election (reliable network)...
  ... Passed --  time  3.5s #peers 3 #RPCs    32 #Ops    0
--- PASS: TestInitialElection3A (3.84s)
=== RUN   TestReElection3A
Test (3A): election after network failure (reliable network)...
  ... Passed --  time  6.2s #peers 3 #RPCs    68 #Ops    0
--- PASS: TestReElection3A (6.54s)
=== RUN   TestManyElections3A
Test (3A): multiple elections (reliable network)...
  ... Passed --  time  9.8s #peers 7 #RPCs   684 #Ops    0
--- PASS: TestManyElections3A (10.68s)
PASS
ok      6.5840/raft1    22.095s
$
```

每行 "Passed" 包含五个数字：测试耗时（秒）、Raft 节点数、测试期间发送的 RPC 数、RPC 消息总字节数、Raft 报告已提交的日志条数。你的数字会与示例不同。可以忽略这些数字，但它们有助于 sanity-check 实现发送的 RPC 数量。对 Lab 3、4、5 全部测试，若总耗时超过 600 秒或任一测试超过 120 秒，批改脚本会判为不通过。

批改时我们会在不带 `-race` 下运行测试。但请确保你的代码**在带 `-race` 时能稳定通过测试**。

---

## Part 3B: Log

实现 leader 和 follower 追加新日志条目的逻辑，使 `make RUN="-run 3B" raft1` 通过全部测试。

* 运行 `git pull` 获取最新实验代码。
* Raft 论文中日志从 1 开始编号，但我们建议实现为**从 0 开始**，在 index=0 放一个 term 为 0 的哑元条目。这样第一次 AppendEntries RPC 可以包含 PrevLogIndex 为 0，且是日志中的有效 index。
* 首要目标应是通过 **TestBasicAgree3B()**。先实现 **Start()**，然后按 Figure 2 编写通过 AppendEntries RPC 发送和接收新日志条目的代码。在每个节点上对每条新提交的条目向 **applyCh** 发送。
* 需要实现**选举限制**（论文 5.4.1 节）。
* 代码中可能有反复检查某事件的循环。不要让这些循环无暂停地连续执行，否则会拖慢实现导致测试失败。使用 Go 的**条件变量**，或在每次循环迭代中 **time.Sleep(10 * time.Millisecond)**。
* 为后续实验着想，尽量把代码写清楚。
* 若测试失败，查看 `raft_test.go` 并沿测试代码追踪，理解在测什么。

后续实验的测试可能会因代码过慢而判为不通过。可用 `time` 命令查看实际时间和 CPU 时间。典型输出：

```bash
$ make RUN="-run 3B" raft1
go build -race -o main/raft1d main/raft1d.go
cd raft1 && go test -v -race -run 3B 
=== RUN   TestBasicAgree3B
Test (3B): basic agreement (reliable network)...
  ... Passed --  time  1.6s #peers 3 #RPCs    18 #Ops    3
--- PASS: TestBasicAgree3B (1.96s)
=== RUN   TestRPCBytes3B
...
=== RUN   TestCount3B
Test (3B): RPC counts aren't too high (reliable network)...
  ... Passed --  time  2.7s #peers 3 #RPCs    32 #Ops    0
--- PASS: TestCount3B (3.05s)
PASS
ok      6.5840/raft1    71.716s
$
```

"ok 6.5840/raft 71.716s" 表示 Go 测得的 3B 测试实际（墙上）时间为 71.716 秒。若 3B 测试实际时间远超过几分钟，后续可能出问题。检查是否有长时间 sleep 或等待 RPC 超时、是否有不 sleep 或不等待条件/ channel 的循环、或是否发送了过多 RPC。

---

## Part 3C: Persistence

基于 Raft 的服务器重启后应从断点恢复。这要求 Raft 维护在重启后仍存在的**持久状态**。论文 Figure 2 指明了哪些状态应持久化。

真实实现会在每次状态变化时将 Raft 的持久状态写入磁盘，并在重启时从磁盘读取。你的实现不使用磁盘，而是从 **Persister** 对象（见 `tester1/persister.go`）保存和恢复持久状态。调用 **Raft.Make()** 的一方提供 Persister，其初始内容为 Raft 最近持久化的状态（若有）。Raft 应从该 Persister 初始化状态，并在每次状态变化时用它保存持久状态。使用 Persister 的 **ReadRaftState()** 和 **Save()** 方法。

在 `raft.go` 中完成 **persist()** 和 **readPersist()**，添加保存和恢复持久状态的代码。需要将状态编码（或“序列化”）为字节数组才能传给 Persister。使用 **labgob** 编码器；参见 **persist()** 和 **readPersist()** 中的注释。labgob 类似 Go 的 gob，但若尝试编码小写字段名的结构体会打印错误。目前将 **nil** 作为第二个参数传给 **persister.Save()**。在实现修改持久状态的位置插入对 **persist()** 的调用。完成上述工作且其余实现正确时，应能通过全部 3C 测试。

你可能需要**每次将 nextIndex 回退多于一个条目的优化**。参见 Raft 扩展论文第 7 页末、第 8 页初（灰线标记处）。论文对细节描述较模糊，需要自行补全。一种做法是让拒绝消息包含：

* **XTerm**：冲突条目的 term（若有）
* **XIndex**：该 term 第一条目的 index（若有）
* **XLen**：日志长度

则 leader 的逻辑可以是：

* **Case 1**：leader 没有 XTerm → `nextIndex = XIndex`
* **Case 2**：leader 有 XTerm → `nextIndex = (leader 中 XTerm 最后一条的 index) + 1`
* **Case 3**：follower 日志过短 → `nextIndex = XLen`

其他提示：

* 运行 `git pull` 获取最新实验代码。
* 3C 测试比 3A 或 3B 更苛刻，失败可能由 3A 或 3B 代码中的问题引起。

你的代码应通过全部 3C 测试（如下所示），以及 3A 和 3B 测试。

```bash
$ make RUN="-run 3C" raft1
...
PASS
ok      6.5840/raft1    180.983s
$
```

提交前多跑几遍测试是个好习惯。

---

## Part 3D: Log Compaction

目前重启的服务器会重放完整 Raft 日志以恢复状态。但对长期运行的服务而言，永远记住完整 Raft 日志不现实。你需要修改 Raft，使其与不定期持久化状态**快照（snapshot）**的服务协作，届时 Raft 丢弃快照之前的日志条目。结果是持久数据更少、重启更快。但可能出现 follower 落后太多，leader 已丢弃其赶上来所需的日志；此时 leader 必须发送快照以及从快照时刻起的日志。Raft 扩展论文 [**Section 7**](../papers/raft-extended-cn.md) 概述了该方案；你需要设计细节。

你的 Raft 必须提供服务可调用的以下函数，传入其状态的序列化快照：

```go
Snapshot(index int, snapshot []byte)
```

在 Lab 3D 中，测试程序会定期调用 **Snapshot()**。在 Lab 4 中，你将编写会调用 **Snapshot()** 的 key/value 服务器；快照将包含完整的 key/value 表。服务层在每个节点（不仅是 leader）上调用 **Snapshot()**。

**index** 参数表示快照所反映的日志中最高条目的 index。Raft 应丢弃该点之前的日志条目。需要修改 Raft 代码，使其在只保存日志**尾部**的情况下运行。

需要实现论文中讨论的 **InstallSnapshot** RPC，使 Raft leader 能告知落后的 Raft 节点用快照替换其状态。可能需要理清 InstallSnapshot 与 Figure 2 中的状态和规则如何交互。

当 follower 的 Raft 代码收到 **InstallSnapshot** RPC 时，可通过 **applyCh** 将快照以 **ApplyMsg** 形式发给服务。`raftapi/raftapi.go` 中 ApplyMsg 结构体定义已包含所需字段（也是测试期望的）。注意这些快照只能推进服务状态，不能使其回退。

若服务器崩溃，必须从持久数据重启。你的 Raft 应**同时持久化 Raft 状态和对应快照**。使用 **persister.Save()** 的第二个参数保存快照。若无快照，第二个参数传 **nil**。

服务器重启时，应用层读取持久化的快照并恢复其保存的应用状态。重启后，应用层期望 **applyCh** 上的第一条消息要么是 **SnapshotIndex** 高于初始恢复快照的快照，要么是 **CommandIndex** 紧接在初始恢复快照 index 之后的普通命令。

实现 **Snapshot()** 和 **InstallSnapshot** RPC，以及 Raft 为支持它们所需的修改（例如在截断日志下运行）。当通过 3D 测试（及此前全部 Lab 3 测试）时，你的方案即完成。

* `git pull` 确保使用最新代码。
* 一个好的起点是修改代码使其能只保存从某 index X 开始的日志部分。初始可将 X 设为 0 并跑 3B/3C 测试。然后让 **Snapshot(index)** 丢弃 index 之前的日志，并将 X 设为 index。顺利的话应能通过第一个 3D 测试。
* 第一个 3D 测试失败的常见原因是 follower 追上 leader 耗时过长。
* 接下来：若 leader 没有使 follower 赶上所需的日志条目，则发送 **InstallSnapshot** RPC。
* **在单次 InstallSnapshot RPC 中发送完整快照**。不要实现 Figure 13 中分片快照的 offset 机制。
* Raft 必须以允许 Go 垃圾回收器释放并重用内存的方式丢弃旧日志条目；这要求**不存在对已丢弃日志条目的可达引用（指针）**。
* Raft 节点重启时，传给 **Make()** 的 persister 会包含应用状态快照以及 Raft 保存的状态。若日志已被截断，Raft 每次调用 **persister.Save()** 时都必须包含非 nil 快照，因此 **Make()** 中调用 **persister.ReadSnapshot()** 并保存结果是好做法。
* 在不带 `-race` 时，完整 Lab 3 测试（3A+3B+3C+3D）合理耗时约为**实际时间 6 分钟**、**CPU 时间 1 分钟**。带 `-race` 时约为**实际时间 10 分钟**、**CPU 时间 2 分钟**。

你的代码应通过全部 3D 测试（如下所示），以及 3A、3B、3C 测试。

```bash
$ make RUN="-run 3D" raft1
...
PASS
ok      6.5840/raft1    301.406s
$
```

---
*来源: [6.5840 Lab 3: Raft](https://pdos.csail.mit.edu/6.824/labs/lab-raft1.html)*
