关于 DavidHLPL · RHINE LAB RSS ↗
档案 X-1190 / 项目实践

数据库租约不够:UltiCode 用 Fence Token 拦住过期单例任务

工程笔记

发布 / DavidHLPL

证据状态:本文依据 UltiCode 固定提交中的 FencedJobLeaseMapper、FencedJobLeaseService、OwnerReconciler、ReconciliationRunMapper、ADR 和 MySQL 迁移整理。源码和边界测试说明了 stale completion 会被拒绝;不等于所有外部副作用都具备 fencing,也不等于单例任务 exactly-once。

假设系统只允许一个 reconciliation 任务运行。实例 A 获取了租约,开始扫描;过了一段时间,A 停顿,租约过期。实例 B 获取新租约并完成了新一轮扫描。此时 A 恢复,继续执行最后一步:把自己的结果写成“任务已完成”。

如果数据库更新只检查 lease_name 或 owner_token,A 可能仍然成功:它曾经是合法 owner,而数据库只知道“现在有一个租约”,不知道这个写入来自哪一代租约。B 的新结果就可能被旧执行者覆盖。

这就是普通租约与 fenced lease 的差别:租约负责决定谁可以开始,fence token 负责决定谁还有资格结束。

先看朴素方案为什么会失效

进程内锁只能解决单进程

synchronized、ReentrantLock 或单例 Bean 可以阻止同一个 JVM 中的重复执行,但服务重启、第二个副本或独立运维脚本出现后,锁的作用域就结束了。它适合明确只有一个进程的离线任务,不适合把数据库写入当作全局事实的服务集群。

只有 owner + expiry 的租约仍允许旧 owner 回来

典型的租约表有 lease_name、owner_token 和 leased_until。获取时抢占过期租约,写入时检查 owner 和时间,看起来已经能处理宕机恢复,但它没有表达“这是第几次获取”。

时间检查也不是万能的:旧 worker 可能在检查后暂停,等到租约过期和新 owner 接管后才继续写;应用层的本地时间还可能和数据库时间存在偏差。

Redis 分布式锁不是自动的 fencing

Redis 锁可以帮助协调获取,但如果下游数据库更新不携带并检查一个单调递增 token,旧 owner 仍然可能在锁过期后完成写入。把锁换成另一种存储,不会自动得到 stale-writer protection。

UltiCode 的核心机制:租约代次随接管递增

UltiCode 用 fenced_job_leases 保存单例任务的租约,并为每次有效接管分配 fence_token:

lease_name = admin:reconciliation

first owner:  owner=A, fence=7, leased_until=T1
lease expires
new owner:   owner=B, fence=8, leased_until=T2

FencedJobLeaseMapper 的数据库 upsert 只有在租约已过期时才接管,并提升 token;获取、续租、释放和 isHeld 都要求 lease_name + owner_token + fence_token 同时匹配。租约时间使用数据库的 CURRENT_TIMESTAMP(3) 参与判断,避免把应用进程的本地时钟当作唯一事实来源。

这一步解决的是“谁是当前 owner”与“这个 owner 属于哪一代租约”之间的歧义。A 即使持有旧的 owner token,也只有旧 fence 7;B 接管后数据库已经进入 fence 8,A 的后续更新可以被条件直接拒绝。

真正的围栏在完成写入处

获取 token 本身还不够。必须把 token 一路带到最终写入,才能在旧 worker 最容易造成损害的地方实施检查。

OwnerReconciler 获取 admin:reconciliation 后,把 owner token 和 fence token 记录到 reconciliation run,并在执行过程中续租。完成 run 时,ReconciliationRunMapper 使用带 join 的条件更新:

UPDATE reconciliation_runs AS r
JOIN fenced_job_leases AS l
  ON l.lease_name = :leaseName
SET r.status = :status,
    r.finished_at = :finishedAt
WHERE r.run_id = :runId
  AND r.fence_token = :runFenceToken
  AND l.owner_token = :ownerToken
  AND l.fence_token = :fenceToken
  AND l.leased_until > CURRENT_TIMESTAMP(3)

