专栏文章
专栏文章
分布式算法系列
1. 分布式算法 #01:Paxos 共识算法 2. 分布式算法 #02:Raft 共识算法 3. 分布式算法 #03:Gossip 协议与 SWIM 故障检测 4. 分布式算法 #04:向量时钟与因果一致性 5. 分布式算法 #05:CRDT(无冲突复制数据类型) 6. 分布式算法 #06:Quorum 与 NWR 模型 7. 分布式算法 #07:Merkle Tree 与反熵同步 8. 分布式算法 #08:Byzantine 容错(BFT/PBFT) 9. 分布式算法 #09:Hybrid Logical Clock(HLC)

分布式算法 #02:Raft 共识算法

发布于 2026-05-29 03:56 👁 27 次阅读
#算法#分布式#consensus#raft

Raft 是 Diego Ongaro 于 2014 年提出的共识算法,以可理解性为首要设计目标,将共识问题分解为 Leader 选举、日志复制、安全性三个相对独立的子问题。本文深入伪代码级别,并重点分析 Leader 宕机、日志冲突、网络分区、脑裂恢复等异常场景。

相关文章Paxos 算法 · 向量时钟与因果一致性 · Quorum 与 NWR 模型


目录

章节 说明
概念速查 阅读本文前先看这里
核心概念 任期、状态机、日志、三种节点状态
Leader 选举 随机超时的关键性、RequestVote RPC
日志复制 AppendEntries、nextIndex/matchIndex、两阶段、客户端响应时机
异常场景分析 Leader 宕机 / 日志冲突 / 网络分区 / 脑裂 / 选举失败
安全性保证 选举限制与提交限制
日志压缩(Snapshot) 防止日志无限增长
成员变更 联合共识(Joint Consensus)
PreVote 扩展 防止分区节点 Term 膨胀扰乱集群
算法威胁模型 Raft 能防什么、不能防什么
节点数量的选择 为什么是 3~5 节点,如何水平扩展
与 Paxos 的对比 关键差异

概念速查

阅读本文前,先了解这些贯穿全文的术语:

节点与角色

术语 含义
Leader 唯一主节点,处理所有写请求;读请求默认也走 Leader(保证线性一致性),Follower 可承接弱一致读(可能返回旧数据)
Follower 被动接收者,响应 Leader 心跳和 Candidate 投票请求
Candidate 竞选中的节点,广播 RequestVote 争取投票
Quorum(多数派) 严格超过半数的节点集合(N 节点需 > N/2),两个 Quorum 必有交集

时间与版本

术语 含义
Term(任期) 单调递增的逻辑时钟,每次选举 +1;每条 RPC 都携带 term,用于识别过期消息
Index(日志索引) 日志条目在日志中的位置编号,从 1 开始单调递增;只由写操作产生,心跳不更新 index

日志相关

术语 含义
Log / LogEntry(日志条目) {index, term, command} 三元组,command 是要应用到状态机的操作
lastLogIndex 节点日志中最后一条条目的 index(已写入,不一定已提交)
lastLogTerm 节点日志中最后一条条目的 term(用于选举时比较日志新旧)
commitIndex 已知已提交的最高 index(多数派已写入),可应用到状态机
lastApplied 已实际应用到状态机的最高 index(lastApplied ≤ commitIndex)
prevLogIndex / prevLogTerm AppendEntries 中的一致性检查字段:新条目前一条的 index 和 term

Leader 专属

术语 含义
nextIndex[i] Leader 记录的"下次要发给 Follower i 的日志 index"(乐观估计,失败则回退)
matchIndex[i] Leader 记录的"已确认复制到 Follower i 的最高 index"(保守确认,用于推进 commitIndex)

RPC

术语 含义
AppendEntries Leader → Follower 的核心 RPC,兼做心跳(entries 为空)和日志复制(entries 非空)
RequestVote Candidate → 所有节点的投票请求 RPC

其他

术语 含义
状态机(State Machine) 实际执行日志命令的业务逻辑(如 KV 存储的 Map),所有节点的状态机应用相同日志序列后状态相同
prevLog 检查 Follower 收到 AppendEntries 时验证 prevLogIndex 处的 term 是否匹配,确保覆盖有安全起点
Split Vote 多个 Candidate 同时竞选,票数分散导致无人获得多数,需随机超时打破对称

核心概念

任期(Term)

Term 是 Raft 的逻辑时钟,单调递增的整数。
每次选举开始时 Term 加 1。
每个 RPC 都携带 Term,用于检测过期信息。

if 收到的 RPC.term > currentTerm:
    currentTerm = RPC.term
    转为 Follower(放弃 Leader/Candidate 身份)

节点状态机

raft state machine

