多个实例同时执行定时任务时,常见做法是先抢一把带过期时间的锁。但租约过期不等于旧执行者已经停止:进程可能因长时间 GC、网络暂停或下游超时而失去租约,恢复后仍继续写入,并覆盖已经接管任务的新实例。要把这个竞态变成可验证的约束,可以在租约之外增加单调递增的 fencing token,让结果存储拒绝旧执行者。
下面用 PostgreSQL 保存租约;每次成功接管任务都递增令牌:
create table job_leases (
job_key text primary key,
lease_owner uuid not null,
lease_until timestamptz not null,
fencing_token bigint not null
);
insert into job_leases (job_key, lease_owner, lease_until, fencing_token)
values (:job_key, :owner, clock_timestamp() + interval '30 seconds', 1)
on conflict (job_key) do update
set lease_owner = excluded.lease_owner,
lease_until = excluded.lease_until,
fencing_token = job_leases.fencing_token + 1
where job_leases.lease_until < clock_timestamp()
returning fencing_token, lease_until;
语句未返回行表示已有有效持有者,本轮直接退出。owner 应是本次执行生成的唯一 UUID,而不是长期复用的实例编号,否则旧进程可能冒充同一持有者续租。续租时同时校验 owner、token 和租约尚未过期:
update job_leases
set lease_until = clock_timestamp() + interval '30 seconds'
where job_key = :job_key
and lease_owner = :owner
and fencing_token = :token
and lease_until >= clock_timestamp()
returning lease_until;
续租失败后执行者必须停止发起新工作,但这仍不能终止已经在途的写入,因此业务结果也要保存最后接受的 token:
create table report_results (
job_key text primary key,
payload jsonb not null,
applied_fencing_token bigint not null
);
insert into report_results (job_key, payload, applied_fencing_token)
values (:job_key, :payload, :token)
on conflict (job_key) do update
set payload = excluded.payload,
applied_fencing_token = excluded.applied_fencing_token
where report_results.applied_fencing_token < excluded.applied_fencing_token;
这样即使 token 17 的执行者暂停到租约失效,token 18 已接管并写入后,旧执行者恢复提交也会因条件不成立而影响零行。调用方必须检查受影响行数,不能把零行当作成功。若任务会修改多张表,应在同一事务内对一个任务状态行做 token 条件更新,并把其他业务写入绑定到这次成功更新;若副作用发生在不支持 token 比较的外部系统,则先在本地事务写 outbox,以稳定的业务键让消费者幂等处理。
可复现验收可以使用两个数据库会话:A 获取 token 1 后暂停且不续租;等待租约过期,B 获取 token 2 并写入结果;随后恢复 A。最终结果必须来自 B,A 的结果写入影响零行。再补充续租边界、执行进程重启、数据库事务回滚、重复投递和外部调用超时等用例。
这套方案的取舍是增加了一列状态和每次接管的数据库写入,但它解决的不是“尽量只有一个执行者”,而是“即使出现两个执行者,旧执行者也不能提交更旧的结果”。监控至少应包含抢占失败数、续租失败数、陈旧 token 拒绝数和任务执行时长分位数;陈旧写入一旦出现,说明租期或下游延迟假设已经被实际运行打破。