1.概述
前段时间接触了raft协议,唯一的感受就是易于理解。对于raft,在分布式领域还是有一片天地的。当然,光看算法不去工程化就是耍流氓,所以我专门拉了一下sofa团队的jraft的开源代码。然后对其实现进行分析,一方面是为了提高自己的编码功底,一方面也是更加深入的理解。今天我们就先看看其架构实现。然后会对其进行逐一拆分,深入底层代码。(sofastack是一个不错平台,大家也可以持续关注,本文借助sofastack的一些博客,深入代码进行分析)
2.架构设计
整个系统围绕着Node进行。涵盖了日志管理、元数据存储、快照、状态机、日志复制等模块。Node和Node之间通过RPC进行通信。
系统日志模块和状态机的任务都是通过Disruptor异步去执行。
这里主要介绍一下几个类的功能。
- FSMCaller主要就是将日志同步到状态机。
- LogManager,顾名思义,就是管理日志。
- MetaStorage用来存储节点的元数据信息。
- SnapshotExecutor就是快照方面的实现。
具体实现细节还是要深入到源代码。
为了我们更方便的去分析代码。我们从对应简单的例子counter出发。
3.源码分析
CounterServer服务端启动流程
// 这里让 raft RPC 和业务 RPC 使用同一个 RPC server, 通常也可以分开
final RpcServer rpcServer = RaftRpcServerFactory.createRaftRpcServer(serverId.getEndpoint());
// 注册业务处理器
CounterService counterService = new CounterServiceImpl(this);
rpcServer.registerProcessor(new GetValueRequestProcessor(counterService));
rpcServer.registerProcessor(new IncrementAndGetRequestProcessor(counterService));
// 初始化状态机
this.fsm = new CounterStateMachine();
// 设置状态机到启动参数
nodeOptions.setFsm(this.fsm);
// 设置存储路径
// 日志, 必须
nodeOptions.setLogUri(dataPath + File.separator + "log");
// 元信息, 必须
nodeOptions.setRaftMetaUri(dataPath + File.separator + "raft\_meta");
// snapshot, 可选, 一般都推荐
nodeOptions.setSnapshotUri(dataPath + File.separator + "snapshot");
// 初始化 raft group 服务框架
this.raftGroupService = new RaftGroupService(groupId, serverId, nodeOptions, rpcServer);
// 启动
this.node = this.raftGroupService.start();
1.首先通过工厂模式创建一个RpcServer。RaftRpcServerFactory#addRaftRequestProcessors 主要就是注册一些时间处理器。根据不同的协议处理。这个序列化协议使用的google的protobuf。每当有不同的请求过来都会调用不同的处理器方法。
2.然后创建一个一个业务类。因为这个例子比较简单,其实就是维护一个分布式自增键。所以只有两个操作,一个就是增加value大小,一个是读取该值。对应的Processor也就是调用了CounterService的这两个操作。当然这不是我们关注的重点。
3.创建业务状态机。raft提供了一个StateMachine接口,奈何他的方法太多,业务方有时候没有必要去实现。所以他提供了一个适配器。这个也就是适配器模式。是可以去学习的。
StateMachine有很多方法。比如节点状态变化的回调。其中核心的还是onApply方法。参数是一个Iterator,可以看出是可以批量apply的。这个方法,业务方一般要去主动实现。
4.设置NodeOptions并初始化RaftGroupService ,最后调用strat启动服务。
RaftGroupService#start方法
1.该节点的信息校验
2.将该几点添加到NodeManager 中。这是一个单例的实现。用于存储该进程RPC地址信息。和raft的group 信息。
3.通过工厂创建raft node并init。init rpc server。
NodeImpl#init方法
这个方法比较长,我们慢慢分析
1.获取JRaftServiceFactory,这个工厂主要用于创建各种存储实现类。这里通过SPI机制暴露给也业务方实现。当然raft有个默认的实现。DefaultJRaftServiceFactory
具体SPI实现后面会介绍一下。其实就是JRaftServiceLoader 实现。
2.初始化配置信息。校验,ip准确,并且一个ip:port只能创建初始化一次。初始化定时线程池。初始化各种计时器。
3.初始化配置管理器,主要就是集群配置
4.初始化请求的disruptor队列
this.applyDisruptor.handleEventsWith(new LogEntryAndClosureHandler());
this.applyDisruptor.setDefaultExceptionHandler(new LogExceptionHandler
这里可以看出来,jraft中大部分都是通过注册回调去执行的。利用了java8函数编程。我们也可以借鉴。LogExceptionHandler其实就是在disruptor异常回调中回调我们这注册的方法reportError。我们在编码中也可以学习LogExceptionHandler的方式,这样好处就是我们可以对于不同的逻辑注册不同的回调。并且易于理解。
LogEntryAndClosureHandler主要逻辑就是将请求预处理。然后提交到LogMannager的队列。并且注册了回调。后面会详细解释。
5.创建初始化FSMCallerImpl。
6.初始化三连,日志存储类,元数据存储类,FSMCaller。
7.初始化BallotBox。初始化SnapshotStorage
8.初始化rpcservice,主要就是raft内部通信,比如投票请求,复制日志请求。这些主要是在ReplicatorGroupImpl 实现,依赖rpcservice。
9.初始化ReadOnlyService ,用于处理读请求。
基本上初始化工作就这么多。
初始化结束之后,当前节点为follower状态。按照论文逻辑(5.2),follower会执行选举超时逻辑。也就是electionTimer 超时。会调用handleElectionTimeout 方法。
if (this.state != State.STATE\_FOLLOWER) {
return;
}
if (isCurrentLeaderValid()) {
return;
}
resetLeaderId(PeerId.emptyPeer(), new Status(RaftError.ERAFTTIMEDOUT, "Lost connection from leader %s.",
this.leaderId));
// Judge whether to launch a election.
if (!allowLaunchElection()) {
return;
}
doUnlock = false;
preVote();
1.如果检测心跳超时(isCurrentLeaderValid),那么他会调用resetLeaderId,这个方法逻辑很简单。如果当前leaderId不为空,调用fsmCaller#onStopFollowin,如果为空调用fsmCaller#onStartFollowing,然后清空leaderId。
2.判断当前节点是否启动选举。这里引入了优先级的概念。每个节点可以有一个优先级,越大的越优先参与选举,本地保存了一个targetPriority。
this.serverId.getPriority() >= this.targetPriority;
如果下届领导人在下次选举之前都没有选出,targetPriority会衰减。
3.如果有资格执行preVote,其实就是请求选举前先询问。避免节点分区产生的没有必要的选举。这个方法首先判断节点是否存在配置中,或者节点是否正在执行快照。如果是,直接返回。因为在执行快照的时候,节点的配置可能有已经失效。
if (oldTerm != this.currTerm) {
LOG.warn("Node {} raise term {} when get lastLogId.", getNodeId(), this.currTerm);
return;
}
this.prevVoteCtx.init(this.conf.getConf(), this.conf.isStable() ? null : this.conf.getOldConf());
for (final PeerId peer : this.conf.listPeers()) {
if (peer.equals(this.serverId)) {
continue;
}
if (!this.rpcService.connect(peer.getEndpoint())) {
LOG.warn("Node {} channel init failed, address={}.", getNodeId(), peer.getEndpoint());
continue;
}
final OnPreVoteRpcDone done = new OnPreVoteRpcDone(peer, this.currTerm);
done.request = RequestVoteRequest.newBuilder() //
.setPreVote(true) // it's a pre-vote request.
.setGroupId(this.groupId) //
.setServerId(this.serverId.toString()) //
.setPeerId(peer.toString()) //
.setTerm(this.currTerm + 1) // next term
.setLastLogIndex(lastLogId.getIndex()) //
.setLastLogTerm(lastLogId.getTerm()) //
.build();
this.rpcService.preVote(peer.getEndpoint(), done.request, done);
}
this.prevVoteCtx.grant(this.serverId);
if (this.prevVoteCtx.isGranted()) {
doUnlock = false;
electSelf();
}
preVote方法:
(1)遍历配置的节点列表,先connect(其实就是ping)
(2)发送RequestVoteRequest请求。这里是带回调的请求。jraft的AbstractClientService有一个线程池用来专门执行回调的。具体逻辑在invokeWithDone中体现。
我们理一下这里的回调逻辑:( handlePreVoteResponse方法)
如果请求前后term不一致或者当前节点不是follower,返回。
如果目标term大于当前term。说明leader失效,那么需要调用stepDown。这个方法后面介绍。
判断是被请求节点否给当前节点投票,如果是调用grant方法执行投票逻辑。其实就是更新Ballot。
private final Ballot voteCtx = new Ballot();
private final Ballot prevVoteCtx = new Ballot();
这里说一下这两个Ballot,raft只认同多半节点的意见。所以Ballot主要就是用来统计的。每次有节点响应同意的时候,都会更新。更新之后会调用他的isGranted方法判断。是否有多半节点已经同意。
所以每个节点返回之后都会执行这个操作。这也就是为什么preVote最后也会调用this.prevVoteCtx.grant和this.prevVoteCtx.isGranted方法。因为他要给自己预投票。
上面我们只是看到了RequestVoteRequest的调用。我们来看看节点收到该请求如何处理。
其实在rpc server启动的时候,已经将处理请求的processor注册上去了(RequestVoteRequestProcessor)。
对应handle流程:
(1)判断是否存活。没有存活,不给预投票
(2)判断当前leader是否有效,如果有效直接不给预投票
(3)如果当前term大于候选者term,并且当前节点为leader,检查replicator。不给投票因为当前节点成为leader的时候,replicator可能没有启动。导致候选者收不到心跳。才发起的预投票。
(4)term=候选者term+1,检查replicator。这个时候如果候选者日志新,会给预投票。
(5)判断日志新旧。如果新。投票,否则不预投票。
最后会返回RequestVoteResponse。
return RequestVoteResponse.newBuilder() //
.setTerm(this.currTerm) //
.setGranted(granted) //
.build();
electSelf方法:
如果有多半节点同意,会调用该方法。
(1)electionTimer.stop(),停止electionTimer,避免再次触发。
(2)resetleader节点。也就是停止follower。会调用fsmCaller.onStopFollowing
(3)更新当前状态为候选人,增加term。启动vote计时器。
(4)遍历所有节点发送vote请求。
后续逻辑和prevote一致,只不过pre通过了之后调用electSelf。而vote通过则成为leader。
在其他节点处理vote请求的时候,先会判断term是否大于请求的,如果大于,直接不给投票。如果小于,调用stepDown.
this.metaStorage.setTermAndVotedFor(this.currTerm, this.serverId);
this.voteCtx.grant(this.serverId);
if (this.voteCtx.isGranted()) {
becomeLeader();
}
becomeLeader方法
1.关闭vote定时器。
2.开启follower的日志复制,可能会add失败,因为如果节点不同的话。所以这就是为什么上面leader收到prevote请求之后需要check
for (final PeerId peer : this.conf.listPeers()) {
if (peer.equals(this.serverId)) {
continue;
}
LOG.debug("Node {} add a replicator, term={}, peer={}.", getNodeId(), this.currTerm, peer);
if (!this.replicatorGroup.addReplicator(peer)) {
LOG.error("Fail to add a replicator, peer={}.", peer);
}
}
3.开启learner的日志复制。
4.更新PendingIndex。因为raft指出,在新的任期不能提交上个任期没有提交的日志。根据配置更新选举优先值。
5.启动stepDownTimer定时器。
简单说下stepDownTimer这个定时任务,它主要是监控集群健康状况
1.检查之前首先会checkReplicator group所有节点,这个方法上面说过,其实就是判断是否开启当前节点的复制,如果没有则尝试开启。
2。调用checkDeadNodes0。这里是根据最后一次节点响应的时间戳和当前时间判断这个节点是否存活。。或者说是leader和此节点是否能进行正常网络通信。如果多半节点存活返回true。
for (final PeerId peer : peers) {
if (peer.equals(this.serverId)) {
aliveCount++;
continue;
}
if (checkReplicator) {
checkReplicator(peer);
}
final long lastRpcSendTimestamp = this.replicatorGroup.getLastRpcSendTimestamp(peer);
if (monotonicNowMs - lastRpcSendTimestamp <= leaderLeaseTimeoutMs) {
aliveCount++;
if (startLease > lastRpcSendTimestamp) {
startLease = lastRpcSendTimestamp;
}
continue;
}
if (deadNodes != null) {
deadNodes.addPeer(peer);
}
}
if (aliveCount >= peers.size() / 2 + 1) {
updateLastLeaderTimestamp(startLease);
return true;
}
return false;
3.如果2返回false,调用stepDown。这里主要避免脑裂和信息不一致。
stepdown方法
1.如果当前节点为候选者,停止voteTimer。
2.如果正在执行leader转移或者为leader,停止stepDownTimer ,如果为leader,调用onLeaderStop
3.清空一些信息。状态设置为follwers。
4.总结
第一篇,主要是对架构的理解。主要有以下几点
- 整个节点变化依赖三个定时器推动。并且在变化之后启动下一个需要启动的定时器,并停止当前定时器。
- 加入prevote。在成为候选者之前,得先要通过prevote。避免无效的选举。
- 整个任务都是异步回调的方式,比如等待选举请求的时候。每次返回通过回到判断。不得不说这里的回调实现的却是不错。
- 上文主要是根据初始化流程走。分析了节点变化过程。从follower到候选者要经过pervote。从候选者到leader需要经过vote。从leader或者候选者到follower执行stepdown。并且也涉及一部分执行stepdown的时间点。
选举依赖读写锁。并且有效措施避免死锁。通过先释放,再获取其他锁最后再重新获取。但是可能会有aba问题。
后面会继续分析日志复制以及提交的逻辑。希望对大家有所帮助,有什么问题大家也可以加我vx和我讨论(wasd695510719).
文章参考:
https://zhuanlan.zhihu.com/p/66355477
https://www.sofastack.tech/projects/sofa-jraft/jraft-user-guide/