让下一台机器接着做(下):我们如何设计一个可恢复的 Workflow Kernel

上篇解释了为什么「一个任务状态 + 一个从头跑到尾的函数」无法继续承载越来越长的任务处理链路,以及 Temporal 的 durable execution 思想给了我们什么启发。下篇进入实现:状态怎样分层,Worker 怎样接管中断任务,为什么 Redis 可以丢而 Mongo 不能丢,以及改造后增加了哪些恢复、干预和观察能力。本文基于一套真实运行的业务实现整理。为避免暴露具体产品和内部结构,示例中的业务名称、类型数量、字段名和流程组合均经过泛化;状态转换、故障窗口和协议约束保持不变。没有数据支撑的成功率、吞吐或成本改善也不会被写成量化结论。
这套 Kernel 处理的核心问题是:在至少一次执行、进程随时退出和外部系统不受我们控制的条件下,怎样让一项工作持续、可控地走向唯一业务终态。
本文内容仅为抽象化的示范讲解,用于说明一类通用设计思路;文中的业务场景、字段名与执行细节均为虚构示例,不代表任何真实线上系统的实际执行逻辑;读者不需要了解原项目也可以独立阅读。
持久化执行轨道让另一台 Worker 接续同一项工作

阅读词表

下篇会进入实现细节。下面这张表不要求读者记住字段名,只用来说明这些词分别在回答什么问题。

在本文中的含义
Workflow Kernel所有业务 Workflow 共用的运行时,负责解释 Definition、约束状态变化、调度、重试和恢复
Definition / InstanceDefinition 是一类流程的规则;Instance 是其中一次真实执行及其持久化状态
Step / ActivityStep 是恢复和观测边界;Activity 是 Step 内可能访问数据库或外部服务的业务代码
Attempt一个 Step 第几次真正进入 Activity。第一次执行是 Attempt 1;失败后再次执行是 Attempt 2
revisionWorkflow Instance 的状态版本号。每接受一次新的状态变化就递增,用来判断两条状态记录谁更新
biz_key一项业务工作的稳定身份,用来把重复请求归并到同一个 Workflow Instance
Business PriorityWorkflow 的候选调度等级,即 P0 到 P3;它只影响 Worker 先看见谁,不允许任务绕过 Mongo Claim 或下游流控
Output / CheckpointOutput 是已完成阶段的结果;Checkpoint 是当前阶段暂停后继续所需的信息,例如外部 task_id
Claim / Lease / claim_tokenClaim 是 Worker 领取某个 Workflow Instance 的执行权;Lease 是有效期;每次重新领取都会生成新的 claim_token,Workflow 状态写入必须携带当前值
fencing_token标记一份结果属于用户发起的哪一轮处理。例如第一次生成使用 f1,用户重新生成后换成 f2;这时才返回的 f1 旧结果会被拒绝。Worker 换机器不会改变这个轮次,第七节会详细解释
Ready Queue / WakeupRedis 中可丢失、可重建的候选索引与短通知,作用是让 Worker 更快发现工作,不决定最终执行权
Signal / Signal InboxSignal 是持久化的“叫醒单”;Inbox 保存尚未或已经匹配的叫醒记录,防止通知早到或进程退出时丢失
CAS / 条件更新Compare-And-Swap;只有数据库中的旧状态仍满足条件才执行更新,用来解决多个 Worker 同时推进的问题
Replay失败实例重新开放执行;已经确认完成的 Step 通常保留,只重做需要再次执行的部分

Outbox、Permit、Finalizer、Saga 和 Poll fallback 等只在特定机制中出现的词,会在对应章节结合业务场景解释。

一、先看改造后的全景

改造后的系统可以先记住一句话:

Mongo 保存不能丢的事实,Redis 加速可以重建的协调,Worker 随时可以替换。

图中的箭头分别承担以下语义:

  • API 先把业务对象和 pending 派发意图写入 Mongo,Publisher 再幂等登记 Workflow Instance;
  • Ready Queue 和 Wakeup 只负责让 Worker 更快发现候选,Worker 仍要回到 Mongo 原子 Claim;
  • Activity 调用下游之前先申请全局 Permit,完成后带 token 提交 Step,暂时不能完成时则保存 checkpoint 并释放 Claim;
  • Timer 或 Signal 可以重新激活 waiting 实例;需要通知大量等待方时,由独立 Fan-out Workflow 分页投递 Signal;
  • Finalizer 可靠提交业务终态,SSE 与 Observation 分别服务客户端进度和 CMS 观察,二者都不决定最终结果。

Mongo 和 Redis 是当前项目的技术选择,并不是这套设计成立的唯一组合。Mongo 可以换成另一种支持持久化和原子条件更新的数据库;Redis 也可以换成其他低延迟索引或通知设施。替换以后,各组件承担的职责仍需保持一致:候选索引只能提示「可能有任务可执行」,执行权必须由持久化存储授予;缓存和通知即使丢失,也不能让已经完成的业务结果发生变化。

以一个既调用下游服务、又写回业务对象的 Step 为例,从开始执行到最终提交完成,需要依次通过几项检查:

持久化状态确认:这个 Step 现在可以执行
+ Definition 确认:它符合预先定义的步骤顺序
+ claim_token 确认:提交者仍是当前持有执行权的 Worker
+ 业务轮次确认:结果属于用户当前这一轮处理
+ permit 确认:下游在调用发生前确实还有容量
= 系统接受这次状态推进

不是每个 Step 都需要全部条件。例如纯本地计算不需要申请下游 permit,不写业务对象的 Step 也不需要校验业务轮次;但只要某项条件已经声明,执行时就不能绕过。

Activity 返回成功,只表示这次函数调用已经结束。随后,Engine 还要使用当前 claim_token 提交 Step 状态;如果 Activity 写入了业务结果,对应更新还要校验 fencing_token。相关条件更新全部成功后,这个 Step 才会被记录为 completed。若执行权或业务轮次已经变化,更新不会命中,当前 Worker 必须停止推进。

从运行时看,Kernel 反复执行的是一个很小的解释循环:

业务 Definition 决定「做什么」,Kernel 解释「在什么条件下允许继续」。异步外部任务、网页处理、事件等待和批量分发因此可以共享同一套重试、租约、流控、等待和终态语义,而不需要共享彼此的业务代码。

二、先跟着一个异步内容任务走完全程

全景图容易让人记住组件,却不容易让人理解组件之间的协议。下面用一个经过泛化的例子贯穿全文:用户订阅一场直播的内容总结。订阅建立时直播可能尚未结束,系统需要先等待资源就绪;直播结束后,多个订阅方会被批量唤醒,再分别完成转录、内容生成和结果交付。具体产品形态并不重要,这个例子只用来串起长时间等待、批量通知、异步 ASR 和流式生成几类执行问题。

为了避免后文每讲一个机制就换一套角色,下面先给这项工作固定一组名字。它们都是为讲解简化过的示例值,后文没有特别说明时,指的都是同一项工作:

示例中的名字它表示什么
「小李的直播总结」object-42用户提交的这一项内容处理
「《架构夜谈》直播」live-7用户订阅的直播;资源就绪后才能开始后续处理
「第一轮生成」f1用户第一次发起处理;只有用户明确重新处理时才换成 f2
biz_key=content:object-42用来把重复请求归并为同一项工作的稳定身份
「总结 Workflow」wf-42承载小李这项总结工作全部执行进度的持久化实例
「订阅通知 Workflow」wf-fanout-7直播就绪后分页通知所有订阅任务的独立实例
「直播已就绪通知」live-ready:7:g1告诉总结 Workflow 第 1 代直播资源已经就绪的持久化叫醒单
preprocess → transcribe → compose → finalize这项工作的四个 Step
「ASR 转录单」a77transcribe Step 提交给 ASR 服务后得到的任务 ID
Worker A、B、C、D同一个 Worker 池在不同时刻认领的空闲槽位;字母只表示认领先后,任何时刻只占一个槽位、挂起时占零个
执行 token t1、t2……Worker 每次取得 Workflow 执行权时得到的临时凭证
派发 token d1、d2……补偿 Dispatcher 竞争派发意图时使用的另一组临时凭证

阅读故事时只需要记住中文名称,不需要背这些短 ID。短 ID 只会在存储结构、条件更新和并发竞争示例中出现,用来说明实现怎样区分不同对象和轮次。

整篇下文都围绕这件事展开:小李订阅了《架构夜谈》的直播总结,系统为他登记了一条总结 Workflow。直播尚未结束时,这条 Workflow 保存等待条件并释放 Worker,不需要让某个进程一直守着。

直播结束后,资源观察 Workflow 启动订阅通知 Workflow,分页向所有订阅任务发送「直播已就绪通知」。小李的总结 Workflow 被唤醒后,由 Worker A 接手;等到 ASR 容量允许,它创建一张 ASR 转录单,然后再次释放 Worker。后来的 Worker B、C 继续查询同一张转录单,不会重新提交音频。转录完成后,C 开始生成总结;如果 C 中途退出,D 会从已经保存的 Step 接着执行。最终写回结果时,系统还会确认它属于小李当前的生成轮次。SSE 和运维后台观察的也是这项工作的进度,但不参与决定它是否成功。

这里的 A、B、C、D 不是四个 Worker 排队轮值,而是同一个 Worker 池在不同时刻认领的空闲槽位:任何时刻这项工作最多占用一个槽位,进入等待后一个都不占。

先用一张图记住这项工作经过的组件和持久化事实。实线表示会改变执行状态的主路径,虚线表示不参与决定业务终态的展示与观察旁路:

后文各节是在放大这条时间线的不同片段:Outbox 解释订阅请求如何抵达总结 Workflow,Signal 与 Fan-out 解释直播就绪后怎样叫醒所有订阅任务,Claim 解释执行权如何先后交接,Permit 解释创建 ASR 转录单前为何可能等待,Checkpoint 解释为什么换人后仍查询同一张转录单,Fencing 解释旧结果为何不能覆盖新一轮,SSE 与监控则解释用户和工程师怎样看到这条执行过程。存储样例和条件更新仍会保留 wf-42a77f1 等短 ID,方便把故事对应到实际字段。

这条流程的业务顺序仍然很朴素:

复杂之处不在箭头数量,而在每条箭头之间都可能相隔几秒、几十分钟,甚至跨过一次服务发布。Workflow 要保存的,正是这些间隙里的执行事实。

1. 一次正常执行涉及六次交接

请求侧先解决「是不是同一次业务请求」

请求入口不会一上来就创建 Workflow。它先根据服务端对象 ID 或客户端幂等 ID,判断这次请求是否对应一项已经存在的业务工作;并发到达的重复请求要归并到同一个业务对象。

请求侧的短锁只保护「创建或选择哪一个业务对象」这段临界区。进入 Workflow 后,持久化幂等由 biz_key 唯一索引负责;即使短锁已经释放或 Redis 暂时不可用,同一项业务工作也不能创建出两条 Workflow Instance。

找到业务对象后,请求侧按业务状态处理:

业务状态请求侧动作
已删除拒绝继续生成
已完成直接返回已有结果
生成中复用现有 workflow_dispatch.fencing_token,不新开一轮
处理失败生成新 fencing token,通过 CAS 安装新的 pending 派发意图
对象不存在创建初始对象,并在同一次写入中附带 pending 派发意图

这里已经出现两层幂等:客户端幂等 ID 保护用户侧重复创建,fencing_token 标识业务对象当前处理轮次。它们和 Workflow 的 biz_key、Worker 的 claim_token 不是同一个概念。

派发给 Workflow 时只携带持久化契约

Workflow input 保存两类小型数据:一类是租户 ID、业务对象 ID、fencing_token 等稳定身份;另一类是入口已经做出的能力决策,例如是否流式输出、是否启用某种增强处理。后者可能依赖客户端能力,Worker 已经没有原始 HTTP 请求上下文,不能在几分钟后的执行阶段重新计算。

这份精确 Input 会先和 pending 派发意图一起写进业务对象。实时派发、Cron 补偿以及 failed Replay 都读取同一份快照,避免三个入口根据变化后的对象或当前配置算出不同参数。附件、业务材料、当前业务状态等大字段仍留在各自业务集合,由 Activity 在执行时重读。随后请求侧执行:

StartOrResume(
  type = async_content_job,
  biz_key = async_content_job:tenant_id:object_id,
  input = { tenant_id, object_id, fencing_token, ... },
  priority = realtime 或 async
)

这里的 priority 会决定实例进入 Redis Ready Queue 的哪一条调度通道,并不是只用于日志展示。第三节会展开四条通道及其加权轮转方式。

这里会更新两份数据:WorkflowInstance 记录「系统准备怎样执行」,业务对象中的 dispatch 记录「这次处理是否已经交给 Workflow」。两者是不同的 Mongo 记录,所以一次 StartOrResume 不会同时修改它们。

正常请求中,负责接收用户请求的业务 Service 按以下顺序处理:

业务对象已有 dispatch = f1 / pending
    ↓
业务 Service 调用 StartOrResume
    ↓
Workflow Store 创建或恢复 WorkflowInstance
    ↓ 成功返回
同一个业务 Service 调用 ConfirmDispatched
    ↓
DAO 将 dispatch 从 f1 / pending 改为 f1 / dispatched

DAO 更新时会同时检查「状态还是 pending」和「token 还是 f1」。如果数据库中已经是新一轮的 f2,说明这次 f1 确认来晚了,DAO 不会用旧请求覆盖 f2。

dispatched 可以理解为业务对象上的「派发回执」:它只说明 Workflow 已经可靠登记,不说明后面的抓取、转录或内容生成已经完成。第四节会继续解释请求在这几次写入之间退出时,补偿 Dispatcher 怎样接着处理。

这里至少发生了六次责任交接:API 交给持久化派发意图,派发意图交给 WorkflowInstance,Redis 候选交给 Mongo Claim,Worker 交给 ASR 外部任务,ASR 等待重新交给调度器,最后 Workflow 交给业务终态。每次交接都必须有一个可重试的接收动作,以及一个能识别重复请求的稳定身份。

2. 数据库里保存的不是调用栈,而是继续执行所需的最小状态

ASR 刚提交完成时,一条 WorkflowInstance 可以抽象成下面这样。字段经过删减,目的是展示恢复协议,不代表完整存储结构:

workflow_id: wf-42
biz_key: content:object-42
status: waiting
steps:
  - name: preprocess
    status: completed
    attempt: 1
  - name: transcribe_audio
    status: waiting
    attempt: 1
    checkpoint:
      asr_task_id: a77
      submit_time: 10:00:00
      deadline: 10:45:00
      poll_failures: 0
  - name: compose_content
    status: pending
  - name: finalize
    status: pending
claimable_time: 10:00:15
claim_token: ""

