这里写自定义目录标题
背景与介绍
在当今大规模分布式系统的背景下,需要可靠、高可用性的分布式数据存储系统。
传统的集中式数据库在面对大规模数据和高并发访问时可能面临单点故障和性能瓶颈的问题。
为了解决这些问题,本项目致力于构建一种基于Raft一致性算法的分布式键值存储数据库,以确保数据的一致性、可用性和分区容错性。
解决的问题
一致性: 通过Raft算法确保数据的强一致性,使得系统在正常和异常情况下都能够提供一致的数据视图。
可用性: 通过分布式节点的复制和自动故障转移,实现高可用性,即使在部分节点故障的情况下,系统依然能够提供服务。
分区容错: 处理网络分区的情况,确保系统在分区恢复后能够自动合并数据一致性。
什么是共识?
在一个分布式系统中,多个服务器(或节点)通常共享相同的资源或数据状态。共识协议的目标就是确保这些节点在面对不可靠的网络环境或节点故障时,依然能够就系统的某个状态或值达成一致,即共识。
对于本项目中的分布式键值存储系统,共识问题具体表现为:如何确保每个服务器节点对同一个键值对(key-value pair)的状态达成一致。系统中可能有多个请求同时尝试修改同一个键的值,而每个节点的响应时间可能不同。共识协议需要确保,无论外部条件如何,所有节点对最终的数据状态具有一致的看法。
共识的关键特性
一致性(Consistency):
当所有非故障节点在某个时刻达成共识时,它们的决策是相同的。任何已经通过共识的值,即使某个节点故障或重启,也不会被覆盖或修改。Raft算法通过日志复制机制保证这一点。
终止性(Termination):
所有非故障节点在有限的时间内最终会做出决策。这意味着,即使存在网络延迟或部分节点失效,系统仍能够继续运行,并最终做出一个全局一致的决定。
安全性(Safety):
一旦某个值或状态被确认(即通过共识),它就不能被改变,甚至在有新的请求或节点恢复后,这一决策仍然保持不变。Raft协议通过选举机制确保只有合法的领导者才能在集群中提出修改请求,避免并发冲突。
容错性(Fault tolerance):
共识协议在容错分布式系统中的一个主要功能就是能够在部分节点失效的情况下仍然保持数据的一致性。这意味着即便有部分节点崩溃,系统仍然能够通过剩余节点达成共识,保障系统的高可用性。
在Raft协议中的共识
Raft协议通过以下机制来确保共识的达成:
领导者选举:集群中的所有节点会通过选举选出一个领导者(Leader),只有该领导者能够提议状态变更(如对键值的修改)。通过这种方式避免了多个节点同时进行修改的冲突。
日志复制:领导者将修改请求记录在其日志中,并将该日志条目复制到所有跟随者(Follower)节点,确保每个节点拥有相同的操作历史。
一致性检查:一旦日志条目在大多数节点上被复制,领导者会进行一致性检查,确保所有节点都达成共识。之后,修改才被应用到状态机(即数据库)上,从而确保一致性和安全性。
总结
在容错分布式系统中,共识是确保多个节点在不可靠网络条件下达成一致的关键。通过共识机制,系统能够确保所有节点对状态机的状态保持一致,不会因为节点失效或网络分区导致数据不一致或错误的修改。Raft协议通过领导者选举、日志复制和一致性检查来有效地解决共识问题,从而确保了分布式系统的安全性和容错性。
一致性算法的多数派原则
在分布式系统中,集群的容错能力是基于多数派原则的,即需要大多数(n/2 + 1)节点达成共识来保持系统的正常运行。这种设计有以下几个关键点:
- 大多数派原则:系统能够容忍最多小于一半的节点失效而不影响服务。换句话说,集群中只要保持大多数节点是健康的,系统就可以继续达成共识并处理请求。
- 故障容忍:在集群中,如果超过一半的节点失效(例如在5个节点的集群中,3个或更多节点故障),那么系统将无法再达成共识,无法继续处理新的写请求。虽然集群停止对外提供服务,但它仍然不会返回不正确的结果。这就是一致性算法中的安全性保证:在不能保证一致性的情况下,系统宁愿停止工作,也不会返回错误的状态或数据。
- Raft、Paxos等一致性算法都基于多数派原则进行共识决策。即使在网络分区或部分节点失效的情况下,只要大多数节点能够通信,系统就能够继续运行并保持一致性。领导者(Leader)通过多数派投票的方式选举产生,并负责向其他节点同步日志条目。
概念
心跳、日志同步:leader向follower发送心跳(AppendEntryRPC)用于告诉follower自己的存在以及通过心跳来携带日志以同步
首先掌握日志的概念,Raft算法可以让多个节点的上层状态机保持一致的关键是让 **各个节点的日志 保持一致,**日志中保存客户端发送来的命令,上层的状态机根据日志执行命令,那么日志一致,自然上层的状态机就是一致的。
所以raft的目的就是保证各个节点的日志是相同的。
Leader :集群内最多只会有一个 leader,负责发起心跳,响应客户端,创建日志,同步日志。
Candidate leader 选举过程中的临时角色,由 follower 转化而来,发起投票参与竞选。
Follower :接受 leader 的心跳和日志同步数据,投票给 candidate。
Raft是一个强Leader 模型,可以粗暴理解成Leader负责统领follower,如果Leader出现故障,那么整个集群都会对外停止服务,直到选举出下一个Leader。如果follower出现故障(数量占少部分),整个集群依然可以运行。
Term|任期:
Raft将Term作为内部的逻辑时钟,使用Term的对比来比较日志、身份、心跳的新旧而不是用绝对时间。Term与Leader的身份绑定,即某个节点是Leader更严谨一点的说法是集群某个Term的Leader。Term用连续的数字进行表示。Term会在follower发起选举(成为Candidate从而试图成为Leader )的时候加1,对于一次选举可能存在两种结果:
1.胜利当选:胜利的条件是超过半数的节点认为当前Candidate有资格成为Leader,即超过半数的节点给当前Candidate投了选票。
2.失败:如果没有任何Candidate(一个Term的Leader只有一位,但是如果多个节点同时发起选举,那么某个Term
的Candidate可能有多位)获得超半数的选票,那么选举超时之后又会开始另一个Term(Term递增)的选举。
Raft中的重要过程
领导人选举相关
代码分析
Raft的主要流程:领导选举(sendRequestVote RequestVote ) 日志同步、心跳(sendAppendEntries AppendEntries )
定时器的维护:主要包括raft向状态机定时写入(applierTicker )、心跳维护定时器(leaderHearBeatTicker )、选举超时定时器(electionTimeOutTicker )。
持久化相关:包括哪些内容需要持久化,什么时候需要持久化(persist)
Raft::init()函数
这段代码实现了 Raft 协议节点的初始化,包括设置初始状态、恢复持久化数据、初始化 I/O 管理器以及启动处理心跳、选举超时和日志应用的线程或协程。代码使用了现代 C++ 特性,如 std::unique_ptr、std::vector 和 std::thread,并通过 std::lock_guard 管理互斥量的锁定,确保线程安全。
void Raft::init(std::vector<std::shared_ptr<RaftRpcUtil>> peers, int me, std::shared_ptr<Persister> persister,
std::shared_ptr<LockQueue<ApplyMsg>> applyCh) {
m_peers = peers;
m_persister = persister;
m_me = me;
// Your initialization code here (2A, 2B, 2C).
m_mtx.lock();
// applier
this->applyChan = applyCh;
// rf.ApplyMsgQueue = make(chan ApplyMsg)
m_currentTerm = 0;
m_status = Follower;
m_commitIndex = 0;
m_lastApplied = 0;
m_logs.clear();
for (int i = 0; i < m_peers.size(); i++) {
m_matchIndex.push_back(0);
m_nextIndex.push_back(0);
}
m_votedFor = -1;
m_lastSnapshotIncludeIndex = 0;
m_lastSnapshotIncludeTerm = 0;
m_lastResetElectionTime = now();
m_lastResetHearBeatTime = now();
// initialize from state persisted before a crash
readPersist(m_persister->ReadRaftState());
if (m_lastSnapshotIncludeIndex > 0) {
m_lastApplied = m_lastSnapshotIncludeIndex;
// rf.commitIndex = rf.lastSnapshotIncludeIndex todo :崩溃恢复为何不能读取commitIndex
}
DPrintf("[Init&ReInit] Sever %d, term %d, lastSnapshotIncludeIndex {%d} , lastSnapshotIncludeTerm {%d}", m_me,
m_currentTerm, m_lastSnapshotIncludeIndex, m_lastSnapshotIncludeTerm);
m_mtx.unlock();
m_ioManager = std::make_unique<monsoon::IOManager>(FIBER_THREAD_NUM, FIBER_USE_CALLER_THREAD);
// start ticker fiber to start elections
// 启动三个循环定时器
// todo:原来是启动了三个线程,现在是直接使用了协程,三个函数中leaderHearBeatTicker
// 、electionTimeOutTicker执行时间是恒定的,applierTicker时间受到数据库响应延迟和两次apply之间请求数量的影响,这个随着数据量增多可能不太合理,最好其还是启用一个线程。
m_ioManager->scheduler([this]() -> void { this->leaderHearBeatTicker(); });
m_ioManager->scheduler([this]() -> void { this->electionTimeOutTicker(); });
std::thread t3(&Raft::applierTicker, this);
t3.detach();
// std::thread t(&Raft::leaderHearBeatTicker, this);
// t.detach();
//
// std::thread t2(&Raft::electionTimeOutTicker, this);
// t2.detach();
//
// std::thread t3(&Raft::applierTicker, this);
// t3.detach();
}
这个 init 函数负责 Raft 节点的初始化工作,包括:
设置节点的基本信息和状态。
从持久化层恢复状态。
初始化快照相关的变量。
启动处理心跳、选举超时和日志应用的线程或协程。
每一步都为 Raft 协议的正常运行奠定了基础。
在 Raft::init 函数中,两个协程和一个线程分别用于处理 Raft 协议中的三个核心任务:领导者心跳、选举超时和日志应用。每一个任务都对应 Raft 算法的重要机制,下面详细解释这两个协程和一个线程的作用。
m_ioManager->scheduler([this]() -> void { this->leaderHearBeatTicker(); });
作用: 这个协程负责在当前节点是领导者时,周期性地向其他 Raft 节点发送心跳消息,以维持其领导者身份并告知其他节点它仍在工作。
Raft 协议中的意义:
在 Raft 中,领导者必须定期向所有跟随者发送心跳,通常是通过发送空的 AppendEntries RPC。心跳的主要目的是防止跟随者发起选举。
如果跟随者在心跳超时时间内没有收到领导者的心跳消息,它们会认为领导者失效,并尝试发起新的选举。
具体工作:
leaderHearBeatTicker 协程周期性运行,检查当前节点的状态是否是领导者。如果是领导者,它就会向每个节点发送心跳(空的 AppendEntries),确保保持领导者身份。
如果当前节点不是领导者,这个协程将不会发送心跳。
m_ioManager->scheduler([this]() -> void { this->electionTimeOutTicker(); });
作用: 这个协程负责处理选举超时。如果在一段时间内没有收到来自领导者的心跳(即领导者可能宕机),该协程会触发新一轮的选举过程。
aft 协议中的意义:
在 Raft 中,如果跟随者在选举超时时间内没有收到领导者的心跳,它会认为领导者已经失效,于是它会提升为候选者并发起选举。
选举超时是随机的(在一定范围内),以减少多个节点同时发起选举导致冲突的可能性。
具体工作:
electionTimeOutTicker 协程会定期检查当前时间是否超过了选举超时时间。
如果超时且当前节点是跟随者或候选人,那么该节点将提升为候选人,增加 currentTerm,并发送请求投票(RequestVote)消息,启动新的选举。
std::thread t3(&Raft::applierTicker, this);
t3.detach();
作用: 这个线程负责将已经提交的日志条目应用到状态机中。
Raft 协议中的意义:
日志条目是 Raft 协议用于记录客户端请求的核心。日志条目先由领导者提议,然后在多数节点上复制后才能被提交。
一旦日志条目提交,它们需要被应用到状态机上以实现客户端的请求,并产生实际的效果。
具体工作:
applierTicker 线程会定期检查已提交的日志条目(m_commitIndex)。
如果 m_commitIndex 超过了 m_lastApplied,则它会按顺序将尚未应用的日志条目应用到状态机,并更新 m_lastApplied。
这个线程独立运行,不与心跳或选举相关联,因此即使心跳和选举过程中有延迟,日志应用仍会按时进行。
leaderHearBeatTicker 协程:负责领导者定期发送心跳消息,防止重新选举。
electionTimeOutTicker 协程:负责检测选举超时,当没有心跳时触发选举。
applierTicker 线程:负责将提交的日志应用到状态机,处理客户端的请求。
这三个定时器的配合保证了 Raft 算法的正常运行:保持领导者身份、处理故障时重新选举、确保日志顺序性应用。
我的叙述:
这是raft节点的初始化函数,1、首先根据参数获得了其他raft节点的通信接口、持久化层的共享指针和将日志应用到状态机的指针;2、根据参数初始化节点信息;3、加锁,保证线程安全的初始化Raft状态;4、根据参数初始化client与raft节点通信的接口;5、设置当前节点的状态,分别为:当前任期term、当前节点状态follower、已提交和已应用到数据层的索引、清空当前的日志数组;6、遍历所有的的其他raft节点,为每一个跟随者设置当前已经和leader匹配的日志索引和下一个索引;7、初始化当前节点并未开始投票、初始化表示当前没有快照数据(任期和日志的索引号)、初始化选举超时的时间和心跳超时的事件为当前时刻;8、根据持久化指针从持久化层读取到当前节点的Raft状态并恢复数据、根据快照索引的判断,来设置已经应用到数据库层的索引m_lastApplied;7、初始化完毕,输出当前初始化节点状态:当前节点标识符、当前节点任期、当前节点快照中最后一个日志条目的索引;快照中最后一个节点的任期号;8、释放锁;9、创建一个 monsoon::IOManager 实例来管理 I/O 操作;10、使用9创建的实例来启动两个协程,分别用来处理心跳超时和选举超时(具体工作见上方);通过创建一个线程来定期的将日志条目应用到状态机并线程分离;
线程的优势:日志应用(applierTicker)操作与心跳或选举不同,可能受到数据库响应延迟或大量日志操作的影响。因此,为了避免协程处理繁重的 I/O 操作,这里使用了独立线程来处理日志应用任务。线程能够更好地应对长期运行和 I/O 密集型任务,这样即使日志条目应用的速度较慢,也不会影响心跳和选举的处理。
协程的优势:协程是一种轻量级的并发机制,能够减少上下文切换的开销,相对于线程来说更加高效。这里选择使用协程来处理心跳和选举超时,保证了处理这两项任务时的性能和低延迟,因为它们需要频繁执行并且是短期的操作。
这其中涉及到的IO你通过 monsoon::IOManager 启动了两个协程来处理:
心跳定时器(leaderHearBeatTicker)
发送心跳:在 Raft 协议中,领导者需要定期向集群中的其他节点发送心跳消息,保持其领导者地位。这个过程涉及到网络 I/O。网络通信:心跳是通过网络 RPC 调用其他节点的 AppendEntries 方法来实现的,所以每次发送心跳时都会涉及到网络 I/O 操作。
选举超时定时器(electionTimeOutTicker)
选举发起:当选举超时时,会发起选举过程,请求集群中的其他节点投票。这也是通过网络 RPC 来实现的,因此涉及到网络 I/O。
网络通信:发起投票请求时,节点向其他节点发送 RequestVote 消息。线程运行的函数是 applierTicker,它的主要任务是将日志条目应用到状态机,并定期向上层的状态机提交应用的日志条目。
applierTicker 的 I/O 操作:状态机的日志应用:日志条目需要被应用到状态机中,通常涉及与存储层的交互(例如写入数据库或持久化日志),这可能会涉及磁盘 I/O 操作。
数据库交互:如果状态机与数据库相连(如 key-value 存储等),则日志的应用过程会涉及数据库的读写操作。void applierTicker(); // 定期将日志应用到状态机
持久化:在 Raft 中,持久化是非常重要的一环。节点需要将某些状态(如当前 term、投票情况、日志条目等)持久化到磁盘,这可能会涉及磁盘 I/O 操作。void persist(); // 将状态持久化协程中的 I/O 操作:主要是网络 I/O,包括领导者发送心跳和节点发起选举投票的 RPC 调用。因为这些操作频繁且短期,所以使用协程来处理更加高效。
线程中的 I/O 操作:主要是与状态机或数据库的交互、日志应用以及状态的持久化。这些操作可能涉及磁盘 I/O,可能会比较耗时,因此使用独立线程可以防止阻塞其他任务。
选leader过程
electionTimeOutTicker:负责查看是否该发起选举,如果该发起选举就执行doElection发起选举。
doElection:实际发起选举,构造需要发送的rpc,并多线程调用sendRequestVote处理rpc及其相应。
sendRequestVote:负责发送选举中的RPC,在发送完rpc后还需要负责接收并处理对端发送回来的响应。
RequestVote:接收别人发来的选举请求,主要检验是否要给对方投票。
electionTimeOutTicker
//Raft 协议中的选举超时定时器,它负责在一个节点不是领导者时,监控选举超时并触发选举。它会在超时后触发选举过程,并检查是否需要重置定时器。这个函数是在协程中运行的,
void Raft::electionTimeOutTicker() {
// Check if a Leader election should be started.
while (true) {//无限循环,它持续运行以监控领导者的状态和处理选举超时。
/**
* 如果不睡眠,那么对于leader,这个函数会一直空转,浪费cpu。且加入协程之后,空转会导致其他协程无法运行,对于时间敏感的AE,会导致心跳无法正常发送导致异常
* 空转问题:当节点是领导者时,不需要进入选举,因此函数进入空转状态,并通过 usleep(HeartBeatTimeout) 来节省 CPU 资源。由于 Raft 协议的心跳间隔通常比选举超时短得多,使用心跳超时时间是合理的。
*/
while (m_status == Leader) {
usleep(
HeartBeatTimeout); //定时时间没有严谨设置,因为HeartBeatTimeout比选举超时一般小一个数量级,因此就设置为HeartBeatTimeout了
}
std::chrono::duration<signed long int, std::ratio<1, 1000000000>> suitableSleepTime{};//随机化的选举超时时间
std::chrono::system_clock::time_point wakeTime{};//wakeTime 表示当前时间
{
m_mtx.lock();//用于锁定互斥量 m_mtx,确保在计算选举超时时间时线程安全。
wakeTime = now();//获取当前时间
//getRandomizedElectionTimeout() 返回一个带有随机性的超时时间,以避免多个节点同时发起选举。
//如果 wakeTime 大于 m_lastResetElectionTime,意味着自从上一次心跳重置后,已经过去了一段时间。
//整个表达式计算出了一个从当前时间点开始,到应该触发选举的时间间隔。这个时间间隔基于随机化的选举超时时间,并考虑到上次心跳重置的时间差。这样确保节点不会在心跳刚刚收到后立即开始选举。
suitableSleepTime = getRandomizedElectionTimeout() + m_lastResetElectionTime - wakeTime;//表示计算合适的睡眠时间,防止不必要的重复选举触发。
m_mtx.unlock();
}
//如果计算的睡眠时间 suitableSleepTime 大于 1 毫秒,则使用 usleep() 让协程休眠这个时间。
if (std::chrono::duration<double, std::milli>(suitableSleepTime).count() > 1) {
// 获取当前时间点
auto start = std::chrono::steady_clock::now();//记录睡眠开始和结束的时间点,并通过 duration 计算实际的睡眠时间。
usleep(std::chrono::duration_cast<std::chrono::microseconds>(suitableSleepTime).count());
// std::this_thread::sleep_for(suitableSleepTime);
// 获取函数运行结束后的时间点
auto end = std::chrono::steady_clock::now();
// 计算时间差并输出结果(单位为毫秒)
std::chrono::duration<double, std::milli> duration = end - start;
// 使用ANSI控制序列将输出颜色修改为紫色
std::cout << "\033[1;35m electionTimeOutTicker();函数设置睡眠时间为: "
<< std::chrono::duration_cast<std::chrono::milliseconds>(suitableSleepTime).count() << " 毫秒\033[0m"
<< std::endl;
std::cout << "\033[1;35m electionTimeOutTicker();函数实际睡眠时间为: " << duration.count() << " 毫秒\033[0m"
<< std::endl;
}
//检查在睡眠期间是否有新的领导者心跳信号重置了选举超时。
if (std::chrono::duration<double, std::milli>(m_lastResetElectionTime - wakeTime).count() > 0) {
//说明睡眠的这段时间有重置定时器,那么就没有超时,再次睡眠
continue;//表示已经有心跳信号,这时不需要进行选举,进入下一轮循环。
}
//如果没有收到领导者的心跳信号且选举超时,则调用 doElection() 发起选举,这是 Raft 协议中的关键步骤。
doElection();
}
}
Raft 协议的选举超时处理逻辑。如果一个节点在选举超时内没有收到领导者的心跳或投票结果,它会开始新的选举过程。
- 空转问题:当节点是领导者时,不需要进入选举,因此函数进入空转状态,并通过 usleep(HeartBeatTimeout) 来节省 CPU 资源。由于 Raft 协议的心跳间隔通常比选举超时短得多,使用心跳超时时间是合理的。
- 随机化的选举超时:当节点不是领导者时,它会计算一个随机化的选举超时(getRandomizedElectionTimeout()),然后让线程进入休眠状态,等待超时。
- 时间重置检测:在休眠结束时,会检查选举超时期间是否有领导者的心跳到达(通过检查 m_lastResetElectionTime)。如果有心跳到达,则说明不需要发起选举,进入下一轮循环。
- 超时处理:如果没有收到心跳,则发起选举(doElection())。
我的叙述:
1、首先若本节点是领导者,则不需要进行选举,进入空转状态;2、定义一个随机化睡眠时间和获得当前时间;如果睡眠时间大于1ms;则让协程休眠此时间;计算睡眠开始和结束的时间点(这里存在设置的睡眠时间和实际睡眠时间),3、判断在睡眠时间内有无重置定时器,有的话就没有超时,再次睡眠;4、若没有收到心跳,则发起选举doElection();
doElection
这个函数实现了 Raft 中的选举逻辑,当节点成为候选者时,会递增当前任期,并向其他节点发送选票请求。
通过多线程发送选票请求,确保了选举过程的并行化和高效性。
如果当前节点获得多数节点的支持(即 votedNum 大于一半),则会进一步成为领导者。
// 实现了 Raft 协议中的选举逻辑。当一个节点在选举超时后未收到来自当前领导者的心跳或其他信号时,它会通过 doElection() 函数开始一次新的选举。
void Raft::doElection() {
std::lock_guard<std::mutex> g(m_mtx);//这一行使用 std::lock_guard 自动管理互斥锁 m_mtx,以确保选举过程是线程安全的。
if (m_status == Leader) {//这段代码判断当前节点是否已经是领导者(Leader)。如果是领导者,则不会发起选举
// fmt.Printf("[ ticker-func-rf(%v) ] is a Leader,wait the lock\n", rf.me)
}
// fmt.Printf("[ ticker-func-rf(%v) ] get the lock\n", rf.me)
//如果当前节点的状态不是领导者(即为追随者 Follower 或候选者 Candidate),说明选举超时,此时节点准备发起选举。
if (m_status != Leader) {
DPrintf("[ ticker-func-rf(%d) ] 选举定时器到期且不是leader,开始选举 \n", m_me);//DPrintf 用于打印调试信息,表明当前节点不是领导者并且即将开始选举。
//当选举的时候定时器超时就必须重新选举,不然没有选票就会一直卡住
//重竞选超时,term也会增加的
m_status = Candidate;//将节点状态设置为 Candidate,并且自增当前任期 m_currentTerm,以表示进入新的选举轮次。
///开始新一轮的选举
m_currentTerm += 1;
m_votedFor = m_me; //即是自己给自己投,也避免candidate给同辈的candidate投 节点会为自己投票(m_votedFor = m_me),这是 Raft 协议的规定,候选者总会首先投票给自己。
persist(); //函数用于将当前状态持久化,确保即使节点崩溃,重新启动后也能从之前的状态恢复。
std::shared_ptr<int> votedNum = std::make_shared<int>(1); // 使用 make_shared 函数初始化 !! 亮点 创建一个共享的整数指针 votedNum,初始值为 1,表示该节点已经为自己投了一票。std::make_shared 是一种高效的内存分配方式,避免了手动管理内存。
// 重新设置定时器
m_lastResetElectionTime = now();//重置选举超时时间,更新 m_lastResetElectionTime 为当前时间,以确保接下来可以正确计算下一次选举的超时时间。
// 发布RequestVote RPC
for (int i = 0; i < m_peers.size(); i++) {//遍历所有节点(包括自身和其他节点)。如果当前遍历到的节点是自己(m_me),则跳过,因为自己不需要向自己发送选票请求。
if (i == m_me) {
continue;
}
int lastLogIndex = -1, lastLogTerm = -1;
getLastLogIndexAndTerm(&lastLogIndex, &lastLogTerm); //获取最后一个log的term和下标 调用 getLastLogIndexAndTerm() 函数,获取日志的最后一个条目的下标(lastLogIndex)和任期号(lastLogTerm)。这两个参数在发起选票请求时非常重要,因为其他节点会根据这些信息来决定是否投票给该候选者。
/*创建 RequestVoteArgs 对象并设置必要的参数:当前的任期(m_currentTerm)、候选者的 ID(m_me)、最后一个日志条目的下标和任期。*/
std::shared_ptr<raftRpcProctoc::RequestVoteArgs> requestVoteArgs =
std::make_shared<raftRpcProctoc::RequestVoteArgs>();
requestVoteArgs->set_term(m_currentTerm);
requestVoteArgs->set_candidateid(m_me);
requestVoteArgs->set_lastlogindex(lastLogIndex);
requestVoteArgs->set_lastlogterm(lastLogTerm);
auto requestVoteReply = std::make_shared<raftRpcProctoc::RequestVoteReply>(); //RequestVoteReply 用于接收其他节点的投票回复。
//使用匿名函数执行避免其拿到锁
//创建一个新的线程来执行 sendRequestVote() 函数,该函数负责向其他节点发送投票请求,并传递参数:目标节点的索引 i,投票请求参数 requestVoteArgs,回复对象 requestVoteReply,以及共享的投票计数 votedNum
std::thread t(&Raft::sendRequestVote, this, i, requestVoteArgs, requestVoteReply,
votedNum); // 创建新线程并执行b函数,并传递参数
t.detach();//线程使用 detach(),表示线程会在后台独立运行,Raft 不会等待它完成,这样可以并行发送投票请求,提高效率。
}
}
}
我的叙述:
1、先加锁,保证线程安全;2、如果当前节点是领导者,则不会发起选举;3、如果当前节点不是领导者,说明选举超时,本节点准备发起选举;(将自己状态调准为candidate、本节点任期+1、投票给自己、将节点当前状态持久化)4、使用共享指针来计数投票数量;5、重置选举超时时间、7、遍历所有节点,获得本节点的最后一个记录的任期和索引、编写投票函数的参数requestVoteArgs(包含本节点任期、状态、最后一个记录的任期和索引);定义一个接受参数;8、定义一个线程(这是在for循环内,是多线程调用),在线程中执行要票函数sendRequestVote,并将参数传递进去,还包括4中定义的一个共享指针;线程分离;
sendRequestVote
bool Raft::sendRequestVote(int server, std::shared_ptr<mprrpc::RequestVoteArgs> args, std::shared_ptr<mprrpc::RequestVoteReply> reply,
std::shared_ptr<int> votedNum) {
bool ok = m_peers[server]->RequestVote(args.get(),reply.get());
if (!ok) {
return ok;//rpc通信失败就立即返回,避免资源消耗
}
lock_guard<mutex> lg(m_mtx);
if(reply->term() > m_currentTerm){
//回复的term比自己大,说明自己落后了,那么就更新自己的状态并且退出
m_status = Follower; //三变:身份,term,和投票
m_currentTerm = reply->term();
m_votedFor = -1; //term更新了,那么这个term自己肯定没投过票,为-1
persist(); //持久化
return true;
} else if ( reply->term() < m_currentTerm ) {
//回复的term比自己的term小,不应该出现这种情况
return true;
}
if(!reply->votegranted()){ //这个节点因为某些原因没给自己投票,没啥好说的,结束本函数
return true;
}
//给自己投票了
*votedNum = *votedNum + 1; //voteNum多一个
if (*votedNum >= m_peers.size()/2+1) {
//变成leader
*votedNum = 0; //重置voteDNum,如果不重置,那么就会变成leader很多次,是没有必要的,甚至是错误的!!!
// 第一次变成leader,初始化状态和nextIndex、matchIndex
m_status = Leader;
int lastLogIndex = getLastLogIndex();
for (int i = 0; i <m_nextIndex.size() ; i++) {
m_nextIndex[i] = lastLogIndex + 1 ;//有效下标从1开始,因此要+1
m_matchIndex[i] = 0; //每换一个领导都是从0开始,见论文的fig2
}
std::thread t(&Raft::doHeartBeat, this); //马上向其他节点宣告自己就是leader
t.detach();
persist();
}
return true;
}
只有leader才需要维护m_nextIndex和m_matchIndex 。
sendRequestVote 的主要作用是通过 RPC 请求其他节点为自己投票,并根据回复更新节点的状态。
当节点发现自己的任期落后时,立即降级为 Follower。
当节点获得超过半数的选票后,成为新的 Leader,初始化 nextIndex 和 matchIndex 并开始发送心跳信号。
我的叙述:
1、通过rpc获得各个通信节点的响应;2、加锁 3、如果响应节点任期大于本节点任期,更新本节点信息;返回true;4、如果响应节点任期小于本节点任期,直接返回true(表明该节点信息过期,不予理会);5、如果目标服务器未投票给本节点,则返回true;6、如果投递给本节点,那么投票数+1;如果投票数达到一半以上,那么给投票数置0;更新当前节点状态为leader;获取当前节点最后一条记录的索引,然后为每一个跟随者维护下一个索引和已经匹配的索引;7、创建一个线程,向其他follower宣告自己的leader地位doHeartBeat;线程分离;8、持久化状态,保证数据安全
日志同步 心跳
leaderHearBeatTicker:负责查看是否该发送心跳了,如果该发起就执行doHeartBeat。
doHeartBeat:实际发送心跳,判断到底是构造需要发送的rpc,并多线程调用sendRequestVote处理rpc及其相应。
sendAppendEntries:负责发送日志的RPC,在发送完rpc后还需要负责接收并处理对端发送回来的响应。
leaderSendSnapShot:负责发送快照的RPC,在发送完rpc后还需要负责接收并处理对端发送回来的响应。
AppendEntries:接收leader发来的日志请求,主要检验用于检查当前日志是否匹配并同步leader的日志到本机。
InstallSnapshot:接收leader发来的快照请求,同步快照到本机。
leaderHearBeatTicker
void Raft::leaderHearBeatTicker() {
while (true) {
//不是leader的话就没有必要进行后续操作,况且还要拿锁,很影响性能,目前是睡眠,后面再优化优化
while (m_status != Leader) {
usleep(1000 * HeartBeatTimeout);
// std::this_thread::sleep_for(std::chrono::milliseconds(HeartBeatTimeout));
}
static std::atomic<int32_t> atomicCount = 0;
std::chrono::duration<signed long int, std::ratio<1, 1000000000>> suitableSleepTime{};
std::chrono::system_clock::time_point wakeTime{};
{
std::lock_guard<std::mutex> lock(m_mtx);
wakeTime = now();
suitableSleepTime = std::chrono::milliseconds(HeartBeatTimeout) + m_lastResetHearBeatTime - wakeTime;
}
if (std::chrono::duration<double, std::milli>(suitableSleepTime).count() > 1) {
std::cout << atomicCount << "\033[1;35m leaderHearBeatTicker();函数设置睡眠时间为: "
<< std::chrono::duration_cast<std::chrono::milliseconds>(suitableSleepTime).count() << " 毫秒\033[0m"
<< std::endl;
// 获取当前时间点
auto start = std::chrono::steady_clock::now();
usleep(std::chrono::duration_cast<std::chrono::microseconds>(suitableSleepTime).count());
// std::this_thread::sleep_for(suitableSleepTime);
// 获取函数运行结束后的时间点
auto end = std::chrono::steady_clock::now();
// 计算时间差并输出结果(单位为毫秒)
std::chrono::duration<double, std::milli> duration = end - start;
// 使用ANSI控制序列将输出颜色修改为紫色
std::cout << atomicCount << "\033[1;35m leaderHearBeatTicker();函数实际睡眠时间为: " << duration.count()
<< " 毫秒\033[0m" << std::endl;
++atomicCount;
}
if (std::chrono::duration<double, std::milli>(m_lastResetHearBeatTime - wakeTime).count() > 0) {
//睡眠的这段时间有重置定时器,没有超时,再次睡眠
continue;
}
// DPrintf("[func-Raft::doHeartBeat()-Leader: {%d}] Leader的心跳定时器触发了\n", m_me);
doHeartBeat();
}
}
这个函数的主要作用是:
确保领导者定期发送心跳消息,以保持领导者的状态。
在非领导者状态下,函数会进入休眠,减少 CPU 的使用。
使用调试输出监视心跳时间的设置和实际休眠时间。
如果心跳定时器在休眠期间被重置,则重新计算休眠时间并继续循环。
我的叙述:
1、如果本节点不是leader,则无限的睡下去;2、如果是leader;定义一个静态计数器,定义一个随机睡眠时间和一个当前时间;3、如果休眠时间大于1ms,那么就让线程睡指定时间,并就设置睡眠时间和实际睡眠时间;4、如果睡眠时间内重置了心跳定时器,则跳过此次心跳;否则将启动心跳;
doHeartBeat
void Raft::doHeartBeat() {
std::lock_guard<std::mutex> g(m_mtx);
if (m_status == Leader) {
DPrintf("[func-Raft::doHeartBeat()-Leader: {%d}] Leader的心跳定时器触发了且拿到mutex,开始发送AE\n", m_me);
auto appendNums = std::make_shared<int>(1); //正确返回的节点的数量
//对Follower(除了自己外的所有节点发送AE)
// todo 这里肯定是要修改的,最好使用一个单独的goruntime来负责管理发送log,因为后面的log发送涉及优化之类的
//最少要单独写一个函数来管理,而不是在这一坨
for (int i = 0; i < m_peers.size(); i++) {
if (i == m_me) {
continue;
}
DPrintf("[func-Raft::doHeartBeat()-Leader: {%d}] Leader的心跳定时器触发了 index:{%d}\n", m_me, i);
myAssert(m_nextIndex[i] >= 1, format("rf.nextIndex[%d] = {%d}", i, m_nextIndex[i]));
//日志压缩加入后要判断是发送快照还是发送AE
if (m_nextIndex[i] <= m_lastSnapshotIncludeIndex) {
// DPrintf("[func-ticker()-rf{%v}]rf.nextIndex[%v] {%v} <=
// rf.lastSnapshotIncludeIndex{%v},so leaderSendSnapShot", rf.me, i, rf.nextIndex[i],
// rf.lastSnapshotIncludeIndex)
std::thread t(&Raft::leaderSendSnapShot, this, i); // 创建新线程并执行b函数,并传递参数
t.detach();
continue;
}
//构造发送值
int preLogIndex = -1;
int PrevLogTerm = -1;
getPrevLogInfo(i, &preLogIndex, &PrevLogTerm);
std::shared_ptr<raftRpcProctoc::AppendEntriesArgs> appendEntriesArgs =
std::make_shared<raftRpcProctoc::AppendEntriesArgs>();
appendEntriesArgs->set_term(m_currentTerm);
appendEntriesArgs->set_leaderid(m_me);
appendEntriesArgs->set_prevlogindex(preLogIndex);
appendEntriesArgs->set_prevlogterm(PrevLogTerm);
appendEntriesArgs->clear_entries();
appendEntriesArgs->set_leadercommit(m_commitIndex);
if (preLogIndex != m_lastSnapshotIncludeIndex) {
for (int j = getSlicesIndexFromLogIndex(preLogIndex) + 1; j < m_logs.size(); ++j) {
raftRpcProctoc::LogEntry* sendEntryPtr = appendEntriesArgs->add_entries();
*sendEntryPtr = m_logs[j]; //=是可以点进去的,可以点进去看下protobuf如何重写这个的
}
} else {
for (const auto& item : m_logs) {
raftRpcProctoc::LogEntry* sendEntryPtr = appendEntriesArgs->add_entries();
*sendEntryPtr = item; //=是可以点进去的,可以点进去看下protobuf如何重写这个的
}
}
int lastLogIndex = getLastLogIndex();
// leader对每个节点发送的日志长短不一,但是都保证从prevIndex发送直到最后
myAssert(appendEntriesArgs->prevlogindex() + appendEntriesArgs->entries_size() == lastLogIndex,
format("appendEntriesArgs.PrevLogIndex{%d}+len(appendEntriesArgs.Entries){%d} != lastLogIndex{%d}",
appendEntriesArgs->prevlogindex(), appendEntriesArgs->entries_size(), lastLogIndex));
//构造返回值
const std::shared_ptr<raftRpcProctoc::AppendEntriesReply> appendEntriesReply =
std::make_shared<raftRpcProctoc::AppendEntriesReply>();
appendEntriesReply->set_appstate(Disconnected);
std::thread t(&Raft::sendAppendEntries, this, i, appendEntriesArgs, appendEntriesReply,
appendNums); // 创建新线程并执行b函数,并传递参数
t.detach();
}
m_lastResetHearBeatTime = now(); // leader发送心跳,就不是随机时间了
}
}
如果 preLogIndex 小于 m_lastSnapshotIncludeIndex,Leader 会发送快照。
如果 preLogIndex 和 Leader 的日志状态不一致,Leader 会从 preLogIndex + 1 开始发送部分日志条目。
如果 Follower 没有日志,Leader 会发送完整的日志条目。
发送部分日志条目:当 preLogIndex != m_lastSnapshotIncludeIndex 时,意味着 Follower 已经有部分日志条目,此时 Leader 只需要发送从 preLogIndex + 1 开始的后续日志条目。
发送完整日志条目:当 preLogIndex == m_lastSnapshotIncludeIndex 时,意味着 Follower 的日志状态是空的(或者已经收到了快照),此时 Leader 会发送整个日志列表。
我的叙述:1、加锁保证线程安全;2、如果本节点是领导者才可进行发送心跳;创建一个共享指针来记录正确返回的节点的数量;通过遍历对所有节点发心跳信息;如果该节点的下一条索引小于leader的快照最后一条索引,那么就创建一个新线程,执行leaderSendSnapShot并传入节点标志符,并进行分离;3、构造一个发送对象AppendEntriesArgs,用于构造要发送的心跳请求消息,将日志条目添加到AppendEntriesArgs中,包括(本节点任期、节点标志符等);4如果从节点的前一个日志索引不等于leader的快照最后一个索引值,那就从preLogIndex+1开始的地方进行传输日志;如果从节点的前一个索引值等于leader的快照最后一个索引值,说明follower的日志状态是空的,此时leader将发送整个日志;5、构造返回值对象appendEntriesReply,创建一个线程,执行sendAppendEntries函数,并传递参数;线程分离;6、标记上一次心跳时间为现在;
sendAppendEntries
bool Raft::sendAppendEntries(int server, std::shared_ptr<raftRpcProctoc::AppendEntriesArgs> args,
std::shared_ptr<raftRpcProctoc::AppendEntriesReply> reply,
std::shared_ptr<int> appendNums) {
//这个ok是网络是否正常通信的ok,而不是requestVote rpc是否投票的rpc
// 如果网络不通的话肯定是没有返回的,不用一直重试
// todo: paper中5.3节第一段末尾提到,如果append失败应该不断的retries ,直到这个log成功的被store
DPrintf("[func-Raft::sendAppendEntries-raft{%d}] leader 向节点{%d}发送AE rpc開始 , args->entries_size():{%d}", m_me,
server, args->entries_size());
bool ok = m_peers[server]->AppendEntries(args.get(), reply.get());//这里通过 AppendEntries 向目标节点 server 发送日志条目,并等待其回复:ok 表示网络层是否成功发送,若 false 表示通信失败,直接返回。
if (!ok) {
DPrintf("[func-Raft::sendAppendEntries-raft{%d}] leader 向节点{%d}发送AE rpc失敗", m_me, server);
return ok;
}
DPrintf("[func-Raft::sendAppendEntries-raft{%d}] leader 向节点{%d}发送AE rpc成功", m_me, server);
if (reply->appstate() == Disconnected) {
return ok;
}
std::lock_guard<std::mutex> lg1(m_mtx);
//对reply进行处理
// 对于rpc通信,无论什么时候都要检查term
if (reply->term() > m_currentTerm) { 如果 AppendEntries 发送成功且通信正常:首先检查返回的 term 是否比当前 Leader 的 term 大:如果是,说明 Leader 的 term 过时,当前节点应降级为 Follower,并更新 term。否则,继续处理日志条目的匹配情况。
m_status = Follower;
m_currentTerm = reply->term();
m_votedFor = -1;
return ok;
} else if (reply->term() < m_currentTerm) {
DPrintf("[func -sendAppendEntries rf{%d}] 节点:{%d}的term{%d}<rf{%d}的term{%d}\n", m_me, server, reply->term(),
m_me, m_currentTerm);
return ok;
}
if (m_status != Leader) {
//如果不是leader,那么就不要对返回的情况进行处理了
return ok;
}
// term相等
myAssert(reply->term() == m_currentTerm,
format("reply.Term{%d} != rf.currentTerm{%d} ", reply->term(), m_currentTerm));
if (!reply->success()) {//如果 reply->success() 为 false,表示 Follower 的日志不匹配,需要调整 nextIndex:
//日志不匹配,正常来说就是index要往前-1,既然能到这里,第一个日志(idnex =
// 1)发送后肯定是匹配的,因此不用考虑变成负数 因为真正的环境不会知道是服务器宕机还是发生网络分区了
if (reply->updatenextindex() != -100) {//
// todo:待总结,就算term匹配,失败的时候nextIndex也不是照单全收的,因为如果发生rpc延迟,leader的term可能从不符合term要求
//变得符合term要求
//但是不能直接赋值reply.UpdateNextIndex
DPrintf("[func -sendAppendEntries rf{%d}] 返回的日志term相等,但是不匹配,回缩nextIndex[%d]:{%d}\n", m_me,
server, reply->updatenextindex());
m_nextIndex[server] = reply->updatenextindex(); //失败是不更新mathIndex的
}
// 怎么越写越感觉rf.nextIndex数组是冗余的呢,看下论文fig2,其实不是冗余的
} else {
*appendNums = *appendNums + 1;
DPrintf("---------------------------tmp------------------------- 節點{%d}返回true,當前*appendNums{%d}", server,
*appendNums);
// rf.matchIndex[server] = len(args.Entries) //只要返回一个响应就对其matchIndex应该对其做出反应,
//但是这么修改是有问题的,如果对某个消息发送了多遍(心跳时就会再发送),那么一条消息会导致n次上涨
m_matchIndex[server] = std::max(m_matchIndex[server], args->prevlogindex() + args->entries_size());//表示该 Follower 当前匹配到的日志条目索引。
m_nextIndex[server] = m_matchIndex[server] + 1;//下一个发送的日志条目索引。
int lastLogIndex = getLastLogIndex();
myAssert(m_nextIndex[server] <= lastLogIndex + 1,
format("error msg:rf.nextIndex[%d] > lastLogIndex+1, len(rf.logs) = %d lastLogIndex{%d} = %d", server,
m_logs.size(), server, lastLogIndex));
if (*appendNums >= 1 + m_peers.size() / 2) {//如果追加日志的节点数达到了多数派:Leader 更新 m_commitIndex,表明这些日志已经被安全提交,可以应用到状态机上。提交的条件是:日志必须成功复制到多数派节点。日志必须是当前任期内的日志。
//可以commit了
//两种方法保证幂等性,1.赋值为0 2.上面≥改为==
*appendNums = 0;
// todo https://578223592-laughing-halibut-wxvpggvw69qh99q4.github.dev/ 不断遍历来统计rf.commitIndex
//改了好久!!!!!
// leader只有在当前term有日志提交的时候才更新commitIndex,因为raft无法保证之前term的Index是否提交
//只有当前term有日志提交,之前term的log才可以被提交,只有这样才能保证“领导人完备性{当选领导人的节点拥有之前被提交的所有log,当然也可能有一些没有被提交的}”
// rf.leaderUpdateCommitIndex()
if (args->entries_size() > 0) {//判断当前 AppendEntries RPC 中是否包含日志条目(即 entries 列表不为空)。
DPrintf("args->entries(args->entries_size()-1).logterm(){%d} m_currentTerm{%d}",
args->entries(args->entries_size() - 1).logterm(), m_currentTerm);//输出最后一条日志条目的 logterm()(即其任期号)以及当前的任期 m_currentTerm,用于后续判断。
}
if (args->entries_size() > 0 && args->entries(args->entries_size() - 1).logterm() == m_currentTerm) {//如果 entries_size() > 0 且最后一条日志的 logterm() 等于当前的 m_currentTerm,说明当前任期内的日志已经被成功复制,可以更新 m_commitIndex。
DPrintf(
"---------------------------tmp------------------------- 當前term有log成功提交,更新leader的m_commitIndex "
"from{%d} to{%d}",
m_commitIndex, args->prevlogindex() + args->entries_size());
m_commitIndex = std::max(m_commitIndex, args->prevlogindex() + args->entries_size());//m_commitIndex 代表 Leader 当前提交的日志条目的最大索引值。这里通过 std::max 函数更新 m_commitIndex 为当前提交日志的索引,
}
myAssert(m_commitIndex <= lastLogIndex, //使用断言 myAssert 检查 m_commitIndex 不超过 lastLogIndex,确保提交索引不会超出日志长度。
format("[func-sendAppendEntries,rf{%d}] lastLogIndex:%d rf.commitIndex:%d\n", m_me, lastLogIndex,
m_commitIndex));
// fmt.Printf("[func-sendAppendEntries,rf{%v}] len(rf.logs):%v rf.commitIndex:%v\n", rf.me, len(rf.logs),
// rf.commitIndex)
}
}
return ok;
}
Leader 向指定 Follower 发送 AppendEntries RPC 请求,进行日志条目的复制。
如果网络通信失败,则返回;否则处理响应。
检查响应中的 term,如果 Follower 的 term 更大,Leader 降级为 Follower 并退出。
如果日志复制失败,调整 nextIndex 以确保之后发送的日志条目能够匹配。
如果日志复制成功,更新 matchIndex 和 nextIndex。
当日志被多数派成功复制时,更新 commitIndex,确保这些日志能够被安全提交。
使用断言确保提交索引的合法性。
这个过程是 Raft 协议的核心部分,确保了日志的一致性和安全性,并且通过多数派复制来保障系统的容错性。
我的叙述:
1、Leader 向 server 发送 AppendEntries RPC 请求,参数 args 包含日志条目和其他状态信息,reply 接收 Follower 的响应结果。返回值 ok 表示网络通信是否成功。如果网络通信失败(ok 为 false),则立即返回,不再继续处理。2、如果网络通信失败,Leader 记录日志,退出函数。此时不会进一步尝试提交日志,也不进行日志条目的状态更新。3、如果通信成功,但 Follower 状态为 Disconnected,直接返回不做进一步处理。4、Raft 协议中,Leader 必须始终跟随最新的任期。如果 Follower 返回的 term 大于 Leader 的当前 m_currentTerm,说明 Leader 的任期已经过时,需要降级为 Follower,并更新自己的 term。如果 Follower 返回的 term 小于 Leader 的 m_currentTerm,则不做任何处理,保持现有状态。5、如果 Leader 的状态在此时发生了变化,不再是 Leader(例如,降级为 Follower),则直接退出,不再处理日志条目。6、当 Follower 返回日志复制失败时,Leader 调整 m_nextIndex,这是 Leader 发送给 Follower 的下一个日志条目的索引。如果 Follower 提供了一个新的索引,Leader 会更新 m_nextIndex。7、如果日志复制成功,Leader 更新 m_matchIndex(Follower 当前匹配的日志条目索引),以及 m_nextIndex(下一个将发送的日志条目索引)。8、当日志被多数节点(超过一半)成功复制时,Leader 更新 m_commitIndex,表明这些日志已经被安全提交,可以应用到状态机上。提交的条件是:日志必须成功复制到多数派节点。日志必须是当前任期内的日志。m_commitIndex 被更新为当前提交的最大索引值。此更新意味着 Leader 已经有信心这些日志被提交到足够多的节点上,并可以应用于状态机。9、使用断言 myAssert 确保 m_commitIndex 不会超过 lastLogIndex,即提交索引必须合法,不应超出日志的实际长度。
m_nextIndex[server] = reply->updatenextindex(); 中涉及日志寻找匹配加速的优化
AppendEntries
实现是 Raft 共识算法中处理 “AppendEntries” RPC 请求的一部分,主要用于在从节点的日志中追加新日志条目。
在代码中,是用AppendEntries函数调用了AppendEntries1函数实现的;要用于心跳机制和日志同步
AppendEntries1函数用于处理Leader节点发送的AppendEntries请求。
1、加锁和初步设置:加锁进行线程同步,保证数据一致性;然后设置rpc的接受状态,表明网络通信正常;
2、如果leader的Term小于本节点term,则表示leader过时,拒绝请求并返回失败,同时通过 reply->set_updatenextindex(-100); 提示 Leader 更新自己的日志索引。如果leader的term大于本节点的term,则更新本节点为term,并重置相关参数;确保本节点能够跟随最新的leader;
3、重置选举超时:无论是否有追加日志的操作,只要接受来自leader的心跳信号或日志请求,follower都应该重置选举超时时间,防止发生新的选举,通过 m_lastResetElectionTime = now(); 实现。
4、日志一致性检查:通过leader的最后一个日志记录的索引和本节点最后一个日志的索引比较,如果 args->prevlogindex() 大于当前节点的 getLastLogIndex(),,说民leader的请求日志过于新,当前节点无法匹配,返回失败,建议leader更新日志索引;如果args->prevlogindex() 小于 m_lastSnapshotIncludeIndex,说明该日志已经被快照覆盖,follower需要提示leader更新日志;
5、日志复制与冲突处理:如果args->prevlogindex() 对应的日志条目与当前节点的日志匹配(通过 matchLog 函数进行验证),则继续处理 Leader 发来的日志条目 (args->entries)。其中包含两种情况:1、如果新日志条目大于当前日志节点的最后一个目录索引,则直接追加日志;
2、如果日志条目的logindex()小于或等于当前节点日志的最后一个日志索引,需要检查日志是否匹配,如果不匹配,更新日志;6、通过比较leader的commit和当前节点的m_commitIndex,更新日志的提交位置,此时不能盲目的更新到最新的日志,而是取 std::min(args->leadercommit(), getLastLogIndex()),确保日志一致性。
7、日志冲突优化:当日志冲突,如果 args->prevlogindex() 对应的 logterm() 与当前节点的日志不匹配,需要寻找冲突的第一个日志条目,并更新 updatenextindex。这个过程通过遍历日志并比较 logterm() 实现,尽量减少 Leader 和 Follower 之间的 RPC 交互。
日志寻找匹配加速
如果日志不匹配的话可以一个一个往前的倒退。但是这样的话可能会设计很多个rpc之后才能找到匹配的日志,那么就一次多倒退几个数。
倒退几个呢?这里认为如果某一个日志不匹配,那么这一个日志所在的term的所有日志大概率都不匹配,那么就倒退到 最后一个日志所在的term的最后那个命令。
快照
系统可能会采取快照(snapshot)的方式来压缩日志。快照是系统状态的一种紧凑表示形式,包含在某个特定时间点的所有必要信息,以便在需要时能够还原整个系统状态。如果你学习过redis,那么快照说白了就是rdb,而raft的日志可以看成是aof日志。rdb的目的只是为了崩溃恢复的加速,如果没有的话也不会影响系统的正确性,这也是为什么选择不详细讲解快照的原因,因为只是日志的压缩而已。
何时创建快照?
快照通常在日志达到一定大小时创建。这有助于限制日志的大小,防止无限制的增长。快照也可以在系统空闲时(没有新的日志条目被追加)创建。
快照的传输
快照的传输主要涉及:kv数据库与raft节点之间;不同raft节点之间。
kv数据库与raft节点之间:因为快照是数据库的压缩表示,因此需要由数据库打包快照,并交给raft节点。当快照生成之后,快照内设计的操作会被raft节点从日志中删除(不删除就相当于有两份数据,冗余了)。
不同raft节点之间:当leader已经把某个日志及其之前的内容变成了快照,那么当涉及这部的同步时,就只能通过快照来发送。