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% |

三个读法:

  1. 续约成本与读 QPS 解耦:读越多,租约越划算(1 万 QPS 下差 4–5 个数量级)。这是"为什么所有高读吞吐的共识系统都想要租约读"的全部理由。
  2. 漂移余量很小,固定开销很大:T=10s、ρ=100ppm 时漂移只占 1ms,但 NTP step、调度延迟、GC 的保守估计往往要留 50ms 以上——租约的实际损耗来自"保守估计",不是"漂移公式"。
  3. 短租约的相对损耗更高:T=1s 要留 5% 的余量;T=30s 只损失 0.2%。但租约越长,故障切换越慢(旧 leader 的"合法期"更长),这是参数表里的核心权衡。

安全性:两端都要等

危险窗口长这样:

授租方视角:  |──── 旧租约 T ────|  此刻之后才能授新租约
持租方视角:  |── T−ε ──|✗ 必须停手
                        └─ 若持租方认为自己还有效,而授租方已经授了新租约 → 双主

安全条件(两条同时成立):

  1. 授租方:在 t_grant + T 之前,不向任何其他人授予同一权力;
  2. 持租方:在本地时钟到达 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 兜底的系统。

关联