这份状态没有保存 goroutine、HTTP 连接和 Go 调用栈。新的 Worker 也不需要还原原进程的内存。它只需回答四个问题:

  1. 当前是哪一个 Workflow,以及 input 中携带的是哪一轮业务 token;
  2. 哪些 Step 已完成,下一步是什么;
  3. 当前在等什么,何时应该再检查;
  4. 恢复后如何复用已经创建的外部任务。

因此,持久化不是把内存完整搬进 Mongo,而是把控制流压缩成一个可以解释、可以条件更新的状态机。

3. 每推进一步,哪些事实会变化

时刻Workflow 状态Step 状态外部事实Worker 是否被占用
请求刚被接受尚未创建或 running全部 pending业务对象内有 pending 派发意图
Worker 成功认领running下一步仍为 pendingclaim_token 和租约已更新
等待 ASR permitrunningtranscribe 仍为 pending只累计 traffic wait
ASR 已提交waitingtranscribe 为 waitingcheckpoint 保存 ASR task_id
ASR 查询成功runningtranscribe 为 completed转录结果已可靠保存
内容生成完成runningcompose 为 completed结果已按业务 token 写入
Finalizer 完成completedfinalize 为 completed业务对象到达成功终态

两个细节很重要。

第一,等待 permit 和调用 ASR 失败不是同一件事。前者表示系统主动没有发起调用,不应该消耗 Activity 的 attempt;后者表示调用已经发生,需要进入错误分类和重试策略。

第二,ASR 的 task_id 放在 Step checkpoint,而业务材料属于业务产物。checkpoint 决定控制流从哪里继续,业务产物决定用户最终得到什么。二者如果混在一个无边界的大 JSON 中,恢复代码很快会重新变成另一种长函数。

4. 模拟一次跨越多次认领的执行

把一次带故障的异步内容处理完整展开。下面 A、B、C、D 是同一个 Worker 池在不同时刻认领的空闲槽位,不是四个专用 Worker;任何时刻这项工作只占一个槽位,进入 waiting 后一个都不占:

  1. Worker A 以 claim_token=t1 认领实例,完成 preprocess;
  2. A 获得 speech_recognition permit,提交外部 ASR,得到 task_id=a77
  3. A 用一次 Mongo 条件更新,同时保存 task_id=a77 这份 checkpoint、把 Step 和 Workflow 改为 waiting,并清除 t1,交还当前执行权。这个原子操作在代码中叫 SuspendClaim
  4. 轮询时间到,调度器激活实例,Worker B 以 t2 Claim;
  5. B 从 checkpoint 解码 a77,查询得到 running,再次 Sleep,并释放 t2;
  6. 下一次由 Worker C 以 t3 Claim,查询 a77 得到 success;
  7. C 调用 PersistTranscribe 保存转录业务产物,CompleteStep 再把 transcribe 状态与 Output 原子提交;
  8. C 继续 compose,内容结果已经按业务 token 写入,但进程在 CompleteStep 前退出;
  9. t3 到期后 Worker D 以 t4 接管,ready 算法发现 compose 仍是 running,于是允许重跑;
  10. compose Activity 重新读取业务对象,发现当前 fencing token 对应的结果已经完成,走幂等短路,不再调用 AI;
  11. D 提交 compose completed,执行 finalize 核验业务终态,SuccessFinalizer 完成通知,最后写 Workflow completed。

这个例子也说明「Step running」不一定表示某个进程此刻仍活着。它只表示该 Attempt 曾经开始,但还没有带合法 token 提交完成;租约到期后,running Step 属于可恢复候选。

5. 用五条不变量约束整条交互

这条执行链可以压缩成五条不变量。后文每个组件都在维护其中一条:

  1. 业务请求一旦被接受,派发意图最终必须抵达 StartOrResume
  2. 同一个 biz_key 永久对应同一个 WorkflowInstance;
  3. Redis 只能推荐候选,Mongo 的原子 Claim 才授予执行权;
  4. 只有当前 claim_token 的持有者可以推进 Workflow 状态;
  5. Activity 可能至少执行一次,所以外部副作用必须能识别重复调用。

有了这条具体路径,再看下面的状态分层、Outbox、租约和流控,就不再是几组孤立的基础设施名词。

三、第一步:先给每类状态找唯一负责人

先把镜头停在总结 Workflow 已经开始运行的时刻。此时同一项工作同时存在几类信息:「小李的直播总结」保存用户最终得到的内容,Workflow Instance 保存四个 Step 做到哪里,Redis 帮 Worker 更快找到候选,运维后台保存便于查询的执行摘要。实现 Step 循环之前,需要先确定这些信息分别由谁负责。

我们最终把状态分成四层。

层次当前载体它回答的问题不应该承担什么
业务事实业务对象、外部任务、订阅关系等集合用户结果和外部任务现在是什么状态Worker 租约、Step 调度
执行事实workflow_instancesworkflow_signals做到哪一步、谁在执行、在等什么大段业务产物、客户端展示
瞬时协调Redis Ready Queue、Wakeup、Permit、SSE 队列怎样更快找到工作、怎样限流和推送最终正确性
观测投影Observation、Snapshot失败率、耗时、排队时间、排障详情恢复依据、计费审计

这个划分解决了一个长期存在的问题:多个系统不再争着成为「真相」。

业务对象保存业务事实

业务对象保存用户最终能看到的结果和业务状态。处理是否完成,应该由它的业务终态回答,而不是由 Redis 中最后一条 SSE 帧回答。

WorkflowInstance 是执行事实

WorkflowInstance 保存:

  • workflow_id:实例的稳定业务标识;
  • type:由哪一种 Definition 执行;
  • biz_key:业务幂等键;
  • status:整体是 running、waiting、failing,还是 completed、failed、canceled 终态;
  • steps:每个步骤的状态、尝试次数、错误和耗时;
  • input 与小型 output:恢复执行所需的信息;
  • claimable_timeclaim_token:Worker 执行租约;
  • waitresume:持久化等待及唤醒原因;
  • definition_fingerprint:创建实例时使用的稳定步骤协议。
  • retry_countrevision:全局重试次数(预留)与每次状态提交后递增的修订号。

Redis 只是加速层

Ready Queue 保存「哪些 Workflow 可能已经可以运行」。它可以按优先级和时间选出候选,但最终是否可执行,必须由 Mongo 的原子 Claim 决定。

因此 Redis 数据丢失的后果是延迟上升,而不是 Workflow 永久消失。Worker 的定时轮询和 Mongo 低频扫描可以把可运行实例重新加入 Ready Queue。

为什么不使用一条 FIFO 队列

如果所有候选都进入一条先进先出队列,一次历史补偿可能批量放入成千上万个任务。此时用户刚发起的实时任务即使只需几十秒,也要先等前面的补偿候选被逐个取走。系统整体吞吐没有下降,用户感受到的等待时间却会明显增加。

另一个直接方案是永远先取实时任务,但它会制造相反的问题:只要实时流量持续存在,历史恢复和后台补偿就可能一直没有执行机会。那些任务不一定紧急,却不能永久不做。

当前 Ready Queue 因此同时表达两个目标:让直接影响用户等待的任务更早被看见,也给低优先级任务保留稳定的推进机会。

四条通道分别表示什么

Ready Queue 按业务来源拆成四个 Redis ZSET。数字越小,业务优先级越高:

通道来源为什么放在这一层
P0 · 实时请求用户正在等待响应或流式结果的请求排队时间会直接变成用户等待时间
P1 · 主动重试用户明确要求再次处理的任务用户已经经历过一次失败,应早于普通后台工作恢复
P2 · 普通异步不要求立即返回结果的常规任务需要稳定吞吐,但不应挤占全部实时容量
P3 · 补偿恢复Cron 找回的漏派发任务、历史恢复和终态修复可以稍后执行,但必须持续前进,不能饿死

每条 ZSET 的 member 是 workflow_id,score 是 claimable_time。因此同一通道内先取已经到期且最早可执行的候选;因为重试退避或流控而安排到未来的任务仍留在集合中,但不会被提前弹出。

8:6:4:2 加权轮转怎样工作

四条通道不是严格执行「P0 清空后才处理 P1」。当前 Lua 脚本按 P0/P1/P2/P3 = 8:6:4:2 加权轮转。当四条通道持续都有到期任务时,每轮 20 次候选弹出大致分配为:

P0 取 8 个 → P1 取 6 个 → P2 取 4 个 → P3 取 2 个 → 下一轮

这样 P0 获得更多调度机会,P3 也至少会周期性被检查。某条通道没有到期候选时,脚本立即跳到下一条,未使用的机会不会让 Worker 空等。

这里还有一个只在队列空闲后触发的「突发优先窗口」(burst)。当一次取任务确认四条通道都没有已到期的候选时,调度器会记下「下次恢复流量时,最多先取 8 个 P0」。之后如果 P0 和其他通道同时来了任务,前 8 次取候选都会先看 P0;取满 8 个后,恢复 8:6:4:2 轮转。如果 P0 只有 3 个,第 4 次发现 P0 已空,这个窗口就提前结束。

burst 不会同时启动 8 个任务,也不会增加 Worker 或下游容量。它只改变「下一次先去哪条队列取一个候选」,使系统从空闲恢复时,刚到达的实时请求不必先等完一轮低优先级检查。

这里的优先级只决定排队次序,不会中断已经开始的任务,也不会增加系统或下游实际能同时处理的任务数。实时任务可以更早被选中;当执行名额已经用完时,它仍需要等待。第九节会再展开容量是怎样控制的。

优先级只能提升,不能被后台任务降回去

同一个 workflow_id 只能存在于一条通道。入队 Lua 会先检查更高优先级的 ZSET:如果候选已经在高优先级通道,后续低优先级入队直接忽略;如果请求要求提升优先级,脚本会从四条通道删除旧位置,再写入新的通道。

Mongo 中持久化的 BusinessPriority 也只允许提升。例如一个实例最初由补偿扫描以 P3 找回,后来更高优先级的业务入口命中同一个 biz_key,Store 可以把它提升到对应通道;后续补偿扫描不能再把它降回 P3。

优先级不是只存在于 Redis。Mongo 会保存实例的 BusinessPriority,Redis 清空后,重建扫描仍能把候选放回原来的通道。

Redis 整体不可用时,Worker 会退回 Mongo,直接认领状态允许执行并且已经到达可执行时间的 Workflow。这个兜底查询不使用 BusinessPriority 排序,因此 P0 到 P3 在这段时间没有调度先后之分,也不保证最早到期的任务一定先执行。任务仍能继续推进,只是暂时失去优先级调度;Redis 恢复后,Mongo 中保存的 BusinessPriority 会用于重建四条 Lane,原来的调度顺序随之恢复。

Worker 从 Redis 取到的只是一个“可以尝试执行”的候选。它还要回到 Mongo,通过一次条件更新确认:这项工作仍然可以运行,而且尚未被其他 Worker 领取。只有这次更新成功,它才真正获得执行权。这样,即使 Redis 里出现重复、过期或放错通道的记录,也只会多一次无效尝试,不会让同一项工作同时被两个 Worker 推进。

CMS 可以分别展示四条 Lane 的总深度、已经到期的 ready 数、尚未到期的 delayed 数和最老等待时间。这样能够区分「实时请求开始积压」与「只是历史补偿尚未清完」,也能发现 P3 长时间不前进这类调度异常。

观测保持在执行旁路

Step 开始、结束、失败等事件会异步写入 Observation,并为每个 Workflow 生成一行适合运维后台查询的 Snapshot。它们允许出现少量缺口,也不能因为写入失败而重跑一次昂贵的 AI Step。

这是有意识的优先级选择:业务正确性高于监控完整性。

运维后台从结果列表扩展到执行过程

早期监控更接近结果清单:哪些任务失败了,哪些 processing 太久。它能告诉我们「有问题」,但很难回答问题发生在哪一段。这与当时按整体任务执行的模型一致;Step 被持久化以后,观察粒度才有条件继续下沉。

对主案例来说,工程师搜索「小李的直播总结」后,不只会看到「处理中」。他还可以看到总结 Workflow 当前停在 transcribe、正在等待下一次查询 ASR 转录单,此前由哪些 Worker 执行过,以及时间主要花在排队、流控等待还是 Activity 本身。

Workflow 把执行过程结构化以后,运维后台才有条件沿着同一条身份链向下查看:

这让排障方式发生了变化。例如积压突然升高时,我们可以先判断任务是在 Ready Queue 等 Worker,还是在 speech_recognition 前等待 permit;如果确实执行过 ASR,再继续查看转录 Step 的错误类别、attempt 和 trace。工程师不必先从一个业务对象 ID 猜测它走过哪条代码路径。

运维后台还可以按 Workflow 类型、Step、业务来源和时间窗口观察失败、等待与队列风险,让排查从「用户反馈以后查单条日志」向「主动发现某个阶段正在恶化」移动。

但运维后台仍然只是观察面。它读取异步生成的 Snapshot 和 Observation,不直接读取或修改 workflow_instances;即使投影短暂缺失,也不能据此重跑任务或改变业务终态。

这可以看作一种范围很窄的读写分离:权威状态走 workflow_instances,运维读侧走异步投影——但它只是观察旁路,不是业务主查询路径,也不承担事件溯源。

这条旁路本身也需要背压。Step 提交后,Observation 先进入进程内有界队列,再按批次写 Mongo:

队列有界是为了防止监控存储故障反向吃光业务进程内存。暂时性写入失败时,批次会保留并重试;某条永久非法记录会被隔离,避免毒数据堵住整个批次。

这里会用到两个编号。Attempt 表示一个 Step 第几次真正进入业务代码;第一次执行是 Attempt 1,失败后再次执行是 Attempt 2。revision 是整个 Workflow Instance 的状态版本号,每当数据库接受一次新的状态变化,它就继续增加。

每条 Observation 都会携带产生它时的 revision。Snapshot 只接受不早于当前记录的状态,因此晚到的旧事件不能覆盖较新的状态。如果投影仍然出现缺口,系统还提供按需修复能力,根据权威 Instance 补齐已经结束的 Workflow 记录。

异步 Observer 使用有界输入队列、批量写入和短周期 flush;具体大小按流量和内存预算配置。Engine 先成功提交 workflow_instances,拿到数据库递增后的 revision,再构造 Observation。attempt 明细按下面的业务键幂等 upsert:

biz_key + workflow_run + step_name + attempt

Snapshot 则按 biz_key 保持一条最新记录。它保存 Workflow 状态和所有 Step 的小型摘要,供总数、趋势、耗时、重试、流控和用户流量查询;只有 Step 诊断页面读取 attempt 明细。运维后台的列表和详情都不回退扫描 workflow_instances,避免监控查询把执行主库变成分析库。

举个例子:某个 Step 第一次开始执行时产生了一条「运行中」事件,但它在传递途中延迟了。这个 Step 随后重试,并且第二次执行已经失败。失败事件对应的 revision 更大,所以即使那条较早的「运行中」事件此时才到,Snapshot 也不会从「失败」倒退成「运行中」。revision 在这里做的事情,就是让监控投影能够分清新旧。

