1.term 和 logIndex
Raft 算法设计了 term 和 logIndex 两个属性,分别用于表示 Leader 节点的任期,以及集群运行期间接收到的指令对应的日志条目的 ID,这两个属性都是单调递增的。
Raft 算法要求节点在给参选节点投票时必须保证参选节点满足以下两个条件之一:
- 参选节点的 term 值大于投票节点,否则拒绝为其投票。
- 如果参选节点与投票节点的 term 值相同,则需要保证参选节点的 logIndex 值不小于投票节点。
这两个条件的目的都在于保证当前参选节点本地的日志数据不能比投票节点要陈旧。
2.为什么需要预选举步骤?
JRaft 在设计层面将选举的过程拆分为预选举和正式选举两个过程,之所以这样设计是为了避免无效的选举进程递增 term 值,进而造成浪费,同时也会导致正常运行的 Leader 节点执行角色降级。
- 正常情况:Raft 算法要求当节点接收到 term 值更大的请求时需要递增本地的 term 值,以此实现集群中 term 值的同步。对于 Leader 节点而言,当收到 term 值更大的请求时,该节点会认为集群中有新的 Leader 节点生成,于是需要执行角色降级。这一机制能够保证在出现网络分区等问题时,在网络恢复时能够促使 term 值较小的 Leader 节点退位为 Follower 节点,从而实现让集群达到一个新的平稳状态。
- 无效选举情况:如果集群中某个 Follower 节点因为某些原因未能接收到 Leader 节点的主权宣示指令,就会一直尝试发动新一轮的选举革命,进而递增 term 值,导致 Leader 节点执行角色降级,最终影响整个集群的正常运行。
- 预选机制:当一个 Follower 节点尝试发起一轮新的选举革命时,该节点不会立即递增 term 值,而是尝试将 term 值加 1 去试探性的征集选票,只有当集群中过半数的节点同意投票的前提下才会进入正式投票的环节,这样对于无效选举而言一般只会停留在预选举阶段,不会对集群的正常运行造成影响。
3.预选举
当启动一个 JRaft 节点时,如果初始化集群节点配置不为空,则节点会调用 NodeImpl#stepDown 方法执行角色降级操作。所谓角色降级实际上是一个宽泛的说法,因为 NodeImpl#stepDown 方法会在多种场景下被调用。而这里调用该方法的背景是一个 FOLLOWER 节点刚刚启动的时候,所以除了初始化一些本地状态之外,整个角色降级过程重点做的一件事就是启动预选举计时器 electionTimer。
NodeImpl#init
-> NodeImpl#stepDown
-> this.electionTimer.restart()
-> RepeatedTimer#restart
-> RepeatedTimer#schedule
-> RepeatedTimer#run
-> 回调 onTrigger()
在NodeImpl#init中看electionTimer的onTrigger()实现:
this.electionTimer = new RepeatedTimer(name, this.options.getElectionTimeoutMs(),
TIMER_FACTORY.getElectionTimer(this.options.isSharedElectionTimer(), name)) {
@Override
protected void onTrigger() {
handleElectionTimeout();
}
@Override
protected int adjustTimeout(final int timeoutMs) {
return randomTimeout(timeoutMs);
}
};
核心是NodeImpl#handleElectionTimeout,默认随机区间为 1~2s。
NodeImpl#handleElectionTimeout
- 1)如果当前节点不是 FOLLOWER 角色,则放弃预选举;
- 2)否则,如果当前节点与 Leader 节点之间的租约仍然有效,则放弃预选举;(Follower 节点会在本地记录最近一次收到来自 Leader 节点的 RPC 请求时间戳,如果该时间戳距离当前时间小于选举超时时间,则说明当前节点与 Leader 节点之间的租约仍然有效,无需继续发起预选举。)
- 3)否则,清空本地记录的 Leader 节点 ID,回调 FSMCaller#onStopFollowing 方法;(方法 NodeImpl#resetLeaderId 会清空本地记录的 Leader 节点 ID,如果当前节点不是 Leader 角色,并且正在追随某个 Leader 节点,则该方法会回调 FSMCaller#onStopFollowing 方法将停止追随的事件透传给状态机。业务可以通过覆盖实现 StateMachine#onStopFollowing 方法捕获这一事件。)
- 4)基于节点优先级判断是否允许发起预选举,如果允许则发起预选举进程。
4-1)校验当前节点是否正在安装快照,如果是则放弃预选举;
4-2)校验当前节点是否位于节点配置列表中,如果不是则说明当前节点不是一个有效节点,放弃预选举;
4-3)从本地磁盘获取最新的 LogId,包含 logIndex 和 term 值;LogManagerImpl#getLastLogId。
如果设置 isFlush = true 则会往该队列提交一个 LAST_LOG_ID 类型事件,并阻塞等待该事件处理完成。方法 StableClosureEventHandler#onEvent 中实现了对 Disruptor 中消息的处理逻辑,并定义了一个 AppendBatcher 类型的属性用于缓存收集到的 LogEntry 数据。在响应 LAST_LOG_ID 事件之前,StableClosureEventHandler 会调用 AppendBatcher#flush 方法将收集到的 LogEntry 数据刷盘。
RocksDBLogStorage 设置了两个 column family,即 conf family 和 data family,其中后者复用了 RocksDB 提供的默认 column family。由上述实现可以看到,JRaft 针对配置类型的 LogEntry 会同时写入这两个 family 中,而其它类型的 LogEntry 仅会写入到 data family 中。
4-4)初始化预选举选票 Ballot 实例;
4-5)遍历向除自己以外的所有连通节点发送 RequestVote RPC 请求,以征集选票,同时给自己投上一票;RaftServerService#handlePreVoteRequest:
A)如果当前节点处于非活跃状态,则响应错误;
B)否则,解析候选节点的节点 ID,如果解析出错,则响应错误;
C)否则,如果当前节点与对应 Leader 节点之间的租约仍然有效,则拒绝投票;
D)否则,如果候选节点的 term 值相较于当前节点小,则拒绝投票;如果当前节点正好是 Leader 节点,还需要检查候选节点与当前节点之间的复制关系(如果当前节点是 Leader 节点,但是仍然有节点发起预选举进程,则说明当前节点与目标节点之间的复制关系存在问题,需要重新建立复制关系,并启动对应的复制器 Replicator。);
E)否则,获取本地最新的 logIndex 和对应的 term 值,如果候选节点的 term 和 logIndex 值更新,则同意投票,否则拒绝投票。
DefaultRaftClientService#preVote:
A)AbstractClientService#invokeWithDone
B)NodeImpl.OnPreVoteRpcDone#run
C)NodeImpl#handlePreVoteResponse
C-1)校验当前节点是否仍然是 FOLLOWER 角色,如果不是则忽略响应,可能已经预选举成功了;
C-2)否则,校验当前节点的 term 值是否发生变化,如果是则忽略响应;
C-3)否则,如果目标节点的 term 值较当前节点更大,则忽略响应,并执行 stepdown;
C-4)否则,如果目标节点拒绝投票,则忽略响应;
C-5)否则,如果目标节点同意投票,则更新得票数,并检查是否预选举成功,如果是则进入正式投票环节。(在处理预选举响应时会让每个目标节点的响应在同意投票的前提下都会回调触发一次 Ballot#grant 操作以更新得票数,并调用 Ballot#isGranted 方法检查得票数是否过半,如果是则进入正式投票的环节。)
4-6)如果票数过半,则执行 NodeImpl#electSelf 操作进入正式投票环节。
预选举的特点:
- 预选举阶段的 RequestVote 请求会设置 preVote = true,以标识自己是一个预选举请求,用来与正式投票阶段的 RequestVote 请求请求相区别。
- 为了避免 term 值无谓的递增,预选举阶段不会真正递增 term 值,而只是将 term 加 1 进行试探性的发起投票。
4.正式选举
触发正式选举进程,除了发生在预选举成功之后之外,主要还包括另外两个场景:
- 在只有一个节点的情况下,此时该节点一定能够竞选成功,所以没有进行预选举的必要。
- 正式选举阶段超时,此时需要再次发起一轮新的正式选举进程,这也是正式选举计时器 voteTimer 的职责。
NodeImpl#electSelf
- 1)校验当前节点是否是合法节点,即属于集群节点配置集合中的一员,如果不是则放弃参选;
- 2)如果当前节点是 FOLLOWER 角色,说明是刚刚从预选举阶段过渡而来,需要停止预选举计时器 electionTimer,避免期间再次发起新的预选举进程;
- 3)重置本地记录的 leader 节点的 ID;
- 4)切换节点为 CANDIDATE 角色、递增 term 值,以及更新 votedId 为当前节点 ID;
- 5)启动正式选举计时器 voteTimer,用于当正式选举超时时,再次发起一轮新的正式选举进程;
- 6)初始化正式选票 Ballot 实例;
- 7)获取本地最新的 logIndex 和对应的 term 值;
- 8)遍历向除自己以外的所有连通节点发送 RequestVote RPC 请求,以征集选票,同时给自己投上一票;
NodeImpl#handleRequestVoteRequest(各节点处理)
A)如果当前节点处于非活跃状态,则响应错误;
B)否则,解析候选节点的节点 ID,如果解析出错则响应错误;
C)否则,如果候选节点的 term 值小于当前节点,则拒绝投票;
D)否则,如果候选节点的 term 值大于当前节点,则需要执行 stepdown(此时处理 RequestVote RPC 请求的节点角色仍然是 FOLLOWER,所以除了重置本地状态和再次启动预选举计时器之外,一个重要的工作就是更新当前节点的 term 值,以保证与当前集群已知的最大 term 值看齐);
E)如果候选节点的 term 值更新,或者 term 值相同但是对应的 logIndex 不小于当前节点,且当前节点未投票给其它节点,则同意投票,同时更新本地元数据信息;
F)否则,拒绝投票。
DefaultRaftClientService#requestVote
A)AbstractClientService#invokeWithDone
B)OnRequestVoteRpcDone#run
C)NodeImpl#handleRequestVoteResponse
C-1)校验当前节点是不是 CANDIDATE 角色,如果不是则可能已经竞选成功,或者被打回成了 FOLLOWER 角色,忽略响应;
C-2)否则,校验等待响应期间节点的 term 值是否发生变化,如果是则忽略响应;
C-3)否则,如果目标节点的 term 值相较于当前节点更大,则需要忽略响应,并执行 stepdown(当前节点角色为 CANDIDATE,所以执行 stepdown 会让当前节点停止正式选举计时器,并切换角色为 FOLLOWER,并再次启动预选举计时器。此外,还会更新当前节点的 term 值,以保证与当前集群已知的最大 term 值看齐);
C-4)否则,如果目标节点同意投票,则更新选票计数,否则忽略响应;
C-5)如果票数过半,则执行 NodeImpl#becomeLeader 方法成为 LEADER 角色。 - 9)更新本地元数据信息,即 term 值和 votedId 值;
- 10)如果票数过半,则执行 NodeImpl#becomeLeader 操作以切换角色为 LEADER,即竞选成功。
NodeImpl#becomeLeader
A)校验当前节点角色是否为 CANDIDATE,LEADER 角色的前置角色必须是 CANDIDATE;
B)停止正式选举计时器 voteTimer;
C)切换节点角色为 LEADER;
D)建立到除自己以外的所有节点之间的复制关系,包括 Follower 和 Learner;
E)重置选票箱 BallotBox;
F)将当前集群的节点配置信息记录到日志中(方法 ConfigurationCtx#flush 会将当前集群的节点配置信息作为当前节点成为 LEADER 角色之后的第一条日志同步给集群中的 Follower 节点。Leader 节点在将日志数据同步出去之前会设置一个 ConfigurationChangeDone 回调,并在日志数据被 committed 之后触发执行 ConfigurationChangeDone#run 方法。);
ConfigurationChangeDone#run
-> 尝试让集群节点配置趋于稳定。在 Leader 选举场景下,集群节点配置上下文 ConfigurationCtx 的 stage 分为 STAGE_STABLE 和 STAGE_JOINT 两类,前者表示集群配置已经趋于稳定,而后者则表示集群目前存在新老配置过渡的情况。
-> 回调StateMachine#onLeaderStart
G)启动 stepdown 计时器 stepDownTimer。
在节点成为 LEADER 角色之后会将集群配置信息作为第一条日志进行提交,还有另外一个考虑。当一个节点刚刚竞选成为 LEADER 角色时,此时该节点本地的 committedIndex 值并不一定是当前整个系统范围内最新的 committedIndex 值,这会影响线性一致性读结果的准确性,而通过提交日志操作则能够保证新的 Leader 节点的 committedIndex 被更新为集群范围内的最新值。
5.Leader 让权
Leader 节点需要定期检查自己的权威是否持续有效,即集群中过半数的 Follower 节点都能响应自己的心跳请求,如果不是则需要让权。这一过程由 stepdown 计时器 stepDownTimer 负责,由前面 NodeImpl#becomeLeader 方法的实现也可以看到在节点成为 LEADER 角色之后会启动 stepdown 计时器。
NodeImpl#handleStepDownTimeout
-> NodeImpl#checkDeadNodes如果集群中认同当前 Leader 节点的 Follower 节点数过半,则无需让权;集群中认同当前 Leader 节点的 Follower 节点数小于一半,执行让权操作
-> NodeImpl#checkDeadNodes0 会检查目标 Follower 节点与当前 Leader 节点最近一次的 RPC 请求时间戳,以此决定对应的租约是否仍然有效
-> NodeImpl#stepDown 节点以 LEADER 角色调用该方法,除了将角色切换成 FOLLOWER、初始化本地状态,以及启动预选举计时器 electionTimer 之外,在此之前还会执行如下一段逻辑:停止 stepdown 计时器、清空选票箱、向状态机调度器发布 LEADER_STOP 事件。LEADER_STOP 状态机事件会触发 FSMCaller 回调应用程序实现的 StateMachine#onLeaderStop 方法。