21.5 Raft:选主
一句话
Raft 选主是四件套——多数派 + 任期 + 日志至少一样新 + 随机超时:前两个保证一个任期最多一个 leader,第三个保证新 leader 一定包含所有已提交条目,第四个保证它真的能选出来。
三个状态、两个 RPC、一个逻辑时钟
| 状态 | 行为 | 什么时候离开 |
|---|---|---|
| follower | 被动接收心跳与日志;超时未收到心跳 → 变 candidate | 收到心跳(重置计时器)/ 超时 |
| candidate | term+1、投自己、并发 RequestVote;拿到多数派 → leader | 拿到多数派 / 发现更高 term / 超时重选 |
| leader | 周期发心跳(空 AppendEntries)、接收写、复制日志 | 发现更高 term → 退回 follower |
任期 term 是逻辑时钟:单调递增、永不回退。它有两个用途——给每次选举编号(一个 term 最多一个 leader),以及让旧 leader 的请求被识别并拒绝(term < currentTerm 一律拒绝)。term 不是时间,不能用来估算超时。
选举流程
follower ──(超时未收到心跳)──▶ candidate: term++,votedFor=self,向所有节点发 RequestVote
candidate ──(收到多数派赞成)──▶ leader: 立即发心跳,宣誓主权
candidate ──(收到更高 term)────▶ follower: 退位,更新 term
candidate ──(选举超时)─────────▶ candidate: term++,重新拉票
超时 多数票
follower ─────▶ candidate ─────────▶ leader
▲ │ │
│ 发现更高 term│ 发现更高 term│
└───────────────┴──────────────────┘
RequestVote:四条授予规则,少一条都不安全
| 投票人检查 | 规则 | 少了会怎样 |
|---|---|---|
| 任期 | args.term < currentTerm → 拒绝 | 过期候选人搅乱集群(把旧 term 的票投出去) |
| 本任期是否已投票 | votedFor ∈ {null, candidateId},否则拒绝 | 同一 term 选出两个 leader(一票投两次) |
| 日志新旧 | 候选人日志必须"至少一样新",否则拒绝 | 日志落后的候选人当选,覆盖已提交条目 |
| term 更大 | 若 args.term > currentTerm,先更新自己的 term 并转为 follower | 拒绝高 term 候选人的同时,自己停留在旧任期,集群无法推进 |
"至少一样新"的精确比较(先比最后一条的 term,再比长度):
candidateUpToDate = (candLastTerm > voterLastTerm) ||
(candLastTerm == voterLastTerm && candLastIndex >= voterLastIndex)
举例:投票人日志 [1(t1), 2(t2)],候选人日志 [1(t1), 2(t2), 3(t3)] → 候选人的 lastTerm 3 > 2 → 投赞成。反过来,投票人 [1(t1), 2(t3)]、候选人 [1(t1), 2(t2), 3(t2)] → 候选人 lastTerm 2 < 3 → 拒绝,哪怕它更长。term 优先于长度,这是"日志新旧"最容易被写错的地方。
🔑 这条规则就是选举限制(election restriction)。它把"多数派相交"从"两拨人一定有交集"升级成"交集里的人不会投给落后者"。没有它,Raft 会退化成"谁先超时谁当主"。
随机超时:把"必然分裂"变成"几乎必成"
如果所有 follower 的超时时间相同,它们会同时变成 candidate,各投自己一票 → 没人拿到多数派 → 下一轮重复,理论上可以永远选不出来。随机化就是打破这个对称。
下面这个简化模型模拟 5 节点集群:每轮每个节点有独立超时(固定或随机),在"最早请求到达"之前超时的节点也自称候选,非候选节点把票投给最早到达的拉票请求,拿 3 票者当选;没选出就 term+1 重来(最多 20 轮)。
package main
import (
"fmt"
"math/rand"
"sort"
)
const (
N = 5
T = 1000.0 // 选举超时基准(ms)
maxR = 20 // 一轮选不出就重试,最多 20 轮
)
// 一次试验:返回是否选出 leader、用了几轮、耗时(ms)
func trial(rng *rand.Rand, randomized bool, rtt float64) (bool, int, float64) {
elapsed := 0.0
for round := 1; round <= maxR; round++ {
// 1) 各节点的选举定时器
t := make([]float64, N)
for i := range t {
if randomized {
t[i] = T * (1 + rng.Float64()) // [1000, 2000)
} else {
t[i] = T // 所有节点同一时刻超时
}
}
// 2) 最早超时者;在「最早请求能到达」之前超时的节点也自称候选
tmin := t[0]
for _, x := range t {
if x < tmin {
tmin = x
}
}
dmax := rtt
cands := []int{}
for i := range t {
if t[i] <= tmin+dmax {
cands = append(cands, i)
}
}
// 3) 候选投自己;其余节点投给最早到达的拉票请求
votes := make([]int, N)
for _, c := range cands {
votes[c]++
}
for j := 0; j < N; j++ {
isCand := false
for _, c := range cands {
if c == j {
isCand = true
}
}
if isCand {
continue
}
best, bestAt := -1, 1e18
for _, c := range cands {
at := t[c] + (rtt/2)*(0.5+rng.Float64()) // 单程延迟带抖动
if at < bestAt {
best, bestAt = c, at
}
}
if best >= 0 {
votes[best]++
}
}
// 4) 拿到多数派(3 票)的人当选
for _, v := range votes {
if v >= N/2+1 {
return true, round, elapsed + tmin + rtt
}
}
elapsed += tmin + rtt
}
return false, maxR, elapsed
}
func main() {
scenarios := []struct {
name string
randomized bool
rtt float64
}{
{"固定超时(都 1000ms)", false, 1},
{"随机超时 1000–2000ms,RTT=1ms", true, 1},
{"随机超时 1000–2000ms,RTT=30ms", true, 30},
{"随机超时 1000–2000ms,RTT=100ms", true, 100},
}
const trials = 10000
fmt.Println("| 场景 | 首轮选出 leader | 平均轮数 | 选举耗时 P50 | 选举耗时 P99 |")
fmt.Println("|---|---|---|---|---|")
for _, s := range scenarios {
rng := rand.New(rand.NewSource(42))
first, rounds := 0, 0
var times []float64
for i := 0; i < trials; i++ {
ok, r, el := trial(rng, s.randomized, s.rtt)
if ok && r == 1 {
first++
}
rounds += r
times = append(times, el)
}
sort.Float64s(times)
fmt.Printf("| %s | %.1f%% | %.2f | %.0fms | %.0fms |\n",
s.name, float64(first)/trials*100, float64(rounds)/trials,
times[trials/2], times[int(float64(trials)*0.99)])
}
}
输出(本机实测):
| 场景 | 首轮选出 leader | 平均轮数 | 选举耗时 P50 | 选举耗时 P99 |
|---|---|---|---|---|
| 固定超时(都 1000ms) | 0.0% | 20.00 | 20020ms | 20020ms |
| 随机超时 1000–2000ms,RTT=1ms | 100.0% | 1.00 | 1129ms | 1600ms |
| 随机超时 1000–2000ms,RTT=30ms | 99.8% | 1.00 | 1158ms | 1658ms |
| 随机超时 1000–2000ms,RTT=100ms | 97.7% | 1.02 | 1232ms | 2646ms |
四个读法:
- 固定超时 = 必然分裂:5 个节点同时成为候选,每人 1 票,20 轮都选不出来。这就是"为什么必须随机"。
- 随机化之后首轮成功率接近 100%:超时窗口
[T, 2T]里最小值唯一,别的节点还没醒。 - RTT 相对超时越大,撞车概率越高:RTT=100ms 时 2.3% 的试验要第二轮(P99 从 1.6s 涨到 2.6s)。经验规则:选举超时 ≈ 10 × RTT 以上,且至少比心跳间隔大一个量级(论文建议 150–300ms,etcd 默认心跳 100ms、选举超时 1000ms,具体以版本配置为准)。
- P50 约 1.1s 而不是 1.0s:5 个
U[1000,2000]的最小值中位数是1000 + 1000×(1−0.5^(1/5)) ≈ 1129ms,实测 P50 正好 1129ms;最小值期望是1000 + 1000/6 ≈ 1167ms。这解释了"故障切换要 1 秒左右"的量级感。
选举耗时与故障切换时间
| 组成 | 量级 | 说明 |
|---|---|---|
| 检测 leader 失联 | ≈ 选举超时 T | follower 等不到心跳才动 |
| 拉票(RequestVote) | 1 RTT | 多数派并行返回 |
| 宣誓与恢复可用 | 1 RTT | 新 leader 提交一条当前任期条目(no-op)后才能确定 commitIndex,读才能线性一致 |
| 合计 | T + 2 RTT | 同城 1ms RTT ≈ T+2ms;跨城 30ms RTT ≈ T+60ms |
⚠️ 常见误解:以为"选出来就能读"。新 leader 的 commitIndex 可能落后(它不知道自己已提交到哪),必须先提交一条当前任期的条目,才能安全地响应读(机制见 21.6 Raft:日志复制,读路径的三种做法见 21.12 共识算法对比)。
任期膨胀:一个被隔离的节点如何打断整个集群
场景:节点 E 因网络分区被隔离,它不断超时、不断 term++ 去竞选。分区恢复后,E 带着 term = 10086 发 RequestVote,健康 leader(term 5)收到后必须退位并更新 term —— 集群白丢一次 leader,重选一次,业务出现一次 1 秒级的写停顿。
两个工业解法:
| 机制 | 做什么 | 代价 |
|---|---|---|
| PreVote(预投票) | 竞选前先问"如果我竞选,你们会投我吗",不先加 term;拿不到多数派就不加 term、不打扰集群 | 多 1 RTT;需要全集群支持 |
| CheckQuorum | leader 周期性确认自己还能联系到多数派,联系不上就主动退位,避免"僵尸 leader" | 网络抖动时可能误退位 |
两者通常一起开(etcd 的 --pre-vote、--check-quorum)。没有它们,一个反复重启/网络抖动的节点就能让集群持续选主——可用性问题被伪装成"共识不稳"。
反模式
| 反模式 | 后果 |
|---|---|
| 所有节点用同一个选举超时 | 必然分裂票,永远选不出 leader |
| 选举超时接近 RTT(如 RTT 30ms、超时 50ms) | 心跳与拉票互相打断,反复选举 |
| 心跳间隔 ≥ 选举超时 | follower 频繁超时,集群持续处于选举中 |
| 投票给日志更旧的候选人(比较写错:先比长度) | 破坏选举限制,已提交条目可能被覆盖 |
| 把 term 当时间用("term 差 3 就是过了 3 秒") | 逻辑错误:term 只在选举时递增 |
不持久化 currentTerm / votedFor | 重启后在同一 term 投两次票 → 一个任期两个 leader |
| 不区分"收到请求"与"已提交" | 客户端以为写成功,实际还没提交 |
| 有 PreVote 却不配 CheckQuorum(或反之) | 前者防打扰、后者防僵尸,缺一个都留一类故障 |
自检
Q1 为什么"随机选举超时"是正确性之外的必需品?固定超时到底会怎样?
▸ 答案:固定超时会让所有 follower 同时超时、同时变 candidate、各投自己一票,没有任何人能拿到多数派;下一轮重复同样的对称,理论上可以永远选不出 leader(实测 20 轮全失败)。随机化把"同时"打散成"有唯一最早者",首轮成功率接近 100%。注意它是活性手段,不改变安全性。
Q2 候选人日志 [1(t1), 2(t2), 3(t2)],投票人日志 [1(t1), 2(t3)]。投票人该投赞成吗?为什么?
▸ 答案:不该。比较先看最后一条的 term:候选人 2 < 投票人 3,所以候选人"更旧",即使它更长(3 条 vs 2 条)。规则是"lastTerm 大者更新;lastTerm 相同才比 lastIndex"。写反成"先比长度"会让日志落后但更长的候选人当选,直接破坏选举限制。
Q3 一个被隔离 10 分钟的节点回来后,为什么会触发一次不必要的选举?怎么避免?
▸ 答案:它隔离期间不断超时并 term++,回来时带着远高于集群的 term。其他节点收到 RequestVote 看到更高 term,必须先更新自己的 term 并转为 follower,健康 leader 因此退位,集群重选。避免方式:PreVote(拉票前不加 term,先试探能否拿到多数派)+ CheckQuorum(leader 确认自己仍有多数派支持,否则主动退位)。
Q4 3 节点集群,leader 挂掉后客户端感知的写停顿大约是多少?由哪几段组成?
▸ 答案:约 T + 2 RTT(T 是选举超时)。① 检测:follower 等到超时才发起选举(≈T);② 拉票:一次 RTT;③ 新 leader 提交当前任期条目(no-op)后才能确定 commitIndex,又一次 RTT。同城(RTT 1ms、T 1s)约 1 秒;跨城(RTT 30ms)约 1.06 秒。把 T 调小能加快切换,但会增加误触发选举的概率(GC/网络抖动就会被当成 leader 挂了)。
Q5 "选出了 leader"就等于"可以安全地读"了吗?
▸ 答案:不等于。新 leader 的 commitIndex 可能落后于已提交位置(它只知道"我有这些日志",不知道"哪些已被多数派确认"),必须先提交一条当前任期的条目,才能通过多数派反推出旧的提交位置(这条规则的证明见 21.7 Raft:安全性证明)。在此之前,线性一致读需要 ReadIndex(一次多数派往返)或等 no-op 提交完成。