Mongo 暂时不可用时,Observer 会保留正在写入的批次并暂停消费,生产者最多继续填满固定容量的进程内队列。业务线程向这条队列写入时不会等待:队列已满就拒绝当前事件并增加 Dropped 计数。这样,无论监控存储故障多久,业务执行都不会被监控旁路卡住,Observer 占用的内存也不会继续增长。它还保留了一个简单语义:已经返回接收成功的事件不会在队列中被后来者悄悄淘汰;容量不足由当前写入立即得到失败结果。

如果 Mongo 明确表明批次中包含一条无论重试多少次都无法写入的记录,例如文档过大或未通过数据校验,Observer 会把批次逐步拆小,直到找出具体是哪一条。它只丢弃并记录这条异常数据,其余记录仍会继续写入。

这套设计接受一个明确结果:运维后台可能短暂落后,但不会因为「监控写不进去」把一项已经完成的任务重做一遍。工程师同时能看到 Observation 丢弃数和写入错误,区分「业务没有执行」与「观察旁路暂时不完整」。

四、从请求到 Workflow:Outbox 关闭派发崩溃窗口

现在把时间倒回总结 Workflow 尚未创建的那一刻。请求侧已经接受了小李的第一轮生成请求,但进程可能在登记 Workflow 之前退出。Outbox 解决的是这段最早的交接。

这里的「Outbox」不是经典 Transactional Outbox 模式:经典版本在同一个数据库事务里写下业务数据与 outbox 记录,再由一个独立 relay 轮询 outbox 表并投递到消息队列。本文没有消息队列这一层,只是借了它「双写进同一原子操作 + 后台可靠捡起」的形——业务对象里内嵌一份 pending 派发意图,由补偿 Dispatcher 轮询后直接调用 StartOrResume。所以它更准确的名字是「内嵌派发意图」。

一次请求至少涉及两份事实:

  1. 业务对象说「用户已经提交了处理请求」;
  2. WorkflowInstance 说「系统已经登记了执行计划」。

如果简单地顺序写两个集合,就必然存在进程恰好在两次写入之间退出的窗口。

两种对称的失败

这里要求的是最终可达:只要业务已经接受请求,执行就最终一定会被登记。 两份数据不需要在同一毫秒出现。

内嵌在业务对象中的最小 Outbox

创建业务对象时,在同一次原子写入中放入一份最小 dispatch 意图:

workflow_dispatch:
  type: async_content_job
  status: pending
  fencing_token: <本次处理轮次>
  input: <稳定身份与入口决策快照>

它不是 Workflow 状态的副本,只表达「这项派发意图还没有确认完成」。状态大致为:

这三个状态只跟「Workflow 是否已经可靠登记」有关,不是 Workflow 本身的运行状态:

dispatch 状态它表示的事实
pending业务请求已经接受,但还没确认 StartOrResume 已成功
processing补偿 Dispatcher 正持有这条派发意图的短租约;它不表示 Workflow 正在执行业务 Step
dispatched同一业务轮次的 WorkflowInstance 已创建或已恢复;它仍可能处于 running、waiting 或 failed

发生 pending -> dispatched 时,实际写入者有两种。

正常请求路径由请求侧 Service 确认。 它先调用 StartOrResume,成功后再调用 ConfirmDispatched。DAO 执行的条件更新可以简化成:

WHERE object_id = 42
  AND workflow_dispatch.fencing_token = f1
  AND workflow_dispatch.status = pending

SET workflow_dispatch.status = dispatched

如果更新没有命中,Service 会再读一次当前 dispatch。同一个 f1 已经是 dispatched,说明重复确认可以直接收敛;同一个 f1 已被补偿 Dispatcher 改成 processing,请求侧也不去抢它的租约。

如果读到的 token 已经变成 f2,说明在这次 f1 请求返回之前,业务对象已经开始了新一轮处理。确认函数此时返回“当前轮次已经变化”,请求侧停止处理这次 f1 请求,并向调用方返回“已有其他处理正在进行”;它不会修改 f2,也不会继续替 f1 确认派发。f2 留下的派发意图会由对应请求或补偿 Dispatcher 继续处理。

由于 StartOrResume 发生在确认之前,f1 对应的 Workflow 此时可能已经登记。系统不会在这一步直接删除它,但它携带的仍是 f1;后续读取业务对象或写回生成结果时,fencing 检查会发现当前轮次已经是 f2,从而阻止 f1 覆盖新一轮结果。

补偿路径由 Recovery Dispatcher 确认。 多个 Pod 可能同时扫描到同一条 pending 记录,但这时它们拿到的都只是候选,还不能同时发起派发。真正调用 StartOrResume 之前,每个 Dispatcher 都要先到 Mongo 竞争一份短期的“派发租约”,只有一个竞争者能够成功。

这份租约就保存在业务对象的 workflow_dispatch 中。取得租约时,Mongo 会在同一次条件更新里把 dispatch 从 pending 改成 processing,并写入本次持有者的 claim_token 和租约到期时间。处理时间较长时,持有者会续租;其他 Dispatcher 看到租约尚未到期,便不会重复处理这条记录。

StartOrResume 成功后,持有者只有在“业务轮次没有变化、dispatch 仍是 processing、claim token 仍属于自己”三个条件都成立时,才能把状态确认成 dispatched,并清除租约字段。派发失败时,也要带着同一枚 token 才能将状态退回 pending。若 Dispatcher 中途退出,租约到期后另一台机器可以生成新的 token 接管;原持有者即使稍后恢复,也无法再修改派发状态。

这里的 token 只保护“谁正在登记 Workflow”这一小段派发过程。Workflow 登记成功以后,Worker 领取并执行 Workflow Instance 时还会生成另一枚执行 token,两者属于不同阶段,互不复用。

补偿派发的执行约束也体现了这层边界:

  • 调度器优先从 Redis Dispatch ZSET 取得已到期候选;
  • 每批与每轮都设置上限,避免积压时一次补偿扫描无限占用进程;
  • Redis 候选只缓存业务身份,不缓存 generation 和 token 等权威状态;
  • 低频扫描通过分布式互斥从 Mongo 找回遗漏;
  • 每个业务对象实际处理前,才用 Mongo CAS 从 pending 或过期 processing 改为 processing;
  • 派发 claim 使用短租约并在处理期间续期;
  • 暂时失败后延迟退回 pending,并重新安排 Redis 候选。

假设一个补偿扫描器一次从 Redis 取出 100 条待派发记录。Redis 会在交出它们的同时将它们从候选集合中删除,避免下一台扫描器马上又取到同一批。如果这台机器刚取完就退出,100 条记录还没来得及逐一处理,却已经不在 Redis 里了。

这些工作并没有真正丢失,因为业务对象中的 pending 派发意图仍保存在 Mongo。低频扫描会重新找到它们,再放回等待处理的候选集合。Redis 负责让补偿及时发生,Mongo 负责保证遗漏最终仍能被发现。

Mongo 扫描找到这 100 条记录时,也不会立刻把 100 条全部标记为“正在处理”。扫描器只有在准备处理某一条时,才为这一条竞争派发租约。否则,排在后面的记录可能还要等待很久,却已经被当前扫描器占住;一旦扫描器中途退出,它们还要等租约到期,其他机器才能接手。

把它放进一次接管过程会更容易理解。这里有两枚不同用途的 token,先分清:f1 是业务轮次,用户不重新发起处理就一直是 f1;d1d2 是派发租约,表示当前是哪个 Dispatcher 在登记 Workflow。假设业务对象 42 正在进行 f1 这一轮处理,dispatch 状态还是 pending:

  1. Dispatcher A 先完成 Mongo 条件更新,把状态改为 processing,并写入派发 token d1 和到期时间。A 随后开始调用 StartOrResume
  2. Dispatcher B 此时也发现了对象 42,但 d1 尚未到期。B 无法取得派发租约,因此跳过这次处理;
  3. 如果 A 一直正常工作,它会定期用 d1 延长到期时间。StartOrResume 成功后,A 再携带 f1 和 d1 把 dispatch 改成 dispatched;
  4. 假设 A 因长时间停顿而没有续租,d1 最终过期。B 便可以接管,将派发 token 更新为 d2,并继续登记同一个 Workflow;
  5. A 随后恢复并尝试续租或确认 dispatched,但 Mongo 中的派发 token 已经是 d2。A 携带的 d1 无法命中更新条件,当前派发 Context 会被取消,只有 B 可以继续提交派发结果。

这三个标识保护的是三个不同范围,也住在两个不同的 Mongo 集合里:

标识它是什么写在哪何时变化
f1(fencing_token)业务轮次Note 的 meta.workflow_dispatch.fencing_token,并拷贝进 Workflow input 快照用户重新发起处理时 f1 → f2
d1/d2(派发 claim_token)谁在登记 WorkflowNote 的 meta.workflow_dispatch.claim_token + claim_expire_timeDispatcher 换人时;派发确认后随 $unset 清掉
t1/t2(执行 claim_token)谁在执行 Workflowworkflow_instances.claim_tokenWorker 换人时

Dispatcher 换人不会改变 f1,Worker 换人也不会复用 d1 或 d2。

f1 拷进 Workflow input 是安全的,而且它不代表有两个 Workflow。f1 是同一个「业务现在是第几轮」的事实存在两个字段位置:Note 上的那份是权威,会随着用户重新发起处理从 f1 更新成 f2;input 里的那份是派发时拍下的快照,回答「我这个执行是替哪一轮跑」,在本次执行期间保持不变。这个不一致是有意为之:旧执行迟到写结果时带的是 f1,而业务对象当前已是 f2,CAS 不命中,旧结果被拒;失败 replay 时,input 被整体替换成携带 f2 的新快照,同一条 instance 继续推进。所谓「独一无二」指的是一个 biz_key 永久对应一个 WorkflowInstance——它约束的是 instance 的数量,与 fencing_token 记在几个字段里无关。

失败后的新 pending 是怎样安装的

这里的「失败」需要分成两种,否则容易误以为每次重试都要更换 fencing_token

第一种是派发过程失败。 例如 StartOrResume 临时访问 Mongo 失败,或 Workflow 已创建但请求在确认 dispatched 之前退出。这仍然是用户的同一轮处理,所以不生成新 token。f1/pending 会留在业务对象中;如果 Recovery 已经取得派发租约,暂时失败后会把 f1/processing 退回 f1/pending,设置下次可重试时间。下次仍使用 f1 和原来的 Input 调用幂等 StartOrResume

第二种是 Workflow 已经运行,最终把业务对象收敛到 failed,用户又主动发起一次处理。 这才是新的业务轮次。请求侧 Service 的处理顺序是:

  1. 读到业务对象为 failed,当前 dispatch 是 f1/dispatched;
  2. 在内存中生成 f2,构造一份完整的 f2/pending dispatch,其中含有新一轮的精确 Input;
  3. 调用 RetryFailedWorkflowDispatch(previous=f1, next=f2/pending)
  4. DAO 在 Mongo 中用「业务状态仍为 failed,且当前 token 仍为 f1」作为更新条件;
  5. 条件成立时,在同一次 Mongo 更新中将业务状态恢复到生成前置状态、用 f2/pending 整体替换旧 dispatch,并清理上一轮错误字段。

这里的 CAS 可以简化成:

WHERE object_id = 42
  AND business_status = failed
  AND workflow_dispatch.fencing_token = f1

SET business_status = initial
SET workflow_dispatch = {
  status: pending,
  fencing_token: f2,
  input: <新一轮输入快照>
}
CLEAR previous_error

「安装新的 pending 派发意图」指的就是这次整体替换。假设两个重试请求同时读到 failed+f1,它们会分别生成 f2 和 f3。先到 Mongo 的请求把对象改成 initial+f2/pending;后到的请求再用 failed+f1 做条件时已经无法命中,所以 f3 不会覆盖 f2。

CAS 成功后,请求侧才用稳定 biz_key 和携带 f2 的 Input 调用 StartOrResume。它会在原来的 WorkflowInstance 上开启 Replay,再由请求侧将 f2/pending 确认为 f2/dispatched。如果进程在 CAS 成功后、调用 StartOrResume 之前退出,f2/pending 依然已经落库,补偿 Dispatcher 会完成后续派发。

把进程退出放到每个写入边界上看,协议会更清楚:

退出发生在留下的事实恢复者怎样处理
业务对象原子写入之前请求没有被业务接受客户端可以安全重试
业务对象已写,尚未调用 StartOrResumedispatch=pending补偿 Dispatcher 重新派发
Workflow 已创建,尚未确认 dispatchedpending 意图与已有 Instance 同时存在再次 StartOrResume 命中相同 biz_key,随后确认
已确认 dispatched,尚未写 RedisMongo 中已有可运行 InstanceMongo 低频扫描重建 Ready Queue
Redis 已入队,但 Worker 尚未处理Redis 有一个候选 ID任意 Worker 取到后仍须回 Mongo Claim

Outbox 没有让两次写入变成分布式事务,而是让每个中间状态都能被安全地重复处理。恢复器不需要判断上一个进程究竟死在第几行,只需根据当前持久化事实继续执行幂等协议。

为什么 Outbox 保存精确 Input,但仍然保持很小

早期补偿派发可以从业务对象临时重建 Input,但这种方式会丢失入口时刻才能知道的客户端能力决策。因此 Outbox 保存本轮准备发布的精确 Workflow Input;恢复时先校验其中的业务身份和 fencing_token 与当前派发意图一致,再原样提交,不能拿旧快照派发新一轮。

存量对象可能还没有完整的 dispatch input。兼容分支会从稳定字段构造 Input;对于无法可靠还原的客户端能力决策,采用保守默认值,而不是根据今天的配置猜测历史请求当时的选择。

这不意味着把整篇业务数据复制进 Outbox。Input 仍只包含持久化引用、监控维度和已经固化的入口决策:

  • 附件 URL、原始材料和生成内容仍在业务集合;
  • 无法在 Worker 端重算的入口决策进入 Input;
  • 执行重试和错误属于 WorkflowInstance;
  • Outbox 只回答「哪一类 Workflow、哪一轮、使用哪份小型 Input、是否派发」。

精确快照保证实时路径和恢复路径使用同一份执行契约;限制大小则避免业务对象、Outbox 和 WorkflowInstance 出现三份会彼此冲突的正文。

五、StartOrResume:把重复请求变成同一次工作

Outbox 保证小李第一轮生成的派发意图不会消失,StartOrResume 接着解决另一件事:请求侧和补偿 Dispatcher 可能同时拿着这份意图来登记,但它们都必须得到同一条总结 Workflow。

用户连点两次、客户端超时重试,以及补偿扫描与实时派发相遇,都可能重复启动同一业务。重复请求因此需要作为正常输入处理。

