一个每日报表作业部署了多个实例,同一时刻只应有一个实例往报表输出库写结果。常见做法是在数据库里放一张租约表:谁抢到租约谁干活,租约到期没有续上就让给别人。这套做法能减少重复执行,但它本身不能阻止一种写入:实例 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
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 租约库 lease26
CREATE TABLE lease (
name text PRIMARY KEY,
holder text NOT NULL,
token bigint NOT NULL CHECK (token > 0),
expires_at timestamptz NOT NULL
);
-- 资源库 res26
CREATE TABLE fence_floor (scope text PRIMARY KEY, max_token bigint NOT NULL);
CREATE TABLE report_part (
scope text, part text, content text NOT NULL, writer text NOT NULL,
token bigint NOT NULL, written_at timestamptz NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY (scope, part)
);

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
2
3
4
5
6
7
8
CASE F1 :: 正常路径:获取 → 写入 → 续租 → 释放 → 下一持有者获取更大 token 并写入
ok holder-a 获取租约,token=1
ok holder-a 以 token=1 写入被接受
ok 续租成功,token 不变
ok holder-a 主动释放
ok 释放后 holder-b 立即获取,token=2
ok holder-b 以 token=2 写入被接受
ok 资源端 fence_floor=2 (expected=2, actual=2)

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
2
3
4
5
6
7
ok   A 未暂停期间(等待 3000ms,TTL=1500ms)续租维持租约
ok SIGSTOP 后 /proc 中 A 的状态为 T(续租线程同样停止) (expected=T, actual=T)
ok B 获取租约,token=2 (expected=ACQUIRED token=2, actual=ACQUIRED token=2)
ok B 写入被接受 (expected=WRITE accepted=true token=2, actual=WRITE accepted=true token=2)
note A 恢复后:WRITE accepted=false token=1;RENEW_AFTER_WRITE ok=false
note 最终 summary = rows=201(B) by holder-b token=2;fence_floor=2
ok write_attempt 记录一次 A 的被拒尝试 (expected=1, actual=1)

A 恢复时 B 已经释放了租约,租约表此刻处于空闲状态,A 的写入照样被拒绝。资源端的判断依据是它自己持久化的 fence_floor,与租约此刻是否有人持有无关。

A 的停顿位置是有意挑选的:放在“本地检查通过”和“发出写请求”之间。如果停顿落在检查之前,A 恢复后会因单调时钟已越过本地截止时间而自行放弃;落在检查之后,任何客户端侧的检查都来不及。网络中滞留的写请求也属于后一类,它离开客户端时持有者确实有效,到达资源端时已经无效。本实验只构造了进程停顿这一种来源。

F5 用完全相同的时间线,只把资源端换成不检查 token 的普通 upsert:

1
2
3
4
CASE F5 :: 负向对照:……资源端模式=UNFENCED
note A 恢复后:WRITE accepted=true token=1;RENEW_AFTER_WRITE ok=false
note 最终 summary = rows=200(A) by holder-a token=1;fence_floor=0
ok negative control exposes: 资源端不检查 token 时,A 的旧结果覆盖了 B 的结果

两次运行中租约服务的行为一模一样: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
2
3
4
5
6
7
8
9
10
String sql = """
INSERT INTO lease AS l (name, holder, token, expires_at)
VALUES (?, ?, 1, now() + make_interval(secs => ?::float8 / 1000))
ON CONFLICT (name) DO UPDATE
SET holder = EXCLUDED.holder,
token = l.token + 1,
expires_at = EXCLUDED.expires_at
WHERE l.expires_at <= now()
RETURNING l.token
""";

RETURNING 只对实际插入或更新的行返回值,因此“有返回行”就是获取成功的判据。PostgreSQL 16 的 INSERT 参考页说明 ON CONFLICT DO UPDATE 在没有独立错误时保证原子的插入或更新结果。F3 用 16 个线程、各自的连接、CyclicBarrier 同时出发验证了这一点:新建一轮、过期后一轮各恰好一个获胜者,有效期内一轮没有获胜者,48 次尝试后 token 为 2。

续租的条件写在 WHERE 里:name 与 token 确定是哪一段持有期(I2 保证 token 不复用),expires_at > now() 拒绝过期后的补续;holder 条件用来防止调用方传错参数。

1
2
3
4
int n = lab.common.Db.update(c, """
UPDATE lease SET expires_at = now() + make_interval(secs => ?::float8 / 1000)
WHERE name = ? AND holder = ? AND token = ? AND expires_at > now()
""", g.ttlMillis(), g.name(), g.holder(), g.token());

过期后不允许“补续”。即使过期期间无人接手,补续在 token 意义上也不算错,但持有者此时已越过了本地截止时间,按约定应当停止工作;让它重新获取、换一个新 token,可以使“一个 token 对应一段连续持有”这条规则没有例外。