状态 角色 触发条件 核心行为
Follower 被动接收者 初始状态;或收到更高 Term 的 RPC 响应 Leader 心跳/复制请求;响应 Candidate 投票请求;选举超时后转为 Candidate
Candidate 竞选者 Follower 选举超时(150~300ms 随机) 自增 Term,为自己投票,广播 RequestVote;赢得多数票 → Leader;收到更高 Term → Follower;超时再次选举
Leader 主节点 赢得多数派投票 接收客户端写请求;周期性广播心跳(防止 Follower 超时);复制日志给所有 Follower;决定 commitIndex 并应用到状态机

计时器重置规则(来自 Raft 论文 Figure 2):

  • ✅ 收到合法 Leader 的 AppendEntries(心跳)→ 重置计时器
  • 投出一票(grant vote,回复 VoteGranted=true)→ 重置计时器
  • 仅收到 RequestVote 但拒绝投票 → 不重置计时器

原因:若"收到 RequestVote"就重置计时器,任何节点(或攻击者)都可以持续广播 RequestVote 来阻止所有 Follower 超时,集群将永远无法选出 Leader。

状态转换触发条件一览:

转换 触发条件
Follower → Candidate 选举超时内:既未收到合法 Leader 心跳,也未向任何 Candidate 投出一票
Candidate → Leader 收到严格多数票(> N/2)
Candidate → Follower 收到更高 Term 的 RPC;或收到合法 Leader 心跳
Candidate → Candidate 选举超时仍无结果(随机退避后重新选举)
Leader → Follower 收到更高 Term 的 RPC(意味着有更新的 Leader 存在)

状态机(State Machine)

状态机就是你的业务数据本身——Raft 负责把命令排好序,状态机负责真正执行这些命令。

最简单的例子:KV 存储

状态机 = Map<String, String>,初始为 {}

日志(Raft 保证所有节点顺序一致):
  index=1:set x=1
  index=2:set y=2
  index=3:del x
  index=4:set x=5

状态机逐条应用后的当前状态:{y=2, x=5}

Raft 不关心命令内容,只管顺序;状态机不关心顺序怎么来的,只管执行。所有节点日志顺序相同 → 所有状态机执行结果相同,这正是"复制状态机"模型的核心。

系统 日志(Raft 管) 状态机(业务管)
etcd 操作序列 配置数据 Map
TiKV 写入操作 RocksDB 存储引擎
CockroachDB SQL 事务 数据库引擎

日志结构

每条日志条目包含:

LogEntry {
  index:   int     // 在日志中的位置(从 1 开始)
  term:    int     // 该条目创建时的 Leader 任期
  command: bytes   // 状态机命令
}

具体示例(5 节点集群,经历了两次 Leader 切换):

索引:  1        2        3        4        5        6
      ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
term: │  1   │ │  1   │ │  1   │ │  2   │ │  2   │ │  3   │
cmd:  │x=1   │ │y=2   │ │x=3   │ │y=4   │ │z=5   │ │x=6   │
      └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘
                                              ↑
                              commitIndex=5(已提交)

index=6 的条目 term=3,尚未提交(Leader 正在等待多数派确认)

关键字段的作用:

字段 作用
index 全局唯一序号,保证日志有序且无空洞(Raft 强制日志连续)
term 用于安全性判断:新 Leader 不直接提交旧 term 的条目(防止图 8 异常)
command 应用到状态机的操作,如 set x=1、数据库事务等

多节点日志对比(Leader 宕机恢复场景):

          index:  1    2    3    4    5
Leader:          [1,A][1,B][2,C][2,D][3,E]  ← commitIndex=4,index=5 未提交
Follower1:       [1,A][1,B][2,C][2,D]       ← 落后 1 条,通过 AppendEntries 追上
Follower2:       [1,A][1,B][2,C]            ← 落后 2 条
Follower3:       [1,A][1,B][2,C][2,D][3,E] ← 与 Leader 一致

日志格式:[term, command],index 从左到右递增
commitIndex=4 意味着 index 1~4 已提交,状态机已应用

Leader 选举

raft election

触发条件

Follower:
  维护选举超时计时器(randomized,150ms ~ 300ms)
  每次收到 Leader 心跳(AppendEntries)时重置计时器

  on 计时器超时:
    currentTerm += 1
    转为 Candidate
    开始选举

随机超时:算法能实际运行的关键

没有随机超时,Raft 无法收敛

所有节点超时固定为 150ms 时:
  T=150:S1、S2、S3、S4、S5 同时成为 Candidate
         各自投自己 1 票 → 无人达到多数(3票)
  T=300:所有节点再次同时超时,Term+1,重复
  → 无限 Split Vote 循环,永远选不出 Leader

加入随机超时(150~300ms)后:
  S1 在 152ms 率先超时 → 广播 RequestVote
  S2~S5 尚未超时,尚未投票 → 全部投给 S1
  S1 赢得 4 票,2ms 内完成选举 ✅

这是 FLP 不可能定理在工程上的解法:纯确定性算法在异步网络中无法保证活性,随机性是打破对称的唯一手段。

超时范围的设置原则:broadcastTime ≪ electionTimeout ≪ MTBF