我们为每项工作构造稳定的 biz_key,并在 Mongo 建永久唯一索引。StartOrResume 的语义是:

为什么失败后不新建第二条 Instance?

  • 同一个业务始终有一个稳定执行载体;
  • 已完成 Step 的进度可以保留;
  • 查询和排障不必合并多条「同一工作」的记录;
  • 旧、新实例不会并行争抢同一份业务对象;
  • Outbox 不需要猜应该关联哪一条实例。

实现上不能只采用「先查询,不存在再插入」。两个调用可能同时查询到不存在,然后都尝试创建。这里的串行化边界是 Mongo 上 biz_key 的永久唯一索引:

唯一键冲突在这里不是事故,而是并发协议的一条正常分支。数据库替系统选出唯一赢家,失败的一方转为复用赢家创建的实例。

StartOrResume 实际修改了哪些字段

新建实例时,Publisher 根据 Plan 生成一组 pending Step,写入 Definition fingerprint,把 claimable_time 设为创建时间,因此实例落库后立即可认领。

如果实例已经存在,Store 按当前状态分支:

已有状态Store 行为executionActivated
running、waiting返回同一实例,不抢当前 claimfalse
failing返回现有实例,不重新激活执行false
completed返回完成实例false
failed在原文档上恢复执行竞争成功者为 true
canceled返回已经被业务淘汰的实例,不按普通失败重跑false
并发创建导致唯一键冲突重新按 biz_key 读取赢家false

failed replay 使用一次 status=failed 的条件更新完成:

  • 顶层状态改回 running;
  • 新一轮 input 整体替换;
  • retry_countrevision 增加——前者是预留的全局重试计数,后者是每次状态提交后递增的修订号;
  • 清除旧 claim、finish time 和 resume;
  • 失败 Step 重置回 pending,并清空 attempt、错误、耗时、流控统计和 checkpoint;completed 与 skipped Step 保持原状。

两个请求同时 replay 时,只有一个能命中 status=failed;另一个重新读取已经 running 的实例。这和并发创建使用相同思路:用状态条件让数据库选择本轮执行的激活者。

failing 也不能直接 replay。这个状态表示旧一轮的 FailureFinalizer 仍在执行,例如正在标记业务失败、归还已占用资源。此时若请求侧同时启动新一轮,新的资源占用和旧一轮归还可能交叉。StartOrResume 直接返回现有实例、不激活新执行(executionActivated=false),业务侧据此保持待派发,等旧轮次可靠进入 failed 后再重试。

BusinessPriority 也只允许提升。例如补偿扫描以 P3 创建的候选,后来用户主动重试提出 P1,Store 可以把它提升为 P1;后续 P3 请求不能把它降回低优先级。

无论本次是新建还是复用,Publisher 都会 best-effort 把实例写入 Ready Queue 并发送 Wakeup。只有 executionActivated=true 时才重置客户端流,清除上一轮失败留下的分片和终态引用。Ready Queue 或流重置失败不会撤销已经落库的 WorkflowInstance。

这里要区分 workflow_id 和 Mongo _id。前者是跨集合和接口使用的业务标识,后者是存储实现细节。把二者分开,使业务协议不被数据库主键策略锁死。

失败 Replay 只重置失败步骤,业务前置条件由 Activity 自己幂等重建

局部 Replay 保留已完成 Step,这个性质能避免重新转录。restartFailedWorkflow 在同一次条件更新里把失败 Step 重置回 pending,清空它们的 attempt、错误、耗时、流控统计和 checkpoint;completed 与 skipped Step 保持原状。checkpoint 只描述一次尚未失败的控制流位置——Activity 一旦返回错误,这份 checkpoint 就应清空,避免下一轮误用一项已经失败的 ASR task 或分页游标。

保留 completed Step 也带来一个需要业务自己负责的问题:FailureFinalizer 可能已经撤销了流程最早阶段建立的业务前置条件。以会占用额度的任务为例:

  1. 初次执行的 preprocess 占用额度,并把业务对象标为处理中;
  2. 后续 compose 失败,FailureFinalizer 退回额度并把对象标为失败;
  3. 用户重试时,completed 的 preprocess 不会再跑;
  4. 从 compose 继续,额度就不会自动重新占用。

Kernel 没有「重跑前先恢复业务状态」的钩子。它只提供一个 PrepareContext:每次 claim 开始时,根据持久化 input 恢复执行上下文(比如用户身份),且必须是无外部 I/O 的确定性操作。额度这类业务前置条件不归调度器管。重跑时内核只做一件事——把失败的步骤重新执行,不猜哪些业务状态需要补。补额度、防重复这些事都落在 Activity 头上:业务代码保持幂等,多跑一次也不会写坏结果。

六、Worker 怎样安全接管一项工作

到这里,小李的第一轮生成已经确认关联到总结 Workflow。接下来不再讨论「能否登记成功」,而是讨论执行权怎样在 Worker 池的不同空闲槽位间先后交接(仍用 A、B、C、D 标记先后),同时避免两个进程一起推进同一份 Step 状态。

1. 调度入口:Wakeup 和轮询只负责填充 Worker 槽位

Engine 启动后创建一个容量为 max_workers 的 channel,作为单进程 Worker 槽位。填槽有三个入口,两个高频、一个低频,最终都进入 scheduleRunnableWorkflows

  • Redis Wakeup(Pub/Sub 短通知):候选刚入队就立即叫醒 Worker,不等下一次轮询,用来压低延迟;
  • 定时 Pollpoll_interval_seconds,默认 5 秒):Worker 主动去 Ready Queue 捞候选,是常规吞吐和保底;
  • 低频 Mongo 扫描(30 秒一次):Redis 索引可丢,它周期性地从 Mongo 把可运行实例和到期等待补回 Ready Queue,并补做 pending Signal 的匹配。
scheduleRunnableWorkflows
  -> 修复待匹配 Signal
  -> 尝试取得一个本地 Worker slot
  -> 从 Ready Queue 取候选
  -> 回 Mongo 原子 Claim
  -> 把 slot 所有权交给执行 goroutine

顺序上先占本地 slot,再去 Claim Mongo。这样进程没有执行容量时不会提前拿走一批租约,让其他 Pod 看得到任务却无法认领。

Ready Queue 是可丢失、可重建的加速索引,不是权威,里面会残留过期 ID——比如已被别的 Worker 抢先认领、已经完成/失败/取消,或租约尚未到期。Worker 从队列 PopReady 拿到的只是一个 workflow_id 候选,回 Mongo 原子 Claim 时可能认领不到;认领不到就当作陈旧候选跳过、继续取下一条。这个循环单轮最多尝试 8 个候选(attempts < 8):如果中途命中有效实例就立即返回,连取 8 个都是陈旧候选则本轮提前结束、让出槽位,避免在一个塞满陈旧 ID 的队列上空转、占着 CPU 和 worker 槽位。

「本轮结束」不是进入睡眠——worker goroutine 会回到调度循环继续等触发点,下一次最迟在 poll_interval_seconds(默认 5 秒)后的定时 Poll 到来,若期间有新候选入队并发出 Redis Wakeup 则更早被唤醒。

为什么 Redis 会残留过期条目?PopReady 的 ZREM 只能删掉「当下这一条缓存」,但缓存本身可能在入队之后、pop 之前就已经过期。两个主要来源:一是兜底路径 claimFromDurableStore 直接扫 Mongo 认领、根本不 pop Redis,所以被它认领并跑完的工作流不会从 ZSET 里移除;二是 MarkWorkflowTerminal(completed/failed/canceled)和 SupersedeWorkflow(废止旧代)只改 Mongo、不清 Ready Queue。两种情况都会留下「索引里有、Mongo 里已经不可认领」的陈旧条目,直到下一个 Worker pop 到它、回 Mongo 认领落空才被顺手清掉。Redis 不可用时,Engine 退回 Mongo 直接扫描;Redis 队列为空但本轮修复扫描失败时,也退回 Mongo。低频重建扫描不能等 Redis 被消费空才执行,因为 Worker 可能在 Pop 之后、Claim 之前崩溃,或者持有 claim 的进程崩溃后租约刚刚到期。即使 Redis 长期有积压,Mongo 里遗漏的候选仍需要周期性补回。

2. Claim Lease:执行权有过期时间

Worker 不会先把实例读出来、再普通更新为「我来执行」。那样两个 Worker 可能同时读到同一条数据。它通过 Mongo 原子条件认领:

status 属于可执行状态
AND claimable_time <= now

认领成功后,Worker 写入新的 claim_token,并把 claimable_time 推到租约到期时间。执行期间持续续租。

一次 Claim 可以近似理解为下面这条原子条件更新:

查找条件:
  workflow_id = 候选 ID
  status in [running, failing]
  claimable_time <= now
  type in 当前 Worker 注册的类型

原子更新:
  claimable_time = now + lease
  claim_token = new_token

返回:
  更新后的完整 WorkflowInstance

Redis 中的候选即使已经过期、重复,或被两个 Worker 同时取到,也不会造成双重所有权。最多一个请求能让 Mongo 的条件成立;其他请求得到「没有可认领文档」,丢弃这个候选即可。

3. Claim Token:旧 Worker 不能覆盖新 Worker

租约过期不等于旧进程一定已经停止。网络暂停、长时间 GC 或不遵守 Context 的外部调用,都可能让旧 Worker 稍后恢复。

因此所有关键状态写入都必须携带当前 claim_token。Worker B 接管后 token 变为 t2,Worker A 即使回来,也不能用旧 token t1 更新实例。

例如完成 Step 时,写入条件仍然包含:

workflow_id = wf-42
status = running
claim_token = t2

Step 状态与该 Step 的小型 Output 在同一次更新中写入,并同时递增 revision。如果更新不到文档,引擎得到的不是普通数据库空结果,而是 claim lost:当前 Worker 必须停止推进,不能用一次无条件重试绕过 fencing。

这类机制叫 fencing。它无法让旧执行从物理世界消失,但可以在关键写入边界拒绝旧 token 越权。

4. 超时依赖 Activity 响应取消

先说明为什么每个 Step 要单独设 Timeout,而不是给整个任务一个总 deadline。Timeout 只约束「单次 Activity 真正在执行」的窗口,防止一个卡死的调用无限占住 worker 槽位和执行租约;跨分钟甚至跨小时的等待交给 durable waiting(Sleep / Signal / Timer),不靠 Step timeout 硬扛。这里的关键是:走进 waiting 的 Activity 不是阻塞在函数里等,而是立刻 return 一个挂起指令(如 workflow.Sleep(...))就结束——Activity 一 return,本次 invokeActivity 的 timeout 随即作废;等 timer 或 Signal 把它重新激活、再次 claim 时,下一次 Activity 调用会重新 ctx.WithTimeout(step.Timeout),领一个全新的完整窗口。所以 timeout 不跨挂起、不累加,它是「单次通话」的限时,不是「整个任务」的限时。因此取值按「这一步一次调用正常需要多久」来定:本地预检和落库几十秒,抓网页几分钟,AI 生成十几分钟,ASR 提交几十分钟。它还要和 MaxRetry 配套——能安全重试的幂等步骤给 MaxRetry > 0;副作用大、靠 checkpoint 续命的步骤给 MaxRetry = 0,让超时走失败分类,而不是盲目重试。

Go 的 Context 可以发出取消信号,却不能强制停止一段忽略 ctx.Done() 的代码。引擎在 Step 超时后取消 Activity Context,并等待当前 Attempt 退出,再释放执行权,尽量避免新旧 Attempt 同时产生外部副作用。

所以 Activity 仍然必须把 Context 传给 HTTP、数据库和其他下游调用。Fencing 是最后的写保护,不是忽略取消的理由。

5. 一次 Claim 可以连续推进多个 Step

认领的对象是 Workflow,不是单个函数调用。同一 Claim 内,Engine 会反复计算就绪 Step;普通线性流程通常可以连续跑完 preprocess -> transcribe -> compose -> finalize,不必每完成一步就释放租约、重新排队和再次 Claim。

以下情况才会停止本轮 Claim:

  • Step 等待 traffic permit,需要延后 claimable_time
  • Step 返回 Sleep、WaitForSignal 或 Yield,控制权已经持久化;
  • Step 失败并安排重试;
  • Workflow 进入 failing 或终态;
  • Worker 停机,或本轮状态提交不再确定。

Claim 执行期间,租约每 lease / 3 续期。续租使用与 Activity Context 分离的 detached Context,业务调用超时不会顺带停掉心跳。这么做的原因是:心跳的生命周期要绑定在「本次 Claim」上,而不是绑定在「某一个 Activity 调用」上。Activity 超时取消只针对它自己;超时后引擎还要等旧 goroutine 真正退出才释放执行权,这段等待窗口里 claim 仍然有效、必须继续续租。如果心跳跟 Activity 共用同一个会被取消的 Context,Activity 一超时心跳就跟着停,租约会在引擎还没等到旧 goroutine 退出时就过期,另一个 Worker 就可能抢走同一个 Workflow,造成本可避免的新旧 Attempt 重叠。所以心跳用 detached Context(WithoutCancel 保留 Trace、日志和 Meta,但不继承父级取消与截止时间),只受 Claim 自己的 stop channel 控制。

但要分清它防的是什么:detached Context 只覆盖「进程还活着、Activity 只是超时或变慢」这一种可控窗口。如果整个进程崩溃,续租 goroutine 也会一起消失,租约照样到期,新 Worker 仍会接管;已经发出去的外部副作用仍在外部系统上执行,这种重叠靠续租无法消除,只能靠 claim_token 的写保护(旧进程晚归也写不进状态)和 Activity 幂等来收敛。续租条件仍包含当前 claim_token;匹配不到表示终态已提交或所有权已经丢失,心跳 goroutine 随即退出。

6. 优雅停机与进程崩溃走不同路径

Kubernetes 滚动部署会给进程一段退出宽限期,从 SIGTERM 发出时开始,覆盖摘流、关停回调和进程退出的全过程。多个 AfterShutdowning 回调并发执行:HTTP Server 停止、Workflow Worker 停止和 legacy 数据库状态清理并没有全局的先后关系。这里的 Workflow Worker 是进程内领取和推进 Workflow 的后台执行器。所有退出工作提前完成时,进程正常退出;到宽限期上限仍未完成时,Kubernetes 可以强制终止它。

再看 Worker 回调的内部,才存在属于它自身的关停顺序:Shutdown 先停止 Poll 和 Wakeup 订阅,不再取得新任务;传给已启动 Claim 的是 Worker root Context,不是已经取消的订阅 Context,因此停机不会立即取消业务 Activity。Engine 随后等待已有 Claim 结束,再关闭并排空 Observation 队列。这个局部顺序不意味着它会等待 HTTP 或数据库回调先完成;三者仍在并发的进程级关停框架中运行。

