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 的详细对比。