整体调用链
Leader.doHeartBeat()
↓ 构造 AppendEntriesArgs
Leader.sendAppendEntries()
↓ RPC
Follower.AppendEntries()
↓
Follower.AppendEntries1()
↓
检查任期、匹配日志、追加日志、更新 commitIndex
↓
返回 AppendEntriesReply
Raft 里同一个 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_guard<std::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。
三、拒绝低任期 Leader
if (args->term() < m_currentTerm) {
reply->set_success(false);
reply->set_term(m_currentTerm);
reply->set_updatenextindex(-100);
return;
}
例如:
Follower.currentTerm = 8
请求.term = 7
说明发送请求的是过期 Leader,Follower 必须拒绝。
这里不会重置选举计时器。否则旧 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;
}
例如:
本地是 term=7 的 Candidate
收到 term=8 的 AppendEntries
本地节点由此得知集群已经进入 term 8,必须:
更新任期
取消旧任期投票
退回 Follower
此处没有立即返回,因为更高任期的请求仍然可能携带合法日志,应继续尝试接收。
六、同任期也转为 Follower
m_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 = 6
Follower 根本没有日志 10,无法验证 prevLogTerm,于是回复:
success = false
updateNextIndex = 7
Leader 下一轮就可以从日志 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 command
Raft 的日志匹配性质保证:两份日志只要拥有相同下标和任期,它们之前的日志以及该条命令都应相同。因此出现这种情况通常意味着实现或持久化数据已经损坏。
标准 Raft 在发现"相同下标、不同任期"时,会删除冲突条目以及它后面的所有日志,再追加 Leader 日志。当前代码采用逐项覆盖方式,没有明确截断冲突后的整个后缀,这与论文中的标准步骤存在差异,需要特别验证乱序请求和 Follower 多余后缀的行为。
十一、 更新 commitIndex
if (args->leadercommit() > m_commitIndex) {
m_commitIndex = std::min(
args->leadercommit(),
getLastLogIndex()
);
}
Follower 的提交位置只能前进,不能后退。
之所以取最小值,是因为 Leader 可能已经提交到日志 20,但当前 Follower 只同步到日志 16:
leaderCommit = 20
Follower.lastLogIndex = 16
Follower.commitIndex = 16
Follower 不能提交自己尚未拥有的日志。
这里只更新 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 = 3
Follower 的日志 7 属于任期 4,不是任期 3。它向前扫描整个任期 4:
日志 7 → term 4
日志 6 → term 4
日志 5 → term 4
日志 4 → term 2,停止
于是建议:
updateNextIndex = 5
Leader 可以直接从冲突任期的开头重试,而不是依次尝试 7、6、5,减少 RPC 次数。