早期模型把关停回调当成业务状态的收敛点,因此数据库中的 failed 与实际执行停止之间可能存在窗口。这个窗口来自关停回调的实现方式:进程退出时批量把「生成中」的笔记标成失败,但它改的只是数据库字段,管不到正在跑的 goroutine,于是分叉成两个方向——一边是 DB 已写 failed、代码仍在跑,过会儿还可能把结果写回来;另一边是执行其实早停了、DB 还停在 generating,要等回调或下一次扫描才发现。本质是「数据库状态」和「真实执行状态」是两份数据,靠一次 fire-and-forget 的批量 UPDATE 去对齐,中间必然有时间差。现在,已经完成的 Step、外部 task ID 和等待条件都已持久化。Pod 退出不再直接决定 Workflow 终态,新 Worker 可以在租约到期后从已提交的位置继续。五分钟因此回到它更适合承担的职责:给进程留出收尾时间,而不是充当长任务的持久化边界。

如果停机等待超时或进程直接崩溃,没有任何执行者能够证明旧 Activity 的外部副作用已经停止。当前实现不会在这个窗口无条件清除租约,只能等待 claimable_time 自然到期。这个等待减少了新旧 Attempt 重叠的概率;即便旧 Worker 随后恢复,claim_token fencing 仍会拒绝它提交状态。

7. 租约解决的是执行权,不是外部副作用的 exactly-once

假设 Worker 已经成功提交 ASR,拿到了外部 task_id,却在保存 checkpoint 之前退出。Mongo 无法知道外部调用是否成功,新 Worker 可能再次进入提交逻辑。这是持久化执行系统绕不开的「副作用已发生、进度未提交」窗口。

因此 Workflow 内核提供的是至少一次执行,不是 exactly-once。业务需要根据副作用性质选择收口方式:

副作用优先采用的保护
写业务结果业务 generation token 加条件更新
创建可查询的外部任务传递幂等键,或持久化并复用外部 task_id
发送可重复通知使用事件唯一键去重
不可逆且无幂等接口的操作缩小自动重试范围,并保留人工处理边界

租约保证「谁有资格提交下一次状态」,幂等保证「相同业务动作发生两次时如何收敛」。二者缺一不可。

七、Claim Token 与业务 Fencing Token:保护两层不同的状态

继续沿用小李的总结 Workflow:Worker A 失联后由 B 接管,是「执行者换了」;小李在旧一轮失败后重新发起处理,是「业务轮次换了」。前者把 claim_token 从 t1 换成 t2,后者把 fencing_token 从 f1 换成 f2。两个变化可能独立发生,因此需要两种 token。

这两个 token 都在拒绝迟到写入,因此很容易被理解成重复设计。区分它们最简单的方法,是先看写入目标:

Token它在问什么写入哪里时检查什么时候变化
claim_token你还是当前负责执行这个 Workflow 的 Worker 吗Workflow Instance、Step、Output 等执行状态每次 Worker 重新 Claim 都变化,例如 t1 → t2
fencing_token这份结果还属于业务对象当前这一轮处理吗内容结果、业务终态、派发状态等业务数据用户开启新一轮处理时变化,例如 f1 → f2

也可以把它们分别理解成「临时工牌」和「业务批次号」。Worker A 领取任务时得到临时工牌 t1;A 失联、租约到期后,Worker B 接管并得到 t2。只要用户没有重新发起处理,A 和 B 做的仍然都是业务轮次 f1。

为什么不能只保留 fencing_token

先看同一个业务轮次内的 Worker 接管:

业务对象当前轮次:f1

Worker A Claim Workflow:claim_token=t1
A 暂时失联,t1 租约到期
Worker B 接管 Workflow:claim_token=t2
A 随后恢复,尝试提交旧的 Step 状态

A 和 B 携带的业务 fencing_token 都是 f1,因为用户并没有开启新一轮。如果只检查 f1,两者都像合法写入者,旧 Worker 就可能覆盖 B 已经推进的 Workflow 状态。

claim_token 在这里提供更短周期的执行所有权。B 接管后,Workflow Instance 中的当前值已经变成 t2;A 使用 t1 提交 Step 时,Mongo 条件更新不会命中。它必须停止推进。

需要注意,claim_token 主要保护 Workflow Store 中的状态提交。它不能撤销 A 已经发出的 HTTP 请求,也不会自动让第三方调用变成 exactly-once。同一业务轮次内的重复副作用仍要依靠 Activity 幂等、外部幂等键和业务状态条件收敛。

为什么不能只保留 claim_token

claim_token 只由 Workflow Store 签发和校验,它不知道业务对象当前属于哪一轮。考虑一个仍然持有合法 t1 的 Worker:

Worker A 领取 Workflow:claim_token=t1,业务轮次=f1
A 执行 f1 最终失败,FailureFinalizer 把业务对象收敛为 failed
用户重试,业务对象 CAS 更新为 f2,开启新一轮
A 残留的旧写入此时才到达,携带 f1

单看 t1,A 确实有权写,但 t1 证明不了 A 手里的结果还是当前这一轮。A 写业务结果时,业务对象会再校验 fencing_token:当前是 f2,A 手里是 f1,条件不命中,旧结果被拒。这里「条件不命中」是 CAS 的 WHERE 条件——写回不是无条件 $set,而是带 fencing_token == f1 的条件更新。f2 一装上,这条更新的条件就地失效,改动 0 行:不是「写进去再回滚」,而是「根本没写进去」。所以晚到的 f1 污染不了 f2。

反过来也一样:A 换成 B,claim_token 从 t1 变 t2,但用户没开新轮次,业务 token 还是 f1,B 该继续做完 f1,而不是因为换了机器就开一轮新业务。

那为什么不合成一个 token?因为每次 Claim 都要跨「Workflow 状态」和「业务对象」两个存储去更新,还得处理半路崩溃的窗口。分开后各管各的:claim_token 在 Workflow 里做条件更新,fencing_token 在业务对象里做条件更新。

完整时间线是:

  1. 第一轮 f1 执行失败,FailureFinalizer 把业务对象收敛为 failed;
  2. 用户重试,业务对象当前轮次更新为 f2,开启新一轮;
  3. 第二轮先完成并以 f2 写入结果;
  4. 第一轮残留的写入此刻才到达,尝试以 f1 写入旧结果;
  5. 业务对象当前已经是 f2,旧写入的条件不成立。

因此,这两个 token 不能合并成一个生命周期:

  • Worker 换人但业务轮次没变时,只换 claim_token,不换 fencing_token
  • 用户开启新一轮但旧 Workflow 仍可能运行时,要换 fencing_token,而两个 Workflow 各自仍有自己的 claim_token
  • 前者维护执行状态的单写者约束,后者维护业务结果的当前轮次约束。

八、步骤化之后,恢复发生在什么位置

再回到执行权先后交接的过程。总结 Workflow 不是一个只能从头调用的长函数,而是四个分别提交状态的 Step。A 完成的 preprocess、C 完成的 transcribe 都会成为后来 Worker 可以直接使用的已确认事实。

以网页内容处理为例,Definition 可以是:

需要异步识别的任务则是:

每一步成功后,引擎把 Step 状态和对应小型 Output 持久化。假设 transcribe_audio 已完成,Worker 在 compose_content 中退出,新 Worker 恢复时应跳过转录,从内容生成继续。

线性流程和 DAG 使用同一个就绪算法

每次执行前,引擎都会重新算一遍「哪些 Step 现在可以跑」:显式声明 DependsOn 就按 DAG 处理,没声明就默认依赖前一步。所以线性流程只是「每一轮就绪集恰好只有一个 Step」的 DAG 特例。

就绪计算分两步:

  1. 把 completed 和 skipped Step 放入「依赖已经满足」集合;
  2. 遍历所有非终态 Step,只有它声明的全部依赖都在集合中,才进入本轮 ready indices。

running 可以重新进入就绪集,因为它可能是上一个 Worker 崩溃留下的状态;waiting 只有先通过 Signal、Poll fallback 或 Timer 激活,使 Workflow 顶层回到 running,才有机会被 Claim。failed Step 不进入就绪集;只有当失败 Workflow 被 Replay 时,restartFailedWorkflow 才在同一次条件更新里把它重置回 pending(清空 attempt、错误和 checkpoint),此后它才重新参与就绪计算。

同一波次的 Step 会并行执行。每个 Step 完成时只更新 steps.output.,不会整张覆盖 Output;进程内更新 Output map 时再使用互斥锁。因此两个并行 Step 可以各自提交结果,避免后完成者把先完成者的 Output 覆盖掉。

Step 的状态不只成功和失败

attempt 只统计已经进入 Activity 的执行。因为流控拿不到 permit 而等待,不增加 attempt,也不记作失败。

Kernel 可以支持「失败即停止」或「失败后跳过」两种策略。本文示例默认停止;是否允许跳过必须由业务语义决定,不能为了让流程继续而吞掉关键错误。这个策略是每个 Step 自己声明的:组装 Definition 时通过 OnFailed 字段填 OnFailedStopOnFailedSkip,不填默认就是 stop。引擎侧的顺序是先按 MaxRetry 重试,重试耗尽后才读这个字段——只有 OnFailedSkip 会跳过继续,其余一律停。

引擎推进一个普通 Step 时,顺序大致是:

CompleteStep 会把 Step 的 completed 状态和该 Step 的 Output 放在同一次 Mongo 更新中。这样不会出现「状态已经完成,但恢复所需的输出还没写」的半完成状态。Observation 位于提交之后,即使观测写入失败,也不会要求 Activity 再执行一次。

仍然存在一个无法由本地事务覆盖的窗口:Activity 的外部副作用已经成功,但 CompleteStep 尚未成功。恢复后该 Activity 可能再次执行。Step 的完成状态和 Output 可以原子提交并受 fencing 约束,Activity 本身仍是至少一次的。文章后面所有关于幂等、业务 fencing 和外部 task_id 复用的要求,都来自这个窗口。

Activity 只能从一个入口进入业务代码

Engine 不会在调度、恢复或重试分支中直接调用 Activity。所有业务函数都经过 invokeActivity

timeoutCtx, cancel := ctx.WithTimeout(step.Timeout)
resultCh := make(chan activityResult, 1)

go func() {
    defer recoverPanicAsStepError()
    result, err := step.Activity(timeoutCtx, runtime)
    resultCh <- activityResult{result: result, err: err}
}()

select {
case <-timeoutCtx.Done():
    result := <-resultCh
    return result.result, ErrStepTimeout(timeoutCtx.Err())
case result := <-resultCh:
    return result.result, result.err
}

这个实现有一个看似反直觉的细节:超时后仍等待旧 goroutine 返回。Go 无法安全强杀 goroutine,如果超时分支立刻释放 claim 并开始下一次 Attempt,忽略 Context 的旧 Activity 可能继续写外部系统,与新 Attempt 重叠。当前做法牺牲了「超时后立即重试」,换取同一个 Step 的新旧 Attempt 尽量不并行。

这仍要求 Activity 把 Context 传给 HTTP、Mongo 和 Redis。若业务函数永久忽略取消,Engine 会被它拖住:超时后 invokeActivity 死等旧 goroutine 返回,而续租心跳用的是 detached Context、会一直续租,所以这个 claim 不会过期,别的 Worker 也接不了手,Workflow 就卡死在这个 Step 上,直到进程崩溃。进程崩溃后心跳停止,租约才真正到期,新 Worker 才能接管;此时旧进程若复活想提交,claim_token fencing 才会拒绝它。

panic 也在该入口被转换为 Step 错误,因此不会让整个 Worker 进程因为一个业务 Activity 崩溃。panic、普通错误和超时随后进入同一套失败分类。

重试的内部状态转换

MaxRetry 表示首次执行之外允许的次数,因此总 Attempt 是 1 + MaxRetry。每次实际进入 Activity 前,Engine 先把 Step 改为 running、attempt++ 并提交;错误发生后再按下面顺序处理:

WorkflowCanceled -> Step skipped,Workflow canceled
仍有重试预算  -> Step pending,释放 claim,立即重新入队
OnFailedSkip    -> Step skipped,继续计算下一波
其余错误        -> Step failed,Workflow 进入失败收敛

重试不会在当前 goroutine 内直接 for 循环。Step 先持久化回 pending,再带 token 释放 Claim、加入 Ready Queue 并 Wakeup,让任意 Worker 都能执行下一次 Attempt。这样重试同样遵守租约、停机和跨 Pod 调度规则。

Finalizer 属于可靠终态的一部分

当所有 Step 成功后,Workflow 还不能直接标记 completed。业务对象的终态可能仍需要可靠收敛。因此 Definition 可以配置:

  • SuccessFinalizer:成功前必须完成的幂等业务收尾;
  • FailureFinalizer:失败前必须完成的幂等失败收尾。

Finalizer 失败时 Workflow 保持可恢复状态,由其他 Worker 继续执行。给客户端发通知、写监控等 best-effort 行为不应放进这个可靠边界。

失败收敛多一层 failing 状态。Engine 先使用 running + claim_token 条件把 Workflow 改成 failing,再调用 FailureFinalizer 把业务对象写为失败,最后才以 failing + claim_token 条件提交 Workflow failed。若 Finalizer 或终态写入失败,实例仍是可 Claim 的 failing;下一台 Worker 会继续收敛失败,而不会重新执行已经失败的业务 Step。

成功路径则先运行 SuccessFinalizer,再把 Workflow 从 running 改为 completed。可靠的业务收尾放入 Finalizer;通知、临时快照清理等非关键动作在业务实现内部按 best-effort 处理。Workflow 终态落库后,Engine 再异步更新监控快照并发送客户端终态帧。

canceled:失败之外,还要表达「这项工作已经不该做」

failed 表示工作仍有业务意义,只是执行没有成功;canceled 表示业务对象已删除,或者旧一代 Workflow 已被新一代替代,继续执行反而可能写入过期结果。

当前存在两条进入 canceled 的路径:

  • Activity 执行时发现业务对象已经取消,返回 ErrWorkflowCanceled。Engine 把当前 Step 记为 skipped,直接将 Workflow 置为 canceled,不重试、不执行后续 Step,也不进入 FailureFinalizer;
  • Workflow 外部已经知道某个旧 workflow_id 作废,调用显式废止接口。Store 用 status in [running, waiting, failing] 条件把它原子改为 canceled,清除 claim、wait 和 resume,并递增 revision。

例如,一个分发 Workflow 正在等待主任务,关联关系随后被删除。业务侧已经知道这份分发不应继续,可以主动废止;即使主任务 Signal 晚到,canceled 实例也不会再次激活。另一个常见用法是隔离资源 generation:新一代观察任务启动时,调度方先废止旧实例,再关联新一代实例。

显式废止不要求当前 Worker 的 claim token,因为它表达的是业务所有权已经从外部发生变化;但它只能阻止后续 Workflow 状态写入,不能撤回已经发给 ASR 或其他外部服务的请求。业务 fencing token 和资源 generation 校验仍然需要保留。重复废止是安全的:目标已经终结或不存在时返回 changed=false

