基于Raft分布式Kv存储:AppendEntries 整体调用链Leader.doHeartBeat() ↓ 构造 AppendEntriesArgs Leader.sendAppendEntries() ↓ RPC Follower.AppendEntries() ↓ Follower.AppendEntries1() ↓ 检查任期、匹配日志、追加日志、更新 commitIndex ↓ 返回 AppendEntriesReplyRaft 里同一个AppendEntries RPC同时承担两种功能entries 为空 → 心跳 entries 不为空 → 日志复制这是 Raft 论文中定义的标准行为。请求参数term Leader 当前任期。 leaderId Leader 的节点编号。 prevLogIndex 新日志前面一条日志的下标。 prevLogTerm prevLogIndex 对应日志的任期。 entries[] 要复制的日志可以为空。 leaderCommit Leader 已知的最大提交下标。回复中主要有term Follower 当前任期。 success prevLogIndex 和 prevLogTerm 是否匹配 以及日志是否成功接受。 updateNextIndex 失败时建议 Leader 下一次从哪个下标重试。 appState 项目自定义的 RPC/应用状态。整体伪代码lock(m_mtx); 标记网络和应用层正常; if (leader.term currentTerm) { 拒绝请求; return; } 退出函数前持久化状态; if (leader.term currentTerm) { 更新 currentTerm; 清空 votedFor; 转为 Follower; } 转为 Follower; 重置选举计时器; if (prevLogIndex 超过本地最后日志) { 拒绝并告诉 Leader 本地日志长度; return; } if (prevLogIndex 位于本地快照之前) { 拒绝并建议从快照之后发送; } if (prevLogIndex 和 prevLogTerm 匹配) { 合并 entries; 更新 commitIndex; 返回成功; } else { 查找冲突任期的起始位置; 返回失败和建议的 nextIndex; }源码实现位于Raft::AppendEntries1()一、对整个处理过程加锁std::lock_guardstd::mutex locker(m_mtx);这个锁覆盖整个AppendEntries1()用于保护m_currentTerm m_status m_votedFor m_logs m_commitIndex m_lastResetElectionTime 快照元数据这样日志匹配、日志修改和提交位置更新构成一个原子状态转换不会和投票、快照或另一个AppendEntries同时修改 Raft 状态。二、标记 RPC 已经正常到达reply-set_appstate(AppNormal);Leader 发送前会把appState初始化成类似Disconnected的状态。Follower 进入处理函数后将其改成AppNormal用于区分RPC 根本没有到达和RPC 到达了但 Raft 逻辑拒绝了请求真正判断日志复制结果的字段仍然是success。三、拒绝低任期 Leaderif (args-term() m_currentTerm) { reply-set_success(false); reply-set_term(m_currentTerm); reply-set_updatenextindex(-100); return; }例如Follower.currentTerm 8 请求.term 7说明发送请求的是过期 LeaderFollower 必须拒绝。这里不会重置选举计时器。否则旧 Leader 持续发送过期心跳就可能阻止 Follower 发起新选举。Raft 接收规则也明确要求低任期请求返回失败。-100是项目自定义的哨兵值表示这次拒绝不是日志位置问题Leader 不应根据该回复调整nextIndex。四、设置延迟持久化DEFER { persist(); };它出现在低任期检查之后因此低任期请求 → 不改变状态不持久化 当前或更高任期请求 → 函数返回前执行 persist()这保证currentTerm、votedFor和日志变更在回复 Leader 前进入持久化状态符合 Raft 对持久状态的要求。不过当前实现会对正常的空心跳也执行persist()。如果persist()每次都序列化并写入完整日志这会带来明显的周期性磁盘开销可以根据“持久状态是否真的发生变化”决定是否写入。五、接受更高任期if (args-term() m_currentTerm) { m_status Follower; m_currentTerm args-term(); m_votedFor -1; }例如本地是 term7 的 Candidate 收到 term8 的 AppendEntries本地节点由此得知集群已经进入 term 8必须更新任期 取消旧任期投票 退回 Follower此处没有立即返回因为更高任期的请求仍然可能携带合法日志应继续尝试接收。六、同任期也转为 Followerm_status Follower; m_lastResetElectionTime now();Candidate 可能因为网络延迟在同一任期内收到合法 Leader 的心跳。此时 Candidate 应承认该 Leader转为 Follower。选举计时器在日志匹配之前就被重置。这意味着即使日志因为prevLogTerm不匹配而被拒绝只要发送方具有合法的当前任期Follower 仍然承认它是当前 Leader不会因为日志修复需要几轮 RPC 就错误发起选举。七、Follower 日志太短if (args-prevlogindex() getLastLogIndex()) { reply-set_success(false); reply-set_term(m_currentTerm); reply-set_updatenextindex(getLastLogIndex() 1); return; }例如Leader 请求 prevLogIndex 10 Follower lastLogIndex 6Follower 根本没有日志 10无法验证prevLogTerm于是回复success false updateNextIndex 7Leader 下一轮就可以从日志 7 开始发送而不是每次只把nextIndex减一。八、请求位置早于本地快照else if ( args-prevlogindex() m_lastSnapshotIncludeIndex ) { reply-set_success(false); reply-set_term(m_currentTerm); reply-set_updatenextindex( m_lastSnapshotIncludeIndex 1 ); }例如 Follower 已经生成快照snapshotIndex 100但收到prevLogIndex 80日志 80 已被压缩Follower 无法再通过普通日志数组匹配它因此建议 Leader 从快照之后开始这里源码缺少一个return设置失败回复后代码仍然会继续调用matchLog(prevLogIndex, prevLogTerm)而matchLog()要求下标不能小于快照位置。这可能触发断言或非法访问。这里应当补上return;这是当前实现中一个明确的控制流问题。九、检查前置日志是否匹配matchLog( args-prevlogindex(), args-prevlogterm() )匹配规则是prevLogIndex snapshotIndex → 和 snapshotTerm 比较 prevLogIndex 在普通日志中 → 和对应日志的 term 比较 下标不存在或 term 不同 → 匹配失败prevLogIndex prevLogTerm相当于 Leader 给出的连接点。只有连接点匹配Follower 才能安全接受后续entries。例如Leader index 4 5 6 7 term 2 2 3 3 请求 prevLogIndex 5 prevLogTerm 2 entries [6, 7]Follower 必须先确认自己的日志 5 也是任期 2。十、匹配成功后合并日志代码逐条处理entries新日志下标超过本地最后日志 → push_back() 本地已经存在相同下标 → 比较 term → term 不同则替换如果同一个index和term对应的命令却不同代码会触发断言same index same term different commandRaft 的日志匹配性质保证两份日志只要拥有相同下标和任期它们之前的日志以及该条命令都应相同。因此出现这种情况通常意味着实现或持久化数据已经损坏。标准 Raft 在发现“相同下标、不同任期”时会删除冲突条目以及它后面的所有日志再追加 Leader 日志。当前代码采用逐项覆盖方式没有明确截断冲突后的整个后缀这与论文中的标准步骤存在差异需要特别验证乱序请求和 Follower 多余后缀的行为。十一、 更新commitIndexif (args-leadercommit() m_commitIndex) { m_commitIndex std::min( args-leadercommit(), getLastLogIndex() ); }Follower 的提交位置只能前进不能后退。之所以取最小值是因为 Leader 可能已经提交到日志 20但当前 Follower 只同步到日志 16leaderCommit 20 Follower.lastLogIndex 16 Follower.commitIndex 16Follower 不能提交自己尚未拥有的日志。这里只更新commitIndex并不直接执行日志。之后applierTicker()会发现lastApplied commitIndex再按顺序把已提交日志交给上层状态机。十二、匹配失败时快速回退如果prevLogIndex存在但任期不匹配代码会找到 Follower 当前冲突任期的第一条日志。例如Follower index 1 2 3 4 5 6 7 term 1 1 2 2 4 4 4 Leader 请求 prevLogIndex 7 prevLogTerm 3Follower 的日志 7 属于任期 4不是任期 3。它向前扫描整个任期 4日志 7 → term 4 日志 6 → term 4 日志 5 → term 4 日志 4 → term 2停止于是建议updateNextIndex 5Leader 可以直接从冲突任期的开头重试而不是依次尝试 7、6、5减少 RPC 次数。