目录
- 1.引言
- 2.CAP理论
- 3.raft
- 4.选举角色与职责
- 5.内部请求与选举
- 6.外部请求与选举
- 7.工程实践-RocketMQ的Dledger模式
1.引言
分布式概念是为了解决大规模数据读写性能瓶颈问题。单机部署性能总有上限,机器性能本身提高速度很缓慢且昂贵。而采用横向扩展机器的方式,通过多个机器分散读写流量,在网络环境绝对理想化的情况下,可以做到无限扩展。同时也解决了单机部署,机器宕机便无法提供服务,数据也可能丢失的问题。这样就是分布式架构。
分布式与多服务通过RPC通信的区别:分布式描述的仅指一个模块,在服务模块拆分之后对某一模块进行分布式改在。再通过多个分布式集群进行RPC通讯,达到高性能的目的。
总结来说,分布式的有点有如下几个:
- 负载均衡,多个节点分摊系统流量,避免单机处理不过来这样的情况
- 数据备份,服务可用,避免了单点故障导致的数据丢失,服务宕机。
但是随之而来的也是分布式架构常见的问题:
- 不同节点之间数据一致性问题
- 引入分布式后,可能会出现的脑裂,服务雪崩等问题。
脑裂(Network Partitioning) 指的是由于网络故障或延迟,导致系统的部分节点之间失去联系,网络分裂成多个不互通的部分。这种情况下,每个分区的节点可能都认为自己是“正常的”,并继续进行操作,导致数据不一致、状态冲突等问题。
服务崩溃,举几个简单例子,比如master节点需要收到所有slave的回应后才会响应client请求,此时如果某个slave处理耗时过高,甚至直接宕机了,master的操作就会被拖垮,久而久之,会引起整个系统的雪崩。
2.CAP理论
分布式系统中的核心理论,CAP理论,P是指分区容错性,可以理解为:即使网络出现分区,系统仍是可以对外提供服务的。
对于单机节点,CP是同时满足的。对于分布式系统,P是必须满足的,在C和A之间进行取舍。当然也不是必须选择一个抛弃另一个。
AP的问题
- 读写延迟问题,导致数据查不到甚至查询到错误数据。
- 时序问题,写入顺序在同步follower时出现错乱,导致新数据被旧数据覆盖。
CP的问题
- 若有slave宕机,master无法完成对client的ack,导致整个集群不可用。
- 如果slave延迟过高,整个系统的响应也会被影响,拖垮。
raft算法是在CP的基础上,尽可能通过牺牲一定的C,来大幅度提高A。在C上,raft能够保证数据的最终一致性,在A上,通过多数派原则, 保证大多数节点能够稳定可用不出错的。
3.raft
3.1raft概念梳理
角色
raft中的节点可以分为三类角色:leader/master主,follower/slave从,candidate候选者。三种角色会互相转换。
组件
状态机,节点存储数据的容器。抽象的预写日志,记录写请求log。任期,标识每任leader的工具。日志索引index。
多数派原则
一半以上的候选者认可后,才能执行某个指令。多数派原则是提高分布式应用A的关键。
3.2一主多从&读写分离
leader负责主要事务处理,follower负责次要以及候选人的处理。
写操作一般由leader进行收口处理,向follower进行同步,即使follower收到了写请求也要交给leader来处理。在这种架构模式下,能够保障最终一致性。leader写时,会先记录预写日志,保证了A后再提交到状态机中,读就是从状态机读。这里涉及到一个两阶段提交。
两阶段提交
有提议和提交两个阶段,leader接收到写请求后,先写入本地的预写日志,将预写日志同步至所有follower,follower接收到同步请求后进行判断,如果允许本次写入,则返回成功。leader收到多数派的支持后会发出提交请求,将预写日志提交到状态机,同时通知follower也进行提交。
3.3选举机制
raft有一套选举机制,保证即使leader挂了,集群也能自动选出新的leader。
这个机制通过leader和follower之间的心跳建立,若follower超过指定时常没收到leader心跳,则认为leader挂了,此时会转变为candidate发起选举。
follower发起选举之后,若得到多数派的支持,则成为新的leader。这样就可以尽可能避免follower误判的情况。
4.选举角色与职责
4.1角色转换
- leader → follower
leader发现集群中有节点的任期比自己的大,表示集群已经经过选举选出新的leader了,此时leader必须放弃自己的身份,转变为follower。
leader发现任期的方式:1.提议时从follower的返回信息中获取。2.收到新leader的心跳或同步请求。3.收到任期更大的candidate的拉票请求
- follower → candidate
理想情况下leader会定期向follower发送心跳,但如果心跳数据中断,follower就会自动切换成candidate尝试发起选举。
- candidate → leader
选举成功后,candidate就会成为新leader,需要多数派支持。
- candidate → follower
选举期间,candidate没有收到多数派支持。或者选举期间收到任期大于等于自身的leader的心跳or同步请求。
4.2领导者leader
leader收口处理写请求,接收到之后广播所有follower进行两阶段提交。
leader还会向所有follower广播心跳,确保连接的可用,同时心跳包中携带最新的term+index,用于让follower检验。
4.3跟随者follower
follower接收leader同步请求,保证自己的数据与leader一致。
follower接收leader心跳,通过term+index检验自己数据是否合法。
根据心跳发起选举,或者为其他节点进行投票。
4.4候选者candidate
候选者会选择当前自己存储的任期term+1,作为竞选任期
为自己投一票
广播所有节点拉票,此过程有超时时间
若超时前获得多数派支持,则成为新leader,否则退回follower
若超时,则再term+1,发起新一轮选举。
5.内部请求与选举
服务端内部的请求大致有
- 日志同步请求,leader接收客户端写请求后会广播所有follower进行日志同步
- 心跳&日志进度提交请求,leader对follower发起心跳数据,辅助follower进行日志提交
- 拉票请求,follower转换为candidate后广播所有节点进行拉票。
5.1日志同步
{
leaderId,
curTerm, 当前任期
leaderCommitIndex,
prevLogIndex, leader当前日志前一条的index
prevLogTerm, leader当前日志前一条的term
log[],日志body
}
日志同步请求会携带如上信息。此时集群中三种角色都有可能存在。
- 向其他leader,称为leader1
leader1会判断leader的term是否大于自己的,如果是,leader1退化成follower
- 向follower
follower先判断term和自己的selfterm,如果term小于selfterm,直接reject
再判断提交过来的prevLogIndex和prevLogTerm是否match,如果否,则reject,返回信息会让leader发送稍早的请求,直到补全请求后再进行正常的流程。
其他情况则accept
- 向candidate
candidate判断term,如果term大于selfterm,则退化成follower
leader发送日志同步请求后接收到角色的响应
若多数派支持,则提交日志
若有节点reject,且回复更高的任期,leader会退化成follower,此时这笔写入数据丢失。
若有节点reject,回复相同任期,则leader同步稍早的日志,帮助其进行补全。
若有节点超时,则重新发送。
5.2心跳请求
同样的,还是leader发起,还是分三个角色讨论。
- other leader
判断term,进行退化或reject
- follower
判断term,若term≥selfterm,更新commit,重置心跳检测计时器。
否则忽略。
- candidate
判断term,选择退化成follower或忽略。
5.3拉票请求
{
term,
candidateId,
lastlogIndex
lastlogTerm
}
该请求由candidate发起,分三个角色讨论
- leader
判断term是否大于自己,如果是,则变成follower
- follower
判断term是否大于selfTerm,如果不是则直接拒绝。
如果是,继续判断lastlogIndex和lastlogTerm,如果大于自己的日志,则accept
如果小于自己的日志,拒绝
- candidate
如果term小于selfterm,拒绝。否则退回成follower继续处理。
响应处理
获得多数派支持,则晋升为leader,更新term
若没有,则退回成follower。
若反对者中有term比自己高,则退回follower,更新任期
若选举超时前,有leader发来大于自己的term的响应,则退化成follower
若超时,则term+1,开启新一轮选举。
6.外部请求与选举
客户端角度,抽象成写or读两种操作,写由leader收口,读分散在follower上。
6.1写请求
理想流程就是进行完两阶段提交,获得多数派支持。上面已经写过。这里补充几个非理想的case
- leader的term滞后
follower响应提议时,返回了比leader更高的任期,表示当前有网络分区存在,leader会退化
- follower日志滞后
follower发现自己的日志有缺失,会响应告知leader,leader辅助其补全日志
- follower日志超前
这里的超前是,term还是当前leader的term的情况下,可能follower有脏数据写入,这是需要让follower向leader看齐。
6.2读请求
读流程除了正常读之外,还需要进行一些额外处理
- apply index校验
leader写完后,会把状态机的apply index返回给客户端,后续客户端读时,follower会接收到这个apply index,如果follower发现自己的index小于这个index,则代表数据滞后,拒绝该次读请求。
- 强C的时候,读请求也要转发给leader处理
7.工程实践-RocketMQ的Dledger模式
rocketmq在4.5版本以前,broker只有主从高可用模式,不能进行自动的切换。后面引入了dledger来进行高可用的日志存储,使用dledger的commitlog保证高可用。
rocketmq相较于其他工程实践比如etcd不同,rocketmq并不能真正做到主从同步,同时其状态机和日志的概念比较模糊。
kafka在后期版本也有引入kRaft算法,来代替zookeeper,后续会去了解一下。
下面的分析基于openmessaging的dledger-master源码
选主流程分析
broker启动后会有一个专门的线程,用于维护raft中三种角色的状态流转。
leader需要主动定时向所有follower发送心跳包保持通讯。见maintainAsLeader
follower开启一个定时任务,对心跳超时做判断,决定发起选举的时机。见maintainAsFollower
发起选举
初始状态时,每个节点都是candidate,这里会触发一次选举,也就是进入maintainAsCandidate
private void maintainState() throws Exception {
if (memberState.isLeader()) {
maintainAsLeader();
} else if (memberState.isFollower()) {
maintainAsFollower();
} else {
maintainAsCandidate();
}
}
term变化代码
if (lastParseResult == WAIT_TO_VOTE_NEXT || needIncreaseTermImmediately) {
// 如果需要等待下一轮投票或立即增加任期
prevTerm = memberState.currTerm(); // 获取当前任期
term = memberState.nextTerm(); // 增加任期
logIncreaseTerm(prevTerm, term); // 记录日志:从 prevTerm 增加到 term
lastParseResult = WAIT_TO_REVOTE; // 更新投票结果状态为等待重新投票
} else {
// 否则,使用当前任期
term = memberState.currTerm();
}
- 首先会判断,currentTime是否到达设定的nextTimeToRequesetVote,或者needIncreaseTermImmediately。前者是设定的选举时间,后者是bool值,表示是否需要加一个term并立刻发起新一轮选举。
- 接着判断是否需要增加term的值,根据上一次选举状态ParseResult#WAIT_TO_VOTE_NEXT,表示上一次没有选举出leader,或者根据needIncreaseTermImmdiately判断。
- 发起竞选申请
- 处理投票响应结果
竞选-发起投票过程
memberstate中维护了一个peermap<String, String>,存储了集群节点信息。
遍历节点发起vote请求
if (memberState.getSelfId().equals(id)) {
//本地处理
voteResponse = handleVote(voteRequest, true);
} else {
//发送网络请求处理
voteResponse = dLedgerRpcService.vote(voteRequest);
}
class voteRequest {
private long ledgerEndIndex
private long ledgerEndTerm
}
有一个疑惑也在这段代码中得到解答,非leader节点如何维护和集群其他节点的关系?看了下代码后得知是需要联系的时候当场创建channel进行通信。
其他节点对投票请求的处理
function handleVote(request, self) {
// 加锁,确保操作 memberState 是线程安全的
synchronized (memberState) {
// 1. 检查请求是否来自已知集群
// 2. 检查请求是否异常(例如 self 节点错误地作为请求发起者)
...
// 3. 验证日志任期和日志索引是否过旧
if (request.ledgerEndTerm < memberState.ledgerEndTerm ||
(request.ledgerEndTerm == memberState.ledgerEndTerm &&
request.ledgerEndIndex < memberState.ledgerEndIndex)) {
return REJECT_OLD_LOG;
}
// 4. 检验request的vote term
if (request.term < memberState.currTerm) {
return REJECT_EXPIRED_TERM;
} else if (request.term == memberState.currTerm) {
// 检查当前任期是否已经投过票
if (alreadyVotedForOther(request.leaderId)) {
doJudge
}
} else {
// 请求的任期更大,当前follower转变为candidate,准备下一轮选举
stepDownToCandidate(request.term);
return REJECT_TERM_NOT_READY;
}
// 5. 检查节点自身是否正在成为 Leader
if (!self && isTakingLeadership() && logNotNewer(request)) {
return REJECT_TAKING_LEADERSHIP;
}
// 6. 如果通过所有验证,记录投票并返回接受
memberState.setVoteFor(request.leaderId);
return ACCEPT;
}
}
投票response处理
回到选举流程,发起投票的candidate收到所有响应后,根据响应结果做如下处理:
// 遍历每个投票响应
for each future in quorumVoteResponses:
// 异步处理每个投票响应,当处理完成时执行的回调
future.whenComplete((voteResponse, exception) -> {
// 如果投票结果不是 UNKNOWN,增加有效响应计数
if (voteResponse.result != UNKNOWN) {
incrementValidNum(); // 增加有效响应计数
}
// 锁定 knownMaxTermInGroup,防止多线程冲突
synchronized (knownMaxTermInGroup) {
// 根据投票结果更新不同计数器和状态
switch (voteResponse.result) {
case ACCEPT:
// 如果投票接受,增加已接受投票的计数
case REJECT_ALREADY_HAS_LEADER:
// 如果投票拒绝,且已存在 Leader,设置已经有 Leader 的标志
setAlreadyHasLeader(true);
case REJECT_TERM_SMALL_THAN_LEDGER: // 等待下一次选举
case REJECT_EXPIRED_VOTE_TERM:
// 如果投票拒绝的原因是任期小于日志,更新已知的最大任期
case REJECT_EXPIRED_LEDGER_TERM:
case REJECT_SMALL_LEDGER_END_INDEX:
// 如果投票拒绝的原因是日志过期,增加日志过期计数
case REJECT_TERM_NOT_READY:
// 如果投票拒绝的原因是任期还未准备好,增加未准备好计数
}
}
// 如果满足以下条件,触发投票结束
if (alreadyHasLeader || isQuorum(acceptedNum) || isQuorum(acceptedNum + notReadyTermNum)) {
voteLatch.countDown(); // 触发投票结束
}
// 增加已处理的响应计数
incrementAllNum();
// 如果所有投票响应都已处理,且达到了预定数量,触发投票结束
if (allNum == peerSize) {
voteLatch.countDown(); // 触发投票结束
}
}
});
投票结果处理完后会等待2000+ms的时间,继续做统计结果的处理,如果结果任期比当前大,则弊然不能changeToleader,需要等到下次投票或重新投票。
// 计算投票耗时
lastVoteCost = elapsed(startVoteTimeMs); // 计算上次投票所花费的时间
if (knownMaxTermInGroup > currentTerm) {
// 当前任期比已知最大任期小,等待下次投票
parseResult = WAIT_TO_VOTE_NEXT;
nextTimeToRequestVote = getNextTimeToRequestVote(); // 计算下一次请求投票的时间
changeRoleToCandidate(knownMaxTermInGroup); // 改变角色为候选人
} else if (alreadyHasLeader) {
// 集群中有不止一个节点在发起选举,等待重新投票
parseResult = WAIT_TO_REVOTE;
nextTimeToRequestVote = getNextTimeToRequestVote() + heartBeatTimeInterval * maxHeartBeatLeak;
} else if (!isQuorum(validNum)) {
// 有效投票数没有达到法定人数,等待重新投票
parseResult = WAIT_TO_REVOTE;
nextTimeToRequestVote = getNextTimeToRequestVote();
} else if (!isQuorum(validNum - biggerLedgerNum)) {
// 剔除较大日志的投票数后,仍然未达到法定人数,等待重新投票
parseResult = WAIT_TO_REVOTE;
nextTimeToRequestVote = getNextTimeToRequestVote() + maxVoteInterval;
} else if (isQuorum(acceptedNum)) {
// 已经获得足够的接受投票,投票通过
parseResult = PASSED;
} else if (isQuorum(acceptedNum + notReadyTermNum)) {
// 投票接受数加上未准备好的投票数达到法定人数,立即重新投票
parseResult = REVOTE_IMMEDIATELY;
} else {
// 其他情况,等待下次投票
parseResult = WAIT_TO_VOTE_NEXT;
nextTimeToRequestVote = getNextTimeToRequestVote();
}
以上就是一个完整的candidate投票过程
leader2follower的心跳
leader定时发起心跳,follower接收,解析,并做一定的回应,重点需要关注的就是follower的解析与leader对follower回应的处理。
follower端处理
follower发现心跳中的term小于自己的term,返回EXPIRED_TERM
相等,再判断leader是否是上次选举后自己选取的leader,若是,则更新lastHeartbeatTimestamp。
如果没有返回成功,则继续判断,如果leaderid没有记录,代表此节点未接受过任何leader请求,可用接收这个心跳包,并转换为follower角色。如果当前term落后,节点将转换为candidate,设置needIncreaseTermImmediately,返回TERM_NOT_READY。
leader端处理
多数派返回成功,leader更新心跳包发送成功时间
如果succnum+notReadynum多数,下次进入leader处理方式时会直接认为已经超过心跳发送间隔,直接发起心跳
如果返回的term中有比自己term大,表示当前节点落后,转换为candidate,使用maxterm作为下次选举的term,等待发起选举
如果有节点已经更换了leader,isconsistLeader,则转换为candidate等待下次选举。
日志复制部分,dledger的工程化实现方式我目前还没有完全理解,不过日志复制的重点其实很好理解,leader同步消息至follower,期间需要有一个logindex标识各节点的同步位置,以及需要一个数据补全的方式,帮助follower或脱离集群的leader进行日志的重构。