九、全局流控:把容量等待单独建模

在主案例中,Worker A 已经准备执行 transcribe,但此时还没有创建 ASR 转录单。如果集群里许多任务同时走到这里,单个 Pod 的并发池无法代表整个集群对 ASR 施加的压力。Permit 就放在「Step 已就绪」和「真正调用下游」之间。

网页抓取、ASR、AI 生成等下游都有容量边界。Definition 可以为 Step 声明一个容量资源,例如:

  • web_crawler
  • speech_recognition
  • content_generation

Redis 中的 permit 控制器同时处理两个限制:

  • max_concurrency:同一时刻最多执行多少个;
  • max_starts_per_second:一秒最多启动多少次。

两种限制解决的不是同一个问题。并发上限保护下游同时承受的在途请求数;启动速率限制削平瞬时尖峰。假设 ASR 平均需要十分钟,即使一秒只启动一个任务,长时间累积后仍可能有很多任务同时在途;反过来,即使并发上限足够高,一秒内同时发出大量请求也可能击穿下游入口。二者需要同时存在。

为了说明两层限制怎样叠加,假设集群有多个 Pod,同时到达一批需要 ASR 的任务;speech_recognition 暂时配置为并发 8、每秒最多启动 2 次:

时间调度结果
第 0 秒最多 2 个 transcribe Step 取得 permit
第 1 秒若在途少于 8,再允许最多 2 个启动
第 2 至 3 秒继续按每秒 2 个放行,直到并发达到 8
并发已经为 8其余 Step 保持 pending,记录 retry_after 后释放 Worker slot
某个 ASR Step 挂起轮询释放本次提交 permit,并唤醒 Worker,下一项可以启动

Pod 数量和本地 Worker 数量不会改变这两个全局上限。max_workers 控制单进程正在执行多少个 Step,容量资源控制所有进程合计可以向某类下游启动多少工作。

permit 自身也有租约,持有期间续租,完成或失败后释放并唤醒其他 Worker。即使唤醒通知丢失,Mongo claimable_time 和轮询仍能兜底。

控制器内部用一段 Redis Lua 把检查与占位放在同一个原子操作中:

permit 的 owner 由 workflow_id + step_name + claim_token 组成。同一个持有者重复 Acquire 只会续租,不会重复占用名额;新的 Claim 会带来新的 owner。permit 到期会被下一次 Lua 调用清理,因此 Worker 消失不会永久泄漏并发额度。

配置在每次 Acquire 时读取。把 speech_recognition.max_concurrency 下调后,已经拿到 permit 的调用不会被强行中断,新的调用会在边界前等待;把两个限制都设为零则表示不启用限制。Redis 出错时控制器选择暂缓并重调度,而不是绕过流控继续冲击下游,这是一种 fail-closed 的取舍。

同一机制也可以按租户生成 Workflow 级资源名,例如 tenant_work:,限制单个租户同时占用的任务数量。这样既保护全局下游,也防止单一来源填满所有 Worker。这里的名称是示意,不对应线上配置。

全局资源还给了工程师一个以前缺少的控制点。假设 ASR 服务开始超时,我们可以下调 speech_recognition 的并发数或每秒启动数。之后新到达的转录 Step 会停在 permit 之前,保持 pending 并记录流控等待,而不是继续把请求压向已经不健康的下游,也不会因此把整项任务判成失败。下游恢复后,再逐步放开容量,积压任务会继续推进。

这里描述的是系统提供的干预机制,不对应一场已经量化复盘的特定事故。流控器把「减速」建模为调度状态,工程师无需临时改代码或停止 Worker,新任务也不会因为容量不足进入失败重试。

这个设计带来一个重要的可观测区别:

总延迟 = 调度等待 + 流控等待 + Activity 实际执行 + 外部事件等待

过去我们看到「任务花了十分钟」,很难知道慢在哪里;现在 elapsed_mstraffic_wait_ms 分开记录,至少能先判断是执行慢还是排队久。

十、持久化等待:让持续数小时的业务不占一条线程

Worker A 创建 ASR 转录单后,识别服务还没有返回正文。此时继续占着 A 的 goroutine 和执行租约没有意义;系统需要保存的是「下一次无论由谁接手,都要继续查询同一张转录单」。持久化等待负责完成这次交接。

外部资源就绪和用户选择都不是靠更快的 CPU 能算出来的。Workflow 需要暂停,并在未来某个事实发生时恢复。

ASR 也属于等待,只是它等待的不是回调,而是下一次轮询时间。提交异步任务后,转录 Step 不会让一个 goroutine 在内存里睡一段时间再查,而是返回 Sleep(interval, checkpoint)

Sleep 返回的不是普通错误,而是 StepResult{Directive: SuspendDirective}。Engine 因此知道 Activity 没有执行失败,它只是需要暂停,等到指定时间再继续。

接下来,Engine 用当前 claim_token 执行一次 Mongo 条件更新。这个存储方法名为 SuspendClaimSuspend 表示将 Workflow 挂起,Claim 指当前 Worker 持有的执行权。它在同一次更新中保存以后继续所需的状态,并交还当前执行权:

steps[i].status     = waiting
steps[i].checkpoint = ASR Checkpoint
workflow.status     = waiting
workflow.wait       = timer + step_index + poll_time
claimable_time      = poll_time
unset claim_token
unset resume
revision += 1

这些字段必须一起更新。如果先释放执行权、后保存 checkpoint,进程可能在中间退出,新 Worker 便不知道应该继续查询哪个 ASR task_id。如果只保存等待状态却没有释放 claim_token,系统又会误以为当前 Worker 仍在执行。

attempt 只记录「真正进入 Activity 执行」的次数:Step 进入 Activity 前 attempt++,返回 Sleep/Yield 这类「暂停指令」时不算一次业务执行,所以挂起时回滚掉 attempt--。净效果是:同一个外部 task 被轮询多少次,attempt 都不涨,只有真正的重试才涨——轮询噪音不会冒充业务重试次数。

到期恢复也分成两个动作。调度器先扫描 status=waiting AND claimable_time<=now,再以同样条件原子执行激活:顶层状态改回 running、当前 Step 改回 pending、清除 wait 和旧 claim,并写入 resume.kind=timer。随后它才进入 Ready Queue,等待普通 Claim。Timer 到期本身不直接执行 Activity。

checkpoint 保存恢复所需的最小信息:外部 task_id、provider、提交时间、总 deadline、轮询间隔、关键选项快照和连续查询失败次数。恢复后的决策不是简单的「没成功就重新提交」:

provider 也必须随 task ID 一起保存:task ID 是某个供应商创建的,恢复后只能回同一个供应商查询,不能拿当前的路由规则重新选一次。Submit 把最终选中的 provider 写进 checkpoint,Poll 按名字还原对应 Client——这样「创建任务时的路由决策」在整段等待期间保持不变。其它影响查询语义的开关同理:保存提交时的快照,不读轮询时可能已经变了的配置。

查询前或查询结果下一步原因
已超过总 deadline结束为超时失败防止无限等待
task_id 已超过安全 TTL不再查询旧 ID,直接失败过期 ID 可能被复用,继续查询有串读风险
queued 或 running清零连续查询失败数,保存 checkpoint 后继续 Sleep外部任务仍在工作,重复提交会重复计费
瞬时查询错误退避后再查,限制连续次数查询失败不代表任务失败
success持久化转录结果并完成 Step后续 compose 可以继续
unknown 或未知状态码直接失败明确不自动重提,避免同一材料重复计费

这些上限都由配置提供,包括总等待 deadline、task ID 安全 TTL 和连续查询失败容忍次数。判断顺序先检查整段 deadline,再检查 task ID TTL,超过任一边界都不会继续查询。具体生产值会随供应商和任务形态调整,不属于 Workflow 协议。

还有一个无法完全消除的窗口:ASR 提交已经成功,但 task_id 还没写进 checkpoint,进程就消失了。新 Worker 看不到外部身份,可能再次提交。Workflow 无法用本地事务覆盖第三方调用,所以外部 Submit 做不到 exactly-once;能保证的是——一旦 task ID 进了 checkpoint,之后始终复用同一个 ID,不再重复提交。

等待期间不消耗 Activity 的重试预算。实现上 Step 挂起时会回滚本轮为了进入 Activity 增加的 attempt,恢复时再次增加,最终净值不变。这样运维后台看到的 attempt 表示实际业务尝试,而不是「同一个 ASR task_id 被查询了多少次」。

timer 等待不依赖 Redis Pub/Sub。claimable_time 是唯一唤醒事实:扫描器找到到期的 waiting 实例,把它原子激活回 running,然后才允许 Worker Claim。Redis 唤醒即使丢失,最多造成一次扫描间隔的延迟。

等待状态保存什么

当前持久化等待可以记录:

  • 等待类型:Signal 或 Timer;
  • signal_type:在等哪类事件;
  • wait_key:在等哪个业务对象;
  • generation:等待的第几轮;
  • 当前 Step;
  • 可选 poll_time:即使 Signal 没到,也允许在该时刻恢复检查;
  • Step checkpoint:保存跨等待的轮询计数或业务观察位置。

进入 waiting 后,Workflow 释放 claim 和 Worker 槽位,不保留 goroutine 或调用栈。

先把 Signal 说清楚:它是一张可持久化的“叫醒单”

贯穿案例里,总结 Workflow 会经历两种等待。preprocess 等待《架构夜谈》直播就绪时使用 Signal:资源状态变化后,由 Fan-out 投递「直播已就绪通知」来叫醒它。transcribe 查询 ASR 时使用 Timer:时间到了,再去查询一次同一张转录单。两者都会把 Workflow 置为 waiting 并释放 Worker,区别在于前者等一个外部事件,后者等一个确定的检查时间。

假设一个 Step 正在等待外部资源准备完成。它读取业务表,发现资源仍在处理中,于是返回:

return workflow.WaitForSignal(
    "resource_state_changed",
    resourceWaitKey,
    workflow.WithWaitGeneration(input.Generation),
)

这段代码不是让当前 goroutine 阻塞在某个 channel 上,也没有在内存里注册一个回调函数。Engine 会把「我在等待哪件事」写入 Workflow Instance,把当前 Step 改成 waiting,然后释放 claim 和 Worker 槽位。此后,即使这台机器重启,等待关系仍在 Mongo 中。

未来资源状态发生变化时,业务代码调用 DeliverSignal。Signal 也不是一份待处理任务,更不是装着最终结果的消息;它表达的是:

某个 Workflow 正在等待的事实可能已经变化,请让它重新获得一次检查机会。

所以这里不需要消息队列。发送方直接把 Signal 交给 Workflow Engine,Engine 先将它写入 Mongo 中的 Signal Inbox,再尝试唤醒匹配的 Workflow。Redis 只在成功激活后加速通知 Worker,不承担 Signal 的持久化。

一次正常唤醒,内部具体发生了什么

以「等待外部资源就绪」为例,完整顺序如下:

  1. 回调处理器先把资源的新状态写入业务表;
  2. 它使用稳定的 signal_id 调用 DeliverSignal
  3. Engine 先把 Signal 插入 Inbox,唯一索引会拦住重复投递;
  4. Engine 查找与这张 Signal 完全匹配的 waiting 实例;
  5. Mongo 执行一次带前置条件的原子更新:只有实例仍处于 waiting,并且等待身份完全一致,才把它改回 running;
  6. 同一次更新还会把当前 Step 改回 pending、清除 wait,并写入 resume.kind=signalresume.signal_id
  7. Engine 把这个实例加入 Redis Ready Queue,并通过 Redis Pub/Sub 发出一次短促的 Worker 唤醒;
  8. 某个 Worker 最终仍要回到 Mongo 原子 Claim。Claim 成功后,Activity 重新读取业务表,决定是继续、再次等待,还是完成。

这里最容易误解的是第 1 步和第 8 步。正确顺序是「先保存业务事实,再按门铃」;Activity 醒来后也不会把门铃声当成结果,而是重新开门查看。即使通知被延迟,业务事实仍然存在。

为什么必须先记录 Signal,再尝试唤醒

直觉上似乎可以直接执行一条更新:如果 Workflow 正在 waiting,就把它改回 running。但这会丢掉“Signal 早到”的情况。

例如,外部回调刚处理完时,Workflow 可能还在上一个 Step,即将进入 waiting。此时直接唤醒不会匹配任何实例;几毫秒后 Workflow 开始等待,却再也没有人叫醒它。进程运行得越快,这种时间差反而越难复现。

Signal Inbox 把瞬时通知变成了可恢复事实:

  • Signal 先到时,它先以 pending 状态留在 Inbox;
  • Workflow 完成上述「保存等待状态并交还执行权」的原子更新后,立刻再查一次是否已有匹配 Signal;
  • 若进程恰好在记录、挂起或匹配之间退出,调度器还会低频扫描「已有 pending Signal,而且现在确实有匹配 waiting 实例」的记录并补做激活。

因此,先到的 Signal 不需要在内存里等待 Workflow。两边只要最终都写入 Mongo,就能在稍后相遇。

还有一个方向要交代:如果 Signal 连 Inbox 都写不进去,这次投递会直接失败,等待方不会被唤醒——方向是安全的,只是晚醒。兜底在生产者一侧:fanout 这类投递者自己就是可恢复的 Workflow,投递失败会让它的 Step 重试,而 signal_id 是稳定幂等键,重试时已写入的 Signal 撞唯一索引被静默忽略、没写的补上,最终一定能落库。再退一层,如果生产者整个流程都没了(而不是单次写失败),资源观察类等待还会配 poll fallback,poll_time 到期后照样被激活重读业务状态。两层叠加,才敢说最终不会漏醒。

signal_id 和 generation 解决的不是同一个问题

一张 Signal 会带两组容易混淆的身份:

字段回答的问题作用
signal_id这是哪一次外部通知通过唯一索引消除同一个回调的重复投递
workflow_id要唤醒哪一个 Workflow 实例防止唤醒其他业务工作
signal_type它通知的是什么变化区分资源就绪、用户选择等事件
wait_key变化对应哪个业务对象防止同类事件串到另一个对象
generation这是该对象的第几轮等待防止上一轮迟到的通知唤醒新一轮

最终匹配条件是:

workflow_id + signal_type + wait_key + generation

还是用《架构夜谈》直播的例子。live-7 这场直播就绪后,fanout 会给每个订阅任务各投一张「直播已就绪」Signal。

先说 signal_id:它是「某一次通知」的唯一编号。假如 fanout 投递成功后网络抖动、回调又重试了一遍,两次拿到的 signal_id 相同——Inbox 的唯一索引只让第一张落库,第二张被丢弃。所以小李的 Workflow 只被叫醒一次,不会被同一份通知重复叫醒。