broadcastTime ≈ 1ms(局域网 RTT)
electionTimeout = 150~300ms(随机区间宽度 150ms ≫ 1ms)
→ 最先超时的节点能在其他节点超时前完成广播并收票
→ Split Vote 概率极低

RequestVote RPC

// Candidate 发送
RequestVote(args):
  args.term         = currentTerm   // 候选人任期
  args.candidateId  = self.id
  args.lastLogIndex = log.lastIndex()
  args.lastLogTerm  = log.lastTerm()

// Follower / 其他 Candidate 处理
on receive RequestVote(args):
  if args.term < currentTerm:
    reply(false, currentTerm)  // 拒绝过期候选人
    return

  if args.term > currentTerm:
    currentTerm = args.term
    转为 Follower
    votedFor = null

  // 投票条件:本任期内未投票 AND 候选人日志至少和自己一样新
  if (votedFor == null or votedFor == args.candidateId)
     AND isAtLeastAsUpToDate(args):
    votedFor = args.candidateId
    重置选举计时器
    reply(true, currentTerm)
  else:
    reply(false, currentTerm)

// 日志新旧比较:先比 term,再比 index(更大 = 更新)
isAtLeastAsUpToDate(args):
  if args.lastLogTerm != log.lastTerm():
    return args.lastLogTerm > log.lastTerm()
  return args.lastLogIndex >= log.lastIndex()

Candidate 处理投票结果

Candidate on receive RequestVote reply:
  if reply.term > currentTerm:
    currentTerm = reply.term
    转为 Follower; return

  if reply.voteGranted:
    votesReceived += 1
    if votesReceived > N/2:   // 赢得多数票
      转为 Leader
      初始化 nextIndex[i] = log.lastIndex() + 1  // 对每个 Follower
      初始化 matchIndex[i] = 0
      立即发送心跳(空 AppendEntries),宣示 Leader 身份

  // 超时未赢得选举:
  on 选举超时:
    currentTerm += 1
    重新开始选举(随机退避防止活锁)

日志复制

raft log replication

AppendEntries RPC

// Leader 发送(心跳 = entries 为空的 AppendEntries)
AppendEntries(args):
  args.term         = currentTerm
  args.leaderId     = self.id
  args.prevLogIndex = nextIndex[followerId] - 1
  args.prevLogTerm  = log[prevLogIndex].term
  args.entries      = log[nextIndex[followerId]:]  // 待复制的条目
  args.leaderCommit = commitIndex

// Follower 处理
on receive AppendEntries(args):
  // 1. 任期检查
  if args.term < currentTerm:
    reply(false, currentTerm); return

  重置选举计时器  // 承认 Leader 合法

  // 2. 日志一致性检查(prevLog 匹配)
  if log[args.prevLogIndex].term != args.prevLogTerm:
    reply(false, currentTerm)  // 告知冲突,Leader 需回退 nextIndex
    return

  // 3. 追加日志(删除冲突条目,追加新条目)
  for i, entry in enumerate(args.entries):
    idx = args.prevLogIndex + 1 + i
    if idx <= log.lastIndex() and log[idx].term != entry.term:
      log.truncateFrom(idx)  // 删除冲突及之后所有条目
      break
  log.append(args.entries)

  // 4. 更新 commitIndex
  if args.leaderCommit > commitIndex:
    commitIndex = min(args.leaderCommit, log.lastIndex())
    应用 [lastApplied+1, commitIndex] 的条目到状态机

  reply(true, currentTerm)

Leader 处理 AppendEntries 回复

Leader on receive AppendEntries reply from Follower i:
  if reply.term > currentTerm:
    currentTerm = reply.term
    转为 Follower; return

  if reply.success:
    matchIndex[i] = args.prevLogIndex + len(args.entries)
    nextIndex[i]  = matchIndex[i] + 1

    // 尝试推进 commitIndex
    // 找到满足条件的最大 N:
    //   log[N].term == currentTerm  ← 只能提交当前任期的日志!
    //   matchIndex 中超过半数 >= N
    for N = log.lastIndex() downto commitIndex+1:
      if log[N].term == currentTerm
         and count(matchIndex[i] >= N) > N_nodes/2:
        commitIndex = N
        break
  else:
    // 日志冲突,回退 nextIndex 重试
    nextIndex[i] -= 1  // 简单回退,可优化为二分查找
    立即重发 AppendEntries

nextIndex 与 matchIndex

只有 Leader 维护这两个数组,Follower 互相不感知彼此状态:

nextIndex[i]   // 下次要发给 Follower i 的日志索引(乐观估计)
matchIndex[i]  // 已确认复制到 Follower i 的最高索引(保守确认)
变量 初始值 更新时机 用途
nextIndex[i] lastLogIndex + 1 成功 +1,失败 -1 回退 决定下次发哪些日志
matchIndex[i] 0 仅收到 success 后更新 决定能否推进 commitIndex

