基于Raft分布式Kv存储:sendAppendEntries

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()。由于 currentTermvotedFor 属于 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 语义。

相关推荐
范什么特西6 小时前
关于kafka
分布式·kafka
wWYy.6 小时前
基于Raft分布式Kv存储:AppendEntries
分布式
霸道流氓气质7 小时前
分布式系统中跨进程上下文传递方案
分布式
wWYy.7 小时前
基于Raft的分布式Kv存储项目:raft.h
开发语言·分布式·qt
wWYy.8 小时前
基于Raft分布式Kv存储:日志寻找匹配
分布式
ACP广源盛139246256738 小时前
YLB3118 存储桥接芯片完整机会点@ACP#联动曙光 8000
大数据·人工智能·分布式·单片机·嵌入式硬件
韩楚风1 天前
【参天引擎】一次宕机后的数据恢复,让我把 Cantian 持久化与恢复的六大机制全搞明白了
服务器·网络·数据库·分布式·mysql·架构·cantian
AAA@峥1 天前
Ceph RBD 块存储全解析:原理与实操汇总
运维·分布式·ceph
LLL人无再少年1 天前
分布式链路追踪系统之二进制安装skywalking
分布式·skywalking