再说 generation:它是「第几轮等待」。小李第一轮订阅是 generation=1,挂在 wait 上等直播就绪。后来这一轮生成失败,小李重新发起,系统开第二轮 generation=2。假设第一轮那张「直播已就绪」Signal 因为延迟,此刻才姗姗来迟——它带着 generation=1,而小李当前等待的是 generation=2,匹配条件 …+ generation 对不上,这张旧通知被忽略,不会把第二轮提前叫醒。

一个是「同一份通知别重复投」,一个是「旧一轮的通知别叫醒新一轮」,两个字段各管一件事。

两个 Mongo 文档之间没有事务,为什么仍能恢复

Signal Inbox 和 Workflow Instance 是两份文档。实现没有假装它们能在一瞬间同时修改,而是明确处理两次写入之间的崩溃窗口:

退出发生的位置持久化现场后续怎样收敛
Signal 已记录,Workflow 尚未激活Inbox 中留有 pending Signal挂起后的复查或低频 matcher 再次匹配
Workflow 已激活,Signal 尚未标记 matchedInstance 已保存 resume.signal_idWorker 执行 Activity 前补写 matched;失败则延后执行
Workflow 已激活,Redis 入队或唤醒失败Mongo 中实例已经是 runningReady Queue 重建或 Mongo 兜底扫描重新发现它
Signal、Poll 或另一个 Worker 同时尝试激活多方看到同一个 waiting只有第一个满足 waiting 条件的原子更新能成功

这里所谓 CAS,可以直接理解为「只有旧状态仍符合条件才允许修改」。例如 Signal 激活时,Mongo 更新条件不只有 workflow_id,还包含 status=waiting 和完整等待身份。第一个调用把状态改成 running 后,第二个调用的条件自然不再成立,因此不会重复推进 Step。

Workflow Instance 中的 resume.signal_id 是“这次唤醒已经归属于谁”的正确性凭据;Inbox 中的 match_time 主要用于审计收敛。即使进程死在两次写入之间,新 Worker 也能根据前者把后者补齐,而不是再次执行一次唤醒。

Signal 不携带业务正文

Signal 的职责只是叫醒,不保存资源内容、用户选择或下游结果。这样可以避免 Callback Payload 与业务数据库出现两份相互矛盾的真相,也不会把 Signal Inbox 逐渐变成第二个业务数据仓库。

以「等待用户在两个候选结果中做选择」为例:接口先把用户的选择写入业务对象,再发送一张稳定 ID 的 Signal。等待中的 Step 被激活后,重新读取业务对象:如果选择已经存在就继续;如果状态仍不满足条件,就再次进入 waiting。

workflow_id 解决「应该叫醒谁」,业务表解决「用户究竟选了什么」。把这两个问题分开后,即使同一 Signal 被重试,或者 Worker 在唤醒后重启,业务结果也不会只存在于一条转瞬即逝的通知里。

Signal 和 Poll fallback 可以同时存在

有些外部系统提供 Callback,但不能把 Callback 当作唯一恢复路径:通知可能延迟、丢失,或者业务状态已经改变而 Signal 投递恰好失败。资源观察类 Workflow 可以在等待 Signal 的同时保存一个兜底检查时间:

return workflow.WaitForSignal(
    "resource_state_changed",
    resourceWaitKey,
    workflow.WithWaitGeneration(input.Generation),
    workflow.WithPollFallback(nextCheckDelay),
    workflow.WithWaitCheckpoint(checkpoint),
)

同一个 waiting 状态因此有两条合法唤醒路径:Callback 的 Signal 可以低延迟激活;若 Signal 没有到达,poll_time 到期后扫描器也会激活,让 Activity 重读业务权威状态。两条路径竞争同一个 status=waiting 条件,只有一个能成功完成状态转换。

ResumeState 只记一件事:这次是被什么叫醒的——Sleep 到期记 timer,被 Signal 匹配记 signal,Signal 没来、兜底检查到期记 poll。它不带任何业务结果,所以无论哪条路醒,Activity 都得重新读业务数据。注意:现在只有 Poll fallback 兜底,并没有「Signal 等待超时就判失败」这种语义。

十一、Durable Fan-out:把一对多分发也变成可恢复工作

回到《架构夜谈》直播刚刚就绪的时刻。数据库里可能有许多像小李的总结 Workflow 一样等待这场直播的订阅任务。资源观察 Workflow 不会在自己的完成回调里用一个大循环逐条唤醒,而是启动独立的订阅通知 Workflow,由它分页读取订阅关系并投递 Signal。

主任务完成后,系统可能需要唤醒大量等待方;共享资源变化后,也可能需要通知所有关联任务。

直接在成功回调中写一个大 for 循环有两个问题:

  • 主 Workflow 的终态被大量下游操作拖住;
  • 循环中途崩溃后,不知道已经通知到哪里。

当前采用独立 Fan-out Workflow:

Yield(checkpoint) 会保存分页游标、释放当前 claim,并让实例很快重新进入调度。崩溃后从 Checkpoint 继续。扇出任务失败不会把已经完成的主任务重新执行一遍。

Yield 与 Sleep 的区别是它没有外部等待条件。Engine 把当前 Step 改回 pending、回滚 Attempt、保存 checkpoint、把 claimable_time 设为当前时间,并在同一次条件更新中清除 claim 和 resume;随后立即 Enqueue 和 Wakeup。这样一批订阅者处理完成后,当前 Worker 主动把执行权交还调度器,其他 Workflow 获得运行机会,下一次再从游标继续。

Signal 投递本身使用 signal_id 去重,所以扇出分页重跑时,已经成功投递的订阅者不会因为同一 Signal 再次写入而重复激活。Checkpoint 解决「列表处理到哪里」,Signal 唯一键解决「某个订阅者是否已经收到」,两层幂等覆盖分页边界上的进程退出。

在业务中,这一模式主要覆盖两类长期逻辑:

场景等待方扇出方
共享资源就绪关联任务 Workflow资源观察 Workflow
主任务完成单个等待方 Workflow批量通知 Fan-out Workflow

十二、SSE 重连:回放已保留的进度,再继续追随新帧

回到小李的总结 Workflow。当某次认领执行 compose 时,模型会持续产生进度;用户的 SSE 连接却可能在任何时刻断开。Workflow 不依赖这条连接继续执行,SSE 这一节只解决「用户重新连接后还能看到什么」。

旧方案已经将 SSE 连接与后台生成分开:客户端断线不会取消笔记生成。局限在于,连接断开后,已经推送和断线期间新产生的进度都无法续接,重连用户只能等待最终结果。

Workflow 改造保留了「断线不取消生成」,并把进度帧从单条 HTTP 连接移到 Redis List。生成 Activity 通过 Client Execution Sink 追加帧;每个 SSE 连接拥有自己的读取游标,不会像破坏性消费队列那样把帧从其他重连请求面前删除。

重连时的实际读取过程如下:

  1. 根据 note_id + workflow_id + fencing_token 找到当前流队列;
  2. 先检查 terminal reference,如果已经完成,直接读取并返回最终笔记;
  3. 如果仍在生成,新 Forwarder 从当前 generation 的第 0 帧开始回放 Redis 中仍然保留的内容;
  4. 追上已有帧之后,游标继续向后读取新追加的帧,直到收到终态或连接再次断开。

当前实现没有使用客户端上报的 Last-Event-ID 精确恢复到断线前的下一帧。新连接会从当前 generation 的队首重放,因此更准确的说法是「回放已保留的过程并继续追随」,而不是帧级精确断点续传。

这套机制还需要遵守下面几项一致性约束:

  • 客户端断线只停止本次实时转发,后台生成继续;
  • SSE 队列丢帧不改变业务对象的最终结果;
  • 完成后重连可以直接读取最终结果,不必重放完整逐字动画;
  • Redis 故障不能把已完成 Workflow 回滚为失败。

流式模型可能按字符回调。若每个字符都成为一条 Redis 记录,协议开销、对象开销和网络往返会远大于正文。项目因此做两层优化:先把连续到达的文本字符攒进缓冲,约 100 毫秒或攒到约 1KB 才 flush 成一条记录(批量聚合);再把这条记录里每次都一样的协议字段省略,只留正文(紧凑编码)。这提醒我们:解耦不会自动消除成本,只是让成本出现在一个可单独治理的边界。

紧凑编码省掉了重复字段,也同时省掉了 JSON 原本携带的格式说明,而 Redis 中的记录不一定由写入它的 Pod 读取。用户断线重连后,请求可能落到另一台机器;滚动发布期间,也可能出现旧版本写入、新版本读取。以后如果载荷格式发生变化,读取端必须先知道这条记录应当按照哪一版规则解释,否则一串合法字节可能被错误地还原成 SSE 内容。

因此,每条紧凑记录都用一个字节的 Header 同时保存编码版本和记录类型:高 4 位表示版本,低 4 位表示类型,后面才是载荷。当前版本是 1,所以普通文本记录的首字节可以理解成 0001 0001:前半段的 0001 是版本,后半段的 0001 是文本类型;终态引用则是 0001 0101

拿一条文本举例。一段流式文本原本每条都是完整的 SSE JSON:固定字段 code、retry、msg_type,外加 data 里的正文。编码后普通文本只存一个字节 0001 0001 加正文的原始字节,固定字段全部省掉,解码时用默认值拼回;终态引用同理,Header 是 0001 0101,后面只存一个精简的终态摘要。几十字节一包的重复协议头,就这样被压成「1 字节 + 正文」。

首字节
┌────────────┬────────────┐
│ 高 4 位     │ 低 4 位     │
│ 编码版本    │ 记录类型    │
└────────────┴────────────┘

编码时通过 version << 4 | record_type 合成首字节;解码时用 header >> 4 取得版本,再用 header & 0x0f 取得类型。当前解码器只支持版本 1,读到其他版本时会明确拒绝,而不是拿旧规则猜测新数据。版本号本身不会自动实现向前或向后兼容;如果以后需要让新旧格式共存,可以根据这个版本号选择不同的解码逻辑。

这里版本化的是 Redis 中进度帧的内部存储格式,不是浏览器看到的 SSE 协议。Handler 解码后仍会恢复成原来的 SSE JSON,因此客户端不需要感知这层变化。

流队列身份至少包含 object_id + workflow_id + fencing_tokenworkflow_id 区分持久化实例,fencing token 区分业务处理轮次;客户端不能只按 object ID 读取,否则上一轮残留分片可能混进新一轮。

连续字符会按大小或短时间窗口批量 flush;控制帧和终态帧之前也会先 flush 文本,保持客户端事件顺序。普通帧通过一段 Lua 原子完成 RPUSH、返回 List offset 和刷新 TTL,offset 同时充当帧序号。具体阈值属于性能参数,不影响这里讨论的一致性协议。

生成期间 frames 使用较长 TTL 覆盖慢任务;终态写入后,frames 只保留短暂读完窗口,generation 和 terminal 引用保留更长的断线重连窗口。成功或失败必须先提交 Mongo,再 best-effort 原子追加终态帧、保存 terminal 引用并调整 TTL。终态后禁止继续追加普通帧。

恢复流式 Step 时,Engine 会在重新执行 Activity 前发送 Reset:清除旧 frames 和 terminal,并递增流 generation。这里的 generation 是「这个 SSE 帧队列重放了多少次」的计数器(Redis 队列旁一个 :generation 键,每次 Reset 清空队列时 +1),跟前面的业务轮次 generation(f1/f2)和 Signal 等待 generation 是两回事。Forwarder 读取前后都校验 generation;如果读取期间恰好发生 Reset,这批不稳定数据被丢弃,从新一代 List 起点重新消费。Reset 失败只影响实时展示,不能阻止 durable Workflow 继续执行。

十三、Definition 也是部署协议

一条总结 Workflow 可能等待几十分钟,这期间服务也可能完成一次滚动发布。新 Worker 不仅要读懂旧 Worker 保存的数据,还必须用兼容的 Step 结构解释它,否则「从上次位置继续」本身就失去了确定含义。

Workflow 可以跨进程恢复,也可能跨版本恢复。假设旧版本的实例步骤是:

A -> B -> C

发布新版本后变成:

A -> X -> B -> C

如果 Worker 只根据数组下标恢复,旧实例的「第二步」会被新代码理解成 X,产生严重错误。

这套设计将 workflow_type + 有序 step name + 依赖关系 计算成稳定 definition_fingerprint。Publisher 登记的是只含稳定步骤协议的 Plan,Worker 注册的是带 Activity 的 Definition;两者必须匹配。Worker 认领实例后也会校验指纹,不兼容实例停止执行,等待明确迁移,而不是猜测怎样继续。

指纹计算会给 type、Step name 和依赖项增加长度前缀,依赖排序后再做 SHA-256,避免简单字符串拼接的歧义。它刻意不包含 Activity 函数、Timeout、MaxRetry 和容量资源:这些属于运行时实现或调节策略,不决定持久化 Step 的身份;Step 名称、顺序和依赖才决定旧状态如何解释。

Publisher Runtime 与 Worker Runtime 的注册表相互独立。Publisher 只需要 Plan 就可以在 API 或 Cron 进程登记实例,不需要链接所有业务执行函数;Worker 必须注册完整 Definition 才能认领对应 type。Worker 取到实例后若发现 fingerprint 不一致,会带当前 claim 把任务延后一小时,并记录 persisted 与 worker fingerprint,等待迁移或兼容版本上线,不会把协议不兼容伪装成一次业务失败。

Step 名称和顺序已经成为持久化执行协议,修改时需要兼容存量实例或提供迁移方案。

十四、代码结构也随执行模型改变

引入 Workflow 后,如果 Activity 继续任意调用一个巨大的 Service 对象,测试和演进仍然困难。业务代码因此逐步收敛为三层:

先看一个经过简化的 Definition。它表达的是运行时契约,不是可直接编译的完整源码:

Definition{
    Type: "async_content_job",
    Steps: []Step{
        {
            Name:        "preprocess",
            Activity:    preprocess,
            Timeout:     30 * time.Second,
            MaxRetry:    2,
        },
        {
            Name:            "transcribe_audio",
            Activity:        transcribe,
            Timeout:         30 * time.Minute,
            MaxRetry:        0,
            TrafficResource: "speech_recognition",
        },
        {
            Name:            "compose_content",
            Activity:        compose,
            Timeout:         15 * time.Minute,
            MaxRetry:        0,
            TrafficResource: "content_generation",
        },
        {
            Name:        "finalize",
            Activity:    verifyBusinessTerminal,
            Timeout:     30 * time.Second,
            MaxRetry:    2,
        },
    },
    SuccessFinalizer: finalizeSuccess,
    FailureFinalizer: finalizeFailure,
}

这段声明同时向三个角色提供信息:

  • Publisher 根据稳定的 Step 名称和依赖登记执行 Plan,不需要持有可调用函数;
  • Worker 注册带 Activity 的完整 Definition,认领后才能执行;
  • Engine 读取超时、重试、流控和失败策略,按统一协议推进状态。