上面是从实现中抽出的简化示例,省略了其他字段;它保留了决定语义的条件。任何一个条件不满足,受影响行数就是 0。调用方不能把 0 行当作“稍后再写一次”,而应把它解释为租约丢失、旧 token 或竞争者接管。

这和只在 Java 代码里写:

if (lease.isHeld()) {
    run.setFinished(true);
}

不同。Java 检查和数据库更新之间存在竞态;条件更新把检查和写入放在同一个数据库原子操作里。

为什么不直接使用数据库悲观锁

SELECT ... FOR UPDATE 可以保护一次事务内的行,但 reconciliation 可能持续很久,不能把数据库事务一直打开;长事务会持有锁、放大资源消耗,也不适合跨多个外部步骤。

fenced lease 把长任务拆成短事务:获取、续租、记录进度、完成,每次都用 token 验证当前资格。它牺牲了“整个任务被一把锁包住”的简单感,却让任务可以在事务之外运行,并让最终提交仍由数据库判定。

这不是说悲观锁没有用。在很短的本地临界区里,它可能更直接;当任务包含网络、文件或长时间计算时,租约 + fencing 通常更贴合生命周期。

这个设计真正解决了什么

  • 跨实例协调:唯一 lease_name 和数据库约束让多个实例竞争同一个单例任务。
  • 过期接管:旧实例不续租时,新实例可以在租约过期后接管。
  • 旧完成写入失效:新接管会提升 fence token,旧执行者无法满足最终写入条件。
  • 数据库时间一致性:租约有效性判断使用数据库时钟,而不是把应用本地时钟当作权威。
  • 失败结果可观测:0 行更新是明确的 stale/lost-lease 信号,而不是静默覆盖。

它没有解决:任务已经调用的外部 API 是否可回滚、文件是否已写入、第三方是否检查 token、旧 worker 是否会继续消耗 CPU,或者整个业务是否只执行一次。Fencing 主要保护“被 fence 的写入 seam”;不受 token 约束的副作用仍需要幂等键、补偿或独立的 fencing 协议。

成本、适用条件与失效边界

适用信号包括:任务必须单例运行;任务可能跨越租约 TTL;实例可能暂停、重启或被替换;最终状态写入可以回到同一个数据库。

它增加的成本也很明确:token 必须从获取点传到每个有副作用的写入点;续租和 TTL 需要监控;每一种终态更新都要写正确的条件;测试必须覆盖“旧 owner 在新 owner 接管后返回”。如果所有工作都在一个短事务内完成,直接使用行锁或唯一约束更简单。

最容易遗漏的边界是只给“获取”加 fence,而没有给“完成、发布、删除或标记成功”加 fence。那样系统只是拥有一个带编号的锁,仍然没有阻止 stale completion。

对 LLM/Agent 调度的迁移

一个长时间运行的 Agent 任务也可能因为模型切换、取消恢复或 worker 重启而出现多个执行者。可以把计划版本或任务 epoch 作为 fence token,并要求工具结果、最终摘要和任务状态更新同时携带它。旧计划可以继续运行到自然结束,但没有资格把新计划标记为完成。

这里的迁移重点不是“给 Agent 加分布式锁”,而是问:哪些副作用必须在数据库门口再次验证当前计划版本?如果一个工具只依赖内存中的 isCurrent 判断,它仍然可能在检查和写入之间失效。

最小验证路径

先阅读 FencedJobLeaseMapper 的获取/续租/释放条件,再阅读 ReconciliationRunMapper 的完成更新。最后看 FencedJobLeaseIT 和 OwnerReconcilerTest:一个验证租约接管后旧释放失败,另一个验证旧 owner 的完成写入被拒绝。

本次 UltiCode 分析阶段已实际执行并通过该 fenced lease 的两项 MySQL 8 Testcontainers 集成测试以及 reconciliation 的定向单元测试;这验证的是选定竞争场景,不是性能、网络分区或所有外部副作用的证明。

来源与关联文档

研究记录

  1. 原始文档: src/content/note/zh-cn/ulticode-fenced-singleton-lease.md
  2. 来源版本: f49953fb0d4bd52a39bf81167b0c4b7dd7d78a64
  3. 证据状态: 暂定

原文来源 ↗