Mit6.824-Lab3A 实现笔记
本次实验为实现Raft中的选举部分,在实现时除了要完全按照论文的Figure2实现节点状态以外。在实际编写代码时,以下部分曾经困扰了我,现在总结如下:
领导者、跟随者、候选人(leader, follower, candidate)三者转换:

在实现时,“discovers current leader or new term” 和“discovers server with higher term”非常重要。更高任期不单单是AppendEnter被拒绝后发现对方term更高,还包括拉票时对方返回的任期。因此,任何会返回对方任期的情况,均需要检查任期是否更新,如果对方的任期更高,应直接转为跟随者。
选举阶段需要比较log的index,这个log是自己所有已接受的log还是已提交的log?:
关键点:这里的 “日志” 指的是 本地存储的所有日志(已持久化的 log entries),而不是只看 已提交的日志(committed entries)。
原因:在选举中,一个日志未提交不代表它没意义,它仍然反映了某个节点曾经和 Leader 的交互,可能包含最新信息。
因此,Raft 在比较时使用 节点日志中的最后一条记录,不管它是否已提交。
极端情况:在明白以上两点后,我开始思考一种极端情况:有一个follower节点和集群失联,它不断的选举并选举超时。在这种情况下,它的term将快速增加并超越集群中实际的leader。然后网络恢复,它重新加入集群。接下来,会发生什么?
假定该任期异常高的节点为节点A:
flowchart TD
Start(["节点A重新加入集群<br>Term: 20"]) --> B["领导者: Term 5<br>发送心跳RPC"]
B --> C{节点A处理RPC}
C -- 因领导者任期低 --> D["节点A: 拒绝RPC"]
C -- 领导者发现更高任期 --> E["领导者: 转为跟随者<br>更新Term为20"]
D --> F["集群状态: 无领导者<br>等待选举超时"]
E --> F
F --> G["节点B: Term 6<br>选举超时, 转为候选者"]
G --> H["节点B: 发送投票请求"]
H --> I{节点A处理投票请求}
I -- 节点B发现更高任期 --> J["节点B: 转为跟随者<br>更新Term为20"]
J --> K["节点A: Term 21<br>选举超时, 转为候选者"]
K --> L{选举过程}
L -- 日志不够新 --> M["节点A: 无法当选<br>但更新其他节点任期"]
M --> N["集群节点: 任期提高至>20"]
N --> O["最终: 日志足够新的节点当选"]
style Start fill:#e1f5e1
style O fill:#fff3cd
candiate请求投票时,面对超时等无法联络节点时,需要像leader的appendEntries一样持续重试吗?:
Candidate 的 RequestVote RPC:不需要重试。即 Candidate 只在选举发起时广播一次投票请求。
如果没选出来 Leader(包括部分节点没响应),等 election timeout 过期,它会进入新一轮选举,再次广播请求。
接下来,是在具体实施过程中,由于对golang不够熟悉导致的问题:
channel与select的配合:
为了方便重置选举超时计时器,我在Raft结构体中添加了electionTimer *time.Timer并使用Reset方法重置时间。同时,我进一步使用互斥锁来保护数据的修改以正确并发,但出现了类似“虚假唤醒”的问题:
|
|
在检查日志时发现,一个节点刚刚重置了计时器,后面马上提示开始选举。我尝试求助AI,问遍了Chatgpt、Deepseek、Qwen、Claude、Gemini,它们都这么回答:
好问题——这是 Go time.Timer 的经典陷阱(并不是 Raft 的逻辑错误本身),原因在于 计时器已经触发了但计时器通道里的事件还没被接收,你在另一个 goroutine 里 Reset/替换计时器,结果旧的“触发信号”仍然会被 ticker 的 <-rf.electionTimer.C 接收到,从而误以为超时发生,开始了选举。
在使用Stop并清空计时器再重置,修正后问题依旧没有解决。我后面才意识到,真正的原因是<-rf.electionTimer.C触发后,因为rf.mu.Lock()在changeState手中,ticker阻塞。此时我重置计时器并退出changeState,ticker立刻获取锁并开始进行选举。这是一个很典型“条件变量虚假唤醒”问题,最后我将计时器的触发和重置均放在ticker中,由select处理,并且ticker中不使用任何锁,以此修正了问题。
无阻塞的写入channel:
在实现时,我需要尝试写入channel,但如果channel没有空余位置就直接放弃信号。最终,使用以下结构解决:
|
|
如果signalChan阻塞了,select会直接执行default分支,从而不做任何动作。