Skip to content

Raft 共识:选举、日志复制与一致性

基于 etcd 3.5 / Raft 论文 · 核于 2026-08

速查

  • Raft 三角色Leader(唯一,处理所有写,发心跳维持地位)、Follower(被动接收 Leader 日志/心跳)、Candidate(Follower 超时未收心跳,转为 Candidate 发起选举)。
  • Leader 选举:Follower 在 election timeout(随机 150-300ms)内没收到 Leader 心跳 → 转 Candidate → 自增 term → 投自己一票 → 向其他节点发 RequestVote → 拿过半票成为新 Leader → 发心跳确立地位。多个 Candidate 分票则重新选(随机超时让一人先胜出)。
  • term 任期号:单调递增的逻辑时钟,每次选举自增。过期的 term 视为「旧时代」请求被拒。这是 Raft 防「脑裂后旧 Leader 复活」的关键。
  • 日志复制:客户端写 Leader → Leader 追加日志(term+index+cmd)→ 并行发 AppendEntries 给 Follower → 过半 ack → Leader 标记 committed → 应用到状态机 → 返回客户端。
  • 过半提交 = 一致性保证:日志要被过半节点复制才算 committed,已 committed 的日志不可丢(因为新选举的 Leader 必含过半节点的日志,保证它有所有 committed 日志)。
  • 脑裂防护:网络分区把集群切成两半——多数派分区能选 Leader 正常写;少数派分区凑不齐过半,无法写(客户端写少数派 Leader 会被拒绝,因 term 过期)。分区恢复后,少数派旧 Leader 的未提交日志被新 Leader 覆盖。
  • 线性一致读(Linearizable Read):etcd 默认读也走 Leader——Leader 先确认自己仍是 Leader(ReadIndex,向过半节点发心跳确认),再读本地。保证读到最新已提交值。代价是读延迟略高。
  • Serializable 读(本地读):可选 --consistency=s,直接读本地副本,快但可能读到旧值(stale read)。适合对实时性要求不高的场景(如读配置)。
  • leader election(业务层):用 Lease + compare-and-swap 实现业务领导选举。每个节点尝试 etcdctl txn「if key 不存在 then put key=me with lease」。成功的是 leader,leader 周期性 keep-alive 续约;宕机不续约 → 租约过期 key 删除 → 其他节点抢到。

一、Raft 三角色与状态机

Raft 把节点分为三种角色,状态在它们之间转换:

                  超时未收心跳,转 Candidate
   ┌──────────────────────────────────────┐
   │                                      ▼
  Follower ◄───────────────────────── Candidate
   ▲  收到 Leader 心跳/日志                │
   │                                      │ 拿过半票 → Leader
   │                                      │ 没拿过半/收到更高 term → Follower
   │                                      ▼
   └─────────────────────────────────── Leader
                  发心跳维持 Leader 地位
  • Follower:启动时默认 Follower,被动接收 Leader 的 AppendEntries(日志复制)和 heartbeat(心跳)。只要持续收到心跳,就保持 Follower。
  • Candidate:Follower 在 election timeout(随机化,避免多个节点同时超时)内没收到心跳,认为 Leader 挂了,转为 Candidate:自增 term,投自己一票,并发起 RequestVote RPC。
  • Leader:Candidate 收到过半节点的票(含自己),成为 Leader,立即发心跳(空的 AppendEntries)确立地位,之后所有写都经过 Leader。

二、Leader 选举全过程

  场景:3 节点(node-1/2/3),node-1 是 Leader,突然宕机。

  node-2 (Follower): 收不到 node-1 心跳 → election timeout 触发

       ▼ 转 Candidate,term: 5 → 6
       │ 投自己 (1 票)
       │ 发 RequestVote(term=6, lastLogIndex=10) 给 node-1(无响应)、node-3

  node-3 (Follower): 看到 term=6 > 自己的 term=5
       │ 投 node-2 一票(node-2 票数=2,过半 3/2+1=2)

  node-2 成为 Leader (term=6),立即发心跳给 node-3 确立地位
  • election timeout 随机化:每个节点的 election timeout 随机(如 150-300ms),避免所有 Follower 同时超时同时转 Candidate(会分票谁也选不上)。先超时的那个大概率先成 Candidate 拿到票。
  • term 单调递增:每次选举 term 自增。任何节点收到 term 比自己高的请求,立即更新自己的 term 并转 Follower。这保证「旧 Leader 复活」时(它的 term 低),会被新 Leader 的更高 term 压制。
  • 过半票原则:要 N/2+1 票才能成 Leader。3 节点要 2 票,5 节点要 3 票。这保证任一任期内只有一个 Leader(不可能两个 Candidate 同时拿过半票)。

