Files
6.824-golabs-2021-6.824/docs/6.5840: Distributed System/5. Lab 3: Raft-cn.md
2026-02-25 23:13:59 +08:00

248 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 毫秒)时合理。因测试限制为每秒十次心跳,你须使用**大于**论文 150300 毫秒的选举超时,但不宜过大,否则可能无法在五秒内选出 leader。
* 可使用 Go 的 **rand**
* 需要编写定期或延迟执行动作的代码。最简单的方式是创建一个带循环的 goroutine 并调用 **time.Sleep()**;参见 **Make()** 中为此创建的 `ticker()` goroutine。**不要使用 Go 的 time.Timer 或 time.Ticker**,它们难以正确使用。
* 若测试难以通过,请再次阅读论文 Figure 2leader 选举的完整逻辑分布在图的多个部分。
* 别忘了实现 **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)*