raft.h 中只有 init 的声明, 它相当于 Raft 节点的"构造完成阶段":绑定外部资源、初始化状态、恢复持久化数据,并启动后台任务。
一、四个参数
peers:集群中所有节点的 RPC 客户端数组。数组下标就是节点编号;本节点位置是 nullptr,发送 RPC 时会跳过自己,构造过程见
me:当前节点在 peers 中的下标,也是节点 ID。
persister:持久化对象,用于保存和恢复任期、投票、日志和快照信息。
applyCh:Raft 向 KV 状态机提交日志的线程安全队列。
二、绑定节点运行环境
m_peers = peers;
m_persister = persister;
m_me = me;
这三行建立节点与外部环境的联系。后续:
m_peers[i] 用于发送 RequestVote、AppendEntries 等 RPC;
m_persister 用于崩溃恢复;
m_me 用于标识自己、跳过给自己发送 RPC。
这些赋值发生在加锁之前,但此时 Raft 自己的后台任务还没有启动,所以从 init 内部来看暂时没有竞争。
三、建立初始 Follower 状态
m_mtx.lock();
applyChan = applyCh;
m_currentTerm = 0;
m_status = Follower;
m_commitIndex = 0;
m_lastApplied = 0;
m_logs.clear();
m_votedFor = -1;
各字段含义如下:
applyChan:保存与 KVServer 共用的提交队列。
m_currentTerm = 0:默认从第 0 任期开始,之后可能被磁盘状态覆盖。
m_status = Follower:节点重启后一律作为 Follower,不能恢复成 Leader。Leader 身份必须通过重新选举获得。
m_commitIndex = 0:目前已知已经提交的最高日志下标。
m_lastApplied = 0:已经交给状态机执行的最高日志下标。
m_logs.clear():先清空内存日志,再从持久化状态恢复。m_votedFor = -1:当前任期尚未投票。
"先给默认值,再读取磁盘覆盖"使首次启动和崩溃恢复可以共用同一套逻辑。
四、初始化 Leader 专用数组
for (int i = 0; i < m_peers.size(); i++) {
m_matchIndex.push_back(0);
m_nextIndex.push_back(0);
}
这两个数组只在节点成为 Leader 后真正使用:
m_nextIndex[i]:下一次应该向节点 i 发送的日志下标。
m_matchIndex[i]:已知节点 i 已成功复制的最高日志下标。
这里主要是建立与集群节点数量相同的数组。初始值 0 并不是最终 Leader 状态;节点当选 Leader 时会重新设置:
m_nextIndex[i] = lastLogIndex + 1;
m_matchIndex[i] = 0;
一个细节是:这里没有先执行 m_nextIndex.clear() 和 m_matchIndex.clear()。因此,同一个 Raft 对象如果多次调用 init,数组会不断增长。当前调用路径通常只初始化一次,所以暂时不会暴露。
五、初始化快照边界
m_lastSnapshotIncludeIndex = 0;
m_lastSnapshotIncludeTerm = 0;
由于日志压缩,m_logs 不一定从日志下标 1 开始。这两个变量记录:
快照覆盖到哪一条日志;
快照最后一条日志所属的任期。
例如:
snapshot 覆盖日志 1~100
m_lastSnapshotIncludeIndex = 100
m_lastSnapshotIncludeTerm = 7
m_logs 中只保存 101 之后的日志
所以本项目区分了"逻辑日志下标"和 m_logs 中的物理下标。
六、初始化两个定时器
m_lastResetElectionTime = now();
m_lastResetHearBeatTime = now();
m_lastResetElectionTime:最近一次重置选举定时器的时间
m_lastResetHearBeatTime:Leader 最近一次发送心跳的时间。
选举线程会用:
随机选举超时 + m_lastResetElectionTime - 当前时间
计算还需要睡多久。选举超时配置为 300~500ms,心跳间隔为 25m,初始化成当前时间可以避免节点刚启动就立刻发起选举。
七、恢复持久化状态
readPersist(m_persister->ReadRaftState());
readPersist() 会覆盖以下字段:
m_currentTerm
m_votedFor
m_lastSnapshotIncludeIndex
m_lastSnapshotIncludeTerm
m_logs
它们对应 Raft 的持久状态:重启后不能遗忘当前任期、已经投给谁以及日志内容。
日志的恢复过程比较特别:先由 Boost 反序列化出字符串数组,再由 Protobuf 将每个字符串解析成 LogEntrym_status 不恢复,因为角色是临时状态;m_nextIndex、m_matchIndex 也不恢复,因为只有 Leader 使用,而且可以重新计算。
八、处理快照恢复边界
if (m_lastSnapshotIncludeIndex > 0) {
m_lastApplied = m_lastSnapshotIncludeIndex;
}
如果快照已经包含日志 1~100,就不能让 apply 线程再次从日志 1 开始执行。因此将:
m_lastApplied = 100
后续只应用 101 之后的日志。
这里没有同时设置:
m_commitIndex = m_lastSnapshotIncludeIndex;
从语义上看,快照包含的日志必然已经提交,所以更常见的初始化是让二者至少等于快照下标。commitIndex 不需要作为独立字段持久化,但可以根据快照边界重建;源码中的 TODO 正是在讨论这个问题。
九、解锁后启动后台执行单元
m_mtx.unlock();
m_ioManager =
std::make_unique<monsoon::IOManager>(
FIBER_THREAD_NUM,
FIBER_USE_CALLER_THREAD);
先解锁再启动后台任务非常重要,否则新任务一启动就可能等待 m_mtx。
当前配置创建一个 IOManager 工作线程,并且不使用调用 init 的线程。IOManager 构造时就会启动调度器。
随后加入两个协程任务:
m_ioManager->scheduler([this] {
leaderHearBeatTicker();
});
m_ioManager->scheduler([this] {
electionTimeOutTicker();
});
leaderHearBeatTicker():节点是 Leader 时,每隔约 25ms 调用 doHeartBeat()。electionTimeOutTicker():Follower/Candidate 长时间没有收到 Leader 消息时调用 doElection()。
虽然两个函数都是无限循环,而且 IOManager 只有一个线程,但协程环境会 hook usleep(),睡眠时让出执行权,因此两个定时器可以交替运行。
十、单独启动日志应用线程
std::thread t3(&Raft::applierTicker, this);
t3.detach();
applierTicker() 不断检查:
m_lastApplied < m_commitIndex
如果存在已经提交但尚未应用的日志,就构造 ApplyMsg 并写入 applyChan。KVServer 在 ReadRaftApplyCommandLoop (line 281)(/C:/Users/LENOVO/Desktop/KVstorageBaseRaft-cpp-main/src/raftCore/kvServer.cpp:281) 中阻塞读取这个队列,然后真正修改 KV 状态机。
它使用独立线程,是为了避免应用流程影响选举和心跳的时间敏感任务。
完整启动链路
KvServer 创建 Raft、Persister 和 applyChan
↓
建立到其他节点的 RPC 客户端
↓
Raft::init()
↓
绑定 peers / persister / applyChan
↓
建立默认 Follower 状态
↓
从持久化数据恢复 term、vote、snapshot、logs
↓
启动心跳协程、选举协程、apply 线程
↓
等待选举或接收其他节点 RPC