多设备同时执行一个会话:用租约明确执行归属
聚焦跨设备的执行互斥:为什么前端 loading 不够,如何用数据库原子获取、带身份的续租与释放,避免旧任务误删新任务的占用状态。
- PostgreSQL
- 并发控制
- 租约
- AI Agent
关联案例:AI Agent 应用平台。本文提炼本地多设备活跃会话的实现思路;示例表名与字段已泛化,SQL 用命名参数表达绑定值,不是可直接操作业务数据库的脚本。
同一个账号,不止一个 loading 状态
桌面端和浏览器可以同时进入同一会话。每台设备自己的按钮禁用状态,只约束当前界面,无法阻止另一台设备开始执行。服务若运行在多个进程里,进程内锁也无法覆盖所有请求。
需要由服务端统一回答:这个会话当前属于哪一次执行,这个归属还能持续多久。
为什么使用有期限的占用记录
如果只写一个 running = true,进程异常退出后可能没人清理。租约把占用限制在有效期内:运行者定期续租,失联者的占用最终过期。
核心字段包括会话归属键、每次执行生成的唯一 run_id 和过期时间。run_id 必须代表一次执行,不能简单复用设备 ID。
CREATE TABLE demo_session_lease (
account_id text NOT NULL,
session_id text NOT NULL,
run_id uuid NOT NULL,
expires_at timestamptz NOT NULL,
PRIMARY KEY (account_id, session_id)
);
会话权限应由服务端先校验;租约只管理已授权执行之间的竞争,不代替用户身份验证。
获取必须是一个原子决定
“先查有没有人运行,再插入”存在竞争窗口:两个请求可能都查到空闲。项目采用带条件的 UPSERT,将冲突裁决交给数据库的唯一约束。
WITH lease_clock AS (
SELECT clock_timestamp() AS observed_at
)
INSERT INTO demo_session_lease AS held (
account_id, session_id, run_id, expires_at
)
SELECT :account_id, :session_id, :run_id,
observed_at + make_interval(secs => :ttl_seconds)
FROM lease_clock
ON CONFLICT (account_id, session_id) DO UPDATE
SET run_id = EXCLUDED.run_id,
expires_at = EXCLUDED.expires_at
WHERE held.expires_at <= (SELECT observed_at FROM lease_clock)
RETURNING run_id;
现有租约有效时,更新条件不成立,RETURNING 没有行,调用方应把它当作未获得执行权。相关语义见 PostgreSQL INSERT。
clock_timestamp() 使用数据库时间,区别于固定在事务起点的 now(),见 PostgreSQL 时间函数。示例在单条语句内取一次时间以保持一致;获取事务应短且有合理的锁等待、语句超时,不能拿到一个早已过期的结果后仍直接执行。
成功获取需要提交事务后,才进入耗时执行。不要让数据库事务贯穿整段模型生成或工具调用。
续租与释放都必须带执行身份
续租应同时核对会话键、run_id 和未过期条件。过期执行不能通过迟到心跳自行复活。
释放也不能只按会话 ID 删除。设 A 超时、B 已接管,此时 A 的清理逻辑才运行;如果它不检查 run_id,就会删掉 B 的租约。
DELETE FROM demo_session_lease
WHERE account_id = :account_id
AND session_id = :session_id
AND run_id = :run_id
RETURNING run_id;
带执行身份的删除,让旧 A 的清理只影响 A 自己的记录。删除零行是可预期的幂等结果,不应被误报为整个会话状态损坏。
租约不自动撤销已发出的副作用
A 失去租约后,它已经发出的外部命令仍可能继续运行。数据库里的占用唯一,不等于任意外部工具都已经严格互斥。
执行侧应在续租失败后停止后续工作。对于必须防止旧执行继续写入的资源,还需要资源端验证执行令牌,必要时使用单调递增的 fencing token。唯一 UUID 能区分执行,不天然提供单调顺序。这属于进一步的资源保护设计,不应把“有租约表”说成问题全部解决。
验证竞争与迟到清理
- 两个独立数据库会话同时竞争同一键,只有一个成功。
- 不同会话互不阻塞,有效租约不能被新执行直接覆盖。
- 过期后可以接管,旧执行不能续租或释放新记录。
- 在获取后进程退出,租约最终过期,前端能够重新获取权威状态。
- 续租失败时执行侧停止后续工作,界面不继续显示自己独占执行。
前端展示数据库裁决后的状态,服务端执行遵守同一份归属约定,这样多设备界面才不会各自宣布“我正在运行”。