[mq系列]-对于raft算法的入门学习与思考

6 阅读15分钟

目录

  • 1.引言
  • 2.CAP理论
  • 3.raft
  • 4.选举角色与职责
  • 5.内部请求与选举
  • 6.外部请求与选举
  • 7.工程实践-RocketMQ的Dledger模式

1.引言

分布式概念是为了解决大规模数据读写性能瓶颈问题。单机部署性能总有上限,机器性能本身提高速度很缓慢且昂贵。而采用横向扩展机器的方式,通过多个机器分散读写流量,在网络环境绝对理想化的情况下,可以做到无限扩展。同时也解决了单机部署,机器宕机便无法提供服务,数据也可能丢失的问题。这样就是分布式架构。

分布式与多服务通过RPC通信的区别:分布式描述的仅指一个模块,在服务模块拆分之后对某一模块进行分布式改在。再通过多个分布式集群进行RPC通讯,达到高性能的目的。

总结来说,分布式的有点有如下几个:

  1. 负载均衡,多个节点分摊系统流量,避免单机处理不过来这样的情况
  2. 数据备份,服务可用,避免了单点故障导致的数据丢失,服务宕机。

但是随之而来的也是分布式架构常见的问题:

  1. 不同节点之间数据一致性问题
  2. 引入分布式后,可能会出现的脑裂,服务雪崩等问题。

脑裂(Network Partitioning) 指的是由于网络故障或延迟,导致系统的部分节点之间失去联系,网络分裂成多个不互通的部分。这种情况下,每个分区的节点可能都认为自己是“正常的”,并继续进行操作,导致数据不一致、状态冲突等问题。

服务崩溃,举几个简单例子,比如master节点需要收到所有slave的回应后才会响应client请求,此时如果某个slave处理耗时过高,甚至直接宕机了,master的操作就会被拖垮,久而久之,会引起整个系统的雪崩。


2.CAP理论

分布式系统中的核心理论,CAP理论,P是指分区容错性,可以理解为:即使网络出现分区,系统仍是可以对外提供服务的。

对于单机节点,CP是同时满足的。对于分布式系统,P是必须满足的,在C和A之间进行取舍。当然也不是必须选择一个抛弃另一个。

AP的问题

  1. 读写延迟问题,导致数据查不到甚至查询到错误数据。
  2. 时序问题,写入顺序在同步follower时出现错乱,导致新数据被旧数据覆盖。

CP的问题

  1. 若有slave宕机,master无法完成对client的ack,导致整个集群不可用。
  2. 如果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角色转换

  1. leader → follower

leader发现集群中有节点的任期比自己的大,表示集群已经经过选举选出新的leader了,此时leader必须放弃自己的身份,转变为follower。

leader发现任期的方式:1.提议时从follower的返回信息中获取。2.收到新leader的心跳或同步请求。3.收到任期更大的candidate的拉票请求

  1. follower → candidate

理想情况下leader会定期向follower发送心跳,但如果心跳数据中断,follower就会自动切换成candidate尝试发起选举。

  1. candidate → leader

选举成功后,candidate就会成为新leader,需要多数派支持。

  1. 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.内部请求与选举

服务端内部的请求大致有

  1. 日志同步请求,leader接收客户端写请求后会广播所有follower进行日志同步
  2. 心跳&日志进度提交请求,leader对follower发起心跳数据,辅助follower进行日志提交
  3. 拉票请求,follower转换为candidate后广播所有节点进行拉票。

5.1日志同步

{
leaderId, 
curTerm, 当前任期
leaderCommitIndex,
prevLogIndex, leader当前日志前一条的index
prevLogTerm, leader当前日志前一条的term
log[],日志body
}

日志同步请求会携带如上信息。此时集群中三种角色都有可能存在。

  1. 向其他leader,称为leader1

leader1会判断leader的term是否大于自己的,如果是,leader1退化成follower

  1. 向follower

follower先判断term和自己的selfterm,如果term小于selfterm,直接reject

再判断提交过来的prevLogIndex和prevLogTerm是否match,如果否,则reject,返回信息会让leader发送稍早的请求,直到补全请求后再进行正常的流程。

其他情况则accept

  1. 向candidate

candidate判断term,如果term大于selfterm,则退化成follower

leader发送日志同步请求后接收到角色的响应

若多数派支持,则提交日志

若有节点reject,且回复更高的任期,leader会退化成follower,此时这笔写入数据丢失。

若有节点reject,回复相同任期,则leader同步稍早的日志,帮助其进行补全。

若有节点超时,则重新发送。

5.2心跳请求

同样的,还是leader发起,还是分三个角色讨论。

  1. other leader

判断term,进行退化或reject

  1. follower

判断term,若term≥selfterm,更新commit,重置心跳检测计时器。

否则忽略。

  1. candidate

判断term,选择退化成follower或忽略。

5.3拉票请求

{
term,
candidateId,
lastlogIndex
lastlogTerm
}

该请求由candidate发起,分三个角色讨论

  1. leader

判断term是否大于自己,如果是,则变成follower

  1. follower

判断term是否大于selfTerm,如果不是则直接拒绝。

如果是,继续判断lastlogIndex和lastlogTerm,如果大于自己的日志,则accept

如果小于自己的日志,拒绝

  1. candidate

如果term小于selfterm,拒绝。否则退回成follower继续处理。

响应处理

获得多数派支持,则晋升为leader,更新term

若没有,则退回成follower。

若反对者中有term比自己高,则退回follower,更新任期

若选举超时前,有leader发来大于自己的term的响应,则退化成follower

若超时,则term+1,开启新一轮选举。


6.外部请求与选举

客户端角度,抽象成写or读两种操作,写由leader收口,读分散在follower上。

6.1写请求

理想流程就是进行完两阶段提交,获得多数派支持。上面已经写过。这里补充几个非理想的case

  1. leader的term滞后

follower响应提议时,返回了比leader更高的任期,表示当前有网络分区存在,leader会退化

  1. follower日志滞后

follower发现自己的日志有缺失,会响应告知leader,leader辅助其补全日志

  1. follower日志超前

这里的超前是,term还是当前leader的term的情况下,可能follower有脏数据写入,这是需要让follower向leader看齐。

6.2读请求

读流程除了正常读之外,还需要进行一些额外处理

  1. apply index校验

leader写完后,会把状态机的apply index返回给客户端,后续客户端读时,follower会接收到这个apply index,如果follower发现自己的index小于这个index,则代表数据滞后,拒绝该次读请求。

  1. 强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();
}
  1. 首先会判断,currentTime是否到达设定的nextTimeToRequesetVote,或者needIncreaseTermImmediately。前者是设定的选举时间,后者是bool值,表示是否需要加一个term并立刻发起新一轮选举。
  2. 接着判断是否需要增加term的值,根据上一次选举状态ParseResult#WAIT_TO_VOTE_NEXT,表示上一次没有选举出leader,或者根据needIncreaseTermImmdiately判断。
  3. 发起竞选申请
  4. 处理投票响应结果

竞选-发起投票过程

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进行日志的重构。