21.6 Raft:日志复制

一句话

日志复制只有一条规则——提交 = 多数派持久化 + 当前任期;AppendEntries 的一致性检查、nextIndex 回退、冲突条目覆盖,全都是为了让这条规则在任意故障后仍然成立。

日志长什么样

index:    1        2        3        4        5        6
term:     1        1        2        2        4        4
command:  a=1      b=2      c=3      d=4      e=5      f=6
                            ▲                          ▲
                       旧任期遗留               leader 当前任期(term 4)

三个不变量(后面所有证明都建立在它们上面):

不变量 含义
日志匹配(Log Matching) 两个日志在某个 (index, term) 相同 ⇒ 该位置之前的所有条目都相同
leader 只追加 leader 从不覆盖或删除自己的日志,只往后加
提交不可逆 一旦某条目被提交,之后所有 leader 都会包含它(证明见 21.7 Raft:安全性证明)

AppendEntries:一次复制带哪些字段

字段 作用 出错会怎样
term leader 的任期 旧 leader 的请求必须被拒绝
leaderId 用于 follower 转发客户端请求 客户端打到 follower 时无法重定向
prevLogIndex / prevLogTerm 一致性检查点:我发的这批日志紧接在你这个位置上 少了它,follower 无法判断自己是否与 leader 分叉
entries[] 要追加的条目(心跳时为空) ——
leaderCommit leader 已知的 commitIndex follower 无法及时应用已提交日志

follower 的处理流程

收到 AppendEntries(term, prevLogIndex, prevLogTerm, entries, leaderCommit)
 ① term < currentTerm            → 拒绝,返回自己的 term
 ② term ≥ currentTerm            → 更新 term、转为 follower、重置选举计时器
 ③ 本地日志在 prevLogIndex 处不存在,或 term ≠ prevLogTerm
                                  → 拒绝(让 leader 回退 nextIndex 重试)
 ④ 与 entries 冲突的位置起,删除本地冲突条目(及之后所有),追加新的
 ⑤ commitIndex = min(leaderCommit, 本地最后一条的 index),然后 apply

第 ③ 步的拒绝是"回退"的触发器;第 ④ 步的删除冲突条目是唯一允许 follower 丢弃自己日志的场合——它丢的必然是"从未提交过的条目"(因为已提交条目一定在 leader 上,见 21.7 Raft:安全性证明 的引理 3)。

nextIndex 回退:日志分叉怎么被修好

leader 为每个 follower 维护 nextIndex(下一条要发的 index)与 matchIndex(已确认复制的最高 index)。日志不一致时,从大到小试探,直到找到最后一致的位置。

package main

import (
	"fmt"
	"sort"
)

type entry struct {
	Term int
	Cmd  string
}

type log []entry // log[0] 对应 index 1

func show(name string, l log) {
	s := ""
	for i, e := range l {
		s += fmt.Sprintf(" %d(t%d)", i+1, e.Term)
	}
	fmt.Printf("%-10s%s\n", name, s)
}

// leader 的 nextIndex 指向 follower 的下一个待发送位置;不匹配就一步一步回退
func backtrack(leader, follower log, nextIndex int) int {
	for nextIndex > 1 {
		prev := nextIndex - 1 // 1-based
		if prev <= len(leader) && prev <= len(follower) && leader[prev-1].Term == follower[prev-1].Term {
			break
		}
		fmt.Printf("  nextIndex=%d:prevLogIndex=%d 的 term 对不上,回退到 %d\n", nextIndex, prev, nextIndex-1)
		nextIndex--
	}
	return nextIndex
}

// 多数派都已复制的最高 index:5 个节点取第 3 大
func commitIndex(match []int) int {
	s := append([]int(nil), match...)
	sort.Ints(s)
	return s[len(s)/2]
}

