add docs
This commit is contained in:
247
docs/6.5840: Distributed System/5. Lab 3: Raft-cn.md
Normal file
247
docs/6.5840: Distributed System/5. Lab 3: Raft-cn.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# 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)*
|
||||
Reference in New Issue
Block a user