两个变量分离的原因:nextIndex 是乐观的发送指针,可能被拒绝回退;matchIndex 是保守的确认指针,只有明确收到 success 才更新。合并成一个变量会导致网络延迟期间状态混乱。

新 Leader 当选后不继承这些数据,直接重置并重新探测:

新 Leader 当选:
  nextIndex[i]  = lastLogIndex + 1  // 乐观假设所有 Follower 都和自己一样新
  matchIndex[i] = 0

立即发心跳,prevLog 不匹配的 Follower 回复失败:
  nextIndex[i] -= 1 → 重试,直到找到匹配点

几轮心跳内即可重建所有 Follower 的位置信息

nextIndex / matchIndex 是可重新推导的协调信息,不是安全性所必需的持久化数据,丢了只需重新探测,不影响正确性。

Index 的来源

index 只来自客户端写请求,心跳不产生新 index。

推进 lastLogIndex 的唯一路径:
  Client → Leader(写请求)
  → Leader 创建 LogEntry,index = lastLogIndex + 1
  → AppendEntries(entries=[新条目]) 发给 Follower
  → Follower 追加到本地日志,lastLogIndex + 1

不推进 lastLogIndex 的操作:
  心跳:AppendEntries(entries=[]),entries 为空,index 不变
  读请求:不写日志,不产生新 index
  投票:RequestVote,不涉及日志写入

心跳只推进 commitIndex,不推进 lastLogIndex:

Leader: lastLogIndex=10, commitIndex=8
发心跳给 Follower(leaderCommit=8):
  Follower 的 commitIndex:6 → 8  ✅(应用了 index 7、8 到状态机)
  Follower 的 lastLogIndex:10 不变  ❌(没有新日志写入)

日志复制的本质

AppendEntries 的最终效果是:Follower 的日志完全等于 Leader 日志的前缀

不管 Follower 本地有什么(落后 / 冲突 / 超前):
  经过若干轮 AppendEntries 之后,全部向 Leader 对齐

Follower 落后:  [1][2][3]           → [1][2][3][4][5]
Follower 冲突:  [1][2][3][4x][5x]   → [1][2][3][4][5]
Follower 超前:  [1][2][3][4x][5x][6x]→ [1][2][3][4][5]

安全起点由 prevLog 检查保证,不是覆盖策略本身:

真正的保护机制:
  if log[prevLogIndex].term != prevLogTerm:
    return false  // 拒绝,让 Leader 往前退一步

这确保了覆盖起点之前的日志已与 Leader 完全一致
覆盖后不会产生中间空洞

读请求的处理方式

读请求不写日志,但一致性级别决定走哪里读:

方式 一致性 原理 适用场景
ReadIndex 线性一致 Leader 先广播心跳确认自己仍是合法 Leader,再读本地状态机 默认推荐
LeaseRead 线性一致 Leader 在租约期内(通常 = 选举超时/2)直接读,无需心跳 高性能场景(TiKV、etcd)
Follower 读 弱一致 直接读 Follower 本地状态机,可能返回旧数据 允许读到几毫秒前数据的场景

为什么 Leader 直接读本地也可能返回旧数据?

场景:Leader 被网络分区隔离,新 Leader 已在另一侧选出并写入新数据
      旧 Leader 不知道自己已失效,直接读本地 → 旧数据

ReadIndex 的解法:
  Leader 收到读请求
  → 记录当前 commitIndex = N
  → 广播心跳,等多数派回复(确认自己仍是合法 Leader)
  → 等 lastApplied >= N
  → 读本地状态机返回
  → 此时一定是最新已提交的数据 ✅

LeaseRead 的解法:
  选举超时 = T,心跳间隔 = T/2
  Leader 只要在 T/2 内收到过多数派心跳回复
  就持有一个"租约":这段时间内不可能有新 Leader 产生
  → 可以直接读本地,无需额外心跳
  → 前提:依赖各节点时钟不能漂移超过选举超时

两阶段提交与客户端响应

Leader 和 Follower 都参与两阶段,但角色不同:

阶段一(写入日志):
  Leader:写本地日志 → 发 AppendEntries → 等多数派回复 success
  Follower:收到 AppendEntries → 写本地日志 → 回复 success
  此时两者都处于"已写入未提交"状态

阶段二(提交):
  Leader:收到多数派 success → 提交 → 应用状态机 → ✅ 返回客户端
  Follower:收到下次心跳中的 leaderCommit → 推进 commitIndex → 应用状态机

客户端在 Follower 提交之前就收到响应

Client 收到 success 时:
  Leader:已提交 ✓
  多数派 Follower:日志已写入,尚未提交 ✗
  少数 Follower:可能还未收到

→ 此时立刻读 Follower 可能读到旧数据
→ 这也是为什么读请求默认也走 Leader

等 Follower 提交再返回 = 多一次网络往返,但安全性并不需要:多数派已写入日志,这条数据不会丢(新 Leader 一定包含它),提前返回是安全的。