func main() {
	fmt.Println("场景 A:follower 的日志与 leader 在 index 3 处分叉(旧任期的条目)")
	leader := log{{1, "a=1"}, {1, "b=2"}, {2, "c=3"}, {2, "d=4"}, {4, "e=5"}, {4, "f=6"}}
	follower := log{{1, "a=1"}, {1, "b=2"}, {3, "x=9"}, {3, "y=9"}}
	show("leader:", leader)
	show("follower:", follower)

	next := backtrack(leader, follower, len(leader)+1)
	fmt.Printf("\n回退到 nextIndex=%d:prevLogIndex=%d 的 term 匹配 → 追加并删除冲突条目\n", next, next-1)
	follower = append(follower[:next-1:next-1], leader[next-1:]...)
	show("追加后:", follower)

	fmt.Println("\n场景 B:commitIndex 由多数派的 matchIndex 决定")
	for _, match := range [][]int{{6, 6, 6, 4, 3}, {6, 5, 4, 3, 2}} {
		ci := commitIndex(match)
		term := leader[ci-1].Term
		fmt.Printf("  matchIndex=%v → commitIndex=%d(term=%d,leader 当前 term=4)", match, ci, term)
		if term == 4 {
			fmt.Println(" → 可提交")
		} else {
			fmt.Println(" → 不能直接提交:必须等某个 term=4 的条目先被多数派复制")
		}
	}

	fmt.Println("\n场景 C:日志匹配性质保证「同 index 同 term ⇒ 同命令」,所以分叉只能发生在 term 不同的位置")
	fmt.Println("  若 follower 在 index3 有 t2 的命令 X,而 leader 在 index3 有 t2 的命令 c,")
	fmt.Println("  则说明有人违反了 AppendEntries —— 正确实现里这种状态不可达。")
}

输出(本机实测):

场景 A:follower 的日志与 leader 在 index 3 处分叉(旧任期的条目)
leader:    1(t1) 2(t1) 3(t2) 4(t2) 5(t4) 6(t4)
follower:  1(t1) 2(t1) 3(t3) 4(t3)
  nextIndex=7:prevLogIndex=6 的 term 对不上,回退到 6
  nextIndex=6:prevLogIndex=5 的 term 对不上,回退到 5
  nextIndex=5:prevLogIndex=4 的 term 对不上,回退到 4
  nextIndex=4:prevLogIndex=3 的 term 对不上,回退到 3

回退到 nextIndex=3:prevLogIndex=2 的 term 匹配 → 追加并删除冲突条目
追加后:       1(t1) 2(t1) 3(t2) 4(t2) 5(t4) 6(t4)

场景 B:commitIndex 由多数派的 matchIndex 决定
  matchIndex=[6 6 6 4 3] → commitIndex=6(term=4,leader 当前 term=4) → 可提交
  matchIndex=[6 5 4 3 2] → commitIndex=4(term=2,leader 当前 term=4) → 不能直接提交:必须等某个 term=4 的条目先被多数派复制

场景 C:日志匹配性质保证「同 index 同 term ⇒ 同命令」,所以分叉只能发生在 term 不同的位置
  若 follower 在 index3 有 t2 的命令 X,而 leader 在 index3 有 t2 的命令 c,
  则说明有人违反了 AppendEntries —— 正确实现里这种状态不可达。

两个细节:

  • 回退是 O(日志长度) 的:一条一条退很慢。实现优化是让 follower 返回"冲突条目的 term 与这个 term 的第一条 index",leader 一次跳过整个 term(etcd/TiKV 都这么做)。上面的代码为了看清机制,用的是逐条回退。
  • 案例 B 的第二行是关键:commitIndex=4 但 term=2 ≠ 当前 term=4,所以不能提交。这条规则是 Raft 安全性的补丁,理由见 21.7 Raft:安全性证明 的图 8 反例。

提交与应用:两个容易混淆的 index

概念 含义 更新时机
commitIndex 多数派已持久化到的最高 index leader 收到多数派 matchIndex 后计算
lastApplied 状态机已经执行到的 index 异步 apply,总是 ≤ commitIndex
matchIndex[i] follower i 已确认复制的最高 index 每次 AppendEntries 成功响应
写请求 → leader 追加本地日志(fsync)
       → 并发发 AppendEntries
       → 多数派响应成功 → commitIndex = 中位数(matchIndex)
       → apply 到状态机(lastApplied 前移)→ 返回客户端成功

三个必须说清的边界:

  1. 先落盘再回 ACK:follower 必须 fsync 后才回成功,否则"多数派持久化"是假的(掉电即丢)。
  2. apply 失败怎么办:状态机执行必须是确定性的;apply 报错通常意味着状态机与日志不匹配(比日志回放不一致更严重),工程上要么 panic 重启回放,要么走快照重建。
  3. 客户端重试:leader 在 apply 后、返回前崩溃,客户端会重试 → 同一条命令可能被应用两次。Raft 只保证日志不重复,业务幂等要自己做(见 25.4 幂等设计与去重)。

吞吐与延迟:批大小是唯一的杠杆

单 leader 串行提交时:

