后端系统工程 26:租约互斥与资源端栅栏
一个每日报表作业部署了多个实例,同一时刻只应有一个实例往报表输出库写结果。常见做法是在数据库里放一张租约表:谁抢到租约谁干活,租约到期没有续上就让给别人。这套做法能减少重复执行,但它本身不能阻止一种写入:实例 A 拿到租约、做完写前检查,然后整个进程停住;租约在停顿期间过期,实例 B 接手并写完;A 恢复后把手里那份已经过时的结果写了进去。A 自己无法察觉停顿,因为它恢复后执行的下一条指令就是“发出写请求”。
本篇的验收条件只有一条:暂停的旧持有者恢复后,无法写入受保护资源。实现上,租约在获取时原子地发出一个单调递增的 fencing token,受保护资源在写入的同一个原子操作里比较 token,拒绝比自己见过的最大值更小的请求。另外用同一时间线说明:资源端如果不检查 token,这个保证就不存在,再完善的租约也补不上。
需求、不变量与排除项
场景是合成的:报表作业 report-job 每天生成作用域 daily-report 下的若干分片(summary、detail),写入报表输出库。持有者标识形如 holder-a。
需要成立的不变量有三条:
- I1,按数据库时钟,同一租约在任一时刻至多有一个未过期的持有者。
- I2,同一租约发出的 token 严格递增,任何情况下不复用,包括数据库崩溃重启之后。
- I3,资源端在一个作用域内接受的写入,其 token 单调不减;一旦接受过 token=n,此后任何 token<n 的写入都被拒绝,无论它针对哪个分片。
I1 只是效率性质,用来让大多数时候只有一个实例在干活;正确性由 I2 与 I3 共同承担。排除项:不处理租约库的主备切换,不处理跨机房网络分区,不实现公平排队或等待通知,报表内容本身的计算正确与否不在范围内。
教学规模假设
以下参数都是教学假设,用来让实验在几秒内跑完,不是推荐值:
| 参数 | 取值 | 用途 |
|---|---|---|
| 竞争实例数 | 2 个独立 JVM;并发获取实验另用 16 个线程 | 构造暂停与抢占 |
| 租约 TTL | 300–1500 ms | 让过期在实验时长内发生 |
| 续租间隔 | 400 ms(F4/F5 中约为 TTL=1500 ms 的四分之一) | 未暂停时租约不应过期 |
| 本地安全余量 | 300 ms(F4/F5) | 本地截止时间提前于服务器过期时间 |
| 作用域与分片 | 1 个作用域、2 个分片 | 足以暴露栅栏粒度问题 |
生产环境的 TTL 要按续租链路的最坏延迟、垃圾回收停顿和故障接管时间来定,本实验不提供这方面的数据。
接口与数据模型
租约服务对调用方暴露三个操作,全部是自动提交模式下的单条 SQL 语句:
acquire(name, holder, ttl):租约不存在、已过期或已释放时成功,返回新 token;租约仍有效时返回空。renew(grant):只有持有者名、token 都匹配,且租约尚未过期时成功;过期后一律失败,必须重新获取并得到新 token。release(grant):把过期时间设为-infinity,token 不回退。
受保护资源只暴露一个写入操作 write(scope, part, content, writer, token),返回是否接受。资源端不调用租约服务,也不读租约表,它只记住自己见过的最大 token。这一点和 Chubby 论文 §2.4 的描述一致:接收方可以把 sequencer 与自己观察到的最新 sequencer 比较,而不必与锁服务保持会话。
两类状态放在同一个临时集群的两个数据库里,以免实现时不自觉地在资源端读租约表:
1 | |
write_attempt 表另外记录每一次写入尝试及其结果,供事后核对被拒绝的请求。
租约状态机
一条租约行只有三种状态:持有中(expires_at > now())、已过期(expires_at <= now())、已释放(expires_at = -infinity,是已过期的特例)。行不存在时视为空闲。
图 26-1:租约行的状态转换、前置条件与拒绝路径(均由 F1–F3 验证)
stateDiagram-v2
[*] --> 持有中: acquire 插入新行 / token=1
持有中 --> 持有中: renew [holder 与 token 匹配 且 expires_at > now()]
持有中 --> 持有中: acquire 被拒(行未变,token 不变)
持有中 --> 已过期: now() 越过 expires_at(无写操作)
持有中 --> 已释放: release [holder 与 token 匹配]
已过期 --> 持有中: acquire / token+1,holder 改为新持有者
已释放 --> 持有中: acquire / token+1
已过期 --> 已过期: renew 被拒(必须重新获取)
“持有中 → 已过期”没有对应的写操作,它只是数据库时钟前进的结果。因此“谁持有租约”这个问题只能在数据库一侧、按某一时刻的 now() 回答,持有者自己的判断只能是近似。
正常路径
F1 用例在一个进程里顺序执行完整生命周期,输出摘录如下:
1 | |
token 只在“获取”时前进,续租不改变它。这意味着一次连续持有期对应一个 token,持有期内可以多次写入;F2 中相等 token 的重复写入被接受,正是这一约定的直接后果。
租约为什么不够:暂停的旧持有者
F4 是本篇的主实验,属于多进程集成。持有者 A 和 B 是两个独立 JVM(lab.lease.Holder),父进程负责发信号和观察。A 拿到租约后启动后台续租线程,然后检查本地截止时间,通过后停在写入之前。父进程先等待 3000 ms(两倍 TTL),确认后台续租让租约一直有效;然后执行 kill -STOP,从 /proc/<pid>/stat 读到状态 T。SIGSTOP 冻结的是整个进程,续租线程也一起停住,于是租约在数据库时钟上过期。之后 B 启动,获取到 token=2,写入、续租、释放后退出。最后父进程把放行行写进 A 的标准输入,再执行 kill -CONT。
图 26-2:F4 的事件序列(多进程,A 在写前检查之后被 SIGSTOP)
sequenceDiagram
participant A as 持有者 A(JVM)
participant L as 租约库 lease26
participant B as 持有者 B(JVM)
participant R as 资源库 res26
A->>L: acquire(ttl=1500ms)
L-->>A: token=1
A->>A: 本地截止时间检查通过
loop 未暂停期间每 400ms
A->>L: renew(token=1) 成功
end
Note over A: kill -STOP:主线程与续租线程都停止
Note over L: now() 越过 expires_at,租约过期
B->>L: acquire
L-->>B: token=2
B->>R: write(summary, token=2)
R-->>B: 接受,fence_floor 0→2
B->>L: renew 成功,随后 release
Note over A: kill -CONT
A->>R: write(summary, token=1)
R-->>A: 拒绝:fence_floor=2 > 1
A->>L: renew(token=1)
L-->>A: 失败(token 已被取代)
实际输出:
1 | |
A 恢复时 B 已经释放了租约,租约表此刻处于空闲状态,A 的写入照样被拒绝。资源端的判断依据是它自己持久化的 fence_floor,与租约此刻是否有人持有无关。
A 的停顿位置是有意挑选的:放在“本地检查通过”和“发出写请求”之间。如果停顿落在检查之前,A 恢复后会因单调时钟已越过本地截止时间而自行放弃;落在检查之后,任何客户端侧的检查都来不及。网络中滞留的写请求也属于后一类,它离开客户端时持有者确实有效,到达资源端时已经无效。本实验只构造了进程停顿这一种来源。
F5 用完全相同的时间线,只把资源端换成不检查 token 的普通 upsert:
1 | |
两次运行中租约服务的行为一模一样:A 的续租都失败了,B 都拿到了更大的 token。唯一的差别在资源端,结果就从“B 的报表”变成了“A 的旧报表”。etcd v3.6 文档在 “Notes on the usage of lock and lease” 一节里给出的结论相同:TTL 是物理时间,服务器与客户端各自计时,可能出现服务器已撤销而客户端仍认为持有;保护 etcd 之外的资源时,资源本身必须提供版本号校验。
方案比较
表 26-1:几种互斥做法在“暂停的旧持有者”下的结果
| 做法 | 旧持有者恢复后的写入 | 依赖的前提 | 本篇验证 |
|---|---|---|---|
| 只靠租约与客户端写前检查 | 被接受 | 检查与写入之间不发生停顿或延迟 | F5 |
| 租约 + 获取后等待一段时间再交给他人(Chubby 的 lock-delay) | 仍可能被接受 | 停顿与延迟短于等待时长 | 未实验,论文 §2.4 自称 imperfect |
| token + 资源端先读后写 | 被接受 | 读与写之间无他人提交 | F6(a) |
| token + 资源端逐行比较 | 对旧持有者新建的行被接受 | 旧持有者只写新持有者已经写过的行 | F7 |
| token + 资源端作用域级条件更新 | 被拒绝 | token 单调且不复用;所有写入经过同一 floor | F4、F6(b)、F7 |
etcd 的做法可以放进同一框架:revision 是集群级单调计数器,每次修改递增,一次事务只分配一个 revision。官方文档把 Chubby 的 sequencer 解释为近似于 revision 加 lease ID 的组合;在 etcd 自己的键上,事务的比较条件就是资源端检查。本篇用 PostgreSQL 的单调列代替 revision,原理相同,规模和可用性完全不同。
关键实现
获取租约是一条 INSERT ... ON CONFLICT DO UPDATE。冲突时只有在旧行已过期的情况下才更新,并在同一条语句里把 token 加一:
1 | |
RETURNING 只对实际插入或更新的行返回值,因此“有返回行”就是获取成功的判据。PostgreSQL 16 的 INSERT 参考页说明 ON CONFLICT DO UPDATE 在没有独立错误时保证原子的插入或更新结果。F3 用 16 个线程、各自的连接、CyclicBarrier 同时出发验证了这一点:新建一轮、过期后一轮各恰好一个获胜者,有效期内一轮没有获胜者,48 次尝试后 token 为 2。
续租的条件写在 WHERE 里:name 与 token 确定是哪一段持有期(I2 保证 token 不复用),expires_at > now() 拒绝过期后的补续;holder 条件用来防止调用方传错参数。
1 | |
过期后不允许“补续”。即使过期期间无人接手,补续在 token 意义上也不算错,但持有者此时已越过了本地截止时间,按约定应当停止工作;让它重新获取、换一个新 token,可以使“一个 token 对应一段连续持有”这条规则没有例外。
资源端的作用域级栅栏把“比较”和“推进 floor”合成一条条件 UPDATE,再在同一事务里写数据:
1 | |
保证来自这条 UPDATE 的两个性质,与“用了事务”这个事实本身无关。第一,它在 fence_floor 行上持有行锁直到提交,同一作用域的写入者在这里排队,数据写入因此按 floor 的推进顺序串行化。第二,按 PostgreSQL 16 文档 §13.2.1,Read Committed 下的 UPDATE 遇到被并发事务更新的行时会等待,对方提交后对更新后的行版本重新求值 WHERE;后到者看到的是已经推进过的 floor,而不是它语句开始时的快照。gap 是测试钩子,生产代码里为空操作。
故障反例
先检查后写入
F6(a) 把资源端拆成两步:先 SELECT max_token,判断通过后再分别更新 floor 和数据,每步自动提交。用 CountDownLatch 构造确定交错:
- A(token=1)读到 floor=0,判断通过,停在钩子里。
- B(token=2)读到 floor=0,判断通过,把 floor 写成 2,写入
new(B)。 - A 继续,把 floor 写成 1,写入
old(A)。
1 | |
floor 回退的后果比一次覆盖更严重:此后 token=1 的其他写入也会被当作合法。F6(b) 用同一交错跑条件 UPDATE 方案:A 推进 floor 后停在写数据之前,事务未提交;B 的 UPDATE 在 pg_stat_activity 中显示为 wait_event_type = 'Lock',迟迟不返回。A 提交后 B 对新版本行求值 1 <= 2 成立,写入被接受,最终内容来自 token=2。反向顺序下,B 先推进到 2,A 的 token=1 被拒绝。两种顺序的结果都满足 I3。
栅栏粒度
F7 检查一个容易被逐行比较掩盖的缺口。A(token=1)写过 summary 后租约过期,B(token=2)只重写了 summary;A 随后写入一个 B 还没触及的分片 detail。
1 | |
逐行比较 WHERE r.token <= EXCLUDED.token 只约束已存在的行,新行走插入分支,没有可比较的旧值。栅栏的粒度必须与租约保护的范围一致:租约保护的是整个作用域,floor 就要按作用域维护,并且所有写入都必须先经过它。
长事务里的 now()
PostgreSQL 16 文档 §9.9.5 说明,now() 等价于 transaction_timestamp(),返回事务开始时间,事务内不变。F2 构造了一个后果:
1 | |
(时间戳每次运行不同。)在获取路径上,旧的 now() 会多拒绝;而一旦获取成功,写入的 expires_at 以事务开始时间为起点,租约实际比客户端本地截止时间更早到期,下文“起点取在请求发出前”的论证在长事务里也随之失效。在续租路径上方向相反:按同一文档的定义推断,长事务里的 expires_at > now() 可能把实际已过期、尚无人接手的租约判为未过期而续上(这一情形本实验没有单独构造)。它不破坏 I3,因为若已有人接手,token 条件会让续租失败;但 I1 的判断时刻从“语句执行时”退回到了“事务开始时”。实现上,LeaseStore 的三个操作都要求在自动提交连接上执行,F4 中的子进程也是如此。若必须在长事务中判断,应改用 clock_timestamp(),它返回调用时刻的实际时间。
时钟:续租、本地截止时间与安全余量
本实现只让一个时钟参与“是否过期”的判断:数据库服务器的 now()。客户端从不把自己的墙钟与 expires_at 比较,因为两台机器的墙钟之差无法从这两个值里得出。
客户端仍然需要一个本地截止时间,用来决定何时停止发起新的写入。LeaseStore.acquire 在发出请求之前读取 System.nanoTime(),以它为起点加上 ttl - margin。起点取在请求发出前而不是响应到达后,是因为服务器计算 expires_at 的时刻必然晚于请求发出,从更早的起点算出的截止时间只会更早。Chubby 论文 §2.8 对会话租约给出了同样形状的约束:客户端维护一个保守的本地租约超时,需要考虑 KeepAlive 回复的在途时间,并要求服务器时钟的速率比客户端快不超过一个已知常数因子。安全余量 margin 就是给这个速率差和在途时间留的空间。
这里有三处失败边界。时钟速率差超过余量所能覆盖的范围时,客户端会在服务器已判过期之后仍认为自己有效;停顿或延迟发生在本地检查之后时,余量多大都没用,F4 就是这种情况;续租失败只能在下一次续租时才被发现,两次续租之间的停顿同样不可见。所以本地截止时间的作用是让正常情况下的持有者及早收手、减少无效写入,安全性仍然只来自资源端的 token 比较。
不支持栅栏的外部副作用
报表作业有时还要调用外部系统,例如向下游发布“某日报表已生成”。如果外部系统不接受 token,资源端检查就无从做起。F9 用一个本地发布替身表示这种系统:它只按业务键去重(INSERT ... ON CONFLICT DO NOTHING)。
1 | |
业务键幂等能保证“每个业务键至多一条发布记录”,但它只是按到达顺序取第一个,不知道谁的数据更新。旧持有者恰好先到时,留下的是旧内容。对这类副作用可选的手段只剩两种:让副作用本身幂等,并把“保留哪一份”的判定放进业务规则,比如发布内容携带其源数据版本,下游只接受不低于已有版本的内容,这在效果上等于在业务层重建了一个栅栏;或者把副作用改为先写入本地受栅栏保护的表,再由单独的投递流程转发。Chubby 的 lock-delay 是另一种折中,它只能缩小窗口,论文自己也说它不完美。
恢复流程
三类故障的恢复都不需要人工清理租约行。
持有者进程崩溃:不再续租,租约按数据库时钟过期,其他实例获取到更大的 token。崩溃前已提交的写入保留,未提交的事务随连接断开由服务器回滚。本篇没有单独对持有者执行 kill -9;它在租约层面的效果与 F4 中“停止续租”相同,区别只是不会再恢复。
持有者暂停后恢复:它的下一次续租失败(F4 中为 RENEW_AFTER_WRITE ok=false),按约定应停止工作、丢弃手中的中间结果,再走一次获取。它在恢复瞬间已经发出的写入,由资源端拒绝。
数据库崩溃:F8 用 pg.sh restart-immediate 做不经检查点的立即停机,再由 WAL 恢复。
1 | |
租约行在重启后原样存在,并且 TTL 还在流逝:数据库停机期间无法续租,持有者在本地截止时间到达后应自行停手。这与 Chubby 主节点切换时暂停会话计时的做法(Chubby 论文 §2.9)不同,本实现没有那种机制,停机时间计入租约。I2 在这里成立的前提是 token 的递增随事务一起持久化,并且数据库重启后不回到更早的状态。
保证清单
表 26-2:各项保证的成立条件、观察位置与失败边界
| 保证 | 成立条件 | 观察位置 | 失败边界 |
|---|---|---|---|
| 同一时刻至多一个有效持有者(I1) | 获取为单条 ON CONFLICT 语句;在自动提交下以 now() 判断 |
F3 获胜线程数;lease.expires_at |
长事务中的 now() 偏旧;客户端用自己的时钟判断 |
| token 严格递增、不复用(I2) | 递增与获取同一语句;同一事务持久化 | F1–F3、F8 中 lease.token |
异步复制切换丢失已提交递增;手工重置 lease 表 |
| 资源端 token 单调不减(I3) | 所有写入先经过同一作用域的条件 UPDATE,并与数据写入同一事务 | fence_floor.max_token;write_attempt.accepted |
先读后写(F6a);逐行比较(F7);绕过 ReportStore 直接写表 |
| 暂停的旧持有者不能写入 | I2 与 I3 同时成立 | F4 中 A 的 WRITE accepted=false |
资源端不检查 token(F5);外部副作用不支持 token(F9) |
证据与限制
运行入口是 examples/lease-fencing/run.sh,它启动端口 55426 的临时集群 b3-26,重建 lease26 与 res26 两个库,编译并运行 lab.lease.LeaseLab 的 F1–F9,把输出写入 evidence/26/output.txt,最后停止集群。本次运行的汇总行为 SUMMARY passed=86 failed=0,退出码 0。环境为 OpenJDK 21.0.12.1、PostgreSQL 16.15、pgJDBC 42.7.7。
实验类别:F4、F5 是多进程集成,两个独立 JVM 由 kill -STOP/kill -CONT 控制;F8 是数据库立即停机后的崩溃恢复;其余为真实 PostgreSQL 事务、约束与行锁上的组件实验,其中 F6 用闩锁构造确定交错。
这些实验不证明以下内容:跨机器的网络延迟与分区;租约库主备切换下 token 的单调性;时钟速率偏差或跳变下安全余量是否足够(本实验未注入时钟故障);暂停在真实负载中出现的概率,F4 中停顿的位置是人为选定的最坏位置。TTL、续租间隔与等待时长只是教学参数,本篇不报告任何延迟或吞吐数据。逐条限制见 evidence/26/LIMITS.md。
概念出处:租约来自 Gray 与 Cheriton 1989 年在 SOSP 发表的 Leases 论文,本环境只取得书目信息,正文未据此作细节论断;sequencer 与 lock-delay 来自 Chubby 论文(OSDI 2006)§2.4,会话租约与本地保守超时来自同文 §2.8;lease 与 revision 来自 etcd v3.6 文档。
本批导航
- 上一篇:25 缓存旧值回填与失效修复
- 下一篇:27 测试账户的余额与账本
- 本批索引:仓库
batch-3/INDEX.md(阅读顺序与运行入口),batch-3/STATUS.md、batch-3/SOURCES.md - 运行入口:
batch-3/examples/lease-fencing/run.sh - 证据目录:
batch-3/evidence/26/ - 本篇工程与证据快照:backend-systems-26.tar.gz(SHA-256 见同目录
backend-systems-26.tar.gz.sha256)。正文中的examples/、evidence/、SOURCES.md路径都相对仓库的batch-3/目录,与快照包内布局一致。






