├── .check-build ├── .gitignore ├── Makefile ├── README.md └── docs ├── lab1.md ├── lab2.md ├── lab3.md └── lab4.md /.check-build: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | REFERENCE_FILES=( 6 | # lab 1 7 | src/mrapps/crash.go 8 | src/mrapps/indexer.go 9 | src/mrapps/mtiming.go 10 | src/mrapps/nocrash.go 11 | src/mrapps/rtiming.go 12 | src/mrapps/wc.go 13 | src/main/mrsequential.go 14 | src/main/mrcoordinator.go 15 | src/main/mrworker.go 16 | 17 | # lab 2 18 | src/raft/persister.go 19 | src/raft/test_test.go 20 | src/raft/config.go 21 | src/labrpc/labrpc.go 22 | 23 | # lab 3 24 | src/kvraft/test_test.go 25 | src/kvraft/config.go 26 | 27 | # lab 4a 28 | src/shardctrler/test_test.go 29 | src/shardctrler/config.go 30 | 31 | # lab 4b 32 | src/shardkv/test_test.go 33 | src/shardkv/config.go 34 | ) 35 | 36 | main() { 37 | upstream="$1" 38 | labnum="$2" 39 | 40 | # make sure we have reference copy of lab, in FETCH_HEAD 41 | git fetch "$upstream" 2>/dev/null || die "unable to git fetch $upstream" 42 | 43 | # copy existing directory 44 | tmpdir="$(mktemp -d)" 45 | find src -type s -delete # cp can't copy sockets 46 | cp -r src "$tmpdir" 47 | orig="$PWD" 48 | cd "$tmpdir" 49 | 50 | # check out reference files 51 | for f in ${REFERENCE_FILES[@]}; do 52 | mkdir -p "$(dirname $f)" 53 | git --git-dir="$orig/.git" show "FETCH_HEAD:$f" > "$f" 54 | done 55 | 56 | case $labnum in 57 | "lab1") check_lab1;; 58 | "lab2a"|"lab2b"|"lab2c"|"lab2d") check_lab2;; 59 | "lab3a"|"lab3b") check_lab3;; 60 | "lab4a") check_lab4a;; 61 | "lab4b") check_lab4b;; 62 | *) die "unknown lab: $labnum";; 63 | esac 64 | 65 | cd 66 | rm -rf "$tmpdir" 67 | } 68 | 69 | check_lab1() { 70 | check_cmd cd src/mrapps 71 | check_cmd go build -buildmode=plugin wc.go 72 | check_cmd go build -buildmode=plugin indexer.go 73 | check_cmd go build -buildmode=plugin mtiming.go 74 | check_cmd go build -buildmode=plugin rtiming.go 75 | check_cmd go build -buildmode=plugin crash.go 76 | check_cmd go build -buildmode=plugin nocrash.go 77 | check_cmd cd ../main 78 | check_cmd go build mrcoordinator.go 79 | check_cmd go build mrworker.go 80 | check_cmd go build mrsequential.go 81 | } 82 | 83 | check_lab2() { 84 | check_cmd cd src/raft 85 | check_cmd go test -c 86 | } 87 | 88 | check_lab3() { 89 | check_cmd cd src/kvraft 90 | check_cmd go test -c 91 | } 92 | 93 | check_lab4a() { 94 | check_cmd cd src/shardctrler 95 | check_cmd go test -c 96 | } 97 | 98 | check_lab4b() { 99 | check_cmd cd src/shardkv 100 | check_cmd go test -c 101 | # also check other labs/parts 102 | cd "$tmpdir" 103 | check_lab4a 104 | cd "$tmpdir" 105 | check_lab3 106 | cd "$tmpdir" 107 | check_lab2 108 | } 109 | 110 | check_cmd() { 111 | if ! "$@" >/dev/null 2>&1; then 112 | echo "We tried building your source code with testing-related files reverted to original versions, and the build failed. This copy of your code is preserved in $tmpdir for debugging purposes. Please make sure the code you are trying to hand in does not make changes to test code." >&2 113 | echo >&2 114 | echo "The build failed while trying to run the following command:" >&2 115 | echo >&2 116 | echo "$ $@" >&2 117 | echo " (cwd: ${PWD#$tmpdir/})" >&2 118 | exit 1 119 | fi 120 | } 121 | 122 | die() { 123 | echo "$1" >&2 124 | exit 1 125 | } 126 | 127 | main "$@" 128 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pkg/ 2 | api.key 3 | *-handin.tar.gz 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # This is the Makefile helping you submit the labs. 2 | # Just create 6.824/api.key with your API key in it, 3 | # and submit your lab with the following command: 4 | # $ make [lab1|lab2a|lab2b|lab2c|lab2d|lab3a|lab3b|lab4a|lab4b] 5 | 6 | LABS=" lab1 lab2a lab2b lab2c lab2d lab3a lab3b lab4a lab4b " 7 | 8 | %: check-% 9 | @echo "Preparing $@-handin.tar.gz" 10 | @if echo $(LABS) | grep -q " $@ " ; then \ 11 | echo "Tarring up your submission..." ; \ 12 | tar cvzf $@-handin.tar.gz \ 13 | "--exclude=src/main/pg-*.txt" \ 14 | "--exclude=src/main/diskvd" \ 15 | "--exclude=src/mapreduce/824-mrinput-*.txt" \ 16 | "--exclude=src/main/mr-*" \ 17 | "--exclude=mrtmp.*" \ 18 | "--exclude=src/main/diff.out" \ 19 | "--exclude=src/main/mrmaster" \ 20 | "--exclude=src/main/mrsequential" \ 21 | "--exclude=src/main/mrworker" \ 22 | "--exclude=*.so" \ 23 | Makefile src; \ 24 | if ! test -e api.key ; then \ 25 | echo "Missing $(PWD)/api.key. Please create the file with your key in it or submit the $@-handin.tar.gz via the web interface."; \ 26 | else \ 27 | echo "Are you sure you want to submit $@? Enter 'yes' to continue:"; \ 28 | read line; \ 29 | if test "$$line" != "yes" ; then echo "Giving up submission"; exit; fi; \ 30 | if test `stat -c "%s" "$@-handin.tar.gz" 2>/dev/null || stat -f "%z" "$@-handin.tar.gz"` -ge 20971520 ; then echo "File exceeds 20MB."; exit; fi; \ 31 | mv api.key api.key.fix ; \ 32 | cat api.key.fix | tr -d '\n' > api.key ; \ 33 | rm api.key.fix ; \ 34 | curl -F file=@$@-handin.tar.gz -F "key= /dev/null || { \ 36 | echo ; \ 37 | echo "Submit seems to have failed."; \ 38 | echo "Please upload the tarball manually on the submission website."; } \ 39 | fi; \ 40 | else \ 41 | echo "Bad target $@. Usage: make [$(LABS)]"; \ 42 | fi 43 | 44 | .PHONY: check-% 45 | check-%: 46 | @echo "Checking that your submission builds correctly..." 47 | @./.check-build git://g.csail.mit.edu/6.824-golabs-2021 $(patsubst check-%,%,$@) 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 实现 2 | 稳定通过 6.824 lab 所有的测试,并尽可能的提升了代码可读性。 3 | 4 | ## 文档 5 | - [x] [Lab1](docs/lab1.md) 6 | - [x] [Lab2](docs/lab2.md) 7 | - [x] [Lab3](docs/lab3.md) 8 | - [x] [Lab4](docs/lab4.md) 9 | - [x] [Challenge1](docs/lab4.md) 10 | - [x] [Challenge2](docs/lab4.md) 11 | 12 | ## 注意 13 | 不保证绝对的 bug-free,但每个 lab 均测试 500 次以上,无一 fail。 -------------------------------------------------------------------------------- /docs/lab1.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | - [思路](#思路) 4 | - [实现](#实现) 5 | - [Worker](#worker) 6 | - [Coordinator](#coordinator) 7 | - [worker-stateless](#worker-stateless) 8 | - [channel-based](#channel-based) 9 | - [laziest](#laziest) 10 | 11 | 12 | 13 | ## 思路 14 | 15 | 很久没有使用 go 了,也很久没有感受到 go channel 的魅力了,因此我在做 lab1 的时候最朴素地想法就是实现一个 lock-free (不过其实 channel 内部也有锁)的版本,总之就是不想在我的代码里看到锁这个结构,想要全部用 channel 实现。 16 | 17 | 一番探索之后,也多亏看了 Russ Cox 的[讲课](https://www.youtube.com/watch?v=IdCbMO0Ey9I&feature=youtu.be),最后总算实现出来了一个勉强满意的版本。 18 | 19 | 下小节会简单介绍我的实现。 20 | 21 | ## 实现 22 | 23 | ### Worker 24 | 25 | worker 这边的逻辑比较简单,轮训做任务即可,其主要逻辑如下,分别实现对应的 `doMapTask` 和 `doReduceTask` 函数即可。 26 | 27 | ```Go 28 | func Worker(mapF func(string, string) []KeyValue, 29 | reduceF func(string, []string) string) { 30 | for { 31 | response := doHeartbeat() 32 | log.Printf("Worker: receive coordinator's heartbeat %v \n", response) 33 | switch response.JobType { 34 | case MapJob: 35 | doMapTask(mapF, response) 36 | case ReduceJob: 37 | doReduceTask(reduceF, response) 38 | case WaitJob: 39 | time.Sleep(1 * time.Second) 40 | case CompleteJob: 41 | return 42 | default: 43 | panic(fmt.Sprintf("unexpected jobType %v", response.JobType)) 44 | } 45 | } 46 | } 47 | ``` 48 | 49 | 需要注意的有以下两点: 50 | * atomicWriteFile:需要保证 map 任务和 reduce 任务生成文件时的原子性,从而避免某些异常情况导致文件受损,使得之后的任务无法执行下去的 bug。具体方法就是先生成一个临时文件再利用系统调用 `OS.Rename` 来完成原子性替换,这样即可保证写文件的原子性。 51 | * mergeSort or hashSort: 对于 `doReduceTask` 函数,其输入是一堆本地文件(或者远程文件),输出是一个文件。执行过程是在保证不 OOM 的情况下,不断把 `` 对喂给用户的 reduce 函数去执行并得到最终的 `` 对,然后再写到最后的输出文件中去。在本 lab 中,为了简便我直接使用了一个内存哈希表 (map[string][]string) 来将同一个 key 的 values 放在一起,然后遍历该哈希表来喂给用户的 reduce 函数,实际上这样子是没有做内存限制的。在生产级别的 MapReduce 实现中,该部分一定是一个内存+外存来 mergeSort ,然后逐个喂给 reduce 函数的,这样的鲁棒性才会更高。 52 | 53 | ### Coordinator 54 | coordinator 这边的逻辑相对复杂些,但我的实现还算相对轻便,一百多行就已经能够过测试了。这里可以谈谈我实现的 coordinator 的三个特点: 55 | * worker-stateless 56 | * channel-based 57 | * laziest 58 | 59 | 我的结构体定义如下: 60 | ```Go 61 | type Task struct { 62 | fileName string 63 | id int 64 | startTime time.Time 65 | status TaskStatus 66 | } 67 | 68 | // A laziest, worker-stateless, channel-based implementation of Coordinator 69 | type Coordinator struct { 70 | files []string 71 | nReduce int 72 | nMap int 73 | phase SchedulePhase 74 | tasks []Task 75 | 76 | heartbeatCh chan heartbeatMsg 77 | reportCh chan reportMsg 78 | doneCh chan struct{} 79 | } 80 | 81 | type heartbeatMsg struct { 82 | response *HeartbeatResponse 83 | ok chan struct{} 84 | } 85 | 86 | type reportMsg struct { 87 | request *ReportRequest 88 | ok chan struct{} 89 | } 90 | ``` 91 | 92 | #### worker-stateless 93 | 94 | 很多人写 coordinator 的时候喜欢维护 worker 的状态,比如 worker 启动时需要先到 coordinator 注册一个 id,然后 coordinator 维护一个 worker 的 map 或者 list,定期检测所有 worker 的工作状态等等,这增加了许多 coordinator 的复杂度和代码量。 95 | 96 | 对于 6.824 的 lab1 来说,其数据文件都是存储在一个共享存储系统上的,因此其不同的 worker 实际上是对等的,即把 map 任务和 reduce 任务扔给任何一个 worker 理论上性能都不会有区别。那么我们实际上没有必要去维护 worker 的状态,我们可以把 worker 设计成 stateless ,我们只需要关注 task 的状态(是否被执行,是否超时等等)即可,这样即可极大程度的减少 coordinator 的代码复杂度。 97 | 98 | 比如在我的 coordinator 结构体中,我只有一个 []Task 数组而没有维护 worker 的状态,这样子的实现会非常简单。 99 | 100 | 此外,stateless 对于云原生可扩展性也是十分友好的,此处不再赘述。 101 | 102 | #### channel-based 103 | 104 | channel 和 goroutine 都是 go 中非常有用的工具,也可以说是 go 的精髓。因此尽管最开始我实现的是 mutex—based 的版本,后来还是改成了 channel-base 的版本,感觉代码上变得优雅了许多。 105 | 106 | 总结一下就是 coordinator 在启动时就开启一个后台 goroutine 去不断监控 heartbeatCh 和 reportCh 中的数据并做处理,coordinator 结构体的所有数据都在这个 goroutine 中去修改,从而避免了 data race 的问题,对于 worker 请求任务和汇报任务的 rpc handler,其可以创建 heartbeatMsg 和 reportMsg 并将其写入对应的 channel 然后等待该 msg 中的 ok channel 返回即可。 107 | 108 | ```Go 109 | func (c *Coordinator) Heartbeat(request *HeartbeatRequest, response *HeartbeatResponse) error { 110 | msg := heartbeatMsg{response, make(chan struct{})} 111 | c.heartbeatCh <- msg 112 | <-msg.ok 113 | return nil 114 | } 115 | 116 | func (c *Coordinator) Report(request *ReportRequest, response *ReportResponse) error { 117 | msg := reportMsg{request, make(chan struct{})} 118 | c.reportCh <- msg 119 | <-msg.ok 120 | return nil 121 | } 122 | 123 | func (c *Coordinator) schedule() { 124 | c.initMapPhase() 125 | for { 126 | select { 127 | case msg := <-c.heartbeatCh: 128 | ... 129 | msg.ok <- struct{}{} 130 | case msg := <-c.reportCh: 131 | ... 132 | msg.ok <- struct{}{} 133 | } 134 | } 135 | } 136 | ``` 137 | 138 | 需要注意的有以下两点: 139 | * channel 传 struct{}:对于仅需要传输 happens-before 语义不需要传输数据的场景,创建的 channel 应该是 struct{} 类型,go 对其做了特别优化可以不耗费内存。 140 | * channel 传指针:对于 report handler,其往 reportCh 中 send msg 时只需要传输 request 的指针,等 coordinator 的 schedule 协程处理完毕后即可返回,这里并没有什么问题。对于 heartbeat handler,其会相对复杂些,因为其往 heartbeatCh 中 send msg 时传输了 response 的指针,coordinator 的 schedule 协程需要对该指针对应的数据做处理后再返回,那么此时 rpc 协程在返回时是否能够看到另一个 goroutine 对其的修改呢?对于这种场景,如果协程间满足 happens-before 语义的话是可以的,如果不满足则不一定可以。那么是否满足 happens-before 语义呢?很多人都知道对于无 buffer 的 channel,其 `receive` 是 happens-before `send` 的,那么似乎就无法判断其是否满足 happens-before 语义了。实际上,`send` 与 `send 完成`是有区别的,可以参考此[博客](https://studygolang.com/articles/14129),严格来说:对于无 buffer 的 channel,其 `send start` happens-before `receive complete` happens-before `send complete`,因此有了这个更清晰的语义,我们很显然可以得到 `schedule 协程修改 response` happens-before `worker rpc 协程返回 response`,因此这样写应该是没有问题的,我的 race detector 也没有报任何错误。(如果分析的有问题,欢迎讨论赐教) 141 | 142 | #### laziest 143 | 144 | 这个就比较有趣了,前面说到我们可以把 worker 搞成 stateless 的,这样就只用监控 task 的状态而不是 worker 的状态,但实际上我没有去启动额外的 goroutine 去监控每个任务的状态,因为这可能又要招致额外的并发复杂度,相反我采用了最懒的方式:只有 worker 来请求任务时才遍历任务列表去查看是否可以分配任务。 145 | 146 | 这样就可能出现一个有争议的现象:比如只有一个 coordinator 和一个 worker,worker 拿了一个 map task 之后挂了,那么即使这个 task 已经超过了限定时间,coordinator 也暂时不知道,其只有在 worker 再一次申请任务时才会检测到这个现象,这样子实现有没有问题呢?我个人感觉是没有大问题的,因为如果没有 worker 来领任务,coordinator 就算检测到了任务超时又能怎么样呢,也不能分配给任何 worker 去做。 147 | 148 | 当然,由于检查任务状态是通过遍历 task 列表来做的,如果这个列表有几百上千万那么多,那么现在的写法可能会有点问题,但如果只是几百上千,那对应的 CPU 操作耗时实际上也不会有什么瓶颈。 149 | 150 | 总之,这个实现不影响正确性也不是很影响性能,但能够降低代码的复杂度,就是看起来似乎有点太懒了。 -------------------------------------------------------------------------------- /docs/lab2.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | - [思路](#思路) 4 | - [实现](#实现) 5 | - [结构体](#结构体) 6 | - [选主](#选主) 7 | - [handler](#handler) 8 | - [sender](#sender) 9 | - [日志复制](#日志复制) 10 | - [handler](#handler-1) 11 | - [sender](#sender-1) 12 | - [复制模型](#复制模型) 13 | - [日志压缩](#日志压缩) 14 | - [服务端触发的日志压缩](#服务端触发的日志压缩) 15 | - [leader 发送来的 InstallSnapshot](#leader-发送来的-installsnapshot) 16 | - [异步 applier 的 exactly once](#异步-applier-的-exactly-once) 17 | - [持久化](#持久化) 18 | - [问题](#问题) 19 | - [问题 1:有关 safety](#问题-1有关-safety) 20 | - [问题 2:有关 liveness](#问题-2有关-liveness) 21 | 22 | 23 | 24 | ## 思路 25 | lab2 的内容是要实现一个除了节点变更功能外的 raft 算法,还是比较有趣的。 26 | 27 | 有关 go 实现 raft 的种种坑,可以首先参考 6.824 课程对 [locking](https://pdos.csail.mit.edu/6.824/labs/raft-locking.txt) 和 [structure](https://pdos.csail.mit.edu/6.824/labs/raft-structure.txt) 的描述,然后再参考 6.824 TA 的 [guidance](https://thesquareplanet.com/blog/students-guide-to-raft/) 。写之前一定要看看这三篇博客,否则很容易被 bug 包围。 28 | 29 | 另外,raft 论文的图 2 也很关键,一定要看懂其中的每一个描述。 30 | 31 | ![](https://user-images.githubusercontent.com/32640567/116203223-0bbb5680-a76e-11eb-8ccd-4ef3f1006fb3.png) 32 | 33 | 下面会简单介绍我实现的 raft,我个人对于代码结构还是比较满意的。 34 | 35 | ## 实现 36 | 37 | ### 结构体 38 | 我的 raft 结构体写得较为干净,其基本都是图 2 中介绍到的字段或者 lab 自带的字段。也算是为了致敬 raft 论文吧,希望能够提升代码可读性。 39 | 40 | ```Go 41 | type Raft struct { 42 | mu sync.RWMutex // Lock to protect shared access to this peer's state 43 | peers []*labrpc.ClientEnd // RPC end points of all peers 44 | persister *Persister // Object to hold this peer's persisted state 45 | me int // this peer's index into peers[] 46 | dead int32 // set by Kill() 47 | 48 | applyCh chan ApplyMsg 49 | applyCond *sync.Cond // used to wakeup applier goroutine after committing new entries 50 | replicatorCond []*sync.Cond // used to signal replicator goroutine to batch replicating entries 51 | state NodeState 52 | 53 | currentTerm int 54 | votedFor int 55 | logs []Entry // the first entry is a dummy entry which contains LastSnapshotTerm, LastSnapshotIndex and nil Command 56 | 57 | commitIndex int 58 | lastApplied int 59 | nextIndex []int 60 | matchIndex []int 61 | 62 | electionTimer *time.Timer 63 | heartbeatTimer *time.Timer 64 | } 65 | 66 | func Make(peers []*labrpc.ClientEnd, me int, 67 | persister *Persister, applyCh chan ApplyMsg) *Raft { 68 | rf := &Raft{ 69 | peers: peers, 70 | persister: persister, 71 | me: me, 72 | dead: 0, 73 | applyCh: applyCh, 74 | replicatorCond: make([]*sync.Cond, len(peers)), 75 | state: StateFollower, 76 | currentTerm: 0, 77 | votedFor: -1, 78 | logs: make([]Entry, 1), 79 | nextIndex: make([]int, len(peers)), 80 | matchIndex: make([]int, len(peers)), 81 | heartbeatTimer: time.NewTimer(StableHeartbeatTimeout()), 82 | electionTimer: time.NewTimer(RandomizedElectionTimeout()), 83 | } 84 | // initialize from state persisted before a crash 85 | rf.readPersist(persister.ReadRaftState()) 86 | rf.applyCond = sync.NewCond(&rf.mu) 87 | lastLog := rf.getLastLog() 88 | for i := 0; i < len(peers); i++ { 89 | rf.matchIndex[i], rf.nextIndex[i] = 0, lastLog.Index+1 90 | if i != rf.me { 91 | rf.replicatorCond[i] = sync.NewCond(&sync.Mutex{}) 92 | // start replicator goroutine to replicate entries in batch 93 | go rf.replicator(i) 94 | } 95 | } 96 | // start ticker goroutine to start elections 97 | go rf.ticker() 98 | // start applier goroutine to push committed logs into applyCh exactly once 99 | go rf.applier() 100 | 101 | return rf 102 | } 103 | ``` 104 | 105 | 此外其有若干个后台协程,它们分别是: 106 | * ticker:共 1 个,用来触发 heartbeat timeout 和 election timeout 107 | * applier:共 1 个,用来往 applyCh 中 push 提交的日志并保证 exactly once 108 | * replicator:共 len(peer) - 1 个,用来分别管理对应 peer 的复制状态 109 | 110 | ### 选主 111 | 选主的实现较为简单。 112 | 113 | #### handler 114 | 基本参照图 2 的描述实现即可。需要注意的是只有在 grant 投票时才重置选举超时时间,这样有助于网络不稳定条件下选主的 liveness 问题,这一点 guidance 里面有介绍到。 115 | 116 | ```Go 117 | func (rf *Raft) RequestVote(request *RequestVoteRequest, response *RequestVoteResponse) { 118 | rf.mu.Lock() 119 | defer rf.mu.Unlock() 120 | defer rf.persist() 121 | defer DPrintf("{Node %v}'s state is {state %v,term %v,commitIndex %v,lastApplied %v,firstLog %v,lastLog %v} before processing requestVoteRequest %v and reply requestVoteResponse %v", rf.me, rf.state, rf.currentTerm, rf.commitIndex, rf.lastApplied, rf.getFirstLog(), rf.getLastLog(), request, response) 122 | 123 | if request.Term < rf.currentTerm || (request.Term == rf.currentTerm && rf.votedFor != -1 && rf.votedFor != request.CandidateId) { 124 | response.Term, response.VoteGranted = rf.currentTerm, false 125 | return 126 | } 127 | if request.Term > rf.currentTerm { 128 | rf.ChangeState(StateFollower) 129 | rf.currentTerm, rf.votedFor = request.Term, -1 130 | } 131 | if !rf.isLogUpToDate(request.LastLogTerm, request.LastLogIndex) { 132 | response.Term, response.VoteGranted = rf.currentTerm, false 133 | return 134 | } 135 | rf.votedFor = request.CandidateId 136 | rf.electionTimer.Reset(RandomizedElectionTimeout()) 137 | response.Term, response.VoteGranted = rf.currentTerm, true 138 | } 139 | ``` 140 | 141 | #### sender 142 | ticker 协程会定期收到两个 timer 的到期事件,如果是 election timer 到期,则发起一轮选举;如果是 heartbeat timer 到期且节点是 leader,则发起一轮心跳。 143 | 144 | ```Go 145 | func (rf *Raft) ticker() { 146 | for rf.killed() == false { 147 | select { 148 | case <-rf.electionTimer.C: 149 | rf.mu.Lock() 150 | rf.ChangeState(StateCandidate) 151 | rf.currentTerm += 1 152 | rf.StartElection() 153 | rf.electionTimer.Reset(RandomizedElectionTimeout()) 154 | rf.mu.Unlock() 155 | case <-rf.heartbeatTimer.C: 156 | rf.mu.Lock() 157 | if rf.state == StateLeader { 158 | rf.BroadcastHeartbeat(true) 159 | rf.heartbeatTimer.Reset(StableHeartbeatTimeout()) 160 | } 161 | rf.mu.Unlock() 162 | } 163 | } 164 | } 165 | 166 | func (rf *Raft) StartElection() { 167 | request := rf.genRequestVoteRequest() 168 | DPrintf("{Node %v} starts election with RequestVoteRequest %v", rf.me, request) 169 | // use Closure 170 | grantedVotes := 1 171 | rf.votedFor = rf.me 172 | rf.persist() 173 | for peer := range rf.peers { 174 | if peer == rf.me { 175 | continue 176 | } 177 | go func(peer int) { 178 | response := new(RequestVoteResponse) 179 | if rf.sendRequestVote(peer, request, response) { 180 | rf.mu.Lock() 181 | defer rf.mu.Unlock() 182 | DPrintf("{Node %v} receives RequestVoteResponse %v from {Node %v} after sending RequestVoteRequest %v in term %v", rf.me, response, peer, request, rf.currentTerm) 183 | if rf.currentTerm == request.Term && rf.state == StateCandidate { 184 | if response.VoteGranted { 185 | grantedVotes += 1 186 | if grantedVotes > len(rf.peers)/2 { 187 | DPrintf("{Node %v} receives majority votes in term %v", rf.me, rf.currentTerm) 188 | rf.ChangeState(StateLeader) 189 | rf.BroadcastHeartbeat(true) 190 | } 191 | } else if response.Term > rf.currentTerm { 192 | DPrintf("{Node %v} finds a new leader {Node %v} with term %v and steps down in term %v", rf.me, peer, response.Term, rf.currentTerm) 193 | rf.ChangeState(StateFollower) 194 | rf.currentTerm, rf.votedFor = response.Term, -1 195 | rf.persist() 196 | } 197 | } 198 | } 199 | }(peer) 200 | } 201 | } 202 | ``` 203 | 需要注意的有以下几点: 204 | * 并行异步投票:发起投票时要异步并行去发起投票,从而不阻塞 ticker 协程,这样 candidate 再次 election timeout 之后才能自增 term 继续发起新一轮选举。 205 | * 投票统计:可以在函数内定义一个变量并利用 go 的闭包来实现,也可以在结构体中维护一个 votes 变量来实现。为了 raft 结构体更干净,我选择了前者。 206 | * 抛弃过期请求的回复:对于过期请求的回复,直接抛弃就行,不要做任何处理,这一点 guidance 里面也有介绍到。 207 | 208 | ### 日志复制 209 | 210 | 日志复制是 raft 算法的核心,也是 corner case 最多的地方。此外,在引入日志压缩之后,情况会更加复杂,因此需要仔细去应对。在实现此功能时,几乎不可避免会写出 bug 来,因此建议在函数入口或出口处打全日志,这样方便以后 debug。 211 | 212 | #### handler 213 | 可以看到基本都是按照图 2 中的伪代码实现的,此外加上了 6.824 的加速解决节点间日志冲突的优化。 214 | 215 | ```Go 216 | func (rf *Raft) AppendEntries(request *AppendEntriesRequest, response *AppendEntriesResponse) { 217 | rf.mu.Lock() 218 | defer rf.mu.Unlock() 219 | defer rf.persist() 220 | defer DPrintf("{Node %v}'s state is {state %v,term %v,commitIndex %v,lastApplied %v,firstLog %v,lastLog %v} before processing AppendEntriesRequest %v and reply AppendEntriesResponse %v", rf.me, rf.state, rf.currentTerm, rf.commitIndex, rf.lastApplied, rf.getFirstLog(), rf.getLastLog(), request, response) 221 | 222 | if request.Term < rf.currentTerm { 223 | response.Term, response.Success = rf.currentTerm, false 224 | return 225 | } 226 | 227 | if request.Term > rf.currentTerm { 228 | rf.currentTerm, rf.votedFor = request.Term, -1 229 | } 230 | 231 | rf.ChangeState(StateFollower) 232 | rf.electionTimer.Reset(RandomizedElectionTimeout()) 233 | 234 | if request.PrevLogIndex < rf.getFirstLog().Index { 235 | response.Term, response.Success = 0, false 236 | DPrintf("{Node %v} receives unexpected AppendEntriesRequest %v from {Node %v} because prevLogIndex %v < firstLogIndex %v", rf.me, request, request.LeaderId, request.PrevLogIndex, rf.getFirstLog().Index) 237 | return 238 | } 239 | 240 | if !rf.matchLog(request.PrevLogTerm, request.PrevLogIndex) { 241 | response.Term, response.Success = rf.currentTerm, false 242 | lastIndex := rf.getLastLog().Index 243 | if lastIndex < request.PrevLogIndex { 244 | response.ConflictTerm, response.ConflictIndex = -1, lastIndex+1 245 | } else { 246 | firstIndex := rf.getFirstLog().Index 247 | response.ConflictTerm = rf.logs[request.PrevLogIndex-firstIndex].Term 248 | index := request.PrevLogIndex - 1 249 | for index >= firstIndex && rf.logs[index-firstIndex].Term == response.ConflictTerm { 250 | index-- 251 | } 252 | response.ConflictIndex = index 253 | } 254 | return 255 | } 256 | 257 | firstIndex := rf.getFirstLog().Index 258 | for index, entry := range request.Entries { 259 | if entry.Index-firstIndex >= len(rf.logs) || rf.logs[entry.Index-firstIndex].Term != entry.Term { 260 | rf.logs = shrinkEntriesArray(append(rf.logs[:entry.Index-firstIndex], request.Entries[index:]...)) 261 | break 262 | } 263 | } 264 | 265 | rf.advanceCommitIndexForFollower(request.LeaderCommit) 266 | 267 | response.Term, response.Success = rf.currentTerm, true 268 | } 269 | ``` 270 | 271 | #### sender 272 | 可以看到对于该 follower 的复制,有 snapshot 和 entries 两种方式,需要根据该 peer 的 nextIndex 来判断。 273 | 274 | 对于如何生成 request 和处理 response,可以直接去看源码,总之都是按照图 2 来的,这里为了使得代码逻辑清晰将对应逻辑都封装到了函数里面。 275 | 276 | ```Go 277 | func (rf *Raft) replicateOneRound(peer int) { 278 | rf.mu.RLock() 279 | if rf.state != StateLeader { 280 | rf.mu.RUnlock() 281 | return 282 | } 283 | prevLogIndex := rf.nextIndex[peer] - 1 284 | if prevLogIndex < rf.getFirstLog().Index { 285 | // only snapshot can catch up 286 | request := rf.genInstallSnapshotRequest() 287 | rf.mu.RUnlock() 288 | response := new(InstallSnapshotResponse) 289 | if rf.sendInstallSnapshot(peer, request, response) { 290 | rf.mu.Lock() 291 | rf.handleInstallSnapshotResponse(peer, request, response) 292 | rf.mu.Unlock() 293 | } 294 | } else { 295 | // just entries can catch up 296 | request := rf.genAppendEntriesRequest(prevLogIndex) 297 | rf.mu.RUnlock() 298 | response := new(AppendEntriesResponse) 299 | if rf.sendAppendEntries(peer, request, response) { 300 | rf.mu.Lock() 301 | rf.handleAppendEntriesResponse(peer, request, response) 302 | rf.mu.Unlock() 303 | } 304 | } 305 | } 306 | ``` 307 | 需要注意以下几点: 308 | * 锁的使用:发送 rpc,接收 rpc,push channel,receive channel 的时候一定不要持锁,否则很可能产生死锁。这一点 locking 博客里面有介绍到。当然,基本地读写锁使用方式也最好注意一下,对于仅读的代码块就不需要持写锁了。 309 | * 抛弃过期请求的回复:对于过期请求的回复,直接抛弃就行,不要做任何处理,这一点 guidance 里面也有介绍到。 310 | * commit 日志:图 2 中规定,raft leader 只能提交当前 term 的日志,不能提交旧 term 的日志。因此 leader 根据 matchIndex[] 来 commit 日志时需要判断该日志的 term 是否等于 leader 当前的 term,即是否为当前 leader 任期新产生的日志,若是才可以提交。此外,follower 对 leader 311 | 的 leaderCommit 就不需要判断了,无条件服从即可。 312 | 313 | #### 复制模型 314 | 对于复制模型,很直观的方式是:包装一个 BroadcastHeartbeat() 函数,其负责向所有 follower 发送一轮同步。不论是心跳超时还是上层服务传进来一个新 command,都去调一次这个函数来发起一轮同步。 315 | 316 | 以上方式是可以 work 的,我最开始的实现也是这样的,然而在测试过程中,我发现这种方式有很大的资源浪费。比如上层服务连续调用了几十次 Start() 函数,由于每一次调用 Start() 函数都会触发一轮日志同步,则最终导致发送了几十次日志同步。一方面,这些请求包含的 entries 基本都一样,甚至有 entry 连续出现在几十次 rpc 中,这样的实现多传输了一些数据,存在一定浪费;另一方面,每次发送 rpc 都不论是发送端还是接收端都需要若干次系统调用和内存拷贝,rpc 次数过多也会对 CPU 造成不必要的压力。总之,这种资源浪费的根本原因就在于:将日志同步的触发与上层服务提交新指令强绑定,从而导致发送了很多重复的 rpc。 317 | 318 | 为此,我参考了 `sofajraft` 的[日志复制实现](https://mp.weixin.qq.com/s/jzqhLptmgcNix6xYWYL01Q) 。每个 peer 在启动时会为除自己之外的每个 peer 都分配一个 replicator 协程。对于 follower 节点,该协程利用条件变量执行 wait 来避免耗费 cpu,并等待变成 leader 时再被唤醒;对于 leader 节点,该协程负责尽最大地努力去向对应 follower 发送日志使其同步,直到该节点不再是 leader 或者该 follower 节点的 matchIndex 大于等于本地的 lastIndex。 319 | 320 | 这样的实现方式能够将日志同步的触发和上层服务提交新指令解耦,能够大幅度减少传输的数据量,rpc 次数和系统调用次数。由于 6.824 的测试能够展示测试过程中的传输 rpc 次数和数据量,因此我进行了前后的对比测试,结果显示:这样的实现方式相比直观方式的实现,不同测试数据传输量的减少倍数在 1-20 倍之间。当然,这样的实现也只是实现了粗粒度的 batching,并没有流量控制,而且也没有实现 pipeline,有兴趣的同学可以去了解 `sofajraft`, `etcd` 或者 `tikv` 的实现,他们对于复制过程进行了更细粒度的控制。 321 | 322 | 此外,虽然 leader 对于每一个节点都有一个 replicator 协程去同步日志,但其目前同时最多只能发送一个 rpc,而这个 rpc 很可能超时或丢失从而触发集群换主。因此,对于 heartbeat timeout 触发的 BroadcastHeartbeat,我们需要立即发出日志同步请求而不是让 replicator 去发。这也就是我的 BroadcastHeartbeat 函数有两种行为的真正原因。 323 | 324 | 这一块的代码我自认为抽象的还是比较优雅的,也算是对 go 异步编程的一个实践吧。 325 | 326 | ```Go 327 | func (rf *Raft) Start(command interface{}) (int, int, bool) { 328 | rf.mu.Lock() 329 | defer rf.mu.Unlock() 330 | if rf.state != StateLeader { 331 | return -1, -1, false 332 | } 333 | newLog := rf.appendNewEntry(command) 334 | DPrintf("{Node %v} receives a new command[%v] to replicate in term %v", rf.me, newLog, rf.currentTerm) 335 | rf.BroadcastHeartbeat(false) 336 | return newLog.Index, newLog.Term, true 337 | } 338 | 339 | func (rf *Raft) BroadcastHeartbeat(isHeartBeat bool) { 340 | for peer := range rf.peers { 341 | if peer == rf.me { 342 | continue 343 | } 344 | if isHeartBeat { 345 | // need sending at once to maintain leadership 346 | go rf.replicateOneRound(peer) 347 | } else { 348 | // just signal replicator goroutine to send entries in batch 349 | rf.replicatorCond[peer].Signal() 350 | } 351 | } 352 | } 353 | 354 | func (rf *Raft) replicator(peer int) { 355 | rf.replicatorCond[peer].L.Lock() 356 | defer rf.replicatorCond[peer].L.Unlock() 357 | for rf.killed() == false { 358 | // if there is no need to replicate entries for this peer, just release CPU and wait other goroutine's signal if service adds new Command 359 | // if this peer needs replicating entries, this goroutine will call replicateOneRound(peer) multiple times until this peer catches up, and then wait 360 | for !rf.needReplicating(peer) { 361 | rf.replicatorCond[peer].Wait() 362 | } 363 | // maybe a pipeline mechanism is better to trade-off the memory usage and catch up time 364 | rf.replicateOneRound(peer) 365 | } 366 | } 367 | 368 | func (rf *Raft) needReplicating(peer int) bool { 369 | rf.mu.RLock() 370 | defer rf.mu.RUnlock() 371 | return rf.state == StateLeader && rf.matchIndex[peer] < rf.getLastLog().Index 372 | } 373 | ``` 374 | 375 | ### 日志压缩 376 | 377 | 加入日志压缩功能后,需要注意及时对内存中的 entries 数组进行清除。即使得废弃的 entries 切片能够被正常 gc,从而避免内存释放不掉并最终 OOM 的现象出现,具体实现就是 shrinkEntriesArray 函数,具体原理可以参考此[博客](https://www.cnblogs.com/ithubb/p/14184982.html) 。 378 | 379 | #### 服务端触发的日志压缩 380 | 实现很简单,删除掉对应已经被压缩的 raft log 即可 381 | 382 | ```Go 383 | func (rf *Raft) Snapshot(index int, snapshot []byte) { 384 | rf.mu.Lock() 385 | defer rf.mu.Unlock() 386 | snapshotIndex := rf.getFirstLog().Index 387 | if index <= snapshotIndex { 388 | DPrintf("{Node %v} rejects replacing log with snapshotIndex %v as current snapshotIndex %v is larger in term %v", rf.me, index, snapshotIndex, rf.currentTerm) 389 | return 390 | } 391 | rf.logs = shrinkEntriesArray(rf.logs[index-snapshotIndex:]) 392 | rf.logs[0].Command = nil 393 | rf.persister.SaveStateAndSnapshot(rf.encodeState(), snapshot) 394 | DPrintf("{Node %v}'s state is {state %v,term %v,commitIndex %v,lastApplied %v,firstLog %v,lastLog %v} after replacing log with snapshotIndex %v as old snapshotIndex %v is smaller", rf.me, rf.state, rf.currentTerm, rf.commitIndex, rf.lastApplied, rf.getFirstLog(), rf.getLastLog(), index, snapshotIndex) 395 | } 396 | ``` 397 | 398 | #### leader 发送来的 InstallSnapshot 399 | 400 | 对于 leader 发过来的 InstallSnapshot,只需要判断 term 是否正确,如果无误则 follower 只能无条件接受。 401 | 402 | 此外,如果该 snapshot 的 lastIncludedIndex 小于等于本地的 commitIndex,那说明本地已经包含了该 snapshot 所有的数据信息,尽管可能状态机还没有这个 snapshot 新,即 lastApplied 还没更新到 commitIndex,但是 applier 协程也一定尝试在 apply 了,此时便没必要再去用 snapshot 更换状态机了。对于更新的 snapshot,这里通过异步的方式将其 push 到 applyCh 中。 403 | 404 | 对于服务上层触发的 CondInstallSnapshot,与上面类似,如果 snapshot 没有更新的话就没有必要去换,否则就接受对应的 snapshot 并处理对应状态的变更。注意,这里不需要判断 lastIncludeIndex 和 lastIncludeTerm 是否匹配,因为 follower 对于 leader 发来的更新的 snapshot 是无条件服从的。 405 | 406 | ```Go 407 | func (rf *Raft) InstallSnapshot(request *InstallSnapshotRequest, response *InstallSnapshotResponse) { 408 | rf.mu.Lock() 409 | defer rf.mu.Unlock() 410 | defer DPrintf("{Node %v}'s state is {state %v,term %v,commitIndex %v,lastApplied %v,firstLog %v,lastLog %v} before processing InstallSnapshotRequest %v and reply InstallSnapshotResponse %v", rf.me, rf.state, rf.currentTerm, rf.commitIndex, rf.lastApplied, rf.getFirstLog(), rf.getLastLog(), request, response) 411 | 412 | response.Term = rf.currentTerm 413 | 414 | if request.Term < rf.currentTerm { 415 | return 416 | } 417 | 418 | if request.Term > rf.currentTerm { 419 | rf.currentTerm, rf.votedFor = request.Term, -1 420 | rf.persist() 421 | } 422 | 423 | rf.ChangeState(StateFollower) 424 | rf.electionTimer.Reset(RandomizedElectionTimeout()) 425 | 426 | // outdated snapshot 427 | if request.LastIncludedIndex <= rf.commitIndex { 428 | return 429 | } 430 | 431 | go func() { 432 | rf.applyCh <- ApplyMsg{ 433 | SnapshotValid: true, 434 | Snapshot: request.Data, 435 | SnapshotTerm: request.LastIncludedTerm, 436 | SnapshotIndex: request.LastIncludedIndex, 437 | } 438 | }() 439 | } 440 | 441 | 442 | func (rf *Raft) CondInstallSnapshot(lastIncludedTerm int, lastIncludedIndex int, snapshot []byte) bool { 443 | rf.mu.Lock() 444 | defer rf.mu.Unlock() 445 | DPrintf("{Node %v} service calls CondInstallSnapshot with lastIncludedTerm %v and lastIncludedIndex %v to check whether snapshot is still valid in term %v", rf.me, lastIncludedTerm, lastIncludedIndex, rf.currentTerm) 446 | 447 | // outdated snapshot 448 | if lastIncludedIndex <= rf.commitIndex { 449 | DPrintf("{Node %v} rejects the snapshot which lastIncludedIndex is %v because commitIndex %v is larger", rf.me, lastIncludedIndex, rf.commitIndex) 450 | return false 451 | } 452 | 453 | if lastIncludedIndex > rf.getLastLog().Index { 454 | rf.logs = make([]Entry, 1) 455 | } else { 456 | rf.logs = shrinkEntriesArray(rf.logs[lastIncludedIndex-rf.getFirstLog().Index:]) 457 | rf.logs[0].Command = nil 458 | } 459 | // update dummy entry with lastIncludedTerm and lastIncludedIndex 460 | rf.logs[0].Term, rf.logs[0].Index = lastIncludedTerm, lastIncludedIndex 461 | rf.lastApplied, rf.commitIndex = lastIncludedIndex, lastIncludedIndex 462 | 463 | rf.persister.SaveStateAndSnapshot(rf.encodeState(), snapshot) 464 | DPrintf("{Node %v}'s state is {state %v,term %v,commitIndex %v,lastApplied %v,firstLog %v,lastLog %v} after accepting the snapshot which lastIncludedTerm is %v, lastIncludedIndex is %v", rf.me, rf.state, rf.currentTerm, rf.commitIndex, rf.lastApplied, rf.getFirstLog(), rf.getLastLog(), lastIncludedTerm, lastIncludedIndex) 465 | return true 466 | } 467 | ``` 468 | 469 | #### 异步 applier 的 exactly once 470 | 471 | 异步 apply 可以提升 raft 算法的性能,具体可以参照 PingCAP 的[博客](https://pingcap.com/blog-cn/optimizing-raft-in-tikv/) 。 472 | 473 | 对于异步 apply,其触发方式无非两种,leader 提交了新的日志或者 follower 通过 leader 发来的 leaderCommit 来更新 commitIndex。很多人实现的时候可能顺手就在这两处异步启一个协程把 [lastApplied + 1, commitIndex] 的 entry push 到 applyCh 中,但其实这样子是可能重复发送 entry 的,原因是 push applyCh 的过程不能够持锁,那么这个 lastApplied 在没有 push 完之前就无法得到更新,从而可能被多次调用。虽然只要上层服务可以保证不重复 apply 相同 index 的日志到状态机就不会有问题,但我个人认为这样的做法是不优雅的。考虑到异步 apply 时最耗时的步骤是 apply channel 和 apply 日志到状态机,其他的都不怎么耗费时间。因此我们完全可以只用一个 applier 协程,让其不断的把 [lastApplied + 1, commitIndex] 区间的日志 push 到 applyCh 中去。这样既可保证每一条日志只会被 exactly once 地 push 到 applyCh 中,也可以使得日志 apply 到状态机和 raft 提交新日志可以真正的并行。我认为这是一个较为优雅的异步 apply 实现。 474 | 475 | ```Go 476 | // a dedicated applier goroutine to guarantee that each log will be push into applyCh exactly once, ensuring that service's applying entries and raft's committing entries can be parallel 477 | func (rf *Raft) applier() { 478 | for rf.killed() == false { 479 | rf.mu.Lock() 480 | // if there is no need to apply entries, just release CPU and wait other goroutine's signal if they commit new entries 481 | for rf.lastApplied >= rf.commitIndex { 482 | rf.applyCond.Wait() 483 | } 484 | firstIndex, commitIndex, lastApplied := rf.getFirstLog().Index, rf.commitIndex, rf.lastApplied 485 | entries := make([]Entry, commitIndex-lastApplied) 486 | copy(entries, rf.logs[lastApplied+1-firstIndex:commitIndex+1-firstIndex]) 487 | rf.mu.Unlock() 488 | for _, entry := range entries { 489 | rf.applyCh <- ApplyMsg{ 490 | CommandValid: true, 491 | Command: entry.Command, 492 | CommandTerm: entry.Term, 493 | CommandIndex: entry.Index, 494 | } 495 | } 496 | rf.mu.Lock() 497 | DPrintf("{Node %v} applies entries %v-%v in term %v", rf.me, rf.lastApplied, commitIndex, rf.currentTerm) 498 | // use commitIndex rather than rf.commitIndex because rf.commitIndex may change during the Unlock() and Lock() 499 | // use Max(rf.lastApplied, commitIndex) rather than commitIndex directly to avoid concurrently InstallSnapshot rpc causing lastApplied to rollback 500 | rf.lastApplied = Max(rf.lastApplied, commitIndex) 501 | rf.mu.Unlock() 502 | } 503 | } 504 | ``` 505 | 需要注意以下两点: 506 | * 引用之前的 commitIndex:push applyCh 结束之后更新 lastApplied 的时候一定得用之前的 commitIndex 而不是 rf.commitIndex,因为后者很可能在 push channel 期间发生了改变。 507 | * 防止与 installSnapshot 并发导致 lastApplied 回退:需要注意到,applier 协程在 push channel 时,中间可能夹杂有 snapshot 也在 push channel。如果该 snapshot 有效,那么在 CondInstallSnapshot 函数里上层状态机和 raft 模块就会原子性的发生替换,即上层状态机更新为 snapshot 的状态,raft 模块更新 log, commitIndex, lastApplied 等等,此时如果这个 snapshot 之后还有一批旧的 entry 在 push channel,那上层服务需要能够知道这些 entry 已经过时,不能再 apply,同时 applier 这里也应该加一个 Max 自身的函数来防止 lastApplied 出现回退。 508 | 509 | ### 持久化 510 | 511 | 6.824 目前的持久化方式较为简单,每次发生一些变动就要把所有的状态都编码持久化一遍,这显然是生产不可用的。生产环境中,至少对于 raft 日志,应该是通过一个类似于 wal 的方式来顺序写磁盘并有可能在内存和磁盘上都 truncate 未提交的日志。当然,是不是每一次变化都要 async 一下可能就是性能和安全性之间的考量了。 512 | 513 | 此外,有好多人会遇到跑几百次测试才能复现一次的 bug,这种多半是持久化这里没有做完备。建议检查一下代码: currentTerm, voteFor 和 logs 这三个变量一旦发生变化就一定要在被其他协程感知到之前(释放锁之前,发送 rpc 之前)持久化,这样才能保证原子性。 514 | 515 | ## 问题 516 | 517 | 尽管 6.824 的测试用例已经相当详细,对 corner case 的测试已经相当完备了。但我还是发现其至少遗漏了两个 corner case: 一个有关 safety,一个有关 liveness。 518 | 519 | ### 问题 1:有关 safety 520 | 521 | raft leader 只能提交当前任期的日志,不能直接提交过去任期的日志,这一点在图 2 中也有展示,具体样例可以参考论文中的图 8。我最开始的实现并没有考虑此 corner case,跑了 400 次 lab2 的测试依然没有出现一次 FAIL,就以为没问题了。后面我又仔细看了一下图 2,发现这种 corner case 不处理是会有 safety 问题的: 即对于论文图 8 的情况,可能导致已经提交的日志又被覆盖。简单看了一下 6.824 对于图 8 的测试,发现其似乎并不能完备地测试出图 8 的场景,感觉这个测试也需要进一步完善吧。 522 | 523 | ### 问题 2:有关 liveness 524 | 525 | leader 上任后应该提交一条空日志来提交之前的日志,否则会有 liveness 的问题,即可能再某些场景下长时间无法提供读服务。 526 | 527 | 考虑这样一个场景:三节点的集群,节点 1 是 leader,其 logs 是 [1,2],commitIndex 和 lastApplied 是 2,节点 2 是 follower,其 logs 是 [1,2],commitIndex 和 lastApplied 是 1,节点 3 是 follower,其 logs 是 [1],commitIndex 和 lastApplied 是 1。即节点 1 将日志 2 发送到了节点 2 之后即可将日志 2 提交。此时对于节点 2,日志 2 的提交信息还没有到达;对于节点 3,日志 2 还没有到达。显然这种场景是可以出现的。此时 A 作为 leader 自然可以处理读请求,即此时的读请求是对于 index 为 2 的状态机做的。接着 leader 节点进行了宕机,两个 follower 随后会触发选举超时,但由于节点 2 日志最新,所以最后一定是节点 2 当选为 leader,节点 2 当选之后,其会首先判断集群中的 matchIndex 并确定 commitIndex,即 commitIndex 为 1,此时如果客户端没有新地写请求过来,这个 commitIndex 就一直会是 1,得不到更新。那么在此期间,如果又有读请求路由到了节点 2,如果其直接执行了读请求,显然是不满足线性一致性的,因为状态机的状态是更旧的。因此,其需要首先判断当前 term 有没有提交过日志,如果没有,则应该等待当前 term 提交过日志之后才能处理读请求。而当前 term 提交日志又是与客户端提交命令绑定的,客户端不提交命令,当前 term 就没有新日志,那么就一直不能处理读请求。因此,leader 上任后应该提交一条空日志来提交之前的日志。 528 | 529 | 我尝试在 leader 上任之后加了空日志,然而加了之后 lab 2b 的测试全挂了,看来他们应该暂时也还没有考虑这种 corner case。 -------------------------------------------------------------------------------- /docs/lab3.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | - [思路](#思路) 4 | - [实现](#实现) 5 | - [讨论](#讨论) 6 | - [客户端](#客户端) 7 | - [服务端](#服务端) 8 | - [状态机](#状态机) 9 | - [处理模型](#处理模型) 10 | - [日志压缩](#日志压缩) 11 | 12 | 13 | 14 | ## 思路 15 | 16 | lab3 的内容是要在 lab2 的基础上实现一个高可用的 KV 存储服务,算是要将 raft 真正的用起来。 17 | 18 | 有关此 lab 的实现,raft 作者[博士论文](https://web.stanford.edu/~ouster/cgi-bin/papers/OngaroPhD.pdf)的第 6.3 小节——实现线性化语义已经做了很详细的介绍,强烈建议在实现之前进行阅读。此外也建议参考 dragonboat 作者对此的[讨论](https://www.zhihu.com/question/278551592)。 19 | 20 | 以下内容都会假定读者已看过以上两个资料。 21 | 22 | 相关 RPC 实现如下图: 23 | 24 | ![](https://user-images.githubusercontent.com/32640567/119603839-900e0180-be20-11eb-9f74-8b39705bc35e.png) 25 | 26 | ## 实现 27 | 28 | ### 讨论 29 | 30 | 考虑这样一个场景,客户端向服务端提交了一条日志,服务端将其在 raft 组中进行了同步并成功 commit,接着在 apply 后返回给客户端执行结果。然而不幸的是,该 rpc 在传输中发生了丢失,客户端并没有收到写入成功的回复。因此,客户端只能进行重试直到明确地写入成功或失败为止,这就可能会导致相同地命令被执行多次,从而违背线性一致性。 31 | 32 | 有人可能认为,只要写请求是幂等的,那重复执行多次也是可以满足线性一致性的,实际上则不然。考虑这样一个例子:对于一个仅支持 put 和 get 接口的 raftKV 系统,其每个请求都具有幂等性。设 x 的初始值为 0,此时有两个并发客户端,客户端 1 执行 put(x,1),客户端 2 执行 get(x) 再执行 put(x,2),问(客户端 2 读到的值,x 的最终值)是多少。对于线性一致的系统,答案可以是 (0,1),(0,2) 或 (1,2)。然而,如果客户端 1 执行 put 请求时发生了上段描述的情况,然后客户端 2 读到 x 的值为 1 并将 x 置为了 2,最后客户端 1 超时重试且再次将 x 置为 1。对于这种场景,答案是 (1,1),这就违背了线性一致性。归根究底还是由于幂等的 put(x,1) 请求在状态机上执行了两次,有两个 LZ 点。因此,即使写请求的业务语义能够保证幂等,不进行额外的处理让其重复执行多次也会破坏线性一致性。当然,读请求由于不改变系统的状态,重复执行多次是没问题的。 33 | 34 | 对于这个问题,raft 作者介绍了想要实现线性化语义,就需要保证日志仅被执行一次,即它可以被 commit 多次,但一定只能 apply 一次。其解决方案原文如下: 35 | > The solution is for clients to assign unique serial numbers to every command. Then, the state machine tracks the latest serial number processed for each client, along with the associated response. If it receives a command whose serial number has already been executed, it responds immediately without re-executing the request. 36 | 37 | 基本思路便是: 38 | * 每个 client 都需要一个唯一的标识符,它的每个不同命令需要有一个顺序递增的 commandId,clientId 和这个 commandId,clientId 可以唯一确定一个不同的命令,从而使得各个 raft 节点可以记录保存各命令是否已应用以及应用以后的结果。 39 | 40 | 为什么要记录应用的结果?因为通过这种方式同一个命令的多次 apply 最终只会实际应用到状态机上一次,之后相同命令 apply 的时候实际上是不应用到状态机上的而是直接返回的,那么这时候应该返回什么呢?直接返回成功吗?不行,如果第一次应用时状态机报了什么例如 key not exist 等业务上的错而没有被记录,之后就很难捕捉到这个执行结果了,所以也需要将应用结果保存下来。 41 | 42 | 如果默认一个客户端只能串行执行请求的话,服务端这边只需要记录一个 map,其 key 是 clientId,其 value 是该 clientId 执行的最后一条日志的 commandId 和状态机的输出即可。 43 | 44 | raft 论文中还考虑了对这个 map 进行一定大小的限制,防止其无线增长。这就带来了两个问题: 45 | * 集群间的不同节点如何就某个 clientId 过期达成共识。 46 | * 不小心驱逐了活跃的 clientId 怎么办,其之后不论是新建一个 clientId 还是复用之前的 clientId 都可能导致命令的重执行。 47 | 48 | 这些问题在工程实现上都较为麻烦。比如后者如果业务上是事务那直接 abort 就行,但如果不是事务就很难办了。 49 | 50 | 实际上,个人感觉 clientId 是与 session 绑定的,其生命周期应该与 session 一致,开启 session 时从 map 中保存该 clientId,关闭 session 时从 map 中删除该 clientId 及其对应的 value 即可。map 中一个 clientId 对应的内存占用可能都不足 30 字节,相比携带更多业务语义的 session 其实很小,所以感觉没太大必要去严格控制该 map 的内存占用,还不如考虑下怎么控制 session 更大地内存占用呢。这样就不用去纠结前面提到的两个问题了。 51 | 52 | ### 客户端 53 | 54 | 前面提到要用 (clientId,commandId) 来唯一的标识一个客户端。 55 | 56 | 对于前者,在目前的实现中,客户端的 clientId 生成方式较为简陋,基本上是从 [0,1 << 62] 的区间中随机生成数来实现的,这样的实现方式虽然重复概率也可以忽略不计,但不是很优雅。更优雅地方式应该是采用一些分布式 id 生成算法,例如 snowflake 等,具体可以参考此[博客](https://zhuanlan.zhihu.com/p/107939861)。当然,由于 clientId 只需要保证唯一即可,不需要保证递增,使用更简单的 uuid 其实也可以。不过由于 go 的标准库并无内置的 uuid 实现,且 6.824 又要求不能更改 go mod 文件引入新的包,所以这里就直接用它默认的 nrand() 函数了。 57 | 58 | 对于后者,一个 client 可以通过为其处理的每条命令递增 commandId 的方式来确保不同的命令一定有不同的 commandId,当然,同一条命令的 commandId 在没有处理完毕之前,即明确收到服务端的写入成功或失败之前是不能改变的。 59 | 60 | 代码如下: 61 | 62 | ```Go 63 | func MakeClerk(servers []*labrpc.ClientEnd) *Clerk { 64 | return &Clerk{ 65 | servers: servers, 66 | leaderId: 0, 67 | clientId: nrand(), 68 | commandId: 0, 69 | } 70 | } 71 | 72 | func (ck *Clerk) Get(key string) string { 73 | return ck.Command(&CommandRequest{Key: key, Op: OpGet}) 74 | } 75 | 76 | func (ck *Clerk) Put(key string, value string) { 77 | ck.Command(&CommandRequest{Key: key, Value: value, Op: OpPut}) 78 | } 79 | func (ck *Clerk) Append(key string, value string) { 80 | ck.Command(&CommandRequest{Key: key, Value: value, Op: OpAppend}) 81 | } 82 | 83 | // 84 | // 85 | // you can send an RPC with code like this: 86 | // ok := ck.servers[i].Call("KVServer.Command", &request, &response) 87 | // 88 | // the types of args and reply (including whether they are pointers) 89 | // must match the declared types of the RPC handler function's 90 | // arguments. and reply must be passed as a pointer. 91 | // 92 | func (ck *Clerk) Command(request *CommandRequest) string { 93 | request.ClientId, request.CommandId = ck.clientId, ck.commandId 94 | for { 95 | var response CommandResponse 96 | if !ck.servers[ck.leaderId].Call("KVServer.Command", request, &response) || response.Err == ErrWrongLeader || response.Err == ErrTimeout { 97 | ck.leaderId = (ck.leaderId + 1) % int64(len(ck.servers)) 98 | continue 99 | } 100 | ck.commandId++ 101 | return response.Value 102 | } 103 | } 104 | ``` 105 | 106 | ### 服务端 107 | 108 | 服务端的实现的结构体如下: 109 | 110 | ```Go 111 | type KVServer struct { 112 | mu sync.Mutex 113 | dead int32 114 | rf *raft.Raft 115 | applyCh chan raft.ApplyMsg 116 | 117 | maxRaftState int // snapshot if log grows this big 118 | lastApplied int // record the lastApplied to prevent stateMachine from rollback 119 | 120 | stateMachine KVStateMachine // KV stateMachine 121 | lastOperations map[int64]OperationContext // determine whether log is duplicated by recording the last commandId and response corresponding to the clientId 122 | notifyChans map[int]chan *CommandResponse // notify client goroutine by applier goroutine to response 123 | } 124 | ``` 125 | 126 | 以下分三点依次介绍: 127 | 128 | #### 状态机 129 | 为了方便扩展,我抽象出了 KVStateMachine 的接口,并实现了最简单的内存版本的 KV 状态机 MemoryKV。 130 | 131 | 实际上在生产级别的 KV 服务中,数据不可能全存在内存中,系统往往采用的是 LSM 的架构,例如 RocksDB 等。 132 | 133 | ```Go 134 | type KVStateMachine interface { 135 | Get(key string) (string, Err) 136 | Put(key, value string) Err 137 | Append(key, value string) Err 138 | } 139 | 140 | type MemoryKV struct { 141 | KV map[string]string 142 | } 143 | 144 | func NewMemoryKV() *MemoryKV { 145 | return &MemoryKV{make(map[string]string)} 146 | } 147 | 148 | func (memoryKV *MemoryKV) Get(key string) (string, Err) { 149 | if value, ok := memoryKV.KV[key]; ok { 150 | return value, OK 151 | } 152 | return "", ErrNoKey 153 | } 154 | 155 | func (memoryKV *MemoryKV) Put(key, value string) Err { 156 | memoryKV.KV[key] = value 157 | return OK 158 | } 159 | 160 | func (memoryKV *MemoryKV) Append(key, value string) Err { 161 | memoryKV.KV[key] += value 162 | return OK 163 | } 164 | ``` 165 | 166 | #### 处理模型 167 | 168 | 对于 raft 的日志序列,状态机需要按序 apply 才能保证不同节点上数据的一致性,这也是 RSM 模型的原理。因此,在实现中一定得有一个单独的 apply 协程去顺序的 apply 日志到状态机中去。 169 | 170 | 对于客户端的请求,rpc 框架也会生成一个协程去处理逻辑。因此,需要考虑清楚这些协程之间的通信关系。 171 | 172 | 为此,我的实现是客户端协程将日志放入 raft 层去同步后即注册一个 channel 去阻塞等待,接着 apply 协程监控 applyCh,在得到 raft 层已经 commit 的日志后,apply 协程首先将其 apply 到状态机中,接着根据 index 得到对应的 channel ,最后将状态机执行的结果 push 到 channel 中,这使得客户端协程能够解除阻塞并回复结果给客户端。对于这种只需通知一次的场景,这里使用 channel 而不是 cond 的原因是理论上一条日志被路由到 raft 层同步后,客户端协程拿锁注册 notifyChan 和 apply 协程拿锁执行该日志再进行 notify 之间的拿锁顺序无法绝对保证,虽然直观上感觉应该一定是前者先执行,但如果是后者先执行了,那前者对于 cond 变量的 wait 就永远不会被唤醒了,那情况就有点糟糕了。 173 | 174 | 在目前的实现中,读请求也会生成一条 raft 日志去同步,这样可以以最简单的方式保证线性一致性。当然,这样子实现的读性能会相当的差,实际生产级别的 raft 读请求实现一般都采用了 Read Index 或者 Lease Read 的方式,具体原理可以参考此[博客](https://tanxinyu.work/consistency-and-consensus/#etcd-%E7%9A%84-Raft),具体实现可以参照 SOFAJRaft 的实现[博客](https://www.sofastack.tech/blog/sofa-jraft-linear-consistent-read-implementation/)。 175 | 176 | ```Go 177 | func (kv *KVServer) Command(request *CommandRequest, response *CommandResponse) { 178 | defer DPrintf("{Node %v} processes CommandRequest %v with CommandResponse %v", kv.rf.Me(), request, response) 179 | // return result directly without raft layer's participation if request is duplicated 180 | kv.mu.RLock() 181 | if request.Op != OpGet && kv.isDuplicateRequest(request.ClientId, request.CommandId) { 182 | lastResponse := kv.lastOperations[request.ClientId].LastResponse 183 | response.Value, response.Err = lastResponse.Value, lastResponse.Err 184 | kv.mu.RUnlock() 185 | return 186 | } 187 | kv.mu.RUnlock() 188 | // do not hold lock to improve throughput 189 | // when KVServer holds the lock to take snapshot, underlying raft can still commit raft logs 190 | index, _, isLeader := kv.rf.Start(Command{request}) 191 | if !isLeader { 192 | response.Err = ErrWrongLeader 193 | return 194 | } 195 | kv.mu.Lock() 196 | ch := kv.getNotifyChan(index) 197 | kv.mu.Unlock() 198 | select { 199 | case result := <-ch: 200 | response.Value, response.Err = result.Value, result.Err 201 | case <-time.After(ExecuteTimeout): 202 | response.Err = ErrTimeout 203 | } 204 | // release notifyChan to reduce memory footprint 205 | // why asynchronously? to improve throughput, here is no need to block client request 206 | go func() { 207 | kv.mu.Lock() 208 | kv.removeOutdatedNotifyChan(index) 209 | kv.mu.Unlock() 210 | }() 211 | } 212 | 213 | // a dedicated applier goroutine to apply committed entries to stateMachine, take snapshot and apply snapshot from raft 214 | func (kv *KVServer) applier() { 215 | for kv.killed() == false { 216 | select { 217 | case message := <-kv.applyCh: 218 | DPrintf("{Node %v} tries to apply message %v", kv.rf.Me(), message) 219 | if message.CommandValid { 220 | kv.mu.Lock() 221 | if message.CommandIndex <= kv.lastApplied { 222 | DPrintf("{Node %v} discards outdated message %v because a newer snapshot which lastApplied is %v has been restored", kv.rf.Me(), message, kv.lastApplied) 223 | kv.mu.Unlock() 224 | continue 225 | } 226 | kv.lastApplied = message.CommandIndex 227 | 228 | var response *CommandResponse 229 | command := message.Command.(Command) 230 | if command.Op != OpGet && kv.isDuplicateRequest(command.ClientId, command.CommandId) { 231 | DPrintf("{Node %v} doesn't apply duplicated message %v to stateMachine because maxAppliedCommandId is %v for client %v", kv.rf.Me(), message, kv.lastOperations[command.ClientId], command.ClientId) 232 | response = kv.lastOperations[command.ClientId].LastResponse 233 | } else { 234 | response = kv.applyLogToStateMachine(command) 235 | if command.Op != OpGet { 236 | kv.lastOperations[command.ClientId] = OperationContext{command.CommandId, response} 237 | } 238 | } 239 | 240 | // only notify related channel for currentTerm's log when node is leader 241 | if currentTerm, isLeader := kv.rf.GetState(); isLeader && message.CommandTerm == currentTerm { 242 | ch := kv.getNotifyChan(message.CommandIndex) 243 | ch <- response 244 | } 245 | 246 | needSnapshot := kv.needSnapshot() 247 | if needSnapshot { 248 | kv.takeSnapshot(message.CommandIndex) 249 | } 250 | kv.mu.Unlock() 251 | } else if message.SnapshotValid { 252 | kv.mu.Lock() 253 | if kv.rf.CondInstallSnapshot(message.SnapshotTerm, message.SnapshotIndex, message.Snapshot) { 254 | kv.restoreSnapshot(message.Snapshot) 255 | kv.lastApplied = message.SnapshotIndex 256 | } 257 | kv.mu.Unlock() 258 | } else { 259 | panic(fmt.Sprintf("unexpected Message %v", message)) 260 | } 261 | } 262 | } 263 | } 264 | ``` 265 | 266 | 需要注意的有以下几点: 267 | * 提交日志时不持 kvserver 的锁:当有多个客户端并发向 kvserver 提交日志时,kvserver 需要将其下推到 raft 层去做共识。一方面,raft 层的 Start() 函数已经能够保证并发时的正确性;另一方面,kvserver 在生成 snapshot 时需要持锁,此时没有必要去阻塞 raft 层继续同步日志。综合这两个原因,将请求下推到 raft 层做共识时,最好不要加 kvserver 的锁,这一定程度上能提升性能。 268 | * 客户端协程阻塞等待结果时超时返回:为了避免客户端发送到少数派 leader 后被长时间阻塞,其在交给 leader 处理后阻塞时需要考虑超时,一旦超时就返回客户端让其重试。 269 | * apply 日志时需要防止状态机回退:lab2 的文档已经介绍过, follower 对于 leader 发来的 snapshot 和本地 commit 的多条日志,在向 applyCh 中 push 时无法保证原子性,可能会有 snapshot 夹杂在多条 commit 的日志中,如果在 kvserver 和 raft 模块都原子性更换状态之后,kvserver 又 apply 了过期的 raft 日志,则会导致节点间的日志不一致。因此,从 applyCh 中拿到一个日志后需要保证其 index 大于等于 lastApplied 才可以应用到状态机中。 270 | * 对非读请求去重:对于写请求,由于其会改变系统状态,因此在执行到状态机之前需要去重,仅对不重复的日志进行 apply 并记录执行结果,保证其仅被执行一次。对于读请求,由于其不影响系统状态,所以直接去状态机执行即可,当然,其结果也不需要再记录到去重的数据结构中。 271 | * 被 raft 层同步前尝试去重:对于写请求,在未调用 Start 函数即未被 raft 层同步时可以先进行一次检验,如果重复则可以直接返回上次执行的结果而不需要利用 raft 层进行同步,因为同步完成后的结果也是一样。当然,即使该写请求重复,但此时去重表中可能暂还不能判断其是否重复,此时便让其在 raft 层进行同步并在 apply 时去重即可。 272 | * 当前 term 无提交日志时不能服务读请求:有关这个 liveness 的问题,lab2 文档的最后已经介绍过了,是需要通过 leader 上线后立即 append 一条空日志来回避的。本文档的 RPC 实现图中也进行了描述:对于查询请求,如果 leader 没有当前任期已提交日志的话,其是不能服务读请求的,因为这时候 leader 的状态机可能并不足够新,服务读请求可能会违背线性一致性。其实更准确地说,只要保证状态机应用了之前 term 的所有日志就可以提供服务。由于目前的实现中读请求是按照一条 raft 日志来实现的,所以对于当前 leader 当选后的读请求,由于 apply 的串行性,其走到 apply 那一步时已经确保了之前任期的日志都已经 apply 到了状态机中,那么此时服务读请求是没有问题的。在实际生产级别的 raft 实现中, raft 读请求肯定不是通过日志来实现的,因此需要仔细考虑此处并进行必要的阻塞。对于他们,更优雅一些的做法还是 leader 一上线就 append 一条空日志,这样其新 leader 读服务不可用的区间会大幅度减少。 273 | * 仅对 leader 的 notifyChan 进行通知:目前的实现中读写请求都需要路由给 leader 去处理,所以在执行日志到状态机后,只有 leader 需将执行结果通过 notifyChan 唤醒阻塞的客户端协程,而 follower 则不需要;对于 leader 降级为 follower 的情况,该节点在 apply 日志后也不能对之前靠 index 标识的 channel 进行 notify,因为可能执行结果是不对应的,所以在加上只有 leader 可以 notify 的判断后,对于此刻还阻塞在该节点的客户端协程,就可以让其自动超时重试。如果读者足够细心,也会发现这里的机制依然可能会有问题,下一点会提到。 274 | * 仅对当前 term 日志的 notifyChan 进行通知:上一点提到,对于 leader 降级为 follower 的情况,该节点需要让阻塞的请求超时重试以避免违反线性一致性。那么有没有这种可能呢?leader 降级为 follower 后又迅速重新当选了 leader,而此时依然有客户端协程未超时在阻塞等待,那么此时 apply 日志后,根据 index 获得 channel 并向其中 push 执行结果就可能出错,因为可能并不对应。对于这种情况,最直观地解决方案就是仅对当前 term 日志的 notifyChan 进行通知,让之前 term 的客户端协程都超时重试即可。当然,目前客户端超时重试的时间是 500ms,选举超时的时间是 1s,所以严格来说并不会出现这种情况,但是为了代码的鲁棒性,最好还是这么做,否则以后万一有人将客户端超时时间改到 5s 就可能出现这种问题了。 275 | 276 | #### 日志压缩 277 | 278 | 首先,日志的 snapshot 不仅需要包含状态机的状态,还需要包含用来去重的 lastOperations 哈希表。 279 | 280 | 其次,apply 协程负责持锁阻塞式的去生成 snapshot,幸运的是,此时 raft 框架是不阻塞的,依然可以同步并提交日志,只是不 apply 而已。如果这里还想进一步优化的话,可以将状态机搞成 MVCC 等能够 COW 的机制,这样应该就可以不阻塞状态机的更新了。 -------------------------------------------------------------------------------- /docs/lab4.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | - [思路](#思路) 5 | - [shardctrler](#shardctrler) 6 | - [讨论](#讨论) 7 | - [实现](#实现) 8 | - [shardkv](#shardkv) 9 | - [讨论](#讨论-1) 10 | - [实现](#实现-1) 11 | - [结构](#结构) 12 | - [日志类型](#日志类型) 13 | - [分片结构](#分片结构) 14 | - [读写操作](#读写操作) 15 | - [配置更新](#配置更新) 16 | - [分片迁移](#分片迁移) 17 | - [分片清理](#分片清理) 18 | - [空日志检测](#空日志检测) 19 | - [客户端](#客户端) 20 | 21 | 22 | ## 思路 23 | 24 | lab4 的内容是要在 lab2 的基础上实现一个 multi-raft 的 KV 存储服务,同时也要支持切片在不同 raft 组上的动态迁移而不违背线性一致性,不过其不需要实现集群的动态伸缩。总体来看,lab4 是一个相对贴近生产场景的 lab。 25 | 26 | lab4 应该是 6.824 最难的 lab,如果要为 6.824 的 lab 难度排序的话,我给出的排序是 lab4 > lab2 >> lab3 = lab1。之所以说 lab4 比 lab2 难,主要是因为虽然 raft 实现起来较为琐碎,很难一次写正确,但只要我们对图 2 有着足够的敬畏,再加上阅读 TA 的 guide 和一些相关资料,基本还是能够做出来的。但对于 lab4 来说,基本没有可以参考的相关资料,需要自己动手设计整个迁移流程,期间还要保证线性一致性,这就可能会有很多坑了。对于不熟悉分布式存储的同学来说,很多时候会很痛苦,怎么写都写不对,而且有些 bug 并不是稳定复现的,找起来十分费劲。此外,lab4 的两个 challenge 对代码提出了更高的要求,需要想清楚很多 corner case,基本上一旦最开始不考虑清楚 challenge 的 corner case,实现完就一定会有 bug。 27 | 28 | lab4 是我耗时最久的 lab,加起来的总耗时可能都有 10 个工作日了。庆幸的是,我总算啃下了这块硬骨头,能够稳定通过 shardctrler,shardkv 及两个 challenge 的所有测试。在实现过程中,我尽量加了一些注释并不断的调整代码结构,以期望更高的代码可读性。 29 | 30 | 以下会分别介绍 shardctrler 和 shardkv 的实现细节。 31 | 32 | ## shardctrler 33 | 34 | ### 讨论 35 | 36 | 有关 shardctrler,其实它就是一个高可用的集群配置管理服务。它主要记录了当前每个 raft 组对应的副本数个节点的 endpoint 以及当前每个 shard 被分配到了哪个 raft 组这两个 map。 37 | 38 | 对于前者,shardctrler 可以通过用户手动或者内置策略自动的方式来增删 raft 组,从而更有效地利用集群的资源。对于后者,客户端的每一次请求都可以通过询问 shardctrler 来路由到对应正确的数据节点,其实有点类似于 HDFS Master 的角色,当然客户端也可以缓存配置来减少 shardctrler 的压力。 39 | 40 | 在工业界,shardctrler 的角色就类似于 TiDB 的 PD 或者 Kafka 的 ZK,只不过工业界的集群配置管理服务往往更复杂些,一般还要兼顾负载均衡,事务授时等功能。 41 | 42 | ### 实现 43 | 44 | 对于 shardctrler 的实现,其实没什么好讲的,基本就是完全照抄 lab3 kvraft 的实现,甚至由于配置一般比较小,甚至都不用实现 raft 的 snapshot,那么 lab3 文档中提到的好几个与 snapshot 相关的 corner case 就都不用考虑了。此外,得益于 raft 层异步 applier 对日志 push channel 的 exactly once 语义,在没有 snapshot 之后,这一层从 channel 中读到的日志可以放心大胆的直接 apply 而不用判断 commandIndex ,这就是将下层写好的收益吧。 45 | 46 | 此外可能有同学觉得将不同的 rpc 参数都塞到一个结构体中并不是一个好做法,这虽然简化了客户端和服务端的逻辑,但也导致多传输了许多无用数据。对于 6.824 来说,的确是这样,但对于成熟的 rpc 框架例如 gRPC,thrift 等,其字段都可以设置成 optional,在实际传输中,只需要一个 bit 就能够区分是否含有对应字段,这不会是一个影响性能的大问题。因此在我看来,多传输几个 bit 数据带来的弊端远不如简化代码逻辑的收益大。 47 | 48 | ```Go 49 | type CommandRequest struct { 50 | Servers map[int][]string // for Join 51 | GIDs []int // for Leave 52 | Shard int // for Move 53 | GID int // for Move 54 | Num int // for Query 55 | Op OperationOp 56 | ClientId int64 57 | CommandId int64 58 | } 59 | 60 | type CommandResponse struct { 61 | Err Err 62 | Config Config 63 | } 64 | ``` 65 | 66 | 至于有关分区表的四个函数,对于 Query 和 Move 没什么好说的,直接实现就好。对于 Join 和 Leave,由于 shard 是不变的,所以需要在增删 raft 组后需要将 shard 分配地更为均匀且尽量产生较少的迁移任务。对于 Join,可以通过多次平均地方式来达到这个目的:每次选择一个拥有 shard 数最多的 raft 组和一个拥有 shard 数最少的 raft,将前者管理的一个 shard 分给后者,周而复始,直到它们之前的差值小于等于 1 且 0 raft 组无 shard 为止。对于 Leave,如果 Leave 后集群中无 raft 组,则将分片所属 raft 组都置为无效的 0;否则将删除 raft 组的分片均匀地分配给仍然存在的 raft 组。通过这样的分配,可以将 shard 分配地十分均匀且产生了几乎最少的迁移任务。 67 | 68 | ```Go 69 | func (cf *MemoryConfigStateMachine) Join(groups map[int][]string) Err { 70 | lastConfig := cf.Configs[len(cf.Configs)-1] 71 | newConfig := Config{len(cf.Configs), lastConfig.Shards, deepCopy(lastConfig.Groups)} 72 | for gid, servers := range groups { 73 | if _, ok := newConfig.Groups[gid]; !ok { 74 | newServers := make([]string, len(servers)) 75 | copy(newServers, servers) 76 | newConfig.Groups[gid] = newServers 77 | } 78 | } 79 | s2g := Group2Shards(newConfig) 80 | for { 81 | source, target := GetGIDWithMaximumShards(s2g), GetGIDWithMinimumShards(s2g) 82 | if source != 0 && len(s2g[source])-len(s2g[target]) <= 1 { 83 | break 84 | } 85 | s2g[target] = append(s2g[target], s2g[source][0]) 86 | s2g[source] = s2g[source][1:] 87 | } 88 | var newShards [NShards]int 89 | for gid, shards := range s2g { 90 | for _, shard := range shards { 91 | newShards[shard] = gid 92 | } 93 | } 94 | newConfig.Shards = newShards 95 | cf.Configs = append(cf.Configs, newConfig) 96 | return OK 97 | } 98 | 99 | func (cf *MemoryConfigStateMachine) Leave(gids []int) Err { 100 | lastConfig := cf.Configs[len(cf.Configs)-1] 101 | newConfig := Config{len(cf.Configs), lastConfig.Shards, deepCopy(lastConfig.Groups)} 102 | s2g := Group2Shards(newConfig) 103 | orphanShards := make([]int, 0) 104 | for _, gid := range gids { 105 | if _, ok := newConfig.Groups[gid]; ok { 106 | delete(newConfig.Groups, gid) 107 | } 108 | if shards, ok := s2g[gid]; ok { 109 | orphanShards = append(orphanShards, shards...) 110 | delete(s2g, gid) 111 | } 112 | } 113 | var newShards [NShards]int 114 | // load balancing is performed only when raft groups exist 115 | if len(newConfig.Groups) != 0 { 116 | for _, shard := range orphanShards { 117 | target := GetGIDWithMinimumShards(s2g) 118 | s2g[target] = append(s2g[target], shard) 119 | } 120 | for gid, shards := range s2g { 121 | for _, shard := range shards { 122 | newShards[shard] = gid 123 | } 124 | } 125 | } 126 | newConfig.Shards = newShards 127 | cf.Configs = append(cf.Configs, newConfig) 128 | return OK 129 | } 130 | ``` 131 | 132 | 需要注意的是,这些命令会在一个 raft 组内的所有节点上执行,因此需要保证同一个命令在不同节点上计算出来的新配置一致,而 go 中 map 的遍历是不确定性的,因此需要稍微注意一下确保相同地命令在不同地节点上能够产生相同的配置。 133 | 134 | ```Go 135 | func GetGIDWithMinimumShards(s2g map[int][]int) int { 136 | // make iteration deterministic 137 | var keys []int 138 | for k := range s2g { 139 | keys = append(keys, k) 140 | } 141 | sort.Ints(keys) 142 | // find GID with minimum shards 143 | index, min := -1, NShards+1 144 | for _, gid := range keys { 145 | if gid != 0 && len(s2g[gid]) < min { 146 | index, min = gid, len(s2g[gid]) 147 | } 148 | } 149 | return index 150 | } 151 | 152 | func GetGIDWithMaximumShards(s2g map[int][]int) int { 153 | // always choose gid 0 if there is any 154 | if shards, ok := s2g[0]; ok && len(shards) > 0 { 155 | return 0 156 | } 157 | // make iteration deterministic 158 | var keys []int 159 | for k := range s2g { 160 | keys = append(keys, k) 161 | } 162 | sort.Ints(keys) 163 | // find GID with maximum shards 164 | index, max := -1, -1 165 | for _, gid := range keys { 166 | if len(s2g[gid]) > max { 167 | index, max = gid, len(s2g[gid]) 168 | } 169 | } 170 | return index 171 | } 172 | ``` 173 | 174 | ## shardkv 175 | 176 | ### 讨论 177 | 178 | 有关 shardkv,其可以算是一个 multi-raft 的实现,只是缺少了物理节点的抽象概念。在实际的生产系统中,不同 raft 组的成员可能存在于一个物理节点上,而且一般情况下都是一个物理节点拥有一个状态机,不同 raft 组使用不同地命名空间或前缀来操作同一个状态机。基于此,下文所提到的的节点都代指 raft 组的某个成员,而不代指某个物理节点。比如节点宕机代指 raft 组的某个成员被 kill 掉,而不是指某个物理节点宕机,从而可能影响多个 raft 的成员。 179 | 180 | 有关如何实现 lab4b 以及两个 challenge,可以首先仔细阅读官网给出的[提示](https://pdos.csail.mit.edu/6.824/labs/lab-shard.html),接着可以参考这篇[博客](https://www.jianshu.com/p/f5c8ab9cd577),该博客较为详细地介绍了如果从 0 开始实现并完成 lab4b,阅读过后可以了解实现过程中的大部分坑。个人感觉该博客的代码实现还可以再做优化,比如其为不同的 config 都保存了全量数据,这显然是一个生产不可用的设计;再比如其通过在 shardkv 结构体中塞了一堆 map 变量来维护配置变更的状态,其实还可以做得更优雅,可读性更高一些。 181 | 182 | 我们可以首先明确系统的运行方式:一开始系统会创建一个 shardctrler 组来负责配置更新,分片分配等任务,接着系统会创建多个 raft 组来承载所有分片的读写任务。此外,raft 组增删,节点宕机,节点重启,网络分区等各种情况都可能会出现。 183 | 184 | 对于集群内部,我们需要保证所有分片能够较为均匀的分配在所有 raft 组上,还需要能够支持动态迁移和容错。 185 | 186 | 对于集群外部,我们需要向用户保证整个集群表现的像一个永远不会挂的单节点 KV 服务一样,即具有线性一致性。 187 | 188 | lab4b 的基本测试要求了上述属性,challenge1 要求及时清理不再属于本分片的数据,challenge2 不仅要求分片迁移时不影响未迁移分片的读写服务,还要求不同地分片数据能够独立迁移,即如果一个配置导致当前 raft 组需要向其他两个 raft 组拉取数据时,即使一个被拉取数据的 raft 组全挂了,也不能导致另一个未挂的被拉取数据的 raft 组分片始终不能在当前 raft 组提供服务。 189 | 190 | 首先明确三点: 191 | * 所有涉及修改集群分片状态的操作都应该通过 raft 日志的方式去提交,这样才可以保证同一 raft 组内的所有分片数据和状态一致。 192 | * 在 6.824 的框架下,涉及状态的操作都需要 leader 去执行才能保持正确性,否则需要添加一些额外的同步措施,而这显然不是 6.824 所推荐的。因此配置更新,分片迁移,分片清理和空日志检测等逻辑都只能由 leader 去检测并执行。 193 | * 数据迁移的实现为 pull 还是 push?其实都可以,个人感觉难度差不多,这里实现成了 pull 的方式。 194 | 195 | 接着我们来讨论解决方案: 196 | 197 | 首先,每个 raft 组的 leader 需要有一个协程去向 shardctrler 定时拉取最新配置,一旦拉取到就需要提交到该 raft 组中以更新配置。此外,为了防止集群的分片状态被覆盖,从而使得某些任务永远被丢弃,因此一旦存在某一分片的状态不是默认状态,配置更新协程就会停止获取和提交新配置直至所有分片的状态都为默认状态为止。 198 | 199 | 然后,配置在 apply 协程应用时的更新是否预示着其所属分片可以立刻动态的提供服务呢?如果不做 challenge2 那是有可能的,大不了可以阻塞 apply 协程,等到所有数据全拉过来,然后将所有数据进行重放并更新配置。但 challenge2 不仅要求 apply 协程不被阻塞,还要求配置的更新和分片的状态变化彼此独立,所以很显然地一个答案就是我们不仅不能在配置更新时同步阻塞的去拉取数据,也不能异步的去拉取所有数据并当做一条 raft 日志提交,而是应该将不同 raft 组所属的分片数据独立起来,分别提交多条 raft 日志来维护状态。因此,ShardKV 应该对每个分片额外维护其它的一些状态变量。 200 | 201 | 那么,我们是否可以在 apply 协程更新配置的时候由 leader 异步启动对应的协程,让其独立的根据 raft 组为粒度拉取数据呢?答案也是不可以的,设想这样一个场景:leader apply 了新配置后便挂了,然后此时 follower 也 apply 了该配置但并不会启动该任务,在该 raft 组的新 leader 选出来后,该任务已经无法被执行了。因此,我们不能在 apply 配置的时候启动异步任务,而是应该只更新 shard 的状态,由单独的协程去异步的执行分片迁移,分片清理等任务。当然,为了让单独的协程能够得知找谁去要数据或让谁去删数据,ShardKV 不仅需要维护 currentConfig,还要保留 lastConfig,这样其他协程便能够通过 lastConfig,currentConfig 和所有分片的状态来不遗漏的执行任务。 202 | 203 | 做完这些之后足够了吗?其实还不够,我们还需要保证集群状态操作的幂等性。举个例子,由于分片迁移协程和 apply 协程是并行的,当 raft 组集体被 kill 并重启时,raft 组首先需要重放所有日志,在重放的过程中,迁移协程可能检测到某个中间状态从而发起数据的迁移,这时如果对数据进行覆盖就会破坏正确性。基于此,我们可以让每个分片迁移和清理请求都携带一个配置版本,保证只有版本相等时才可以覆盖,这样即使重启时迁移协程检测到了中间状态做了重复操作,也不会导致分片状态发生改变,从而保证了幂等性。 204 | 205 | 听起来是不是已经足够了呢?实际上还不够,考虑这样一个场景:当前 raft 组首先更新了配置,然后迁移协程根据配置从其他 raft 组拉了一个分片的数据和去重表过来并进行了提交,接着系统开始提供服务,这时迁移协程也不会再去重复拉该分片的数据。然而就在这时整个 raft 组被 kill 掉了,重启后 raft 组开始重放日志。当重放到更新配置的日志时,迁移协程恰好捕捉到最新配置的中间状态,并再次向其他 raft 组拉数据和去重表并尝试提交,这样在 apply 该分片更新日志时两者版本一致,从而进行了直接覆盖。这就可能出错了,因为可能在 raft 回放完毕和迁移协程拉到数据并提交日志中间该 raft 组又执行了该分片的写操作,那么直接覆盖就会导致这些改动丢失。因此,仅为分片迁移和清理请求携带配置版本还不完备。在 apply 时,即使配置版本相同也要保证幂等性,这一点可以通过判断分片的状态实现。 206 | 207 | 最后,在 lab2 的文档中我就提到了 leader 上线后应该立刻 append 一条空日志,这样才可以保证 leader 的状态机最新,然而不幸的是,lab2 的测试在加了空日志后便 Fail 了,因此我便没有再关注。在实现 lab4 时,我最开始并没有关注这件事,最后成了一个大坑,导致我花费了一天的时间才找到问题。该 bug 一般跑 100 次测试能够复现一次,对外的表现是集群出现活锁,无法再服务请求直到超时,而且仅会在几个涉及到重启的测试中出现。经过一番探索,最终发现是在节点频繁的重启过后,出现了 lab2 中描述空日志必要性的例子。这导致某一 raft 组的状态机无法达到最新且不全是默认状态,这使得配置更新协程也无法提交新的配置日志,此时客户端碰巧没有向该 raft 组执行读写请求,因而该 raft 组始终没有当前 term 的日志,从而无法推进 commitIndex,因此整个集群便出现了活锁。该 bug 的解决方法很简单,就是让 raft 层的 leader 在 kv 层周期性的去检测下层是否包含当前 term 的日志,如果没有便 append 一条空日志,这样即可保证新选出的 leader 状态机能够迅速达到最新。其实我认为将空日志检测做到 KV 层并不够优雅,KV 层不需要去了解 raft 层有无空日志会怎么样,更优雅地方式应该是 raft 层的 leader 一上线就提交一个空日志。但总之目前在 6.824 的框架下,也只能在 KV 层做检测了。 208 | 209 | 讨论清楚这些问题,具体的实现其实就比较朴素了。接下来简单介绍一下实现: 210 | 211 | ### 实现 212 | 213 | #### 结构 214 | 215 | 首先给出 shardKV 结构体和 apply 协程的实现,较为干净整洁: 216 | 217 | ```Go 218 | type ShardKV struct { 219 | mu sync.RWMutex 220 | dead int32 221 | rf *raft.Raft 222 | applyCh chan raft.ApplyMsg 223 | 224 | makeEnd func(string) *labrpc.ClientEnd 225 | gid int 226 | sc *shardctrler.Clerk 227 | 228 | maxRaftState int // snapshot if log grows this big 229 | lastApplied int // record the lastApplied to prevent stateMachine from rollback 230 | 231 | lastConfig shardctrler.Config 232 | currentConfig shardctrler.Config 233 | 234 | stateMachines map[int]*Shard // KV stateMachines 235 | lastOperations map[int64]OperationContext // determine whether log is duplicated by recording the last commandId and response corresponding to the clientId 236 | notifyChans map[int]chan *CommandResponse // notify client goroutine by applier goroutine to response 237 | } 238 | 239 | // a dedicated applier goroutine to apply committed entries to stateMachine, take snapshot and apply snapshot from raft 240 | func (kv *ShardKV) applier() { 241 | for kv.killed() == false { 242 | select { 243 | case message := <-kv.applyCh: 244 | DPrintf("{Node %v}{Group %v} tries to apply message %v", kv.rf.Me(), kv.gid, message) 245 | if message.CommandValid { 246 | kv.mu.Lock() 247 | if message.CommandIndex <= kv.lastApplied { 248 | DPrintf("{Node %v}{Group %v} discards outdated message %v because a newer snapshot which lastApplied is %v has been restored", kv.rf.Me(), kv.gid, message, kv.lastApplied) 249 | kv.mu.Unlock() 250 | continue 251 | } 252 | kv.lastApplied = message.CommandIndex 253 | 254 | var response *CommandResponse 255 | command := message.Command.(Command) 256 | switch command.Op { 257 | case Operation: 258 | operation := command.Data.(CommandRequest) 259 | response = kv.applyOperation(&message, &operation) 260 | case Configuration: 261 | nextConfig := command.Data.(shardctrler.Config) 262 | response = kv.applyConfiguration(&nextConfig) 263 | case InsertShards: 264 | shardsInfo := command.Data.(ShardOperationResponse) 265 | response = kv.applyInsertShards(&shardsInfo) 266 | case DeleteShards: 267 | shardsInfo := command.Data.(ShardOperationRequest) 268 | response = kv.applyDeleteShards(&shardsInfo) 269 | case EmptyEntry: 270 | response = kv.applyEmptyEntry() 271 | } 272 | 273 | // only notify related channel for currentTerm's log when node is leader 274 | if currentTerm, isLeader := kv.rf.GetState(); isLeader && message.CommandTerm == currentTerm { 275 | ch := kv.getNotifyChan(message.CommandIndex) 276 | ch <- response 277 | } 278 | 279 | needSnapshot := kv.needSnapshot() 280 | if needSnapshot { 281 | kv.takeSnapshot(message.CommandIndex) 282 | } 283 | kv.mu.Unlock() 284 | } else if message.SnapshotValid { 285 | kv.mu.Lock() 286 | if kv.rf.CondInstallSnapshot(message.SnapshotTerm, message.SnapshotIndex, message.Snapshot) { 287 | kv.restoreSnapshot(message.Snapshot) 288 | kv.lastApplied = message.SnapshotIndex 289 | } 290 | kv.mu.Unlock() 291 | } else { 292 | panic(fmt.Sprintf("unexpected Message %v", message)) 293 | } 294 | } 295 | } 296 | } 297 | 298 | ``` 299 | 300 | 接着给出 shardKV 的启动流程,可以看到除了基本地数据初始化外,还会启动 apply 协程,配置更新协程,数据迁移协程,数据清理协程,空日志检测协程。 301 | 302 | 由于后四个协程都需要 leader 来执行,因此抽象出了一个简单地周期执行函数 `Monitor`。为了防止后四个协程做重复的工作,该四个协程的实现均为同步方式,因而当前一轮任务未完成时不会进行下一轮任务。可以参考之后相应部分的代码。 303 | 304 | ```Go 305 | func StartServer(servers []*labrpc.ClientEnd, me int, persister *raft.Persister, maxRaftState int, gid int, ctrlers []*labrpc.ClientEnd, makeEnd func(string) *labrpc.ClientEnd) *ShardKV { 306 | // call labgob.Register on structures you want 307 | // Go's RPC library to marshall/unmarshall. 308 | labgob.Register(Command{}) 309 | labgob.Register(CommandRequest{}) 310 | labgob.Register(shardctrler.Config{}) 311 | labgob.Register(ShardOperationResponse{}) 312 | labgob.Register(ShardOperationRequest{}) 313 | 314 | applyCh := make(chan raft.ApplyMsg) 315 | 316 | kv := &ShardKV{ 317 | dead: 0, 318 | rf: raft.Make(servers, me, persister, applyCh), 319 | applyCh: applyCh, 320 | makeEnd: makeEnd, 321 | gid: gid, 322 | sc: shardctrler.MakeClerk(ctrlers), 323 | lastApplied: 0, 324 | maxRaftState: maxRaftState, 325 | currentConfig: shardctrler.DefaultConfig(), 326 | lastConfig: shardctrler.DefaultConfig(), 327 | stateMachines: make(map[int]*Shard), 328 | lastOperations: make(map[int64]OperationContext), 329 | notifyChans: make(map[int]chan *CommandResponse), 330 | } 331 | kv.restoreSnapshot(persister.ReadSnapshot()) 332 | // start applier goroutine to apply committed logs to stateMachine 333 | go kv.applier() 334 | // start configuration monitor goroutine to fetch latest configuration 335 | go kv.Monitor(kv.configureAction, ConfigureMonitorTimeout) 336 | // start migration monitor goroutine to pull related shards 337 | go kv.Monitor(kv.migrationAction, MigrationMonitorTimeout) 338 | // start gc monitor goroutine to delete useless shards in remote groups 339 | go kv.Monitor(kv.gcAction, GCMonitorTimeout) 340 | // start entry-in-currentTerm monitor goroutine to advance commitIndex by appending empty entries in current term periodically to avoid live locks 341 | go kv.Monitor(kv.checkEntryInCurrentTermAction, EmptyEntryDetectorTimeout) 342 | 343 | DPrintf("{Node %v}{Group %v} has started", kv.rf.Me(), kv.gid) 344 | return kv 345 | } 346 | 347 | func (kv *ShardKV) Monitor(action func(), timeout time.Duration) { 348 | for kv.killed() == false { 349 | if _, isLeader := kv.rf.GetState(); isLeader { 350 | action() 351 | } 352 | time.Sleep(timeout) 353 | } 354 | } 355 | ``` 356 | 357 | #### 日志类型 358 | 359 | 为了实现上述题意,我定义了五种类型的日志,这样 apply 协程就可以根据不同地类型来强转 `Data` 来进一步操作: 360 | * Operation:客户端传来的读写操作日志,有 Put,Get,Append 等请求。 361 | * Configuration:配置更新日志,包含一个配置。 362 | * InsertShards:分片更新日志,包含至少一个分片的数据和配置版本。 363 | * DeleteShards:分片删除日志,包含至少一个分片的 id 和配置版本。 364 | * EmptyEntry:空日志,`Data` 为空,使得状态机达到最新。 365 | 366 | ```Go 367 | type Command struct { 368 | Op CommandType 369 | Data interface{} 370 | } 371 | 372 | func (command Command) String() string { 373 | return fmt.Sprintf("{Type:%v,Data:%v}", command.Op, command.Data) 374 | } 375 | 376 | func NewOperationCommand(request *CommandRequest) Command { 377 | return Command{Operation, *request} 378 | } 379 | 380 | func NewConfigurationCommand(config *shardctrler.Config) Command { 381 | return Command{Configuration, *config} 382 | } 383 | 384 | func NewInsertShardsCommand(response *ShardOperationResponse) Command { 385 | return Command{InsertShards, *response} 386 | } 387 | 388 | func NewDeleteShardsCommand(request *ShardOperationRequest) Command { 389 | return Command{DeleteShards, *request} 390 | } 391 | 392 | func NewEmptyEntryCommand() Command { 393 | return Command{EmptyEntry, nil} 394 | } 395 | 396 | type CommandType uint8 397 | 398 | const ( 399 | Operation CommandType = iota 400 | Configuration 401 | InsertShards 402 | DeleteShards 403 | EmptyEntry 404 | ) 405 | ``` 406 | 407 | #### 分片结构 408 | 409 | 每个分片都由一个具体存储数据的哈希表和状态变量构成。可能有同学认为应该将去重表也放到分片结构中去,这样就可以在 raft 组间迁移分片时只传输对应分片去重表的内容。这样的说法的确正确,最开始我的直观想法是让分片结构尽可能地保持干净简单,这样其扩展性更高,所以就没有将去重表放进来。实现完后又仔细想了想,其实将去重表放进来也不会影响扩展性,大不了再抽象一层,即将 KV 的类型再抽象为一个接口,这样其不论是内存哈希表还是 LSM 都可扩展,shard 这一层就负责维护数据的状态,去重表和 KV 接口即可。 410 | 411 | 总之,目前的实现就是这样,去重表在分片外面会导致分片迁移时多传一些数据,但影响不会很大。 412 | 413 | 目前每个分片共有 4 种状态: 414 | * Serving:分片的默认状态,如果当前 raft 组在当前 config 下负责管理此分片,则该分片可以提供读写服务,否则该分片暂不可以提供读写服务,但不会阻塞配置更新协程拉取新配置。 415 | * Pulling:表示当前 raft 组在当前 config 下负责管理此分片,暂不可以提供读写服务,需要当前 raft 组从上一个配置该分片所属 raft 组拉数据过来之后才可以提供读写服务,系统会有一个分片迁移协程检测所有分片的 Pulling 状态,接着以 raft 组为单位去对应 raft 组拉取数据,接着尝试重放该分片的所有数据到本地并将分片状态置为 Serving,以继续提供服务。之后的分片迁移部分会介绍得更为详细。 416 | * BePulling:表示当前 raft 组在当前 config 下不负责管理此分片,不可以提供读写服务,但当前 raft 组在上一个 config 时复制管理此分片,因此当前 config 下负责管理此分片的 raft 组拉取完数据后会向本 raft 组发送分片清理的 rpc,接着本 raft 组将数据清空并重置为 serving 状态即可。之后的分片清理部分会介绍得更为详细。 417 | * GCing:表示当前 raft 组在当前 config 下负责管理此分片,可以提供读写服务,但需要清理掉上一个配置该分片所属 raft 组的数据。系统会有一个分片清理协程检测所有分片的 GCing 状态,接着以 raft 组为单位去对应 raft 组删除数据,一旦远程 raft 组删除数据成功,则本地会尝试将相关分片的状态置为 Serving。之后的分片清理部分会介绍得更为详细。 418 | 419 | ```Go 420 | type ShardStatus uint8 421 | 422 | const ( 423 | Serving ShardStatus = iota 424 | Pulling 425 | BePulling 426 | GCing 427 | ) 428 | 429 | type Shard struct { 430 | KV map[string]string 431 | Status ShardStatus 432 | } 433 | 434 | func NewShard() *Shard { 435 | return &Shard{make(map[string]string), Serving} 436 | } 437 | 438 | func (shard *Shard) Get(key string) (string, Err) { 439 | if value, ok := shard.KV[key]; ok { 440 | return value, OK 441 | } 442 | return "", ErrNoKey 443 | } 444 | 445 | func (shard *Shard) Put(key, value string) Err { 446 | shard.KV[key] = value 447 | return OK 448 | } 449 | 450 | func (shard *Shard) Append(key, value string) Err { 451 | shard.KV[key] += value 452 | return OK 453 | } 454 | 455 | func (shard *Shard) deepCopy() map[string]string { 456 | newShard := make(map[string]string) 457 | for k, v := range shard.KV { 458 | newShard[k] = v 459 | } 460 | return newShard 461 | } 462 | ``` 463 | 464 | #### 读写操作 465 | 466 | 可以看到,如果当前 raft 组在当前 config 下负责管理此分片,则只要分片的 status 为 Serving 或 GCing,本 raft 组就可以为该分片提供读写服务,否则返回 ErrWrongGroup 让客户端重新 fecth 最新的 config 并重试即可。 467 | 468 | 读写操作的基本逻辑和 lab3 一致,可以在向 raft 提交前和 apply 时都检测一遍是否重复以保证线性化语义。 469 | 470 | 当然,canServe 的判断和去重类似,也需要在向 raft 提交前和 apply 时都检测一遍以保证正确性并尽可能提升性能。 471 | 472 | ```Go 473 | // check whether this raft group can serve this shard at present 474 | func (kv *ShardKV) canServe(shardID int) bool { 475 | return kv.currentConfig.Shards[shardID] == kv.gid && (kv.stateMachines[shardID].Status == Serving || kv.stateMachines[shardID].Status == GCing) 476 | } 477 | 478 | func (kv *ShardKV) Command(request *CommandRequest, response *CommandResponse) { 479 | kv.mu.RLock() 480 | // return result directly without raft layer's participation if request is duplicated 481 | if request.Op != OpGet && kv.isDuplicateRequest(request.ClientId, request.CommandId) { 482 | lastResponse := kv.lastOperations[request.ClientId].LastResponse 483 | response.Value, response.Err = lastResponse.Value, lastResponse.Err 484 | kv.mu.RUnlock() 485 | return 486 | } 487 | // return ErrWrongGroup directly to let client fetch latest configuration and perform a retry if this key can't be served by this shard at present 488 | if !kv.canServe(key2shard(request.Key)) { 489 | response.Err = ErrWrongGroup 490 | kv.mu.RUnlock() 491 | return 492 | } 493 | kv.mu.RUnlock() 494 | kv.Execute(NewOperationCommand(request), response) 495 | } 496 | 497 | func (kv *ShardKV) applyOperation(message *raft.ApplyMsg, operation *CommandRequest) *CommandResponse { 498 | var response *CommandResponse 499 | shardID := key2shard(operation.Key) 500 | if kv.canServe(shardID) { 501 | if operation.Op != OpGet && kv.isDuplicateRequest(operation.ClientId, operation.CommandId) { 502 | DPrintf("{Node %v}{Group %v} doesn't apply duplicated message %v to stateMachine because maxAppliedCommandId is %v for client %v", kv.rf.Me(), kv.gid, message, kv.lastOperations[operation.ClientId], operation.ClientId) 503 | return kv.lastOperations[operation.ClientId].LastResponse 504 | } else { 505 | response = kv.applyLogToStateMachines(operation, shardID) 506 | if operation.Op != OpGet { 507 | kv.lastOperations[operation.ClientId] = OperationContext{operation.CommandId, response} 508 | } 509 | return response 510 | } 511 | } 512 | return &CommandResponse{ErrWrongGroup, ""} 513 | } 514 | ``` 515 | 516 | #### 配置更新 517 | 518 | 配置更新协程负责定时检测所有分片的状态,一旦存在至少一个分片的状态不为默认状态,则预示其他协程仍然还没有完成任务,那么此时需要阻塞新配置的拉取和提交。 519 | 520 | 在 apply 配置更新日志时需要保证幂等性: 521 | * 不同版本的配置更新日志:apply 时仅可逐步递增的去更新配置,否则返回失败。 522 | * 相同版本的配置更新日志:由于配置更新日志仅由配置更新协程提交,而配置更新协程只有检测到比本地更大地配置时才会提交配置更新日志,所以该情形不会出现。 523 | 524 | ```Go 525 | func (kv *ShardKV) configureAction() { 526 | canPerformNextConfig := true 527 | kv.mu.RLock() 528 | for _, shard := range kv.stateMachines { 529 | if shard.Status != Serving { 530 | canPerformNextConfig = false 531 | DPrintf("{Node %v}{Group %v} will not try to fetch latest configuration because shards status are %v when currentConfig is %v", kv.rf.Me(), kv.gid, kv.getShardStatus(), kv.currentConfig) 532 | break 533 | } 534 | } 535 | currentConfigNum := kv.currentConfig.Num 536 | kv.mu.RUnlock() 537 | if canPerformNextConfig { 538 | nextConfig := kv.sc.Query(currentConfigNum + 1) 539 | if nextConfig.Num == currentConfigNum+1 { 540 | DPrintf("{Node %v}{Group %v} fetches latest configuration %v when currentConfigNum is %v", kv.rf.Me(), kv.gid, nextConfig, currentConfigNum) 541 | kv.Execute(NewConfigurationCommand(&nextConfig), &CommandResponse{}) 542 | } 543 | } 544 | } 545 | 546 | func (kv *ShardKV) applyConfiguration(nextConfig *shardctrler.Config) *CommandResponse { 547 | if nextConfig.Num == kv.currentConfig.Num+1 { 548 | DPrintf("{Node %v}{Group %v} updates currentConfig from %v to %v", kv.rf.Me(), kv.gid, kv.currentConfig, nextConfig) 549 | kv.updateShardStatus(nextConfig) 550 | kv.lastConfig = kv.currentConfig 551 | kv.currentConfig = *nextConfig 552 | return &CommandResponse{OK, ""} 553 | } 554 | DPrintf("{Node %v}{Group %v} rejects outdated config %v when currentConfig is %v", kv.rf.Me(), kv.gid, nextConfig, kv.currentConfig) 555 | return &CommandResponse{ErrOutDated, ""} 556 | } 557 | ``` 558 | 559 | 560 | #### 分片迁移 561 | 562 | 分片迁移协程负责定时检测分片的 Pulling 状态,利用 lastConfig 计算出对应 raft 组的 gid 和要拉取的分片,然后并行地去拉取数据。 563 | 564 | 注意这里使用了 waitGroup 来保证所有独立地任务完成后才会进行下一次任务。此外 wg.Wait() 一定要在释放读锁之后,否则无法满足 challenge2 的要求。 565 | 566 | 在拉取分片的 handler 中,首先仅可由 leader 处理该请求,其次如果发现请求中的配置版本大于本地的版本,那说明请求拉取的是未来的数据,则返回 ErrNotReady 让其稍后重试,否则将分片数据和去重表都深度拷贝到 response 即可。 567 | 568 | 在 apply 分片更新日志时需要保证幂等性: 569 | * 不同版本的配置更新日志:仅可执行与当前配置版本相同地分片更新日志,否则返回 ErrOutDated。 570 | * 相同版本的配置更新日志:仅在对应分片状态为 Pulling 时为第一次应用,此时覆盖状态机即可并修改状态为 GCing,以让分片清理协程检测到 GCing 状态并尝试删除远端的分片。否则说明已经应用过,直接 break 即可。 571 | 572 | ```Go 573 | func (kv *ShardKV) migrationAction() { 574 | kv.mu.RLock() 575 | gid2shardIDs := kv.getShardIDsByStatus(Pulling) 576 | var wg sync.WaitGroup 577 | for gid, shardIDs := range gid2shardIDs { 578 | DPrintf("{Node %v}{Group %v} starts a PullTask to get shards %v from group %v when config is %v", kv.rf.Me(), kv.gid, shardIDs, gid, kv.currentConfig) 579 | wg.Add(1) 580 | go func(servers []string, configNum int, shardIDs []int) { 581 | defer wg.Done() 582 | pullTaskRequest := ShardOperationRequest{configNum, shardIDs} 583 | for _, server := range servers { 584 | var pullTaskResponse ShardOperationResponse 585 | srv := kv.makeEnd(server) 586 | if srv.Call("ShardKV.GetShardsData", &pullTaskRequest, &pullTaskResponse) && pullTaskResponse.Err == OK { 587 | DPrintf("{Node %v}{Group %v} gets a PullTaskResponse %v and tries to commit it when currentConfigNum is %v", kv.rf.Me(), kv.gid, pullTaskResponse, configNum) 588 | kv.Execute(NewInsertShardsCommand(&pullTaskResponse), &CommandResponse{}) 589 | } 590 | } 591 | }(kv.lastConfig.Groups[gid], kv.currentConfig.Num, shardIDs) 592 | } 593 | kv.mu.RUnlock() 594 | wg.Wait() 595 | } 596 | 597 | func (kv *ShardKV) GetShardsData(request *ShardOperationRequest, response *ShardOperationResponse) { 598 | // only pull shards from leader 599 | if _, isLeader := kv.rf.GetState(); !isLeader { 600 | response.Err = ErrWrongLeader 601 | return 602 | } 603 | kv.mu.RLock() 604 | defer kv.mu.RUnlock() 605 | defer DPrintf("{Node %v}{Group %v} processes PullTaskRequest %v with response %v", kv.rf.Me(), kv.gid, request, response) 606 | 607 | if kv.currentConfig.Num < request.ConfigNum { 608 | response.Err = ErrNotReady 609 | return 610 | } 611 | 612 | response.Shards = make(map[int]map[string]string) 613 | for _, shardID := range request.ShardIDs { 614 | response.Shards[shardID] = kv.stateMachines[shardID].deepCopy() 615 | } 616 | 617 | response.LastOperations = make(map[int64]OperationContext) 618 | for clientID, operation := range kv.lastOperations { 619 | response.LastOperations[clientID] = operation.deepCopy() 620 | } 621 | 622 | response.ConfigNum, response.Err = request.ConfigNum, OK 623 | } 624 | 625 | func (kv *ShardKV) applyInsertShards(shardsInfo *ShardOperationResponse) *CommandResponse { 626 | if shardsInfo.ConfigNum == kv.currentConfig.Num { 627 | DPrintf("{Node %v}{Group %v} accepts shards insertion %v when currentConfig is %v", kv.rf.Me(), kv.gid, shardsInfo, kv.currentConfig) 628 | for shardId, shardData := range shardsInfo.Shards { 629 | shard := kv.stateMachines[shardId] 630 | if shard.Status == Pulling { 631 | for key, value := range shardData { 632 | shard.KV[key] = value 633 | } 634 | shard.Status = GCing 635 | } else { 636 | DPrintf("{Node %v}{Group %v} encounters duplicated shards insertion %v when currentConfig is %v", kv.rf.Me(), kv.gid, shardsInfo, kv.currentConfig) 637 | break 638 | } 639 | } 640 | for clientId, operationContext := range shardsInfo.LastOperations { 641 | if lastOperation, ok := kv.lastOperations[clientId]; !ok || lastOperation.MaxAppliedCommandId < operationContext.MaxAppliedCommandId { 642 | kv.lastOperations[clientId] = operationContext 643 | } 644 | } 645 | return &CommandResponse{OK, ""} 646 | } 647 | DPrintf("{Node %v}{Group %v} rejects outdated shards insertion %v when currentConfig is %v", kv.rf.Me(), kv.gid, shardsInfo, kv.currentConfig) 648 | return &CommandResponse{ErrOutDated, ""} 649 | } 650 | ``` 651 | 652 | 653 | #### 分片清理 654 | 655 | 分片清理协程负责定时检测分片的 GCing 状态,利用 lastConfig 计算出对应 raft 组的 gid 和要拉取的分片,然后并行地去删除分片。 656 | 657 | 注意这里使用了 waitGroup 来保证所有独立地任务完成后才会进行下一次任务。此外 wg.Wait() 一定要在释放读锁之后,否则无法满足 challenge2 的要求。 658 | 659 | 在删除分片的 handler 中,首先仅可由 leader 处理该请求,其次如果发现请求中的配置版本小于本地的版本,那说明该请求已经执行过,否则本地的 config 也无法增大,此时直接返回 OK 即可,否则在本地提交一个删除分片的日志。 660 | 661 | 在 apply 分片删除日志时需要保证幂等性: 662 | * 不同版本的配置更新日志:仅可执行与当前配置版本相同地分片删除日志,否则已经删除过,直接返回 OK 即可。 663 | * 相同版本的配置更新日志:如果分片状态为 GCing,说明是本 raft 组已成功删除远端 raft 组的数据,现需要更新分片状态为默认状态以支持配置的进一步更新;否则如果分片状态为 BePulling,则说明本 raft 组第一次删除该分片的数据,此时直接重置分片即可。否则说明该请求已经应用过,直接 break 返回 OK 即可。 664 | 665 | ```Go 666 | func (kv *ShardKV) gcAction() { 667 | kv.mu.RLock() 668 | gid2shardIDs := kv.getShardIDsByStatus(GCing) 669 | var wg sync.WaitGroup 670 | for gid, shardIDs := range gid2shardIDs { 671 | DPrintf("{Node %v}{Group %v} starts a GCTask to delete shards %v in group %v when config is %v", kv.rf.Me(), kv.gid, shardIDs, gid, kv.currentConfig) 672 | wg.Add(1) 673 | go func(servers []string, configNum int, shardIDs []int) { 674 | defer wg.Done() 675 | gcTaskRequest := ShardOperationRequest{configNum, shardIDs} 676 | for _, server := range servers { 677 | var gcTaskResponse ShardOperationResponse 678 | srv := kv.makeEnd(server) 679 | if srv.Call("ShardKV.DeleteShardsData", &gcTaskRequest, &gcTaskResponse) && gcTaskResponse.Err == OK { 680 | DPrintf("{Node %v}{Group %v} deletes shards %v in remote group successfully when currentConfigNum is %v", kv.rf.Me(), kv.gid, shardIDs, configNum) 681 | kv.Execute(NewDeleteShardsCommand(&gcTaskRequest), &CommandResponse{}) 682 | } 683 | } 684 | }(kv.lastConfig.Groups[gid], kv.currentConfig.Num, shardIDs) 685 | } 686 | kv.mu.RUnlock() 687 | wg.Wait() 688 | } 689 | 690 | func (kv *ShardKV) DeleteShardsData(request *ShardOperationRequest, response *ShardOperationResponse) { 691 | // only delete shards when role is leader 692 | if _, isLeader := kv.rf.GetState(); !isLeader { 693 | response.Err = ErrWrongLeader 694 | return 695 | } 696 | 697 | defer DPrintf("{Node %v}{Group %v} processes GCTaskRequest %v with response %v", kv.rf.Me(), kv.gid, request, response) 698 | 699 | kv.mu.RLock() 700 | if kv.currentConfig.Num > request.ConfigNum { 701 | DPrintf("{Node %v}{Group %v}'s encounters duplicated shards deletion %v when currentConfig is %v", kv.rf.Me(), kv.gid, request, kv.currentConfig) 702 | response.Err = OK 703 | kv.mu.RUnlock() 704 | return 705 | } 706 | kv.mu.RUnlock() 707 | 708 | var commandResponse CommandResponse 709 | kv.Execute(NewDeleteShardsCommand(request), &commandResponse) 710 | 711 | response.Err = commandResponse.Err 712 | } 713 | 714 | func (kv *ShardKV) applyDeleteShards(shardsInfo *ShardOperationRequest) *CommandResponse { 715 | if shardsInfo.ConfigNum == kv.currentConfig.Num { 716 | DPrintf("{Node %v}{Group %v}'s shards status are %v before accepting shards deletion %v when currentConfig is %v", kv.rf.Me(), kv.gid, kv.getShardStatus(), shardsInfo, kv.currentConfig) 717 | for _, shardId := range shardsInfo.ShardIDs { 718 | shard := kv.stateMachines[shardId] 719 | if shard.Status == GCing { 720 | shard.Status = Serving 721 | } else if shard.Status == BePulling { 722 | kv.stateMachines[shardId] = NewShard() 723 | } else { 724 | DPrintf("{Node %v}{Group %v} encounters duplicated shards deletion %v when currentConfig is %v", kv.rf.Me(), kv.gid, shardsInfo, kv.currentConfig) 725 | break 726 | } 727 | } 728 | DPrintf("{Node %v}{Group %v}'s shards status are %v after accepting shards deletion %v when currentConfig is %v", kv.rf.Me(), kv.gid, kv.getShardStatus(), shardsInfo, kv.currentConfig) 729 | return &CommandResponse{OK, ""} 730 | } 731 | DPrintf("{Node %v}{Group %v}'s encounters duplicated shards deletion %v when currentConfig is %v", kv.rf.Me(), kv.gid, shardsInfo, kv.currentConfig) 732 | return &CommandResponse{OK, ""} 733 | } 734 | ``` 735 | #### 空日志检测 736 | 737 | 分片清理协程负责定时检测 raft 层的 leader 是否拥有当前 term 的日志,如果没有则提交一条空日志,这使得新 leader 的状态机能够迅速达到最新状态,从而避免多 raft 组间的活锁状态。 738 | 739 | ```Go 740 | func (kv *ShardKV) checkEntryInCurrentTermAction() { 741 | if !kv.rf.HasLogInCurrentTerm() { 742 | kv.Execute(NewEmptyEntryCommand(), &CommandResponse{}) 743 | } 744 | } 745 | 746 | func (kv *ShardKV) applyEmptyEntry() *CommandResponse { 747 | return &CommandResponse{OK, ""} 748 | } 749 | ``` 750 | 751 | #### 客户端 752 | 753 | 对于 shardKV 的实现,需要满足以下要求: 754 | * 缓存每个分片的 leader。 755 | * rpc 请求成功且服务端返回 OK 或 ErrNoKey,则可直接返回。 756 | * rpc 请求成功且服务端返回 ErrWrongGroup,则需要重新获取最新 config 并再次发送请求。 757 | * rpc 请求失败一次,需要继续遍历该 raft 组的其他节点。 758 | * rpc 请求失败副本数次,此时需要重新获取最新 config 并再次发送请求。 759 | 760 | 实际上 client 实现的不好也可能导致活锁,有些读写请求会卡在客户端的 for 循环中而无法脱身,因此当出现活锁时,也可以先检查一下客户端是否能够满足以上要求。 761 | 762 | ```Go 763 | type Clerk struct { 764 | sm *shardctrler.Clerk 765 | config shardctrler.Config 766 | makeEnd func(string) *labrpc.ClientEnd 767 | leaderIds map[int]int 768 | clientId int64 // generated by nrand(), it would be better to use some distributed ID generation algorithm that guarantees no conflicts 769 | commandId int64 // (clientId, commandId) defines a operation uniquely 770 | } 771 | 772 | func MakeClerk(ctrlers []*labrpc.ClientEnd, makeEnd func(string) *labrpc.ClientEnd) *Clerk { 773 | ck := &Clerk{ 774 | sm: shardctrler.MakeClerk(ctrlers), 775 | makeEnd: makeEnd, 776 | leaderIds: make(map[int]int), 777 | clientId: nrand(), 778 | commandId: 0, 779 | } 780 | ck.config = ck.sm.Query(-1) 781 | return ck 782 | } 783 | 784 | func (ck *Clerk) Get(key string) string { 785 | return ck.Command(&CommandRequest{Key: key, Op: OpGet}) 786 | } 787 | 788 | func (ck *Clerk) Put(key string, value string) { 789 | ck.Command(&CommandRequest{Key: key, Value: value, Op: OpPut}) 790 | } 791 | func (ck *Clerk) Append(key string, value string) { 792 | ck.Command(&CommandRequest{Key: key, Value: value, Op: OpAppend}) 793 | } 794 | 795 | func (ck *Clerk) Command(request *CommandRequest) string { 796 | request.ClientId, request.CommandId = ck.clientId, ck.commandId 797 | for { 798 | shard := key2shard(request.Key) 799 | gid := ck.config.Shards[shard] 800 | if servers, ok := ck.config.Groups[gid]; ok { 801 | if _, ok = ck.leaderIds[gid]; !ok { 802 | ck.leaderIds[gid] = 0 803 | } 804 | oldLeaderId := ck.leaderIds[gid] 805 | newLeaderId := oldLeaderId 806 | for { 807 | var response CommandResponse 808 | ok := ck.makeEnd(servers[newLeaderId]).Call("ShardKV.Command", request, &response) 809 | if ok && (response.Err == OK || response.Err == ErrNoKey) { 810 | ck.commandId++ 811 | return response.Value 812 | } else if ok && response.Err == ErrWrongGroup { 813 | break 814 | } else { 815 | newLeaderId = (newLeaderId + 1) % len(servers) 816 | if newLeaderId == oldLeaderId { 817 | break 818 | } 819 | continue 820 | } 821 | } 822 | } 823 | time.Sleep(100 * time.Millisecond) 824 | // ask controller for the latest configuration. 825 | ck.config = ck.sm.Query(-1) 826 | } 827 | } 828 | 829 | ``` --------------------------------------------------------------------------------