21.11 领导者租约
一句话
租约是「用时间换往返」的授权:持有者在
[t0, t0+T)内不必再问多数派就能行使权力;它省下的每一次 RTT,代价是一次赌注——系统必须有界地信任时钟,而且授租方与持租方两端都要在 T 到期前不动手。
租约是什么:三个角色、一条时间线
授租方(协调服务 / 多数派) 持租方(leader / 客户端 / 锁持有者)
│ t0:授予租约 T,并承诺「T 内不再授给别人」
├───────────────▶ t0:开始行使权力(读/写/持锁)
│ │
│ │ 期间可以不问授租方,直接行使
│ │
│ t0+T:才可以授新租约 │ 必须在 t0+T−ε 前停手(ε 覆盖时钟误差)
▼ ▼
三个要素缺一不可:
| 要素 | 含义 | 缺了会怎样 |
|---|---|---|
| 授租方 | 必须是权威(多数派/共识组),负责"不重复授权" | 谁都发租约 → 两个 leader 各有一份"有效期" |
| 有效期 T | 一段绝对时间,不是"直到我说停" | 没有 T 就退化成锁,每次都要校验 |
| 双端等待 | 授租方等到 T 才授新,持租方提前 ε 停手 | 只做一端 → 新旧持有者重叠 |
🔑 租约与锁的区别只有一句:锁靠"每次校验"保证互斥,租约靠"时间"保证互斥。前者付 RTT,后者付时钟假设。
为什么需要:把每次确认摊销成一次
Raft 的线性一致读有两种实现(读路径的分类见 21.12 共识算法对比):
- ReadIndex:每次读先向多数派确认"我仍是 leader",再本地读 → 每次读多 1 个 RTT;
- 租约读:leader 在租约内直接本地读 → 每 T 秒只付 1 次续约的 RTT。
package main
import "fmt"
func main() {
fmt.Println("表 1:强一致读,每读一次要确认「我仍是 leader」的成本")
fmt.Println("| 读 QPS | 租约 T | quorum 读:额外往返/秒 | 租约读:额外往返/秒 | 降幅 |")
fmt.Println("|---|---|---|---|---|")
for _, qps := range []float64{100, 1000, 10000} {
for _, t := range []float64{1, 5, 10} {
quorum := qps // 每次读一次多数派往返
lease := 1 / t // 每 T 秒续约一次
fmt.Printf("| %.0f | %.0fs | %.0f | %.2f | %.0f× |\n", qps, t, quorum, lease, quorum/lease)
}
}
fmt.Println("\n表 2:租约的安全余量 = T × ρ(ρ 是两端时钟漂移率之和,单位 ppm)")
fmt.Println("| 租约 T | ρ=10ppm | ρ=100ppm | ρ=1000ppm |")
fmt.Println("|---|---|---|---|")
for _, t := range []float64{1, 5, 10, 30} {
fmt.Printf("| %.0fs |", t)
for _, ppm := range []float64{10, 100, 1000} {
margin := t * ppm / 1e6 * 1000 // ms
fmt.Printf(" %.2fms |", margin)
}
fmt.Println()
}
// 实际可用的租约 = T - 漂移余量 - 固定开销(NTP step / 调度延迟的保守估计)
fmt.Println("\n表 3:扣掉保守固定开销 50ms 后,实际可用租约(ρ=100ppm)")
fmt.Println("| 名义 T | 漂移余量 | 固定开销 | 可用租约 | 相对损失 |")
fmt.Println("|---|---|---|---|---|")
for _, t := range []float64{1, 5, 10, 30} {
margin := t * 100 / 1e6 * 1000
usable := t*1000 - margin - 50
fmt.Printf("| %.0fs | %.2fms | %.0fms | %.1fms | %.1f%% |\n", t, margin, 50.0, usable, (1-usable/(t*1000))*100)
}
}
输出(本机实测):
表 1:强一致读,每读一次要确认「我仍是 leader」的成本
| 读 QPS | 租约 T | quorum 读:额外往返/秒 | 租约读:额外往返/秒 | 降幅 |
|---|---|---|---|---|
| 100 | 1s | 100 | 1.00 | 100× |
| 100 | 5s | 100 | 0.20 | 500× |
| 100 | 10s | 100 | 0.10 | 1000× |
| 1000 | 1s | 1000 | 1.00 | 1000× |
| 1000 | 5s | 1000 | 0.20 | 5000× |
| 1000 | 10s | 1000 | 0.10 | 10000× |
| 10000 | 1s | 10000 | 1.00 | 10000× |
| 10000 | 5s | 10000 | 0.20 | 50000× |
| 10000 | 10s | 10000 | 0.10 | 100000× |
表 2:租约的安全余量 = T × ρ(ρ 是两端时钟漂移率之和,单位 ppm)
| 租约 T | ρ=10ppm | ρ=100ppm | ρ=1000ppm |
|---|---|---|---|
| 1s | 0.01ms | 0.10ms | 1.00ms |
| 5s | 0.05ms | 0.50ms | 5.00ms |
| 10s | 0.10ms | 1.00ms | 10.00ms |
| 30s | 0.30ms | 3.00ms | 30.00ms |
表 3:扣掉保守固定开销 50ms 后,实际可用租约(ρ=100ppm)
| 名义 T | 漂移余量 | 固定开销 | 可用租约 | 相对损失 |
|---|---|---|---|---|
| 1s | 0.10ms | 50ms | 949.9ms | 5.0% |
| 5s | 0.50ms | 50ms | 4949.5ms | 1.0% |
| 10s | 1.00ms | 50ms | 9949.0ms | 0.5% |
| 30s | 3.00ms | 50ms | 29947.0ms | 0.2% |
三个读法:
- 续约成本与读 QPS 解耦:读越多,租约越划算(1 万 QPS 下差 4–5 个数量级)。这是"为什么所有高读吞吐的共识系统都想要租约读"的全部理由。
- 漂移余量很小,固定开销很大:T=10s、ρ=100ppm 时漂移只占 1ms,但 NTP step、调度延迟、GC 的保守估计往往要留 50ms 以上——租约的实际损耗来自"保守估计",不是"漂移公式"。
- 短租约的相对损耗更高:T=1s 要留 5% 的余量;T=30s 只损失 0.2%。但租约越长,故障切换越慢(旧 leader 的"合法期"更长),这是参数表里的核心权衡。
安全性:两端都要等
危险窗口长这样:
授租方视角: |──── 旧租约 T ────| 此刻之后才能授新租约
持租方视角: |── T−ε ──|✗ 必须停手
└─ 若持租方认为自己还有效,而授租方已经授了新租约 → 双主
安全条件(两条同时成立):
- 授租方:在
t_grant + T之前,不向任何其他人授予同一权力; - 持租方:在本地时钟到达
t_grant + T − ε后,停止行使权力(ε 覆盖两端时钟误差 + 处理延迟)。
只做第 1 条:持租方可能多干一会儿(它的钟慢了),与新持有者重叠。 只做第 2 条:授租方可能提前把权力给别人,而旧持有者还以为自己合法。
为什么"租约读"是线性一致的(在时钟假设下):设读发生在 [t0, t0+T−ε)。任何新 leader 要提交,必须先拿到授租方的新租约,而那只能在 t0+T 之后;因此在读的这一刻,不存在第二个能提交的 leader —— 读到的就是"当前唯一真相"。∎
⚠️ 这个证明的每一步都压在一个假设上:时钟漂移有界。Spanner 的 TrueTime 走的是另一条路——它不假设"我知道现在几点",而假设"我知道我的误差上界 ε",并让提交等待
2ε。租约假设漂移小且稳定;TrueTime 假设误差可查询。两者都把时间当资源,但一个向后看(漂移率),一个向前看(不确定区间)。
四种租约,四种安全边界
| 类型 | 谁授租 | 保护什么 | 时间假设 | 典型实现 |
|---|---|---|---|---|
| leader 读租约 | 共识组(多数派) | 读不返回旧 leader 的陈旧数据 | 漂移有界 + 双端等待 | etcd 的 lease-based read、Raft 实现优化 |
| leader 写租约 | 同上 | 旧 leader 不再接受写 | 同上 + fencing 更稳 | 多数实现用 term/fencing 代替(见 48.4 Leader 选举) |
| 会话租约(session) | 协调服务 | 客户端"活着"的证明(临时节点/锁不被回收) | 服务端时钟 | ZooKeeper session、etcd lease |
| 锁 TTL | 存储/协调服务 | 持锁者崩溃后锁能自动释放 | 存储端时钟(不是客户端) | Redis SET NX PX、etcd lease + txn |
🔑 四种租约的危险程度递减、可靠性递增:leader 写租约最危险(写错就是双主写),锁 TTL 最安全(最坏是锁提前释放,配合 fencing 可兜底)。所以工程上"能不用写租约就不用",用任期 + fencing 更省心。
失效模式:租约是怎么破的
| 失效模式 | 机制 | 现象 | 防线 |
|---|---|---|---|
| GC / STW 暂停 | 进程暂停 P > 剩余租约,恢复后不重新检查 | 僵尸 leader 继续读/写 | 每次行使前重新检查过期时间;fencing |
| VM 冻结 / 迁移 | 宿主暂停数秒,客户机时钟照走 | 同上,且更难发现 | 心跳自检 + 租约留足余量 |
| NTP step(时钟跳变) | 墙钟被向前/向后拨 | 本地判断提前或延后过期 | 用单调时钟算时长;禁止用墙钟判断过期 |
| 网络分区 | 旧 leader 联系不上授租方,但租约还没到期 | 分区内仍在服务(合法!) | 到期即停;分区恢复后靠 term 拒绝旧写 |
| 续约失败但进程健康 | 续约请求丢了/被限流 | 以为自己还有效 | 续约失败要立即降级,不能"再等等" |
| 两端时钟都准但 ε 给小了 | 处理延迟/调度抖动吃掉余量 | 极小概率重叠 | 保守估计 ε(几十 ms 起),别按理论值 1ms 给 |
⚠️ 最容易写错的一行代码:把
isLeader缓存成布尔值,在请求处理路径上不再检查时间。租约的正确用法是"每次用之前看表",不是"拿到租约就设个标志位"。暂停后恢复的进程,标志位还是 true,但租约早过期了。
参数怎么给
| 约束 | 取法 | 代价 |
|---|---|---|
| 故障切换时间 | T 越小切换越快 | 续约更频繁;相对损耗更大(表 3) |
| 暂停预算 | T 必须大于"正常情况下最长暂停"(GC/迁移) | T 太大 → 切换慢 |
| 网络抖动 | 续约重试要快于 T(常见 T/3 续约、T/2 重试) | 重试风暴 |
| 时钟假设 | ε ≥ 漂移余量 + NTP step + 调度延迟(几十 ms 量级) | 可用租约变短 |
| 跨地域 | T 不能接近 quorum RTT(跨洲 180ms RTT 配 300ms 租约必然抖) | 只能拉长 T |
租约 TTL 的具体取值与切换流程,主场在 48.4 Leader 选举;本节只定边界:T 是故障检测参数,不是业务超时(业务超时见 35.5 超时、重试与退避)。
反模式
| 反模式 | 后果 |
|---|---|
| 只在授租方做等待,持租方不提前停 | 时钟慢的一方多干一会儿 → 双主 |
| 只在持租方做等待,授租方提前授新租 | 新持有者已上岗,旧持有者还在写 |
| 用墙钟判断租约过期 | NTP step 直接击穿安全边界 |
把 isLeader 缓存成 bool,不检查时间 | 暂停恢复后的僵尸 leader |
| 按理论漂移值给 ε(1ms) | 调度抖动就吃掉余量,实际重叠 |
| 租约 TTL 当成业务超时 | 切换极慢或频繁误切换 |
| 用租约代替 fencing | 租约只是"减少重叠概率",fencing 才是"让重叠无效" |
| 多副本各自发租约 | 等于没有授租方,两个 leader 都"合法" |
自检
Q1 租约读为什么能省掉每次读的 RTT?它把成本转移到了哪里?
▸ 答案:它把"每次读都要确认我还是 leader"摊销成"每 T 秒确认一次"——租约期内本地读即可。成本转移到了时钟假设上:系统必须保证授租方在 T 内不授新租、持租方在 T−ε 前停手,而 ε 依赖时钟漂移有界。一旦时钟不可信(NTP 跳变、长暂停),省下的 RTT 会以"读到旧数据"或"双主"的形式还回来。
Q2 为什么说"只做一端等待"的租约是错的?分别描述两种只做一端的后果。
▸ 答案:只做授租方等待(持租方不提前停):持租方时钟慢,认为自己还有效,继续服务,而授租方已在 t0+T 授出新租约 → 新旧重叠。只做持租方等待(授租方提前授新):授租方在 t0+T 之前就把权力给了别人,旧持有者的"提前停"是按自己的 T−ε 算的,两边重叠窗口更大。安全要求是两端的等待区间必须覆盖同一个时间点:授租方等到 t0+T,持租方停于 t0+T−ε。
Q3 T=10s、两端合计漂移 100ppm,漂移余量是多少?为什么工程上还要留 50ms?
▸ 答案:10s × 100ppm = 1ms。但真实系统还有 NTP step(可跳几十到几百 ms)、调度延迟、GC 边界、监控采样误差,这些不是"漂移率"能描述的。所以 ε 要按"最坏跳变 + 最坏延迟"保守估计(几十 ms 量级),否则一次时钟跳变就击穿安全边界。余量的本质是"对不可建模误差的保险"。
Q4 一个持有租约的 leader 遇到 12 秒的完整 GC 暂停(租约 T=10s),恢复后会发生什么?正确的实现应该怎样?
▸ 答案:租约早已过期,授租方可能已授出新租约、新 leader 已开始提交。如果实现把 isLeader 缓存成布尔值,它会继续接受读写 → 双主写/脏读。正确实现:每次行使权力前用单调时钟检查是否仍在租约内;恢复后发现过期,立即退回 follower,并拒绝所有在途写(配合 term/fencing,让存储端拒绝旧 term 的写)。
Q5 既然租约有风险,为什么高读吞吐的共识系统还要用它?什么场景下不该用?
▸ 答案:因为读是高频操作,ReadIndex 的每读一次 RTT 在大流量下会显著抬高 P99 与 leader 的出带宽;租约把这份成本压到每 T 秒一次,收益是数量级的。不该用的场景:① 时钟不可信(无 NTP/PTP 保障、虚拟机频繁迁移/冻结);② 跨地域部署且 RTT 与 T 同量级;③ 写路径(写本来就要多数派确认,租约省不了 RTT,却引入双主风险);④ 对"读到旧值"零容忍又没有 fencing 兜底的系统。