其中 MaxRetry 不包含首次执行,零表示失败后不由 Engine 直接重跑;异步 ASR 的持续查询由 checkpoint 和 durable waiting 推进,不应被计算成多次转录尝试。

Definition 只描述稳定结构和执行策略。租户 ID、业务对象 ID、generation 等属于 Instance input;ASR task ID 和扇出游标属于运行中的 Output 或 Checkpoint;DAO、HTTP Client 和业务 Service 通过 Activity 的 Ports 注入。把这几类信息分开,才能让同一份 Definition 被多个实例复用,也让持久化数据不需要序列化函数和进程对象。

Step 名称需要稳定,Activity 实现可以在兼容契约下演进。若步骤结构发生变化,Definition fingerprint 会阻止新 Worker 直接猜测旧实例的位置。这使 Definition 同时承担代码组织和持久化协议两种职责。

每一种业务流程通过 Ports 显式声明所需能力,例如:

  • 读取业务对象;
  • 检查 AI 额度;
  • 调用转录;
  • 保存生成结果;
  • 发送完成通知。

这些接口让 Workflow Definition 不再依赖整个业务 Service。测试可以为一个能力提供 Fake,而不必启动数据库或 Patch 大对象。相同类型的 Workflow 也遵循一致的装配形态,新增流程不再重新决定代码应该放在哪里。

十五、这套模型最终承载了哪些形态

具体的生产 Type 名称和数量没有必要公开。更有价值的是观察它们最终收敛成了哪些可复用形态:

泛化后的 Workflow 形态典型步骤主要使用的内核能力
短链路内容处理prepare → transform → finalizeStep 恢复、幂等终态、下游流控
异步外部任务prepare → submit / poll → compose → finalizeCheckpoint、Timer、失败 Step 重置
外部事件等待prepare → wait for signal → apply → finalizeSignal Inbox、generation、Poll fallback
批量分发scan page → deliver idempotent signal → yield分页 Checkpoint、Yield、Signal 去重

这张表也展示了迁移的演进路线:先用线性 Step 解决断点恢复,再让内核承担持久化等待和可恢复扇出。它并非在第一天就拥有全部能力,而是由真实故障窗口逐步逼出协议。

这里还要区分「内核能力」和「业务当前用法」。Kernel 的就绪算法支持 DependsOn,可以在同一轮并行执行没有依赖关系的 Step;但业务是否并行仍由数据依赖决定。转录与内容生成不能并行,因为后者需要前者的结果;网页抓取若承担后续恢复材料,也必须先完成。保留 DAG 能力是为了避免未来重写调度模型,不代表每条生产流程都应该画成复杂 DAG。

多个 Workflow 协作时也不应重新合并成一条长函数。主任务的 SuccessFinalizer 只负责幂等发布 fan-out;fan-out 分页投递 Signal;每个等待方再独立处理自己的结果。共享资源则由单独的观察 Workflow 负责,状态变化后分页唤醒关联实例。这样,某个等待方失败不会重做主任务,单个关联任务失败也不会让所有实例分别轮询同一个共享资源。

十六、改造后,项目具体获得了什么

这次改造最重要的收益,可以从复杂度放置的位置观察。

用小李的直播总结来概括:请求被接受后,即使原进程退出,pending 派发意图仍能创建总结 Workflow;《架构夜谈》直播就绪后,订阅通知 Workflow 会通过持久化 Signal 唤醒包括它在内的订阅任务;等待期间 Workflow 不占用任何 Worker 槽位,每次被唤醒由当时空闲的 Worker 重新认领;ASR 容量不足时任务等待而不失败;转录单 ID 写入 checkpoint 后不会在每次轮询时重新创建;已经完成的 Step 不因后续故障重做;用户重连可以回放仍保留的生成过程;工程师则能直接看到任务停在哪个 Step。下面的收益只是把这条具体路径拆成可复用的系统能力。

业务类型较少时,由各条链路分别处理重启恢复、重复消息、并发限制、SSE 断线、超时和失败状态,可以减少公共抽象,也更容易快速交付。随着类型和外部依赖增加,业务类型与执行问题开始形成叉乘:相似的重试、恢复和补偿逻辑出现在多条链路中,修改一项执行语义也需要逐条核对。

Workflow 化之后,业务侧主要提交 Definition、Activity 和幂等业务写入;跨进程执行语义由 Kernel 统一解释:

复杂度没有消失。租约、fencing、Signal、permit 和版本校验集中进入 Kernel,并由故障测试共同验证。变化在于这些问题不再随着业务类型重复增长。新增一种 Workflow 时,评审可以把注意力放在 Step 边界、Activity 幂等和业务终态,而不必在每条链路中分别设计「服务重启后怎么办」。

1. 恢复粒度从整项任务下沉到业务步骤

抓取或转录一旦可靠完成,后续失败不必默认重做前面的昂贵步骤。恢复点与真实业务成本对齐。

2. 执行生命周期不再绑定单个进程

Worker 崩溃后,租约到期即可由其他 Worker 认领。等待 Signal 或 Timer 时甚至不需要任何 Worker 持续存活。

3. 重复派发有了统一的幂等入口

实时请求、客户端重试和补偿扫描都收敛到 StartOrResume + biz_key,不再由各个业务入口分别编写防重逻辑。失败 Replay 只重置失败 Step,保留已完成 Step 的结果,不会从头重做已经完成的转录或抓取。

4. 下游容量从单机配置变成集群级治理

网页抓取、ASR 和 AI 可以分别设置跨进程并发与启动频率。资源不足被建模为等待,而不是业务失败。

5. 长期等待不再占用线程

外部资源、订阅关系和用户选择可以暂停数分钟乃至更久。等待条件被持久化,Signal 早到、迟到、重复到达都有明确的收敛协议;Poll fallback 还可以修补 Callback 丢失路径。

6. 断线重连不再只能等待最终结果

客户端断线不取消后台生成,这是旧方案已有的行为。这次增加的是进度回放与跨轮次隔离:重连后先从当前 generation 的队首回放仍保留的帧,再继续追随新帧;流队列由 workflow_id + fencing_token 隔离,Replay 前通过 generation Reset 废弃旧帧,业务终态则先提交到 MongoDB,再尽力发送 SSE 终态帧。

7. 排障从「任务失败了」推进到「哪一步为什么失败」

Step 记录 attempt、错误、实际耗时、流控等待时间和等待次数。监控快照可以按 Workflow 类型和业务元数据聚合,Observation 可以查看具体 Attempt。

8. 不同业务类型共享同一套执行语义

超时、重试、租约、fencing、流控、持久化等待、Signal 和观测不必在每一种内容处理、异步调用或分发任务中分别实现。新增业务主要关注自己的 Definition 和 Activity。

9. 迁移可以按业务类型渐进进行

旧 Task 链路和新 Workflow Worker 可以独立开关。新类型逐个迁移、观察、回退,不需要一次性替换整个生成系统。

10. 架构讨论有了共同语言

团队可以明确讨论「这是业务失败还是流控等待」「这是可靠 Finalizer 还是 best-effort 通知」「Signal 只唤醒还是携带数据」。这些概念把原来混在重试逻辑中的异常路径拆成了可以分别处理的问题。

十七、执行模型发生了哪些变化

早期方案已经可以接收任务、调用下游、生成结果,也能处理多数正常请求,并且在任务较短时具有更低的开发和运行复杂度。这次改造保留了串行主路径,新增能力主要覆盖中断、等待、拥堵和重复请求。下面比较的是两种方案在不同约束下承担的执行语义,不是对早期工作的优劣评判。

维度早期方案Workflow 方案
完成功能一个长函数串行跑完整条链路仍然串行,但执行位置已经持久化
服务关停关停回调可能将命中条件的未完成任务批量置失败Pod 退出不决定 Workflow 终态;租约到期后可由其他 Worker 接管
失败粒度记录整项任务的结果记录具体 Step、attempt 和错误类别
长时间等待进程内轮询或依赖原任务存活保存 checkpoint,释放 Worker 后再恢复
重复派发各入口分别防重统一收敛到 StartOrResume + biz_key
失败后重试从头再跑,或由业务猜测恢复位置completed Step 保留,失败 Step 重置回 pending 后继续
业务取消或代次替换常被混入失败,晚到任务仍可能继续canceled 与显式废止接口停止旧实例
下游保护单机并发池或各业务自行重试跨 Pod 的并发和启动速率控制
实时体验断线不取消生成,但重连后只能等待最终结果重连后回放当前 generation 中仍保留的帧,再继续追随新帧
故障处理用户重试、研发拼接日志运维后台按 Workflow、Step 和 Attempt 下钻

Workflow 没有把串行流程变成并行流程。 需要转录的任务仍然要先取得转录结果,再生成内容;需要网页材料的任务也仍然要先抓取,再交给模型。执行位置从进程内存移动到了持久化状态中,这使其他 Worker 可以识别已经完成的 Step 和下一步工作。

这些变化没有消除故障。外部副作用仍需幂等,持久化调度会增加数据库写入和重复 Claim,监控投影可能短暂延迟,Definition 变更也开始需要版本纪律。下一节继续讨论这些成本。

十八、我们付出的代价

这些恢复能力会增加存储、开发和运维成本。

1. 更多持久化写入

每个 Step 状态变化、Output、租约和终态都需要写数据库。Step 过细会明显放大写入与调度成本。

2. Activity 幂等是恢复的前提

引擎提供至少一次执行语义。外部调用成功而状态未落库的窗口仍然存在,业务写入需要幂等键、CAS 或 fencing。

3. 持久化协议需要版本纪律

在 Definition 层,Step 名称、顺序和依赖已经成为持久化协议。一次看似普通的重命名,可能需要实例迁移或兼容策略。

项目里其实还有一套容易与它混淆的版本:SSE 进度帧紧凑编码把 CompactVersion 放在记录首字节的高 4 位。两者解决的不是同一个问题:definition_fingerprint 防止新 Worker 用不兼容的步骤结构解释存量 Workflow;CompactVersion 防止读取端用错误格式解释 Redis 中的进度帧。前者保护可能跨多次发布仍未完成的执行状态,后者保护生命周期较短的流式缓存。它们共同说明了一件事:只要数据会被另一个进程、另一个版本的代码继续读取,格式就已经是协议,而不再只是实现细节。

4. 状态更多,排障门槛先升后降

工程师要理解业务对象、Outbox、Instance、Step、Claim、Signal、Ready Queue 和 Observation 的边界。设计清楚后排障更准确,但学习成本客观存在。

5. 自研内核也有维护成本

租约竞争、崩溃窗口、Signal 早到、异步观测乱序和索引性能都需要持续测试。采用现有 Mongo、Redis 并不代表基础设施成本为零。

6. 外部副作用需要明确补偿边界

当前实现通过 FailureFinalizer 完成 Workflow 粒度的业务补偿,例如收敛失败状态并尝试释放已占额度;Replay 只重置失败 Step;被 Finalizer 撤销的业务前置条件需要由 Activity 自身的幂等逻辑重新建立。这是一套面向当前业务的 Saga,但不会自动撤销每个 Step 已经发出的外部副作用。当前失败状态写入会阻止 Finalizer 提前结束,额度释放失败则按 best-effort 处理。对于无法撤销的外部调用,仍需业务设计幂等键和人工处置边界。

十九、当前明确保留的边界

为了防止内核无限膨胀,我们没有把它定位成公司级通用平台。当前应谨慎或暂不扩展的方向包括:

  • 跨服务控制面和通用 Definition Registry;
  • 完整事件历史回放;
  • 通用 Saga 补偿框架;
  • 让监控投影承担审计或计费;
  • 把任意业务大字段复制进 WorkflowInstance;
  • 为没有真实消费者的未来能力预建抽象。

内核包含线性执行、步骤依赖校验、租约、Replay 准备、流控、Timer、Signal 与 Poll fallback、Yield、显式废止和多类终态收敛等能力;具体业务仍应优先选择最简单、已经足够的模式。

几个边界也需要明确:Signal 不携带业务 Payload;Poll fallback 不是通用 Wait Timeout;Activity 仍是至少一次;允许跳过失败 Step 不代表业务应该采用;DAG 能力也不代表线性流程必须并行;canceled 只能停止后续状态推进,不能撤销已发出的外部副作用。

二十、如果重新做一次,我会坚持的实施顺序

几个具体原则:

  1. 先列出每个外部副作用,再谈自动重试;
  2. 先确定权威状态,再选 Redis、Kafka 或扫描器;
  3. Step 按恢复、限流和观测价值划分,不按函数数量划分;
  4. 先证明单一线性流程可靠,再增加 DAG、Signal 或 Fan-out;
  5. 主路径和补偿路径必须汇入同一个幂等入口;
  6. 每设计一个通知,都问一句「它丢了会不会破坏正确性」;
  7. 用进程退出、重复投递、Redis 清空、Signal 早到和旧 Worker 晚归做故障测试;
  8. 新旧执行链路独立开关,小范围灰度,不把引擎风险和业务迁移风险一次叠加。

二十一、结语:Workflow 的价值是让失败成为正常路径

一个只考虑成功路径的长函数,看起来很短:从头执行,遇错返回。进入生产环境后,进程重启、网络超时、重复消息、用户重试、下游限流和外部事件迟到都会进入执行路径。

Workflow 没有消灭这些失败。它做的是更务实的事:

  • 把进度放到进程之外;
  • 把等待与失败分开;
  • 把重复执行当作常态;
  • 把执行权变成有期限、可校验的租约;
  • 把最终事实和实时通知分开;
  • 把恢复逻辑从各业务 Consumer 中提炼成共同执行语义。

评价这套设计时,比功能数量更有意义的问题是:在任意一个持久化边界让进程退出,系统能否根据现有事实确定下一步;两个执行者同时出现时,是否只有一个提交者;下游被人为降速时,等待是否会被误记成失败;旧版本实例遇到新代码时,系统是否会拒绝猜测。

这些问题分别由 Outbox、唯一 biz_key、Claim lease、两类 fencing token、Step 原子提交、durable waiting、容量资源和 Definition fingerprint 回答。它们并非彼此独立的功能开关,而是同一个执行协议的不同约束。

这次重构增加了一套能够持续回答「现在发生了什么、下一步由谁做、失败后从哪里继续」的执行面。流程图只是它的静态描述,持久化状态和恢复协议才负责运行时推进。

当任务还短、依赖还少时,这套机制可能显得沉重;当生成链路开始跨越分钟、跨越进程、跨越外部事件时,它恰好把那些原本会散落成补丁的复杂度,收进了一个有边界、可观测、可恢复的模型里。

正常路径仍然保持串行。变化发生在异常路径中:系统在执行者消失、下游拥堵或外部事件迟到时,仍然保存当前位置和后续动作。

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论