基于Raft分布式Kv存储:AppendEntries

整体调用链

复制代码
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()

这保证 currentTermvotedFor 和日志变更在回复 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 不同则替换

如果同一个 indexterm 对应的命令却不同,代码会触发断言:

复制代码
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 次数。

相关推荐
霸道流氓气质3 小时前
分布式系统中跨进程上下文传递方案
分布式
wWYy.3 小时前
基于Raft分布式Kv存储:日志寻找匹配
分布式
ACP广源盛139246256734 小时前
YLB3118 存储桥接芯片完整机会点@ACP#联动曙光 8000
大数据·人工智能·分布式·单片机·嵌入式硬件
韩楚风20 小时前
【参天引擎】一次宕机后的数据恢复,让我把 Cantian 持久化与恢复的六大机制全搞明白了
服务器·网络·数据库·分布式·mysql·架构·cantian
AAA@峥1 天前
Ceph RBD 块存储全解析:原理与实操汇总
运维·分布式·ceph
LLL人无再少年1 天前
分布式链路追踪系统之二进制安装skywalking
分布式·skywalking
心念枕惊1 天前
分布式、服务化的ERP系统架构设计
分布式·系统架构
重庆小透明1 天前
分布式事务方案对比
分布式
向夏威夷 梦断明暄2 天前
从架构特点到功能缺陷,重新认识分析型分布式数据库
数据库·分布式·架构