sendAppendEntries() 可以理解为:
针对某一个 Follower,执行一次
AppendEntries RPC,然后把回复合并回 Leader 的 Raft 状态。
它不负责构造请求。请求由 doHeartBeat() 提前构造,它只负责:
发送 RPC
↓
等待结果
↓
检查任期和 Leader 身份
↓
失败:回退 nextIndex
成功:推进 matchIndex、nextIndex
↓
尝试推进 commitIndex
源码实现可见于该项目的 raft.cpp。
函数参数
函数大致接收四个参数:
void Raft::sendAppendEntries(
int server,
std::shared_ptr<AppendEntriesArgs> args,
std::shared_ptr<AppendEntriesReply> reply,
std::shared_ptr<int> appendNums);
含义分别是:
server
目标 Follower 的编号。
args
doHeartBeat() 构造好的 AppendEntries 请求。
包含 term、prevLogIndex、prevLogTerm、entries、leaderCommit。
reply
保存 Follower 返回的响应。
appendNums
本轮心跳中共享的"成功节点数量"。
doHeartBeat() 创建它时通常初始化为 1,代表 Leader 自己。
使用 shared_ptr 是因为函数运行在 detach() 出去的线程中。doHeartBeat() 返回后,请求、回复和计数器仍必须存活。
一、发送 RPC
核心调用类似:
bool ok = m_peers[server]->AppendEntries(
args.get(),
reply.get()
);
这里值得注意的是:执行网络 RPC 时没有持有 m_mtx。
这是正确的锁边界设计。网络请求可能超时或阻塞,如果在 RPC 期间一直持有 Raft 主锁,当前节点将无法及时:
处理其他节点发来的 RPC;
接收更高任期;
处理客户端请求;
更新选举和心跳状态。
因此它采用:
无锁执行网络调用
↓
RPC 返回
↓
加锁处理回复
但这也意味着 RPC 飞行期间,当前节点的任期和身份可能发生变化,所以后面必须重新校验。
二、网络失败直接返回
if (!ok) {
return;
}
ok == false 通常表示连接失败、超时或者底层 RPC 没有成功完成。
这种情况下函数不会:
修改 nextIndex
修改 matchIndex
修改 currentTerm
增加 appendNums
它也不会立即重试。后续的周期性 doHeartBeat() 会再次尝试。
这是合理的,因为网络失败不代表日志不匹配,不能因为一次超时就随意回退 nextIndex。
三、重新获得 Raft 锁
std::lock_guard<std::mutex> lock(m_mtx);
从这里开始,回复处理期间的共享状态修改都受同一把锁保护,包括:
m_currentTerm
m_status
m_votedFor
m_nextIndex
m_matchIndex
m_commitIndex
appendNums
因此 appendNums 虽然只是普通 int,而不是原子变量,但它的读写发生在 m_mtx 内,当前实现下不会因为多个回复线程同时执行 ++ 而产生直接的数据竞争。
四、处理更大的任期
if (reply->term() > m_currentTerm) {
m_status = Follower;
m_currentTerm = reply->term();
m_votedFor = -1;
return;
}
这是 Raft 非常重要的一条规则:
任何节点只要发现其他节点的任期比自己大,就必须更新任期并退回 Follower。
例如:
当前节点认为:
自己是 term=6 的 Leader
Follower 回复:
term=7
这说明 term 6 已经过期,集群至少已经进入 term 7。当前节点不能再继续发送 term 6 的日志,必须立即退位。
这里同时清空:
m_votedFor = -1;
表示新任期中还没有投票。
不过这份实现此处分支没有明显调用 persist()。由于 currentTerm 和 votedFor 属于 Raft 的持久化状态,更严格的实现应该在释放锁或返回之前将它们持久化,否则节点崩溃重启后可能恢复出旧任期。
五、丢弃较小任期的回复
else if (reply->term() < m_currentTerm) {
return;
}
例如:
发送请求时:
currentTerm = 6
等待 RPC 期间:
当前节点进入 term 7,并且重新成为 Leader
旧请求返回:
reply.term = 6
这个回复属于过去的任期,不能再修改当前状态,所以直接丢弃。
随后通常还有断言:
assert(reply->term() == m_currentTerm);
经过前面两个分支后,继续执行的回复原则上必须与当前任期一致。
六、再次确认自己仍然是 Leader
if (m_status != Leader) {
return;
}
即使回复的任期等于 m_currentTerm,也不能说明当前节点仍然是 Leader。
RPC 飞行期间可能发生:
Leader 发送 AppendEntries
↓
收到合法的同任期 Leader 消息或状态发生变化
↓
当前节点转为 Follower
↓
旧 AppendEntries 回复返回
此时不能再更新 Leader 专属的:
nextIndex[]
matchIndex[]
commitIndex
所以需要独立检查 m_status。
七、处理日志匹配失败
if (!reply->success()) {
if (reply->updatenextindex() != -100) {
m_nextIndex[server] = reply->updatenextindex();
}
}
success == false 一般说明:
Follower 不存在 prevLogIndex
或者:
Follower 在 prevLogIndex 位置的 term
和 Leader 给出的 prevLogTerm 不同
Follower 会通过 updateNextIndex 告诉 Leader,下次应该从哪里尝试。
例如:
Leader:
nextIndex[F] = 8
本次发送 prevLogIndex = 7
Follower:
实际只有日志 1~4
Follower 可以回复:
success = false
updateNextIndex = 5
Leader于是执行:
m_nextIndex[F] = 5;
下一轮请求变成:
prevLogIndex = 4
entries = [5, 6, 7, ...]
这就是 Raft 的日志回退过程。
-100 是这份代码使用的特殊哨兵值,表示 Follower 没有提供可用的新下标。工程上更清晰的方式是使用 Protobuf 的字段存在性或明确的状态枚举,而不是魔法数字。
八、成功时更新复制进度
成功分支首先增加本轮成功数:
*appendNums = *appendNums + 1;
然后计算这次请求能够确认的最大日志下标:
int replicatedIndex =
args->prevlogindex() + args->entries_size();
例如:
prevLogIndex = 5
entries = [6, 7, 8]
entries_size = 3
那么:
replicatedIndex = 5 + 3 = 8
意味着 Follower 已经确认拥有截至日志 8 的完整前缀。
为什么更新 matchIndex 要用 max
代码类似:
m_matchIndex[server] =
std::max(
m_matchIndex[server],
args->prevlogindex() + args->entries_size()
);
原因是网络回复可能乱序。
假设同时存在两个请求:
请求 A:确认到日志 8
请求 B:确认到日志 12
如果 B 先回来:
matchIndex = 12
随后旧请求 A 才回来。如果直接赋值,就会错误地变成:
matchIndex = 8
使用 max 可以保证:
matchIndex 只能前进,不能后退
这是成功回复处理里做得比较稳妥的地方。
更新 nextIndex
m_nextIndex[server] = m_matchIndex[server] + 1;
两个字段的关系是:
matchIndex[i]
已确认 Follower i 拥有的最后日志下标
nextIndex[i]
下一次应从哪个下标继续发送
如果已经确认 Follower 拥有到日志 8:
matchIndex = 8
nextIndex = 9
下一次心跳如果 Leader 没有新日志,就会发送:
prevLogIndex = 8
entries = 空
这就是纯心跳。
九、尝试推进 commitIndex
当前实现使用:
if (*appendNums >= 1 + m_peers.size() / 2) {
*appendNums = 0;
if (args->entries_size() > 0) {
m_commitIndex =
std::max(m_commitIndex, m_matchIndex[server]);
}
}
假设有 5 个节点:
多数派数量 = 1 + 5 / 2 = 3
appendNums 初始是 1,因为 Leader 自己已经有日志。收到两个 Follower 的成功回复后:
appendNums = 3
于是代码认为获得多数派,可以推进提交位置。
设置成 0 是为了避免本轮后续回复再次触发提交逻辑。
这里存在一个重要正确性问题
appendNums 只统计"RPC 成功了几个",但不同 Follower 成功确认的日志位置可能不同。
例如 5 节点集群:
Leader:拥有日志到 10
Follower A:成功确认到 5
Follower B:成功确认到 10
成功数量是:
Leader + A + B = 3,已经过半
如果 B 的回复正好让 appendNums 达到 3,当前代码可能执行:
commitIndex = 10
但日志 10 实际只有:
Leader
Follower B
只有两个节点,并没有过半。Follower A 只拥有到日志 5。
正确算法应该针对每个候选下标 N 统计:
有多少节点满足 matchIndex[i] >= N
只有满足:
多数节点的 matchIndex >= N
并且 log[N].term == currentTerm
才能把 commitIndex 推进到 N。这也是 Raft 论文描述的 Leader 提交规则。(usenix.org)
该项目其实已经存在类似的 leaderUpdateCommitIndex(),回复成功后调用它会比 appendNums 更符合 Raft 语义。