看到这里,恭喜你,已经实现了一个“麻雀虽小、五脏俱全”的基本版本的 Raft(当然,我们没有实现成员变更)。如果你是跟着课程一步步代码敲过来的,想必你会有很多经验、也有很多困惑,欢迎留言跟大家分享。

整体测试

针对多线程并发访问的数据竞态测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
➜  raft git:(raft) ✗ go test -run Part -race 
Test (PartA): initial election ...
... Passed -- 3.1 3 94 26978 0
Test (PartA): election after network failure ...
... Passed -- 4.5 3 184 38101 0
Test (PartA): multiple elections ...
... Passed -- 5.4 7 972 201839 0
Test (PartB): basic agreement ...
... Passed -- 0.5 3 22 5616 3
Test (PartB): RPC byte count ...
... Passed -- 1.8 3 54 115804 11
Test (PartB): test progressive failure of followers ...
... Passed -- 4.4 3 184 38230 3
Test (PartB): test failure of leaders ...
... Passed -- 5.0 3 270 61114 3
Test (PartB): agreement after follower reconnects ...
... Passed -- 3.6 3 120 32157 7
Test (PartB): no agreement if too many followers disconnect ...
... Passed -- 3.3 5 318 64828 3
Test (PartB): concurrent Start()s ...
... Passed -- 0.7 3 24 6302 6
Test (PartB): rejoin of partitioned leader ...
... Passed -- 6.1 3 258 65932 4
Test (PartB): leader backs up quickly over incorrect follower logs ...
... Passed -- 20.6 5 2517 1956195 102
Test (PartB): RPC counts aren't too high ...
... Passed -- 2.2 3 65 19294 12
Test (PartC): basic persistence ...
... Passed -- 4.9 3 168 45357 7
Test (PartC): more persistence ...
... Passed -- 14.7 5 1266 277162 16
Test (PartC): partitioned leader and one follower crash, leader restarts ...
... Passed -- 1.1 3 41 10873 4
Test (PartC): Figure 8 ...
... Passed -- 31.2 5 1759 387419 46
Test (PartC): unreliable agreement ...
... Passed -- 3.8 5 220 80853 246
Test (PartC): Figure 8 (unreliable) ...
... Passed -- 35.4 5 4664 8256169 251
Test (PartC): churn ...
... Passed -- 16.3 5 1443 886128 82
Test (PartC): unreliable churn ...
... Passed -- 16.2 5 954 422659 524
Test (PartD): snapshots basic ...
... Passed -- 4.9 3 145 53724 229
Test (PartD): install snapshots (disconnect) ...
... Passed -- 55.2 3 1736 672025 344
Test (PartD): install snapshots (disconnect+unreliable) ...
... Passed -- 65.0 3 2020 796668 351
Test (PartD): install snapshots (crash) ...
... Passed -- 71.0 3 2034 713683 308
Test (PartD): install snapshots (unreliable+crash) ...
... Passed -- 73.9 3 2055 771062 336
Test (PartD): crash and restart all servers ...
... Passed -- 7.9 3 259 73616 50
Test (PartD): snapshot initialization after crash ...
... Passed -- 2.4 3 82 22474 14
PASS
ok course/raft 465.425s

使用测试工具进行多并发的多轮次测试:

1
2
3
4
5
6
7
➜  raft git:(raft) ✗ dstest Part  -p 30 -n 100 
Verbosity level set to 0
┏━━━━━━┳━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Test ┃ Failed ┃ Total ┃ Time. ┃
┡━━━━━━╇━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━┩
│ Part │ 0 │ 100 │ 460.74 ± 8.04 │
└──────┴────────┴───────┴───────────────┘

aft 小结

Raft 虽然号称是针对 Paxos 的简化版,且面向是面向工程实现的,但在实践过程中仍然有海量的细节。但只要掌握了一些基本的原则和规律,是可以对这些细节进行梳理的。

我们在 [01. Raft 论文解读]一节中已经讲了对 Raft 的感性认识。那么在经过了一遍完整的实现了之后,是时候谈谈一些实践上的经验了。

模块化

尽量的相关联的逻辑拆到单独的文件中去:

  1. raft_election.go:领导选举相关逻辑和 RPC
  2. raft_replication.go:日志复制相关的逻辑和 RPC
  3. raft_application.go:日志应用 Loop
  4. raft_compaction.go:日志压缩相关逻辑和 RPC
  5. raft_log.go:底层日志模块,包含 snapshot 和尾部日志
  6. raft_persistence.go:序列化和反序列化函数
  7. raft.go:Raft 结构体定义和一些公共逻辑

模块化就像给代码建“索引”。当你想改哪一块的时候,直接从相关文件中找就可以,如果全部都放 raft.go 中,虽然开始感觉很方便,但随着代码的不断膨胀,之后修改代码时,会有大量的时间花在定位所需逻辑上。

这工业中,这点更为重要,对于动辄数万、数十万的代码,如果没有一定层次的抽象和组织,维护起来简直是灾难。

其他的模块化,或者说抽象化,还有类似 RaftLog 结构体的实现。我们将物理上的 snapshottailLog 打包在一起,形成一个整体的、看似从零开始的逻辑日志。重构后会发现,所有依赖代码可以进行简单的“平替”,基本不需要做任何逻辑上的修改。这就是通过抽奖,来维持逻辑上的不变性、将大量细节隐藏的一种思想。

RPC 处理

