核心目标
Raft 的日志寻找匹配,本质上是在寻找 Leader 和 Follower 的最大公共前缀:
找到最大的 index = M,使得:
Leader.log[M].term == Follower.log[M].term
根据 Raft 的日志匹配性质,只要同一位置的 index + term 相同,那么该位置之前的日志也应相同。找到 M 后,Leader 就可以把 M+1 之后的日志发送给 Follower。(usenix.org)
最朴素的回退
Leader 为每个 Follower 保存:
nextIndex[i]
它表示下一条准备发送的日志位置。因此每次发送:
prevLogIndex = nextIndex[i] - 1
prevLogTerm = Leader.log[prevLogIndex].term
entries = Leader.log[nextIndex[i] ...]
如果 Follower 回复不匹配,最简单的处理是:
nextIndex[i]--;
然后重新发送。
假设:
Leader.nextIndex = 1001
真正匹配位置 = 500
那么可能需要:
1000 → 999 → 998 → ... → 501
也就是大约 500 次失败 RPC。论文中的基础算法就是逐项递减,优化版本则允许一次跳过整个冲突任期。
为什么可以按 Term 跳跃
Raft 日志中的任期具有分段特征:
index: 1 2 3 | 4 5 | 6 7 8 | 9 10
term: 1 1 1 | 2 2 | 3 3 3 | 5 5
同一任期生成的日志通常连续出现。
如果 Follower 告诉 Leader:
你探测的位置属于我的 term=4
term=4 在我的日志中从 index=20 开始
Leader 就没有必要对 Follower 的整个 term 4 区间逐个尝试,可以直接跳到这个任期的边界。
优化后,失败 RPC 数量通常从:
和冲突日志条数成正比
降为:
和冲突任期段数量成正比
标准加速回复
一个完整的快速回退回复通常包含:
struct AppendEntriesReply {
int term;
bool success;
int conflictTerm;
int conflictIndex;
};
两个关键字段:
conflictTerm
Follower 在 prevLogIndex 位置上的任期。
conflictIndex
conflictTerm 在 Follower 日志中第一次出现的位置。
Follower 有两种主要失败情况。
情况一:Follower 日志太短
例如:
Leader 发送:
prevLogIndex = 10
Follower:
lastLogIndex = 6
Follower 根本不存在日志 10,因此返回:
conflictTerm = 无
conflictIndex = 7
Leader直接执行:
nextIndex[follower] = 7;
这样一次就从 11 跳到 7,而不是执行:
11 → 10 → 9 → 8 → 7
当前项目已经实现了这种优化:
reply->set_updatenextindex(
getLastLogIndex() + 1
);
Leader 收到后直接更新 m_nextIndex[server]。
情况二:下标存在,但 Term 不同
假设 Leader 探测:
prevLogIndex = 12
prevLogTerm = 5
Follower 的日志 12 存在,但属于:
Follower.log[12].term = 4
Follower 不只是返回失败,而是向前扫描整个 term 4:
index: 8 9 10 11 12
term: 4 4 4 4 4
最终返回:
conflictTerm = 4
conflictIndex = 8
表示:
我的日志 12 属于 term 4,并且这一段 term 4 从日志 8 开始。
Leader 如何利用两个字段
Leader收到 conflictTerm=4 后,先在自己的日志里找 term 4。
如果 Leader 也有 term 4:
Leader 最后一个 term 4 日志位于 index=6
则设置:
nextIndex[follower] = 6 + 1;
下一次发送:
prevLogIndex = 6
prevLogTerm = 4
为什么找 Leader 中 term 4 的最后一个位置?
因为这是双方最有希望匹配的最靠后位置。如果匹配成功,可以保留更多已有日志,减少重复传输。
如果 Leader 完全没有 term 4,则设置:
nextIndex[follower] = conflictIndex;
也就是直接跳到 Follower 的 term 4 之前,让 Leader 日志覆盖这段冲突日志。
完整例子
假设双方日志为:
index: 1 2 3 4 5 6 7 8 9 10 11 12
Leader: 1 1 1 2 2 3 3 5 5 5 5 5
Follower: 1 1 1 2 2 2 2 2 4 4 4 4
Leader 初始:
nextIndex = 13
prevLogIndex = 12
prevLogTerm = 5
Follower 在日志 12 看到 term 4,返回:
conflictTerm = 4
conflictIndex = 9
Leader没有 term 4,于是:
nextIndex = 9
第二次探测:
prevLogIndex = 8
Leader.log[8].term = 5
Follower.log[8].term = 2
Follower返回:
conflictTerm = 2
conflictIndex = 4
Leader存在 term 2,并且最后一个 term 2 位于日志 5,所以标准算法设置:
nextIndex = 6
prevLogIndex = 5
双方日志 5 都是 term 2,匹配成功。Leader随后发送日志 6~12。
整个过程只有两次失败:
13 → 9 → 6
逐项回退则可能需要:
13 → 12 → 11 → 10 → 9 → 8 → 7 → 6
当前项目实现了哪一部分
Follower 的冲突处理代码逻辑是:
int conflictTerm =
term(args->prevlogindex());
reply->set_updatenextindex(
args->prevlogindex()
);
for (int index = args->prevlogindex();
index >= snapshotIndex;
--index) {
if (term(index) != conflictTerm) {
reply->set_updatenextindex(index + 1);
break;
}
}
也就是:
1. 取得 Follower 在 prevLogIndex 上的本地 term。
2. 向前扫描。
3. 找出该 term 第一次出现的位置。
4. 通过 updateNextIndex 返回这个位置。
Leader端则直接执行:
m_nextIndex[server] =
reply->updatenextindex();
所以当前实现能够一次跳过 Follower 的整个冲突任期,而不是只执行 nextIndex--。