## 运行
在 Lab2 中,你的 Raft 实现代码主要位于 `6.824/src/raft/` 目录下。与 Lab1 不同,这里不需要像之前运行 MapReduce 一样先编译插件和手动启动进程;相反,你的测试是通过运行 `go test` 命令来自动执行的。
**主要命令**(在 `6.824/src/raft` 目录下执行):
1. 进入 Raft 源码目录:
```bash
cd ~/6.824/src/raft
```
2. 运行所有测试(包括 2A, 2B, 2C, 2D 全部):
```bash
go test -race
```
3. 单独测试某个部分(例如只测试2A部分):
```bash
go test -run 2A -race
```
类似地:
```bash
go test -run 2B -race
go test -run 2C -race
go test -run 2D -race
```
## 核心功能实现
1. **Leader 选举**:
```go
// 开始选举
func (rf *Raft) StartElection() {
request := rf.genRequestVoteRequest()
grantedVotes := 1 // 自己给自己投票
rf.votedFor = rf.me
// 向其他节点发送投票请求
for peer := range rf.peers {
if peer == rf.me {
continue
}
go func(peer int) {
response := new(RequestVoteResponse)
if rf.sendRequestVote(peer, request, response) {
rf.mu.Lock()
defer rf.mu.Unlock()
// 如果获得投票
if response.VoteGranted {
grantedVotes += 1
// 获得多数票就成为 Leader
if grantedVotes > len(rf.peers)/2 {
rf.ChangeState(StateLeader)
rf.BroadcastHeartbeat(true)
}
}
}
}(peer)
}
}
```
2. **日志复制**:
```go
// Leader 广播日志条目
func (rf *Raft) BroadcastHeartbeat(isHeartBeat bool) {
for peer := range rf.peers {
if peer == rf.me {
continue
}
if isHeartBeat {
go rf.replicateOneRound(peer)
} else {
rf.replicatorCond[peer].Signal()
}
}
}
// 复制日志到某个 Follower
func (rf *Raft) replicateOneRound(peer int) {
// ...
request := rf.genAppendEntriesRequest(prevLogIndex)
response := new(AppendEntriesResponse)
if rf.sendAppendEntries(peer, request, response) {
rf.mu.Lock()
rf.handleAppendEntriesResponse(peer, request, response)
rf.mu.Unlock()
}
}
```
3. **提交日志**:
```go
// Leader 推进 commitIndex
func (rf *Raft) advanceCommitIndexForLeader() {
// 统计有多少节点复制了日志
n := len(rf.matchIndex)
srt := make([]int, n)
copy(srt, rf.matchIndex)
insertionSort(srt)
// 取得多数节点都复制的最大索引
newCommitIndex := srt[n-(n/2+1)]
if newCommitIndex > rf.commitIndex {
// 只提交当前任期的日志
if rf.matchLog(rf.currentTerm, newCommitIndex) {
rf.commitIndex = newCommitIndex
rf.applyCond.Signal()
}
}
}
```
4. **应用日志**:
```go
// 应用已提交的日志到状态机
func (rf *Raft) applier() {
for rf.killed() == false {
rf.mu.Lock()
// 等待新的提交
for rf.lastApplied >= rf.commitIndex {
rf.applyCond.Wait()
}
// 获取需要应用的日志
entries := make([]Entry, commitIndex-lastApplied)
copy(entries, rf.logs[lastApplied+1-firstIndex:commitIndex+1-firstIndex])
rf.mu.Unlock()
// 应用日志到状态机
for _, entry := range entries {
rf.applyCh <- ApplyMsg{
CommandValid: true,
Command: entry.Command,
CommandIndex: entry.Index,
}
}
rf.lastApplied = commitIndex
}
}
```
5. **心跳机制**:
```go
func (rf *Raft) ticker() {
for rf.killed() == false {
select {
case <-rf.electionTimer.C:
// 超时未收到心跳,开始选举
rf.mu.Lock()
rf.ChangeState(StateCandidate)
rf.currentTerm += 1
rf.StartElection()
rf.mu.Unlock()
case <-rf.heartbeatTimer.C:
// Leader 定期发送心跳
rf.mu.Lock()
if rf.state == StateLeader {
rf.BroadcastHeartbeat(true)
}
rf.mu.Unlock()
}
}
}
```
这些代码实现了 Raft 的核心机制:
- Leader 选举通过投票实现
- 日志复制通过 AppendEntries RPC 实现
- 通过多数确认来提交日志
- 心跳机制维护 Leader 地位
- 持久化关键状态确保崩溃恢复
每个部分都通过精心的并发控制和状态管理来保证正确性。
## 测试
下面是一个针对 `raft_test.go` 中测试用例的说明表格。表格中列出了各测试方法的名称、主要测试目标和关键关注点。
| | 测试名称 | 测试内容 | 重点关注点 |
| ---------- | ------------------------------- | --------------------------- | ------------------------------------------- |
| 领导选举 | TestInitialElection2A | 测试初始节点选举 | 检查初始条件下是否能在一段合理时间内选出领导者,以及 term 是否正确增长 |
| | TestReElection2A | 测试在网络故障后重新选举 | 当领导者断开后能否正确选出新领导者;旧领导者重连后系统的稳定性 |
| | TestManyElections2A | 测试多次随机断连和重新连接场景下的多轮选举 | 在反复断开、重连部分节点的环境中是否始终能维持或选出正确的领导者 |
| 基本一致性和日志复制 | TestBasicAgree2B | 基本达成一致性测试 | 测试在一个健康集群中对简单命令是否能正确提交日志条目 |
| | TestRPCBytes2B | 测试 RPC 字节数量 | 确保同一命令不重复向各节点发送过多的 RPC;检查实现的高效性 |
| | TestFailAgree2B | 测试在一个 follower 断开时的日志一致性 | 即使有 follower 掉线,领导者和剩余的 follower 也能对新日志达成一致 |
| | TestFailNoAgree2B | 测试过半 follower 断开后无法达成一致 | 若大多数 follower 不在线,则无法提交新日志条目 |
| | TestConcurrentStarts2B | 并发调用 Start() 的一致性测试 | 同一任期内,对多条并发提交的命令能否全部正确提交 |
| | TestRejoin2B | 测试领导者分区后再加入的场景 | 一个长期断开的领导者重新加入集群后,系统仍然能够保证一致性并选出合理的领导者 |
| | TestBackup2B | 测试领导者对日志不一致的 follower 的快速回退 | 在 follower 日志落后且存在不一致的情况下,领导者是否能快速进行日志回退并同步 |
| | TestCount2B | 测试 RPC 调用次数是否合理 | 检查正常操作下 RPC 调用数量不应过高或过低,以保证性能和正确性 |
| 持久化和恢复 | TestPersist12C | 基本持久化测试 | 节点重启后能否正确恢复状态,从而保证系统安全性 |
| | TestPersist22C | 更复杂的持久化测试 | 多次断开、重启和重新连接场景下,确保日志和状态机的一致性和持久性 |
| | TestPersist32C | 测试分区的领导者和 follower 宕机后恢复 | 在有节点崩溃和网络分区的复杂场景下,重启后的节点能否正确恢复状态并加入新的任期 |
| | TestFigure82C | 模拟 Figure 8 场景 | 随机节点崩溃、恢复、分区下,对日志提交和领导者选举的稳定性进行长时间大规模测试 |
| | TestUnreliableAgree2C | 在不可靠网络中达成一致性 | 测试在网络不可靠、丢包条件下,系统仍能提交大多数日志 |
| | TestFigure8Unreliable2C | Figure 8 场景下的不可靠网络测试 | 结合随机崩溃、分区和不可靠网络的极端情况,确保系统的最终一致性和可用性 |
| | TestReliableChurn2C | 有限可靠性的节点频繁变化场景 | 不断重启、断线、重连节点,检查系统在大范围混乱中的表现 |
| | TestUnreliableChurn2C | 不可靠网络下节点频繁变化场景 | 与上一个测试类似,但加入网络不可靠因素,进一步测试稳定性 |
| 快照 | TestSnapshotBasic2D | 基本快照功能测试 | 当日志不断增长时,领导者和 follower 是否能正确生成和安装快照以压缩日志 |
| | TestSnapshotInstall2D | 测试在断开连接条件下的快照安装 | follower 掉线后重新连接是否能通过接收快照快速追上最新日志 |
| | TestSnapshotInstallUnreliable2D | 不可靠网络 + 断开连接下的快照安装 | 在不可靠网络和 follower 掉线下,检查快照同步的健壮性 |
| | TestSnapshotInstallCrash2D | 崩溃后重启下的快照安装测试 | 在节点崩溃并重启后,通过快照安装快速恢复一致性日志状态 |
| | TestSnapshotInstallUnCrash2D | 不可靠网络 + 崩溃恢复下的快照安装 | 最复杂场景下,测试通过快照恢复日志一致性的能力 |
这些测试全面覆盖了Raft算法的核心功能,包括领导选举、日志复制、持久化、恢复、快照等,并在各种网络条件(可靠、不可靠)和故障情况(节点崩溃、网络分区)下进行测试,以确保算法的正确性和鲁棒性。
## 游戏的全局设置器 make_config
下面是对该函数 `make_config` 的目的
- 创建多个 Raft 节点实例
- 控制节点间的通信(可以模拟网络分区、延迟等)
- 启动和停止节点
- 检查节点的状态(如谁是领导者,当前的任期等)
- 模拟客户端请求
- 验证一致性和正确性
**函数参数与关键变量**:
| 参数/变量 | 类型 | 含义 |
| ------------ | ------------ | --------------------------------------------------------------------------- |
| `t` | `*testing.T` | Go 的测试框架中用于表示当前测试用例的对象。`make_config` 使用它来在测试过程中输出错误信息或终止测试。|
| `n` | `int` | 要创建的 Raft 节点个数,即集群中服务器的数量。|
| `unreliable` | `bool` | 是否使用不可靠网络。当 `true` 时,模拟丢包、网络延迟、不按序到达的消息,从而测试 Raft 在不可靠通信环境下的表现。|
| `snapshot` | `bool` | 是否启用快照测试场景。如果为 `true`,则使用 `applierSnap` 来处理日志和快照,否则使用 `applier` 只对日志进行正常应用。|
**关于网络可靠/不可靠的实现**:
在 `make_config` 函数中使用了 `cfg.net = labrpc.MakeNetwork()` 来创建一个模拟的网络对象。`labrpc.Network` 提供了对消息发送、接收的钩子,可通过配置指定如下特性:
- **可靠网络(unreliable = false)**:
网络不会有意丢包或乱序,消息基本按照发送顺序和预期路线到达对端。
- **不可靠网络(unreliable = true)**:
当将网络设置为不可靠后,这个 `labrpc.Network` 实例可能会丢弃某些 RPC 消息、对消息进行延迟、或是对消息重新排序。这就模拟了真实网络中的各种异常情况,使得 Raft 在面对丢包、乱序的环境下仍需要保持正确性与一致性。通过 `cfg.setunreliable(unreliable)` 来开启或关闭这种不确定性。
**关于 `snapshot` 参数的本质与形象解释**:
`setunreliable(true)` 会让网络按一定概率丢包、延迟消息;`setunreliable(false)` 则保证消息不丢不乱,从而达到控制网络可靠性的目的。
- **不使用快照时(snapshot=false)**:每次你从商店带回的新衣服(日志条目)都往衣柜里塞,衣柜越来越满(日志越来越长)。
- **使用快照时(snapshot=true)**:当衣柜塞满时,你会把一些旧衣服整理打包起来(生成快照),清空衣柜一部分空间,让衣柜保持较小的负载。随后的新衣服(日志条目)就不会因为衣柜满了而无处安置。
**总结**:
通过 `make_config` 函数,可以快速搭建一个包含 n 个 Raft 节点的测试环境,包括网络模拟、持久化存储初始化、日志处理逻辑设置以及节点连接状态的默认配置。这为后续测试用例(如选举、日志复制、持久化、网络分区和快照安装等)提供了基础的运行环境。
## 代码流程-TestInitialElection2A
TestInitialElection2A的主要思路是验证Raft集群在初始选举时的基本功能。
1. 初始设置:
```go
servers := 3
cfg := make_config(t, servers, false, false)
```
- 创建3个Raft服务器的集群
- false, false 参数表示网络是可靠的(不丢包),且不使用快照功能
1. 第一个检查点 - 选举出领导者:
```go
cfg.checkOneLeader()
```
- 这个函数会循环检查10次,每次等待450-550ms
- 检查是否有且仅有一个服务器认为自己是leader
- 如果发现多个leader或没有leader会失败
1. 第二个检查点 - term一致性检查:
```go
time.Sleep(50 * time.Millisecond)
term1 := cfg.checkTerms()
if term1 < 1 {
t.Fatalf("term is %v, but should be at least 1", term1)
}
```
- 等待50ms让follower同步选举结果
- 检查所有服务器的term是否一致
- term必须至少为1(因为经过了一次选举)
1. 第三个检查点 - 稳定性检查:
```go
time.Sleep(2 * RaftElectionTimeout)
term2 := cfg.checkTerms()
```
- 等待2个选举超时时间
- 再次检查term
- 如果term改变了(意味着发生了新的选举),会打印警告
- 这说明在没有故障的情况下,leader应该保持稳定
1. 最后检查点:
```go
cfg.checkOneLeader()
```
- 再次确认仍然有一个leader
这个测试验证了Raft的几个基本特性:
1. 能够选出唯一的leader
2. 所有节点就term达成一致
3. 在无故障情况下保持稳定
4. leader能持续保持领导地位
代码执行流程:
1. Make_config创建集群 -> 启动3个Raft节点
2. 节点启动后开始选举过程
3. 等待并验证选举结果
4. 等待并验证集群稳定性
5. 最后验证leader状态
这是对Raft最基本功能的测试,确保实现的Raft能够正确完成领导者选举。
## 代码流程-TestPersist32C
`TestPersist32C` 的测试代码顺序如下:
1. **初始化设置**:
```go
servers := 3
cfg := make_config(t, servers, false, false)
defer cfg.cleanup()
```
- 创建一个包含3个服务器的Raft集群。
- `make_config` 函数初始化网络、Raft实例、持久化存储等。
2. **开始测试**:
```go
cfg.begin("Test (2C): partitioned leader and one follower crash, leader restarts")
```
3. **提交第一个命令**:
```go
cfg.one(101, 3, true)
```
- 提交命令 `101`,期望所有3个服务器都能达成一致。
4. **检查当前的领导者**:
```go
leader := cfg.checkOneLeader()
```
5. **断开一个跟随者**:
```go
cfg.disconnect((leader + 2) % servers)
```
- 断开与领导者不相邻的一个跟随者。
6. **提交第二个命令**:
```go
cfg.one(102, 2, true)
```
- 提交命令 `102`,期望剩下的2个服务器达成一致。
7. **崩溃两个服务器**:
```go
cfg.crash1((leader + 0) % servers)
cfg.crash1((leader + 1) % servers)
```
- 崩溃领导者和另一个跟随者。
8. **重新连接并重启一个崩溃的服务器**:
```go
cfg.connect((leader + 2) % servers)
cfg.start1((leader+0)%servers, cfg.applier)
cfg.connect((leader + 0) % servers)
```
- 重新连接之前断开的跟随者。
- 重启并连接之前崩溃的领导者。
9. **提交第三个命令**:
```go
cfg.one(103, 2, true)
```
- 提交命令 `103`,期望2个服务器达成一致。
10. **重启并连接另一个崩溃的服务器**:
```go
cfg.start1((leader+1)%servers, cfg.applier)
cfg.connect((leader + 1) % servers)
```
11. **提交第四个命令**:
```go
cfg.one(104, servers, true)
```
- 提交命令 `104`,期望所有3个服务器都能达成一致。
12. **结束测试**:
```go
cfg.end()
```
这个测试的目的是验证在领导者和一个跟随者崩溃后,系统能否正确恢复并继续达成一致。通过模拟网络分区和崩溃,测试Raft的持久化和恢复能力。
使用了不同的颜色来表示节点状态:
- 蓝色:领导者节点 leader
- 绿色:正常工作的节点 worker
- 红色:崩溃的节点
- 黄色:网络隔离的节点 脑裂
```mermaid
flowchart TD
%% 定义阶段节点
subgraph Stage1["阶段1: 初始状态 (提交 101)"]
S0_1[["S0\n(Leader)\n日志:{101}"]]
S1_1[["S1\n(Follower)\n日志:{101}"]]
S2_1[["S2\n(Follower)\n日志:{101}"]]
%% 所有节点初始连接且一致
S0_1-->S1_1
S0_1-->S2_1
end
subgraph Stage2["阶段2: 断开S2 (提交 102)"]
S0_2[["S0\n(Leader)\n日志:{101,102}"]]
S1_2[["S1\n(Follower)\n日志:{101,102}"]]
S2_2[["S2\n(断开)\n日志:{101}"]]
%% 断开S2后S0和S1还能提交新日志102
S0_2-->S1_2
%% S2断线,无法获得新日志,仍只有{101}
end
subgraph Stage3["阶段3: 崩溃S0和S1"]
S0_3[["S0\n(Crashed)\n日志:{101,102}"]]
S1_3[["S1\n(Crashed)\n日志:{101,102}"]]
S2_3[["S2\n(活跃,单独)\n日志:{101}"]]
%% 领导者S0和跟随者S1崩溃,只剩S2在线但落后
end
subgraph Stage4["阶段4: 重启S0并恢复连接S2 (提交 103)"]
S0_4[["S0\n(Follower恢复)\n日志:{101,102}"]]
S2_4[["S2\n(Leader选举)\n日志:{101,103}"]]
%% S2被重新连接后可能成为新Leader,提交103
%% S0重启后有旧日志{101,102},S2已提交{103}
S2_4-->S0_4
end
subgraph Stage5["阶段5: 重启S1并全部同步 (提交 104)"]
S0_5[["S0\n(Follower)\n日志:{101,102,103,104}"]]
S1_5[["S1\n(Follower恢复)\n日志:{101,102,103,104}"]]
S2_5[["S2\n(Leader)\n日志:{101,102,103,104}"]]
%% S1重启后加入集群,通过Leader S2的日志同步拿到103和104
S2_5-->S0_5
S2_5-->S1_5
end
%% 流程连接
Stage1 -->|"提交命令101(3台一致)"| Stage2
Stage2 -->|"断开S2,提交命令102(2台一致)"| Stage3
Stage3 -->|"崩溃S0&S1"| Stage4
Stage4 -->|"恢复S0,连接S2,提交命令103(2台一致)"| Stage5
Stage5 -->|"重启S1,提交命令104(3台一致)"| Done
subgraph Done["阶段6: 测试结束"]
end
%% 样式定义
classDef leader fill:#bbdefb,stroke:#1976d2,stroke-width:2px;
classDef follower fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px;
classDef crashed fill:#ffcdd2,stroke:#c62828,stroke-width:2px;
classDef isolated fill:#fff9c4,stroke:#f9a825,stroke-width:2px;
class S0_1 leader
class S1_1,S2_1 follower
class S0_2 leader
class S1_2 follower
class S2_2 isolated
class S0_3,S1_3 crashed
class S2_3 follower
class S0_4 follower
class S2_4 leader
class S0_5,S1_5 follower
class S2_5 leader
```
**为什么阶段4中 S0 拥有 {101,102} 而 S2 是 {101,103}?**
- 当 S0 重启时,它是从持久化存储中恢复日志(S0 在崩溃前已经将 {101,102} 持久化)。此时 S2 虽然在线,但缺少 {102}。在重新连上 S0 后,Raft 必须重新选举领导者并保证日志一致。
- 由于测试代码 `cfg.one(103, 2, true)` 要求在这个场景下达成两个节点对于 `103` 的一致提交,那么不管最终是谁成为领导者(可能是 S2 也可能是 S0,在真实实现里通常是日志更"新"的节点成为领导者,但这里我们重点不纠结领导者身份转换的细节),领导者都会尝试让另一个存活节点复制最新的日志项 `103`。
- 在此过程中,领导者发现双方的日志不一致(S0 有 {102},S2 没有)。Raft 协议会通过 AppendEntries RPC 尝试匹配日志索引和任期。如果发现不匹配,领导者会回退并尝试找到一致的前缀,然后通过发送缺失的日志项来使跟随者日志追上来。
- **102 已经在之前达成过多数派提交,它是不可逆的**。无论现在的领导者是谁,它都必须最终包含所有已提交的日志条目(包括 102)。
- 因此,领导者在尝试写入 `103` 时,会先确保所有参与的节点对于之前已经提交的条目达成一致。也就是说,最终 S2 将通过日志复制机制获得 {102},然后才会正确接受 {103}。
这个过程可能会在内部进行多次 AppendEntries RPC 往返,直到 S2 更新日志变为 {101,102,103}。同时,S0 作为重启后加入集群的节点,已经有 {101,102},现在再加上 {103},两者同步达成了一致。
## 代码流程-TestSnapshotInstall2D