一个基本的原则是,我们不能对 RPC args/reply 有没有收到、收到的顺序有任何假设。有没有收到容易处理,但是我们往往会不自觉的对收到的 Reply 的顺序有假设。举个例子:

在 PartB 中 Leader 向 Follower 同步日志时,会从 index 较大处向着 index 较小处进行“试探”,且在试探失败时要更新 next 进行下一次试探。在更新 next 时,如果我们的某次先前试探的 reply,后面才到,就很容易造成 next 并不是单调递减的,因此要加一个简单判断:

1
2
3
if prevIndex < rf.nextIndex[peer]  {
rf.nextIndex[peer] = prevIndex
}

当然,这种情况很难测出来,所以即使不加可能大多数情况下也能通过测试。这也正是多线程 + RPC 的难调之处之一。

另外,更常见的,就是处理 RPC reply 前, 先要判断是否还满足 RPC args 发出去前的状态,如果不满足,则该 reply 可以直接废弃掉了。这就好比,在进行状态机转换的时候,要判断自己是否还处于“自己认为的状态”。在单线程中,这是天经地义的,根本不需要思考。但在多线程编程中,要时刻注意维持这种“不变性”。

1
2
3
4
5
// check context lost
if rf.contextLostLocked(Leader, term) {
LOG(rf.me, rf.currentTerm, DLog, "-> S%d, Context Lost, T%d:Leader->T%d:%s", peer, term, rf.currentTerm, rf.role)
return
}

下标处理

对于初学者来说,日志下标处理可以算作最繁琐、易错的事情之一了。尤其是很多时候,往往下标多一个还是少一个不能弄的很清楚。有很多个因素造就了这点:

  1. 数组下标从零开始
  2. Golang 切片操作上下界问题
  3. 是否在开头放一个不包含数据的特殊日志
  4. PartD 中引入 snapshot 之后截断日志的下标转换

只能说下标操作的确很繁琐,即使我写了好多年代码,也依然一不留神就犯错,最后还得靠测试来兜底。但也有一些简单的方法,可以避免一些常见错误。

特例法。当你吃不准某个下标计算时,可以想个边界上的特例,看看代入进去是否符合预期。比如我们在逐个 apply 日志时,由于 rf.lastApplied 不是同步更新的,因此每次 CommandIndex 就得算,这个计算就很容易出错。这时,就可以举个例子,当 i = 0 时,表达式是否正确。

1
2
3
4
5
6
7
for i, entry := range entries {
rf.applyCh <- ApplyMsg{
CommandValid: entry.CommandValid,
Command: entry.Command,
CommandIndex: rf.lastApplied + 1 + i, // must be cautious
}
}

迭代法。不是每次计算,而是随着外层循环迭代一起迭代,这样只要初始对的上,之后肯定就对的上。其实道理和上面的特例是一样的,不过代码更为直观。如上面的例子,可以改成:

1
2
3
4
5
6
7
8
9
cmdIdx := rf.lastApplied + 1
for i, entry := range entries {
rf.applyCh <- ApplyMsg{
CommandValid: entry.CommandValid,
Command: entry.Command,
CommandIndex: cmdIdx, // must be cautious
}
cmdIdx++
}

哨兵法。比如我们在 PartD 中在 tailLog 的下标为零的位置,放了一个 mock 出来的日志,起到一个“哨兵”作用,且可以避免计算时额外 +/- 1 。如在进行下标转换时:

1
2
3
4
5
6
7
8
// logicIdx = rl.snapLastIdx + tailIdx
func (rl *RaftLog) idx(logicIdx int) int {
// if the logicIdx fall beyond [snapLastIdx, size()-1]
if logicIdx < rl.snapLastIdx || logicIdx >= rl.size() {
panic(fmt.Sprintf("%d is out of [%d, %d]", logicIdx, rl.snapLastIdx, rl.size()-1))
}
return logicIdx - rl.snapLastIdx
}

如果开头不放这么一个没有实际数据的哨兵,则每次转换要变成:logicIdx - rl.snapLastIdx - 1

另外一个问题,就是在使用下标前,尽量保证下标在合法区间内,这在对使用 RaftLog.at 时尤为重要。

可优化点

无论针对课程,还是在工业实践中,现在的代码都有很多的优化空间。下面随意列举一些:

  1. 锁粒度变细。现在是一把大锁保护 Raft 结构体中的所有字段,如果想要吞吐更高的话,需要将锁的粒度进行拆分,将每组常在一块使用的字段单独用锁。
  2. Leader 退出。如果 Leader 在同步日志时发现大部分节点都持续连不上,可以自行退出,因为这时候可能发生了网络隔离,其他节点可能已经自行选出了新的 Leader。
  3. 日志并发同步。现在所有的日志同步都是一股脑的同步过去的,如果日志量特别大,会出现单个 RPC 放不下的问题。此时就要分段发送,最简单的方法就是确认一段之后再发送下一段(类似 TCP 中的停等协议),可以优化成滑动窗口协议,并发的发送和确认。
  4. Leader 收到日之后立即同步。现在每次 Leader 收到应用层的日志后,都会等待下一个心跳周期才会同步日志。为了加快写入速度,可以在 Leader 收到日志后就立即发送,但要注意 batch,不要每次收到一条就立即发。否则一下收到 100 条日志,就会产生 100 次 RPC。
  5. 读优化。这个增加 KV 层才会涉及。不过工业界常用的优化有 Lease Read,可以分摊 Leader 的读取压力。