三、日志复制:写请求的完整流程

  客户端:PUT /config/db/host = 10.0.0.100


  Leader (node-1):
       1. 追加日志:term=6, index=11, cmd="PUT /config/db/host 10.0.0.100"
       2. 并行发 AppendEntries 给 node-2、node-3


  Follower (node-2, node-3):
       - 收到 AppendEntries,追加日志,ack Leader

       ▼ (node-1 收到 node-2 的 ack → 过半含自己 = 2/3)
  Leader: 标记 index=11 为 committed
       - 应用到状态机(KV store 真正写入 /config/db/host)
       - 返回客户端「成功」
       - 下一次心跳告知 Follower "committed 到 index=11"
  • 顺序一致:所有节点按相同顺序(index 递增)追加日志,保证状态机最终一致。
  • committed 不可撤销:日志一旦标记 committed,永远不丢。因为新选举的 Leader 必须包含过半节点的日志——已 committed 的日志在过半节点上,必然被新 Leader 持有。
  • 写延迟 = 多数派 RTT:一次写要等过半节点 ack,所以写延迟 ≈ Leader 到多数派网络往返。跨 DC 部署会显著拖慢写(这也是 etcd 不适合跨 DC 大集群的原因)。

四、脑裂(Split-Brain)防护

网络分区把 etcd 集群切成两半,Raft 保证不会两个分区都写(脑裂):

  5 节点集群:node-1(Leader) 2 3 | 4 5     (| 是分区边界)

  多数派分区 {1,2,3}:
    node-1 仍是 Leader(能凑齐 3/5 过半),正常接受写,复制到 2、3。

  少数派分区 {4,5}:
    node-4、node-5 凑不齐过半(2/5),无法提交任何写。
    若 node-4 超时选 Leader,拿到 2 票也不够(要 3 票),选不上。
    客户端写 node-4 → node-4 转发找不到 Leader 或 term 过期 → 失败。

  分区恢复:
    node-4、node-5 回归,发现 node-1 term 更高,转 Follower,
    少数派分区的未提交日志被多数派的日志覆盖。
  • 过半原则是防线:少数派永远凑不齐过半,无法提交写,所以不会产生脑裂(两个分区都写不一致数据)。
  • CP 代价:少数派分区完全不可写——这是 etcd 选 CP 的代价(牺牲可用性保一致性)。

五、线性一致读 vs Serializable 读

etcd 提供两种读一致性级别:

级别原理一致性延迟适用
Linearizable(默认)读走 Leader,Leader 先用 ReadIndex 确认自己仍是 Leader(发心跳给过半节点),再读本地强一致(最新已提交)较高(多一次 RTT)不能容忍读到旧值
Serializable(--consistency=s直接读本地副本(Follower 也能读)可能读到旧值(stale read)低(本地)容忍旧值(读配置、监控)
  • 为什么默认读要 ReadIndex:如果不确认就读,可能读到「旧 Leader」(它还不知道自己被选下台)的数据——那是不一致的。ReadIndex 让 Leader 先证明自己仍是 Leader(过半心跳),再读。
  • Serializable 的取舍:本地读快、可扩展(读压力分散到 Follower),但可能读到几个修订前的旧值。适合读配置、聚合监控这种「旧一秒无所谓」的场景。K8s 的部分只读查询会用 Serializable 减轻 apiserver 压力。

六、业务层 leader election:Lease + CAS

etcd 的 Lease(租约)+ 事务(TXN compare-and-swap)是业务层做领导选举的标准套路:

bash
# 1. 每个节点创建租约(TTL 30s)
LEASE_ID=$(etcdctl lease grant 30 | awk '{print $2}')

# 2. 抢 leader key(原子:if key 不存在 then put,绑定 lease)
etcdctl txn <<EOF
mod_revision("/services/leader") = 0
put /services/leader node-1 --lease=$LEASE_ID
EOF
# 成功 = 我是 leader,失败 = 别人已是 leader(我做 follower)

# 3. leader 周期性续约(保持 leader 地位)
etcdctl lease keep-alive $LEASE_ID

# 4. leader 宕机 → 不续约 → 30s 后租约过期 → /services/leader 自动删除
#    → 其他节点的 txn 条件(mod_revision=0)变成立 → 抢到新 leader
  • 租约过期 = 自动释放:leader 宕机不续约,30s 后租约过期,绑定的 key 自动删除,其他节点能重新抢。这是「故障自动转移」的核心。
  • etcdctl elect:etcd 提供封装命令 etcdctl elect /services/leader node-1,内部就是上面的 Lease+txn+keep-alive 循环,简化使用。

交互演示

本叶无专门可视化。Raft 可视化推荐看 http://thesecretlivesofdata.com/raft/ (动画演示选举与日志复制)。

下一步

理解了 Raft 共识后,下一步看Kubernetes 与工程用法——etcd 如何成为 K8s 状态存储、Watch 机制实战、命名服务与分布式锁,以及与 ZooKeeper 的详细对比。