吞吐上限 ≈ 批大小 / RTT          单条延迟 ≈ RTT + fsync
手段 效果 代价
流水线(pipeline) 不等上一条 ACK 就发下一条,吞吐 ≈ 窗口 × 1/RTT 乱序响应要按 index 归位;内存占用上升
批量(batch) 一次 AppendEntries 带 N 条,摊薄 RTT 与 fsync 单条延迟上升(等攒批)
并行发 follower 5 节点只需等 3 个,慢节点不拖后腿 慢节点日志越来越落后 → 触发快照
按 follower 分级 给慢节点单独限流,避免拖垮 leader 的出带宽 实现复杂

🔑 共识的吞吐不是"算法快不快",而是每 RTT 能确认多少字节。同城 1ms RTT、批 100 条 × 200B ≈ 20 MB/s;跨城 30ms RTT 同样的批只有 0.67 MB/s——差 30 倍,全部来自 RTT。这也解释了为什么跨地域共识只用来放元数据。

落后太多的节点:补日志还是发快照

落后程度 做法 理由
几十~几千条 AppendEntries 逐批补齐 传输小、可流水线
超过日志保留范围(已被快照截断) InstallSnapshot 发整个状态机镜像 缺失的日志已经不存在,无法补
落后但网络差 先限速补,避免拖慢正常复制 慢节点会持续拉高 leader 的出带宽

快照的机制、截断边界与坑见 21.8 Raft:成员变更与快照。

反模式

反模式 后果
follower 先 ACK 再落盘 掉电后"已提交"的日志消失,共识变戏法
把 commitIndex 当 lastApplied(收到就执行) 未提交的日志被应用到状态机,回滚时数据错乱
用多数派 matchIndex 提交旧任期条目 图 8 反例:已提交条目被覆盖
允许日志有洞(学 Paxos 的 slot) Raft 的日志匹配与提交规则都假设连续,洞会让 commitIndex 失去意义
leader 把客户端请求转给 follower 处理 follower 不发起提交,请求悬挂
忽略慢节点 它长期落后 → 每次重启都要全量快照
不做幂等,指望共识去重 重试导致重复扣款/重复下单

自检

Q1 follower 在 prevLogIndex 处找不到条目,或 term 不匹配,它会怎么做?leader 又怎么反应?

▸ 答案:follower 拒绝这次 AppendEntries(返回失败),不做任何修改;leader 收到失败后把该 follower 的 nextIndex 减一重试,直到找到最后一个一致的 (index, term),再从那里开始追加。实现上会用"冲突 term + 该 term 的首个 index"一次跳过整段,避免逐条回退的 O(n) 开销。

Q2 matchIndex = [6,6,6,4,3],5 节点集群,leader 当前 term 4,日志 index 6 的 term 是 4。commitIndex 能推进到几?

▸ 答案:6。排序后中位数(第 3 大)是 6,且 index 6 的 term(4)等于当前 term,满足"多数派 + 当前任期"两条规则,可以提交并 apply 到 6。如果 index 6 的 term 是 2,即使多数派都有它也不能直接提交——必须等某个 term=4 的条目先提交,再间接带上它。

Q3 为什么 Raft 要求日志连续,而 Paxos 允许有洞?

▸ 答案:Raft 的提交规则依赖"多数派的 matchIndex 中位数"来推进 commitIndex,只有当 index 之前的条目都确定、且每个 follower 的日志是 leader 的前缀时,中位数才等价于"该 index 及之前都已被多数派持久化"。Paxos 每个 slot 独立决定,槽位可以先空着后续再填,所以不需要连续;代价是恢复与提交位置的计算复杂得多(要在多个 slot 上分别求多数)。

Q4 客户端超时重试,会不会在 Raft 里产生两条相同的日志?

▸ 答案:会。Raft 不去重(它只管日志的顺序与一致性):leader 可能已经提交并 apply,但响应在返回途中丢失,客户端重试后同一条命令再走一遍日志。去重必须由业务层做——用请求 id/业务唯一键 + 幂等表(见 25.4 幂等设计与去重)。把"共识"当成"恰好一次"是常见误解。

Q5 3 节点集群,写延迟由哪几段组成?为什么加批能提高吞吐却不降低单条延迟?

▸ 答案:延迟 ≈ 一次 RTT(等多数派)+ 本地 fsync + apply。批处理把 N 条写摊到同一次 RTT 与同一次 fsync 上,所以吞吐随批大小近似线性上升;但每一条仍然要等"这一批"凑齐、等一次 RTT,所以单条延迟不降反升(还可能因为攒批而增加排队)。流水线能让吞吐继续上升,但单条延迟的下限仍是 RTT。

关联