异常场景分析

异常 1:Leader 宕机(最常见)

场景:Leader 在复制日志后、提交前宕机

状态:
  Leader:   log=[1,2,3,4],commitIndex=3,entry4 已发出但未确认
  Follower1: log=[1,2,3,4]  (已收到 entry4)
  Follower2: log=[1,2,3]    (未收到 entry4)
  Follower3: log=[1,2,3]    (未收到 entry4)

新选举:
  Follower1 或 Follower2/3 都可能成为新 Leader

情况A:Follower1 当选新 Leader
  Follower1 日志最新(含 entry4)
  新 Leader 继续复制 entry4 给 Follower2/3
  entry4 最终被提交 → 数据不丢失 ✅

情况B:Follower2 当选新 Leader(日志不含 entry4)
  Follower1 的 entry4 term 是旧 Leader 的 term
  新 Leader 不会主动提交旧 term 的日志(安全性限制)
  新 Leader 写入新的 entry,提交时顺带将 entry4 也提交
  → entry4 仍会提交 ✅(如果 Follower1 的日志能赢得选举)

  但如果 Follower2 日志更新(有更高 term 的条目):
  Follower1 无法赢得选举(日志新旧比较失败)
  entry4 被 Follower2 的日志截断覆盖
  → entry4 丢失,但这是正确行为!
  (entry4 未被多数派持久化,Client 应重试)

异常 2:日志冲突(分区后恢复)

场景(5节点集群):
  旧 Leader L1 在任期 3 时写入 entry[5]=X,复制给 F1、F2
  L1 与 F3、F4 网络分区
  F3 发起选举,任期 4,写入 entry[5]=Y,复制给 F4

  此时:
  L1: log=[..., (3,X)]  commitIndex=4(只有 F1、F2 确认,未达多数)
  F1: log=[..., (3,X)]
  F2: log=[..., (3,X)]
  F3: log=[..., (4,Y)]  新 Leader
  F4: log=[..., (4,Y)]

分区恢复后:
  L1 收到 F3 的心跳,发现 term=4 > term=3,退为 Follower
  F3 向 L1、F1、F2 发送 AppendEntries
  prevLogIndex 不匹配 → L1/F1/F2 回复冲突
  F3 递减 nextIndex,回退到 entry[4]
  F3 发送 entry[4] 开始的日志,L1/F1/F2 截断 entry[5]=X
  追加 entry[5]=Y → 日志统一

结论:
  entry[5]=X 被丢弃(未提交的日志在冲突时可以被覆盖,这是安全的)
  已提交的日志(commitIndex 以内)永远不会被覆盖

异常 3:网络分区导致双 Leader(脑裂)

场景(5节点,分区 {L,F1} | {F2,F3,F4}):

分区 1:L(旧 Leader,term=3)
  L 仍然认为自己是 Leader
  向 F1 发心跳,F1 回复
  L 接受 Client 写入,但只有 F1 确认,无法达到 Quorum
  → 旧 Leader 无法提交新写入 ✅(不影响安全性)

分区 2:{F2,F3,F4}(多数派)
  F2 超时,发起选举,term=4
  F2 赢得 F3、F4 的投票,成为新 Leader
  F2 正常处理写入,可以达到 Quorum

脑裂期间:
  有两个 Leader,但只有新 Leader(term=4)能提交
  旧 Leader 的写入卡住(无法提交),Client 超时重试

分区恢复后:
  旧 Leader L 收到 term=4 的 RPC,退为 Follower
  L 的未提交写入被覆盖
  → Safety 完全保证

异常 4:选举超时设置不当

问题:如果选举超时 << 心跳间隔,Follower 会频繁误认为 Leader 宕机而发起选举

正确设置原则:
  broadcastTime << electionTimeout << MTBF(平均无故障时间)

  broadcastTime:单次 RPC 往返延迟(通常 0.5ms ~ 20ms)
  electionTimeout:150ms ~ 300ms(随机化,防止同时超时)
  MTBF:服务器平均稳定运行时间(几个月到几年)

随机化的意义:
  固定超时 → 多个 Follower 同时超时 → 票数分散 → 无人胜出 → 反复选举
  随机超时 → 最先超时的 Follower 优先广播 RequestVote
            → 其他 Follower 收到后重置超时,投票给第一个候选人
            → 通常一轮就能选出 Leader

异常 5:Follower 落后太多(日志差距巨大)

场景:某 Follower 宕机 1 小时后恢复,日志远落后于 Leader

简单回退方案:nextIndex 每次递减 1,需要大量 RPC

优化方案(冲突信息加速回退):
  Follower 在拒绝 AppendEntries 时附带额外信息:
  {
    conflictTerm:  log[prevLogIndex].term,  // 冲突位置的 term
    conflictIndex: 该 term 的第一条日志下标  // 跳过整个 term
  }

  Leader 收到后:
  if 自己日志中存在 conflictTerm:
    nextIndex[i] = 自己日志中 conflictTerm 的最后一条 + 1
  else:
    nextIndex[i] = conflictIndex  // 直接跳到冲突 term 的起始位置

