并发单体
摘要Corevo 如何拆开 WebSocket 入口、持久 Job 和执行 owner,让空闲 Worker 接走尚未开始的 Agent 任务。
文章目录8 节
Corevo 还没有拆成一组微服务时,已经先遇到了分布式系统的问题。
同一份后端可以启动多个 API Worker。用户的 WebSocket 落在 Worker A,一条耗时很长的 Agent 任务也在 A 执行。新的短任务继续从这条连接进来时,A 在排队,另外的 Worker 却可能空闲。入口负载均衡没有失效:它均衡的是连接,不是连接后面持续几分钟、会调用工具并产生副作用的 Agent 工作量。
我把这种形态叫作并发单体:同一应用制品和版本仍然一起构建、发布、回滚,运行时却已经有多个进程、事件循环、线程池和外部执行环境。发布边界还是单体,正确性边界不能再按单进程思考。
下面先用一个确定性的状态机模拟需要排队的 Web Chat 任务。场景固定为:WebSocket 在 W1,W1 正在执行长任务,W2 与 W3 空闲;随后从原连接进入一个短任务。三条 Worker 只是为了让调度关系可见,不代表 Corevo 的生产拓扑或容量。
Clean-room 实验 · 任务归属
连接在哪,不等于任务必须在哪执行
这是离散 tick 的状态机模拟,只比较所有权关系,不模拟 Redis、数据库、模型或网络延迟。
长任务占用
空闲
空闲
到开始执行
从进入系统到真正进入 running。
当前 owner
epoch 随重新 claim 单调增加。
状态结果
fencing 模式只验证迟到的 started。
选择一种模式,观察短任务的执行归属。
单体不等于单进程
同一应用制品可以同时运行 API、后台任务等不同角色;每个角色又可能有多个进程。此时内存、锁、任务句柄和 event loop 都是局部状态,“单体”只剩下一起发布和回滚的含义。
Corevo 没有先把它们拆成网络服务,而是先在代码里分出平台控制面、中立契约和 Agent Runtime。当前自动检查明确禁止新拆出的控制面反向导入内核,也禁止内核跨过中立契约依赖控制面;历史 Runtime 代码里尚未清零的反向依赖,则作为显式债务“只减不增”。这比画一张分层图更重要:新依赖越界会直接让测试失败。
把连接、任务和执行者拆开
最危险的耦合,不是三者在类型上真是同一个对象,而是它们被同一条进程本地 WebSocket 链路绑定:入口 handler 接收输入,本地状态记录排队,进程内句柄决定执行 owner。这里实际混在一起的是三种身份:
- Gateway:谁持有客户端连接,负责接收输入和推送流式事件。
- Job:用户提交了什么、是否准入、为什么正在排队。
- Execution owner:此刻由哪个 Worker 执行这一次尝试。
只要三种身份仍被这条本地链路绑定,连接落到哪里,任务就只能在哪里排队;用户重连到另一个进程后,本地也可能找不到原来的运行句柄。
Job 和 Execution 也不是同一个事实。Job 记录准入、排队、owner 和控制状态;Execution 描述一次真实执行尝试及其父子关系。前者解释“为什么还没开始”,后者让 trace、成本和副作用归到真正发生的那次执行。
并不是所有请求都绕一圈全局队列。入口 Worker 有容量时仍走本地快路径;只有需要排队的 Web Chat 才持久化启动输入,写入 ownerless Job,再进入可重建的候选索引:
Gateway 创建 Job
├─ 当前 Worker 有容量 → local direct
└─ 需要排队 → 持久化 submission
→ ready index 暴露候选
→ 空闲 Worker 尝试 claim
→ 数据库决定唯一 owner
这里有一个刻意的“不聪明”:Redis 只保存待办目录,不负责宣布“谁抢到了”。Worker 看到候选后,用数据库条件更新争夺 owner。下面的字段名已经泛化,但原子条件保留完整:
UPDATE jobs
SET owner = :worker,
claimed_at = :now,
epoch = epoch + 1,
token = :token
WHERE id = :job
AND state = 'queued'
AND owner IS NULL
AND claimed_at IS NULL
AND execution_id IS NULL
AND execution_started_at IS NULL;
只有更新命中一行才算 claim 成功。两个 Worker 同时看到同一个候选没有关系,数据库只允许一个成为 owner;随后候选才从 Redis 移除。索引丢失可以从数据库重建,反过来却不成立——一次 Redis pop 不能成为任务所有权的事实。
execution_started 是恢复边界
一个 Worker 在真正开始执行前短暂失联,任务被释放并由另一个 Worker 重新领取;旧 Worker 随后恢复,仍可能提交迟到的 running。因此每次 claim 都生成新的 epoch + token,queued → running 必须同时匹配 owner、epoch 和 token:
UPDATE jobs
SET state = 'running',
execution_id = :execution,
execution_started_at = :now
WHERE id = :job
AND state = 'queued'
AND owner = :worker
AND claimed_at IS NOT NULL
AND epoch = :epoch
AND token = :token
AND execution_id IS NULL
AND execution_started_at IS NULL;
epoch 提供顺序,token 绑定这一次租约。旧 Worker 即使补交 started 写入,也不能绑定过期 Execution。终态收敛是另一条独立的不变量:完成链还必须携带并校验自己预期的 execution 身份,不能从“started CAS 被拒绝”推导出“旧调用方的后续清理一定无害”。因此上面的实验停在迟到 started 被拒绝,不宣称 W3 后续一定完成。
更重要的是:只有 execution_started 之前的 claim 才会释放并重新排队。 一旦 Agent 已经开始调用外部工具,自动重跑可能重复发消息、写数据或扣款。运行中的 owner 失联,只能把平台事实收敛为 lost 或 timeout,不能假装把外部副作用无状态迁移走。
这些条件更新保护的是 claim 与 started ownership,不是整条完成链,更不是外部世界的 exactly-once。
任务换了 Worker,事件怎样回来
queued submission 必须先持久化,因为后来 claim 它的 W2 没有 W1 那条 WebSocket 的 Python 对象。W2 从持久输入重建运行命令,启动无前端连接的执行器;模型 delta、工具事件和终态再经过跨进程事件通道,投影回仍持有连接的 W1。Gateway 和 execution owner 至此真正分开。
取消和运行中追加输入走反方向:Gateway 先读取当前 owner,再把命令路由过去;owner 消费前还要复核 Job、Execution 和租约,避免旧命令落到新一代执行上。运行句柄继续留在本地进程,不需要为了横向扩展把整个 Agent 对象序列化。
但“消息已发出”不等于“操作已完成”。尽力投递的控制消息只能表示 requested,最终还要区分 owner accepted、already finished 和 unknown。平台把 Job 标成 lost,也不能自动证明远端进程和外部副作用已经停止。
同理,Agent 循环进入 completed 只说明平台执行结束。发消息、写第三方系统等动作仍需要业务回执、幂等键或 outbox;模型说“已完成”不能充当 commit log。
这条链怎样验证
浏览器实验只解释状态关系。团队对真实实现的验收还需要并发和故障用例:
| 故障用例 | 必须成立的不变量 |
|---|---|
| 两个 Worker 同时 claim | 只有一个数据库更新命中,只有一个 owner |
| Gateway 与 owner 不同 | 流式事件和终态仍回到原连接 |
| pre-start owner 失联 | 原排队时间不重置,epoch 增加后可重新领取 |
| running owner 失联 | 收敛为 lost,不自动重新执行 |
| 控制命令发到旧 owner | 租约复核失败,不能触碰当前 Execution |
这些用例定义了多 Worker 链路应守住的不变量,不等于端到端正确性已经自动成立。尤其是旧代 started 被拒绝后的失败清理,也必须继续携带旧代身份,不能误收口当前 Execution;这正是独立代码复审仍然抓得到的边界。受控环境的结果不能换算成生产并发数字,“一个平台终态”也不能写成外部副作用 exactly-once。
为什么我停止重写,也没有先拆服务
Corevo 早期耦合很重时,我曾从零写过一套更干净的 Agent 内核。它有独立循环、工具层、CLI 和测试,却没有承载真实产品主链。测试能证明新循环自己可以运行,不能证明它保留了旧系统积累的上下文、工具、错误和流式语义;继续维护只会得到两个内核、两套事实。
我最终停止重写,删除未接入主链的新循环和工具骨架,改为迁移当时经过真实链路验证的七层代码,并用薄 CLI 适配和依赖测试守住新增边界。后续依赖清理由团队继续完成。真实调用方、行为对照和可回滚迁移,比一套崭新的目录更重要。
这也形成了我的判断:此时不应先拆微服务。当状态 owner 和失败协议还在变化时,加入 RPC、独立发布和网络部分失败,只会把模糊边界搬到进程外。等某个模块确实需要独立扩缩容、故障隔离或发布节奏,而且协议已经稳定,再拆部署单元才是运维选择,不是业务重写。
调度之外还踩过什么坑
- 进程内锁不是全局配额。 我在另一条空间并发链路里处理过“先计数、再占位”的竞态:改成一次原子地清理过期成员、计数和准入,并用持有者心跳、正常释放和过期回收闭环。它和本文的 Platform Job claim 是两套机制,不能混为一谈。
- 多进程不会修复 event loop 阻塞。 同步上传、文件扫描或数据库调用进入
async def,仍会冻结所在 Worker 的心跳和流式输出;具体证据链在 《同步 I/O 拖死 Agent》。 - 本地缓存一扩副本就是分布式状态。 一个进程清掉工具缓存,不代表其他进程已经失效。Pub/Sub 适合提醒,持久事实、版本或 TTL 才负责自愈。
- 数据库事务不能跨长时间远程操作。 启动沙箱、上传对象和等待模型前先提交短事务,之后再用条件更新收敛结果;否则长任务会同时占住连接池和外部副作用。
这些坑的共同点是:多开进程能暴露错误假设,却不会自动替你建立全局不变量。
现在的边界
当前代码已经具备模块化控制面、queued Web Chat 的全局 claim、pre-start release/reclaim、started CAS 和跨 Worker 事件回推;这套 Job/Execution 控制面由团队共同演进。旧代失败清理还没有端到端带齐 fencing 身份,因此我不把它表述成“完整的 pre-start 恢复”。本文中我能直接归到自己名下的,是最初的依赖边界检查、一次错误内核重写的停止与迁移转向、旧空间配额的原子准入,以及子 Agent 的 best-effort 跨 Worker 取消语义。
我不把运行中迁移、持久化控制命令、任意复制后台角色或外部副作用 exactly-once 算作已经完成的能力。这些不是谦虚措辞,而是系统对故障负责的边界。
并发单体最终教会我的不是“单体比微服务好”。更准确的结论是:发布单元可以晚拆,状态所有权必须早拆;网络边界可以暂缓,正确性边界不能含糊。