# 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)*