48.4 Leader 选举
一句话
Leader 选举是在协调服务上选出一个「主」来承担单写或单任务;难点不是「选出来」,而是旧主在暂停/分区后恢复时仍以为自己还是主——任期、租约、fencing 三者缺一不可。
两种「Leader」,先分清
| 类型 | 谁选 | 作用域 | 主场 |
|---|---|---|---|
| 共识层 Leader | Raft/ZAB 内部投票 | 一个复制组的日志提交 | 21.5 Raft:选主 |
| 应用层 Leader | 业务进程通过协调服务竞选 | 「谁是主库/主任务/主分片」 | 本节 |
应用层 Leader 常见于:主备切换、定时任务只跑一份、单写者(single writer)、分片 owner、Controller 选主。它通常复用共识服务的租约/临时节点,但语义是「业务主」,不是「日志主」。
四个阶段
① 竞选:CAS 创建 leader key / 创建临时顺序节点
② 续约:leader 定期续租(lease keepalive / session 心跳)
③ 失效:租约过期 / 会话超时 / 主动 resign
④ 切换:follower 收到事件 → 竞选 → 新 leader 上任(term+1)
| 阶段 | 失败会发生什么 | 关键设计 |
|---|---|---|
| 竞选 | 多个候选人同时写,只有一个成功 | CAS/顺序节点保证唯一 |
| 续约 | 网络抖动导致续约失败,误切换 | 续约间隔 = TTL/3,失败重试 + 抖动 |
| 失效 | 检测慢,故障期间无主 | TTL/会话超时,配合主动探测 |
| 切换 | 旧主未停、新主已写(双主) | 任期 + fencing + 旧主自检 |
实现方式对照
| 方式 | 机制 | 公平性 | 失效检测 | 备注 |
|---|---|---|---|---|
| etcd | lease + 事务 CAS 写 leader key;follower watch | 基本按 revision | 租约过期 | concurrency.Election 封装;Resign 主动让位 |
| ZooKeeper | 临时顺序节点,序号最小者为 leader;watch 前驱 | 是 | 会话超时 | Curator LeaderLatch/LeaderElection |
| Consul | session + KV acquire;session TTL | 基本 | TTL 检查/会话失效 | 原生多数据中心 |
| K8s Lease | coordination.k8s.io/Lease 对象,client-go leaderelection | 无 | renewTime/leaseDuration | 给控制器选主用,不是业务锁 |
| 数据库 | UPDATE ... WHERE expires_at < NOW() | 无 | 租约时间 | 依赖数据库时钟;跨库难 |
| Redis | SET NX PX | 无 | TTL | 主从切换可能双主(48.3) |
K8s Lease 的三个参数
| 参数 | 含义 | 典型关系 |
|---|---|---|
leaseDuration | 租约总时长 | 故障切换的上界 |
renewDeadline | 必须在这之前续约成功 | < leaseDuration |
retryPeriod | 续约重试间隔 | < renewDeadline |
controller-runtime / client-go 的选主都按这三个参数工作;它适合「多个副本里只有一个干活」,不适合保护业务数据(业务仍需 fencing)。
任期与 fencing:旧主必须被拒绝
t=0 A 是 leader,term=7,租约 5s
t=2 A 进入 GC,暂停 10s
t=5 租约过期
t=5.2 B 竞选成功,term=8
t=6 B 写入,storage.lastTerm = 8
t=12 A 恢复,仍以为自己是 leader,用 term=7 写入
无 fencing:A 覆盖 B(双主写)
有 fencing:storage 拒绝 term=7 < 8
三个要点:
- term 由协调服务发放/递增,不能由客户端本地生成;
- 每个写请求都带 term,存储端原子校验
term > lastTerm; - 旧 leader 的读也要停:它可能返回过期状态;要么每次读前确认租约,要么标记为 stale read,由调用方决定能不能用。
🔑 租约解决「多久没心跳就换人」,任期解决「换人之后旧人说的话还算不算数」。只做前者,双主写迟早发生。
算一笔账:选举延迟与双主写
模拟:任期 10s 内随机时刻发生一次暂停;99% 的暂停 ≤200ms,1% 的暂停 1–30s。暂停超过 TTL 触发选举;follower 检测 0–200ms、竞选 0–100ms;新 leader 工作 0.5s 后写入,旧 leader 恢复 0.1s 后写入。
package main
import (
"fmt"
"math/rand"
)
// 99% 的暂停 ≤200ms;1% 的暂停 1–30s(长 STW、VM 迁移、网络分区)
func pause(r *rand.Rand) float64 {
if r.Float64() < 0.99 {
return r.Float64() * 0.2
}
return 1 + r.Float64()*29
}
func run(ttl float64, trials int) (elections, splits int, lat50, lat99 float64) {
r := rand.New(rand.NewSource(42))
var lats []float64
for i := 0; i < trials; i++ {
start := r.Float64() * 10 // 任期内随机时刻暂停
p := pause(r)
if p <= ttl {
continue // 没触发选举
}
elections++
detect := r.Float64() * 0.2 // follower 发现租约过期
race := r.Float64() * 0.1 // 竞选 + CAS
electionLat := ttl + detect + race
lats = append(lats, electionLat)
newWrite := start + electionLat + 0.5 // 新 leader 工作 0.5s 后写入
oldWrite := start + p + 0.1 // 旧 leader 恢复后 0.1s 写入
if oldWrite > newWrite {
splits++
}
}
for i := 1; i < len(lats); i++ {
for j := i; j > 0 && lats[j] < lats[j-1]; j-- {
lats[j], lats[j-1] = lats[j-1], lats[j]
}
}
if len(lats) > 0 {
lat50 = lats[len(lats)/2]
lat99 = lats[int(float64(len(lats))*0.99)]
}
return
}
func main() {
const trials = 200000
fmt.Printf("%-7s %-10s %-12s %-12s %-12s\n", "TTL(s)", "触发选举", "选举P50", "选举P99", "选举后双主写")
for _, ttl := range []float64{3, 5, 10} {
e, s, p50, p99 := run(ttl, trials)
fmt.Printf("%-7.0f %-10s %-12s %-12s %-12s\n", ttl,
fmt.Sprintf("%.2f%%", float64(e)*100/trials),
fmt.Sprintf("%.2fs", p50), fmt.Sprintf("%.2fs", p99),
fmt.Sprintf("%.2f%%", float64(s)*100/float64(e)))
}
}
输出:
TTL(s) 触发选举 选举P50 选举P99 选举后双主写
3 0.94% 3.15s 3.28s 97.81%
5 0.87% 5.15s 5.28s 97.76%
10 0.68% 10.15s 10.28s 97.71%
读法:
- 选举延迟 ≈ TTL + 0–0.3s:TTL 直接决定故障切换时间,3s TTL 意味着故障后约 3.2s 才恢复写入;
- 触发选举的比例很低(<1%):因为 99% 的暂停很短;但只要触发,「选举后双主写」就接近 98%——长暂停让旧主在新主写入之后才醒来,几乎必然重叠;
- 这 98% 不是「系统设计得差」,而是「没有 fencing 的必然结果」。加上 fencing,旧主的写全部被拒绝,双主写变成显式失败。
🔑 TTL 调大减少误切换,但拖慢故障恢复;TTL 调小恢复快,但 GC 抖动就可能触发切换。无论怎么调,双主窗口都存在——所以 fencing 不是可选项。
切换流程:让旧主先闭嘴
| 步骤 | 动作 | 目的 |
|---|---|---|
| 1 | 旧主停止接收新写,标记 not leader | 缩小双写窗口 |
| 2 | 旧主 Resign/删除临时节点 | 让 follower 尽快感知 |
| 3 | follower 收到事件,随机抖动后竞选 | 避免惊群 |
| 4 | 新主上任,term+1,广播/被 watch | 建立新任期 |
| 5 | 新主等待旧主租约过期/确认旧主已停 | 防止旧主还在写 |
| 6 | 客户端重新发现新主 | 恢复流量 |
故障切换(旧主没机会 resign)没有第 1、2 步,只能靠租约过期 + fencing。优雅切换能减少不可用时间,但不能替代 fencing。
租约时长怎么选
| 约束 | 说明 |
|---|---|
| 故障切换目标 | TTL 应接近可接受的中断时间(如 3–10s) |
| 暂停预算 | TTL 必须大于「正常情况下最长 GC/调度暂停」,否则频繁误切换 |
| 跨地域 RTT | TTL 不能接近或小于 quorum RTT;跨洲 180ms RTT 配 300ms TTL 会抖动 |
| 续约间隔 | 通常 TTL/3;失败重试要快于 TTL |
| 时钟 | 过期判断由协调服务做,客户端不要用本地时钟判断 |
一个常见错误:把 TTL 设成「业务超时时间」。租约 TTL 是故障检测参数,不是业务 SLA;业务超时该在调用侧设(35.5 超时、重试与退避)。
惊群与选举风暴
Leader 失效时,所有 follower 同时收到事件、同时竞选:
| 问题 | 后果 | 解法 |
|---|---|---|
| 所有 follower 同时 CAS | 协调服务瞬时写放大 | 随机抖动(0–TTL/2)+ 指数退避 |
| 所有 follower watch 同一个 key | 事件广播到所有人 | ZK 只 watch 前驱;etcd 用 Election 封装 |
| 落选者立即重试 | 持续打满 | 退避 + 只允许一个候选(preferred leader) |
| 网络分区恢复 | 两边都认为自己赢了 | term + fencing;旧主必须自检 |
💡 「随机抖动」不是可选项。没有抖动,leader 故障会变成一次针对协调服务的 DDoS。
SQL 租约选主:一份能跑的写法
CREATE TABLE leader_lease (
name VARCHAR(64) PRIMARY KEY,
holder VARCHAR(64) NOT NULL,
term BIGINT NOT NULL,
expires_at DATETIME(3) NOT NULL
) ENGINE=InnoDB;
-- 1) 接管已过期的租约:只有过期才能改,条件本身就是互斥
UPDATE leader_lease
SET holder = :me, term = term + 1, expires_at = NOW(3) + INTERVAL 5 SECOND
WHERE name = 'order-worker' AND expires_at < NOW(3);
-- 2) 首次插入:主键冲突说明别人正持有
INSERT IGNORE INTO leader_lease(name, holder, term, expires_at)
VALUES ('order-worker', :me, 1, NOW(3) + INTERVAL 5 SECOND);
-- 3) 回读确认:不能只看影响行数(已持有也是 0 行)
SELECT holder, term FROM leader_lease
WHERE name = 'order-worker' AND holder = :me AND expires_at > NOW(3);
实测:A 抢到 term=1;未过期时 B 执行同样的语句,holder 仍是 A;把 expires_at 改到过去后 B 接管,term=2。
| 配套 | 做法 | 不做会怎样 |
|---|---|---|
| fencing token | 每次接管 term+1,下游写必须带 term 并拒绝更小值 | 暂停恢复的旧主继续写(双主) |
| 续约 | UPDATE ... SET expires_at=NOW(3)+INTERVAL 5 SECOND WHERE holder=:me | 一次 GC 暂停就丢主 |
| 时钟 | 用 DB 的 NOW(3),不用应用时钟 | 机器时钟漂移 → 租约提前/延后失效 |
| 租约时长 | 5s 租约、1–2s 续约,租约 > 2×续约 + 最大暂停 | 续约太慢 → 频繁切换 |
两条纪律:① 影响行数为 0 不等于没抢到(可能已经持有),必须回读;② 续约失败要立刻停止业务,而不是“再试一次”——旧主继续写就是双主。
# 看当前 holder 与剩余租约
mysql -e "SELECT holder, term, TIMESTAMPDIFF(SECOND, NOW(3), expires_at) AS left_s \
FROM leader_lease WHERE name='order-worker';"
# 演练:把租约改到过去,模拟旧主暂停;下一个竞争者接管后 term 应 +1
mysql -e "UPDATE leader_lease SET expires_at = NOW(3) - INTERVAL 1 SECOND \
WHERE name='order-worker';"
反模式
| 反模式 | 后果 |
|---|---|
| 只靠 TTL,不做 term/fencing | 旧主恢复后覆盖新主,双主写 |
| 旧主恢复后直接继续服务 | 读旧数据、写冲突 |
| 用客户端本地时钟判断租约 | 时钟漂移导致误判(16.1 严格一致性) |
| 所有 follower 无抖动同时竞选 | 选举风暴,协调服务过载 |
| 续约失败仍继续写 | 租约已过期,实际已失去 leadership |
| 把 K8s Lease 当业务锁 | 它只保证控制器选主,不保护业务数据 |
| TTL 小于跨区 RTT | 租约还没续上就过期,频繁切换 |
| 没有切换告警/演练 | 真故障时才发现选主逻辑坏了 |
自检
Q1 为什么「租约 TTL 设得足够大」不能消除双主写?
▸ 答案:TTL 只能降低「暂停超过租约」的概率,不能消除。暂停可能来自 GC/STW、VM 迁移、网络分区,长度没有硬上界;只要发生一次超过 TTL 的暂停,旧主就会在租约过期后继续以为自己有效,而新主已经上任。模拟里即使 TTL 10s,触发选举后的双主写仍接近 98%。正确做法是 term/fencing:让存储拒绝旧任期的写。
Q2 应用层 Leader 与 Raft 的 Leader 有什么区别?
▸ 答案:Raft Leader 是共识组内部选出的日志提交者,负责复制与提交,由 Raft 协议保证安全(21.5 Raft:选主);应用层 Leader 是业务进程通过 etcd/ZK/Consul 等协调服务选出的「主任务/主写者」,协调服务只提供租约与 CAS,不替业务保证「旧主不会写」。因此应用层 Leader 必须自己做 term/fencing,不能把 Raft 的安全性直接迁移过来。
Q3 K8s 的 Lease 对象能直接拿来保护业务数据吗?
▸ 答案:不能。Lease + client-go leaderelection 解决的是「多个控制器副本里只有一个干活」,它保证的是「在 leaseDuration 内只有一个 holder 认为自己有效」,但不提供 fencing token,也不阻止旧 holder 在暂停恢复后写业务存储。业务数据要么用数据库条件更新/事务,要么用带 term 的 fencing。
Q4 优雅切换的步骤是什么?为什么第 1 步是「旧主先停写」而不是「先 resign」?
▸ 答案:先 resign 会让 follower 立刻开始竞选,而旧主可能还有在途请求正在写——形成「新主已上任、旧主还在写」的窗口。正确顺序是:旧主先停止接收新写(并等待在途请求结束或超时),再 resign,让新主上任;这样切换期间是「短暂不可写」,而不是「双写」。如果旧主已经崩溃、无法执行这套流程,就只能靠租约过期 + fencing 兜底。
Q5 跨地域部署,TTL 设 500ms 可以吗?
▸ 答案:通常不行。跨地域 RTT 可能是 30ms(同城)到 180ms(跨洲),quorum 写入/续约要一个 RTT 以上;TTL 500ms 意味着只要一次网络抖动或一次慢盘就过期,导致频繁切换、甚至永远选不出稳定的 leader。TTL 应至少是「quorum RTT + 落盘 + 安全余量」的数倍;跨地域场景更适合把 quorum 放在同区域,异地做灾备/观察者,而不是把租约压到毫秒级。