极端情况:Follower 落后太多,Leader 已做 Snapshot:
  Leader 发送 InstallSnapshot RPC(见日志压缩章节)

安全性保证

Raft 的 Safety 由三层机制共同保证,缺一不可:

层次 机制 防止的问题
第一层 同 Term 只投一票 同一任期出现两个 Leader
第二层 投票时检查日志新旧 新 Leader 丢失已提交的旧日志
第三层 只提交当前 Term 的日志 旧 Term 日志被错误提交后又遭覆盖

第一层:选举安全(Election Safety)

保证:任意时刻最多只有一个 Leader。

机制

每个节点每个 term 只投一票,持久化 votedFor

假设 A、B 同时竞选 term=5:
  A 赢得 quorum Q_A(> N/2)
  B 赢得 quorum Q_B(> N/2)
  Q_A ∩ Q_B ≠ ∅(两个多数派必有交集)
  交集节点已投给 A → 无法再投 B
  → B 不可能同时获得多数票 ✅

第二层:Leader 完整性(Leader Completeness)

保证:一旦某条日志被提交,所有未来的 Leader 都包含该日志,即使所有 Follower 都还没有 commit,这条日志也不会丢失

原因链条

第一步:commit 的前提 = 多数派已写入日志

  Leader 决定 commit 的条件:
    收到多数派(> N/2)AppendEntries success
    → 这条日志已物理写入多数派节点的本地日志文件
    (注意:是 write to log,不是 commit)

第二步:多数派写入 → 任何新 Leader 必然包含这条日志

  5 节点集群,S1 commit 了 index=5,S1 随即宕机:
    S1、S2、S3 的日志里有 index=5(多数派)
    S4、S5 没有

  新的选举:
    S4 想当 Leader → 日志比 S2/S3 旧 → S2/S3 拒绝投票
    S5 想当 Leader → 同上
    S4+S5 只能拿到 2 票 < 3(多数) → 无法当选

    只有 S2 或 S3 能当选(日志足够新)
    S2、S3 的日志里都有 index=5 → 新 Leader 必然包含它 ✅

第三步:新 Leader 将这条日志复制给所有人并最终 commit

  S2 当选新 Leader:将 index=5 复制给 S4、S5,最终所有节点 commit ✅

本质:两个多数派必有交集(鸽巢原理)

旧多数派(写入了 index=5):{S1, S2, S3}
新 Leader 需要的多数派:    {S2, S3, S4} 或 {S2, S3, S5} 等

两个多数派必有交集
交集节点有 index=5,且会拒绝给日志更旧的候选人投票
→ 新 Leader 一定包含所有已 commit 的日志

一句话:Leader commit = 多数派已持久化 = 任何未来 Leader 必然包含它 = 数据永不丢失。Follower 有没有 commit 只是"何时应用到状态机"的问题,不是"数据是否存在"的问题。

第三层:只提交当前 Term 的日志

保证:避免"旧 term 日志被多数派复制后直接提交,随后又被新 Leader 覆盖"的窗口。

raft figure8

时序表(5 节点:S1~S5)

时序 Leader 事件 S1 S2 S3 S4 S5
T1 S1(t=2) S1 向 S2 复制 idx=2,随即宕机 [1,2t2] [1,2t2] [1] [1] [1]
T2 S5(t=3) S5 当选(日志最旧但 S3/S4 投票),写入自己的 idx=2 [1,2t2] [1] [1] [1,2t3]
T3 S1(t=4) S1 复活当选,将 idx=2(t2) 复制给 S3,达到多数派 [1,2t2] [1,2t2] [1,2t2] [1] [1,2t3]
T4❌ S1(t=4) ❌ 直接提交 idx=2(t2),随即宕机 [1,2t2✓] [1,2t2] [1,2t2] [1] [1,2t3]
T5❌ S5(t=5) S5 当选(S5.lastTerm=3 > S3.lastTerm=2),覆盖全部 idx=2 [1,2t3] [1,2t3] [1] [1,2t3]
T4✅ S1(t=4) ✅ 追加 idx=3(t4) 并提交,idx=2 随之间接提交 [1,2t2✓,3t4✓] [1,2t2✓,3t4✓] [1,2t2✓,3t4✓] [1] [1,2t3]
T5✅ S5 落选(S5.lastTerm=3 < S3.lastTerm=4),数据安全 [1,2✓,3✓] [1,2✓,3✓] [1] 落选

核心差异

危险路径(T4❌ → T5❌):
  S1 提交 idx=2(t2) 后宕机
  S5 竞选:S5.lastLogTerm=3 > S3.lastLogTerm=2
  → S3 投票给 S5,S5 胜选,覆盖 idx=2 → 已提交数据丢失 💥