资源端的作用域级栅栏把“比较”和“推进 floor”合成一条条件 UPDATE,再在同一事务里写数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
c.setAutoCommit(false);
try {
// 比较与推进栅栏是同一条 UPDATE;它持有 fence_floor 行锁直到提交,
// 同作用域的其他写入者在这里排队,并在锁释放后对新版本行重新求值 WHERE。
int n = Db.update(c, "UPDATE fence_floor SET max_token = ? WHERE scope = ? AND max_token <= ?",
token, scope, token);
if (n == 0) {
c.rollback();
return false;
}
gap.run();
Db.update(c, UPSERT, scope, part, content, writer, token);
c.commit();
return true;
}

保证来自这条 UPDATE 的两个性质,与“用了事务”这个事实本身无关。第一,它在 fence_floor 行上持有行锁直到提交,同一作用域的写入者在这里排队,数据写入因此按 floor 的推进顺序串行化。第二,按 PostgreSQL 16 文档 §13.2.1,Read Committed 下的 UPDATE 遇到被并发事务更新的行时会等待,对方提交后对更新后的行版本重新求值 WHERE;后到者看到的是已经推进过的 floor,而不是它语句开始时的快照。gap 是测试钩子,生产代码里为空操作。

故障反例

先检查后写入

F6(a) 把资源端拆成两步:先 SELECT max_token,判断通过后再分别更新 floor 和数据,每步自动提交。用 CountDownLatch 构造确定交错:

  1. A(token=1)读到 floor=0,判断通过,停在钩子里。
  2. B(token=2)读到 floor=0,判断通过,把 floor 写成 2,写入 new(B)。
  3. A 继续,把 floor 写成 1,写入 old(A)。
1
2
note check-then-write:B accepted=true,A accepted=true,最终 old(A) by holder-a token=1,fence_floor=1
ok negative control exposes: A 的检查结果在写入时已过时:旧 token 覆盖新结果,fence_floor 从 2 回退到 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
2
3
note FENCED_ROW:B summary accepted=true,A(token=1) detail accepted=true
ok negative control exposes: 行级比较只看被写的那一行;新行没有旧 token 可比,旧持有者写入成功
note FENCED_SCOPE:B summary accepted=true,A(token=1) detail accepted=false

逐行比较 WHERE r.token <= EXCLUDED.token 只约束已存在的行,新行走插入分支,没有可比较的旧值。栅栏的粒度必须与租约保护的范围一致:租约保护的是整个作用域,floor 就要按作用域维护,并且所有写入都必须先经过它。

长事务里的 now()

PostgreSQL 16 文档 §9.9.5 说明,now() 等价于 transaction_timestamp(),返回事务开始时间,事务内不变。F2 构造了一个后果:

1
2
3
4
ok   同一事务内 now() 不随时间前进 (expected=2026-10-19 13:06:49.33416+08, actual=2026-10-19 13:06:49.33416+08)
ok clock_timestamp() 已越过 now()
ok 自动提交连接已判定过期,但长事务内的获取仍按事务开始时间判为未过期而拒绝
ok 自动提交模式下获取成功,token=4

(时间戳每次运行不同。)在获取路径上,旧的 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
2
3
ok   顺序 B→A:A 的重复发布被业务键去重
note 顺序 A→B:A accepted=true,B accepted=false,保留 A-stale
ok negative control exposes: 幂等只保证一次,旧持有者先到时保留的是旧内容

业务键幂等能保证“每个业务键至多一条发布记录”,但它只是按到达顺序取第一个,不知道谁的数据更新。旧持有者恰好先到时,留下的是旧内容。对这类副作用可选的手段只剩两种:让副作用本身幂等,并把“保留哪一份”的判定放进业务规则,比如发布内容携带其源数据版本,下游只接受不低于已有版本的内容,这在效果上等于在业务层重建了一个栅栏;或者把副作用改为先写入本地受栅栏保护的表,再由单独的投递流程转发。Chubby 的 lock-delay 是另一种折中,它只能缩小窗口,论文自己也说它不完美。

恢复流程

三类故障的恢复都不需要人工清理租约行。

持有者进程崩溃:不再续租,租约按数据库时钟过期,其他实例获取到更大的 token。崩溃前已提交的写入保留,未提交的事务随连接断开由服务器回滚。本篇没有单独对持有者执行 kill -9;它在租约层面的效果与 F4 中“停止续租”相同,区别只是不会再恢复。

持有者暂停后恢复:它的下一次续租失败(F4 中为 RENEW_AFTER_WRITE ok=false),按约定应停止工作、丢弃手中的中间结果,再走一次获取。它在恢复瞬间已经发出的写入,由资源端拒绝。

数据库崩溃:F8 用 pg.sh restart-immediate 做不经检查点的立即停机,再由 WAL 恢复。

1
2
3
4
ok   租约行恢复:holder-a 仍持有 token=2(租约不随数据库连接断开而失效) (expected=holder-a/2, actual=holder-a/2)
ok fence_floor 恢复为 2 (expected=2, actual=2)
ok 重启后的下一次获取得到 token=3,不复用 (expected=3, actual=3)
ok holder-a 的 token=2 写入被拒绝

租约行在重启后原样存在,并且 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/ 目录,与快照包内布局一致。