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

17 KiB
Raw Blame History

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 扩展论文 的设计,尤其注意 Figure 2。你将实现论文中的大部分内容,包括保存持久状态并在节点失败重启后读取。不需要实现集群成员变更(第 6 节)。

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


起步

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

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

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

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

$ 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.goraftapi/raftapi.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 中有发送 RPCsendRequestVote())和处理传入 RPCRequestVote())的示例代码。你的 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 选举相关的状态。
  • 填写 RequestVoteArgsRequestVoteReply 结构体。修改 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 页。
  • 若测试失败,测试程序会生成一个可视化时间线文件,标出事件、网络分区、崩溃的服务器和执行的检查。参见可视化示例。你也可以添加自己的标注,例如 tester.Annotate("Server 0", "short description", "details")

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

$ 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 时间。典型输出:

$ 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 1leader 没有 XTerm → nextIndex = XIndex
  • Case 2leader 有 XTerm → nextIndex = (leader 中 XTerm 最后一条的 index) + 1
  • Case 3follower 日志过短 → nextIndex = XLen

其他提示:

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

你的代码应通过全部 3C 测试(如下所示),以及 3A 和 3B 测试。

$ 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 概述了该方案;你需要设计细节。

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

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 测试。

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

参考链接