安全路径(T4✅ → T5✅):
  S1 先追加 idx=3(t4),提交 idx=3(idx=2 随之间接提交)
  S5 竞选:S5.lastLogTerm=3 < S3.lastLogTerm=4
  → S3 拒绝投票,S5 仅得 2 票,落选 ✅

关键:idx=3(t4) 将 S3 的 lastLogTerm 从 2 提升到 4
     使 S5(lastLogTerm=3)永远无法再赢得选举

日志压缩(Snapshot)

问题:日志无限增长,重放时间过长,磁盘空间耗尽

解决方案:快照(Snapshot)
  状态机周期性将当前状态序列化为 Snapshot
  Snapshot 包含:
    lastIncludedIndex: 快照覆盖的最后一条日志 index
    lastIncludedTerm:  对应的 term
    state:             状态机完整状态

  快照完成后,删除 lastIncludedIndex 之前的所有日志

InstallSnapshot RPC(Leader 向严重落后的 Follower 发送):
  args.term              = currentTerm
  args.leaderId          = self.id
  args.lastIncludedIndex = snapshot.lastIncludedIndex
  args.lastIncludedTerm  = snapshot.lastIncludedTerm
  args.data              = snapshot.state  // 分块传输

Follower on receive InstallSnapshot:
  if args.term < currentTerm: return
  if log contains args.lastIncludedIndex with matching term:
    保留该 index 之后的日志,丢弃之前的
  else:
    丢弃所有日志
  用 args.data 重置状态机
  更新 lastIncludedIndex, lastIncludedTerm

成员变更

直接切换的危险

旧配置 {S1,S2,S3},新配置 {S1,S2,S3,S4,S5}
如果各节点在不同时刻切换配置:
  S1 用新配置,多数 = 3:S1+S4+S5 选出 Leader1
  S2 用旧配置,多数 = 2:S2+S3 选出 Leader2
  → 脑裂!

联合共识(Joint Consensus)

变更过程分两阶段:
  阶段1:进入联合配置 Cold+new
    日志需要同时得到旧多数派 AND 新多数派的确认才能提交
    Leader 选举也需要两个多数派都同意

  阶段2:切换到 Cnew
    仅需新配置的多数派

这样任意时刻最多只有一个 Leader,不存在脑裂窗口

PreVote 扩展

PreVote 是 Raft 论文 §9.6 提出的优化,etcd、TiKV 均已实现。

问题根源:分区节点的 Term 膨胀

场景:5 节点集群,S5 被网络分区隔离

S5 在分区期间:
  反复选举超时 → currentTerm 不断自增
  分区 10 分钟,每 300ms 一次 → currentTerm ≈ +2000

分区恢复后:
  S5 重新连接,发送任意 RPC(携带 term=2000+)
  所有节点收到更高 Term → 立即退为 Follower
  现任 Leader(term=5)被迫退位
  集群触发新一轮选举,服务中断数百毫秒

S5 本身日志落后,不可能赢得选举
→ 白白中断了一次服务

PreVote 解法:选举前先"试探"

标准 Raft 选举(两步):
  Step 1: currentTerm += 1
  Step 2: 广播 RequestVote(term)

PreVote 在 Step 1 之前增加一轮"预询问":

  Step 0(PreVote):
    不递增 currentTerm
    广播 PreVote(term+1),询问"如果我现在发起选举,你会投我吗?"

  其他节点回复 PreVoteGranted 的条件:
    ① 当前没有收到合法 Leader 心跳(Leader 可能真的宕机了)
    ② 候选人的日志至少和自己一样新

  如果收到多数 PreVoteGranted:
    → 真正递增 currentTerm,发起正式 RequestVote

  如果 PreVote 被拒绝(Leader 心跳正常):
    → currentTerm 保持不变,不扰乱集群 ✅

效果对比:

不加 PreVote:
  S5 分区恢复 → RPC(term=2005) → 所有节点退为 Follower → 选举中断

加 PreVote 后:
  S5 发送 PreVote(term=6)
  S1~S4 收到后检查:Leader 心跳正常 → 拒绝 PreVote
  S5 收不到多数 PreVote → currentTerm 不变,保持 term=5
  现任 Leader 不受影响 ✅

PreVote 解决不了的问题

PreVote 只防"诚实但分区"的节点(Term 膨胀是无意的)

如果节点是恶意的:
  直接跳过 PreVote,发送 RequestVote(term=9999)
  → 仍然会导致所有节点退为 Follower

PreVote ≠ 安全防御,只是工程优化

算法威胁模型

Raft 的设计假设:崩溃容错(CFT),节点只会崩溃,不会撒谎。

Raft 能容忍:
  ✅ 节点崩溃(宕机、断电)
  ✅ 网络分区(节点间通信中断)
  ✅ 消息延迟、乱序、重复
  ✅ 分区节点的 Term 膨胀(配合 PreVote)

Raft 不能容忍:
  ❌ 恶意节点发送伪造/矛盾消息(拜占庭故障)
  ❌ 攻击者持续发送高 Term RPC(DoS)
  ❌ 节点伪装成其他节点

实际部署的防御层次:

层次 手段 防御目标
网络层 mTLS 双向认证(etcd 默认开启) 外部攻击者无法发送任何 RPC
网络层 VPC 私网隔离 / 防火墙 集群节点只在内网可达
算法层 PreVote 防止内部分区节点的 Term 膨胀
算法层 CheckQuorum Leader 主动检测是否仍有多数派,防脑裂
算法层 BFT(PBFT/HotStuff) 节点本身不可信时(区块链、联盟链)

结论:Raft 本身不防恶意攻击,这是算法设计范围内的已知局限。工程上通过 mTLS + 网络隔离将威胁模型降级为"节点只会崩溃",使 Raft 的假设成立。需要容忍恶意节点的场景,参见 Byzantine 容错(BFT与PBFT)


节点数量的选择

容错与延迟的权衡

节点数 N 可容忍故障数 f 写入需要确认数 延迟代价
3 1 2 低 ✅
5 2 3 中 ✅
7 3 4 较高
9 4 5 高 ❌

容错收益递减

3 → 5:多容忍 1 台故障,收益显著 ✅
5 → 7:多容忍 1 台故障,但写延迟增加 30~50%
7 → 9:再多容忍 1 台,实际意义越来越小

同时丢失 3 台(7 节点才能容忍)的概率
远低于同时丢失 2 台(5 节点),
边际收益不值得付出延迟代价
→ 生产环境 5 节点是行业默认

为什么必须用奇数节点

4 节点集群,quorum = 3:
  发生 2-2 网络分区
  两侧各 2 台,均无法达到 quorum=3
  → 整个集群不可写,完全阻塞

4 节点容错能力 = 只能丢 1 台(和 3 节点相同)
4 节点写延迟   = 需要 3 台确认(比 3 节点更高)
→ 偶数节点无任何收益,实际等于退化版的奇数节点

结论:永远用奇数(3、5、7…)

跨机房部署建议

机房数 推荐配置 说明
3 机房 3 节点,每机房 1 台 任一机房故障,剩余 2 台仍构成多数派
2 机房 5 节点,3+2 分布 主机房 3 台可独立达到 quorum,从机房 2 台故障不影响写入
2 机房 ❌ 2+2(偶数)或 1+1 主机房故障时从机房无法形成多数派,不可用

Raft 的实际性能

"Raft 性能差"是个常见误解,需要分清对比基准:

对比单机内存写入:确实差(多了 1~2ms 网络往返)
对比其他强一致分布式方案:相当甚至更好
对比 2PC:没有协调者单点,故障处理更简单

Raft 单次写入实际延迟:
  本地写 WAL + 一次 AppendEntries 往返 ≈ 1~5ms(局域网)
  对数据库场景完全可接受

真正的吞吐瓶颈在于单 Raft 组所有写入都走同一个 Leader,解法是多组分片(见下节)。

数据规模大时:多 Raft 组 + 分片

共识组本身保持小(3~5 节点),通过横向分片实现扩展:

单 Raft 组的瓶颈:
  所有写入 → 同一个 Leader → 单点写入上限(通常数万 QPS)

TiKV / CockroachDB 的分片方案:
  数据按 key range 切分为多个 Region
  每个 Region = 独立的 3~5 节点 Raft 组
  不同 Region 的写入完全并行

  Region1(key a~m)→ Raft 组 A,Leader 在节点 S1
  Region2(key n~z)→ Raft 组 B,Leader 在节点 S3
  Region3(...)    → Raft 组 C,Leader 在节点 S2

结果:
  每个共识组保持 3~5 节点 → 延迟低
  集群可横向扩展至数百节点
  写入吞吐量随分片数线性增长

一句话原则:共识组尽量小(3~5 节点),要扩规模就横向分片成多个独立小组,而不是把一个组做大。


与 Paxos 的对比

维度 Raft Multi-Paxos
可理解性 ✅ 高,子问题分解清晰 ❌ 低,实现细节分散
日志连续性 ✅ 强制连续,按序提交 Slot 可以乱序提交
Leader 选举 内置,基于日志新旧 需要额外机制
成员变更 Joint Consensus 有明确规范 各实现不统一
性能上限 略低(日志必须连续) 略高(可并行填充 slot)
工程参考 etcd、TiKV、CockroachDB Chubby、Spanner

参考资料

  • Ongaro, D. & Ousterhout, J. (2014). In Search of an Understandable Consensus Algorithm (Extended Version)
  • 《Designing Data-Intensive Applications》Ch.9 — Linearizability and Consensus
  • etcd Raft 实现:https://github.com/etcd-io/raft
← 返回列表

评论 (0)

暂无评论,来留下第一条吧。

发表评论