服务重启了,为什么用户的任务失败了?(上):从长任务到 Workflow
本文来自一次真实的长任务处理链路重构。上篇先讲一个在早期约束下成立、后来逐渐超出适用边界的设计:服务关停时,通过回调把一批未完成任务收敛为失败。然后从零解释 Workflow 是什么、它和任务队列、状态机、DAG 有什么区别,以及 Temporal 的设计思想如何影响了我们的选择。本文内容仅为抽象化的示范讲解,用于说明一类通用设计思路;文中的业务场景、字段名与执行细节均为虚构示例,不代表任何真实线上系统的实际执行逻辑;读者不需要了解原项目也可以独立阅读。
一个在早期约束下合理的关停逻辑
很长一段时间里,服务关停时会由一个回调把命中条件的处理中业务对象标记为失败。
这个选择有明确的工程背景。进程马上就要退出,内存中的 goroutine 也不会继续运行。将任务收敛到失败状态,可以避免用户长期看到「生成中」,也给后续重试留下一个明确入口。
服务运行在 Kubernetes 上,滚动部署时会进入优雅终止流程。Pod 的退出宽限期配置为五分钟:
spec:
terminationGracePeriodSeconds: 300
300 秒是从 Kubernetes 发出 SIGTERM 开始计时的整个退出预算,不是执行关停回调之前单独等待五分钟。在当时的启动框架配置下,进程收到信号后先进入摘流阶段;约 16 秒后,已注册的 AfterShutdowning 开始执行。如果整个退出过程到第 300 秒仍未结束,Kubernetes 才会强制终止进程。
这里还有一个容易被忽略的并发关系:多个 AfterShutdowning 会同时执行。停止 HTTP Server、停止后台任务执行器,以及更新数据库状态之间没有全局的先后保证。这里的「后台任务执行器」,指进程内负责领取并推进异步任务的常驻执行循环,不是 Kubernetes Worker Node。因此,「数据库已经把任务改为失败」并不能证明 HTTP 已经完全停止,也不能证明后台任务执行器中的业务执行已经结束。在多数任务较短时,这个窗口很少暴露问题;随着任务变长,「业务状态」和「真实执行」就可能短暂分叉。
在早期链路里,任务较短,步骤较少,整体重试的时间与外部调用成本也可以接受。用户提交一份待处理材料,后台调用几个服务,最后把结果写回数据库。用整体状态管理一条串行链路,开发、上线和排障成本都更低。
回头看,这不是一个从第一天起就错误的设计。它解决了当时更直接的问题:请求需要异步执行,服务退出后不能让业务状态永久悬挂。后来的改造也没有否定这条判断,而是因为任务的持续时间、外部依赖和中间结果成本已经发生变化。
后来,链路变长了。
一次长任务可能已经完成文件准备、占用业务额度、提交异步 ASR,并等待了几分钟;服务恰好在内容生成阶段重启。此时,一次「进程关停事件」被提前翻译成了「业务失败」。用户再次处理后,新请求可能从头提交 ASR,而旧的异步任务或尚未停止的后台任务执行器仍可能继续运行。
继续增大 terminationGracePeriodSeconds,可以覆盖更多尾部任务,却会同时延长滚动发布、节点迁移和异常实例退出所需的时间,而且任何有限窗口都会遇到更慢的任务。进程崩溃、节点故障等路径也不一定拥有完整的优雅退出时间。优雅停机适合让常见请求收尾,但它不能代替任务自身的恢复协议。
沿着这条路径回看,旧状态模型记录了任务的整体结果,却没有记录步骤级进度:系统能够判断任务是否结束,但无法据此确定这项工作已经走到了哪里。
当一次生成包含多个昂贵步骤,并且可能持续几分钟甚至等待一个外部事件时,「一个任务状态 + 一个从头跑到尾的函数」就不再足够。我们需要把执行过程本身保存下来,让它不依赖某一个进程、某一条消息或某一条 HTTP 连接继续存在。
早期方案已经解决了「任务能不能异步跑完」。随着链路增长,我们还需要回答三个问题:任务中断后能否继续,下游异常时能否统一减速,工程师能否判断任务状态是否异常。
这正是本文所说的 Workflow:
Workflow 是一份可持久化的执行计划,以及这份计划当前执行到哪里的状态。
在这次改造中,Workflow 用来回答四个实际问题:
- 进程现在退出,下一台机器应该从哪里继续?
- 同一个请求被投递两次,怎样避免做出两份结果?
- 下游只能承受固定并发时,多个实例怎样共同遵守限制?
- 一次任务到底是在排队、执行、等待外部事件,还是已经失败?
一、我们的业务为什么天然会变成「长任务」
先用「总结网页内容」举例。用户给出一个网页地址,希望系统读取页面内容,再生成一份结构化结果。看起来只有「读取网页,再交给 AI」两件事,实际至少包含以下阶段:
每一步的特性并不相同:
| 阶段 | 典型耗时 | 主要风险 | 独立恢复的必要性 |
|---|---|---|---|
| 校验与准备 | 毫秒到秒 | 参数、额度、业务状态冲突 | 较低,通常可以重做 |
| 网页抓取 | 秒到分钟 | 超时、反爬、下游限流 | 较高,结果获取成本明显 |
| 语音识别 | 分钟级 | 大文件、异步回调、供应商容量 | 高,重复提交可能增加等待和费用 |
| AI 内容生成 | 秒到分钟 | 流式连接、模型错误、配额 | 高,需要保留前序材料和生成进度 |
| 结果落库 | 毫秒到秒 | 并发覆盖、部分成功 | 需要幂等写入 |
再加入更多长任务形态后,问题会继续扩大:
- 一类任务要先等待外部异步处理,再根据结果生成内容;
- 一类任务要等待某个外部资源就绪,等待可能远长于一次进程生命周期;
- 一类主任务完成后,还要把结果分发给大量等待方;
- 一类任务生成多个候选结果后,需要暂停并等待用户选择;
- SSE 需要把生成中的内容实时推给客户端,但客户端随时可能断网。
这类任务有三个共同特征:耗时长、外部依赖多、中间结果有价值。一次数据库事务或普通 RPC 无法覆盖它们的完整生命周期。
二、早期方案是怎样工作的
在改造前,典型路径可以抽象成这样:
这套方案与当时的约束是匹配的。任务队列负责异步化,Task 表负责展示整体状态,Consumer 负责执行,超时扫描器负责找回卡住的任务。当业务类型较少、单次执行较短时,这些组件已经覆盖了主要故障路径,也避免了过早引入一套持久化执行内核。
适用边界是在执行链路不断增长之后发生变化的。
1. 整体重试,不等于断点恢复
假设网页抓取花了两分钟并且已经成功,AI 生成到一半时 Worker 重启。Task 的整体状态只能表明任务尚未成功,无法表达「抓取已经是可信的完成状态」。用户再次重试时,整个函数通常从头执行:
「可以重试」表示业务还有机会重新执行;「可以恢复」还要求系统知道哪些工作已经完成,可以从中间位置继续。
整体重试的代价包括:
- 重复调用收费或受限的外部服务;
- 重复等待已经成功的慢步骤;
- 更容易触发反爬、QPS 或并发限制;
- 无法准确判断失败属于抓取、转录、AI 还是落库;
- 每个业务类型都要自行编写「半残状态」的判断逻辑。
2. 任务的生命等于进程的生命
如果执行进度只存在于 goroutine 的调用栈中,那么 goroutine 在,进度就在;进程没了,进度也没了。
数据库里的 processing 只能证明「某个 Worker 曾经开始过」,不能证明它正在执行哪一步、已经产出了什么、是否仍然拥有执行权。为了判断任务是否死亡,我们会不断增加心跳、处理人、开始时间、最后更新时间和超时扫描规则。
这些字段可以帮助识别失联任务,但仍没有记录当前 Step、已完成结果和后续恢复位置。
3. Kafka 能可靠投递消息,但不会替我们执行长任务
这是一个很容易混淆的边界。
Kafka 擅长保存和分发事件,例如「音频已经上传」「订单已经支付」。它保证消息不会轻易丢失,并允许多个系统独立订阅。Kafka Consumer Group 管理的是分区消费权,长任务还需要单独的任务级执行租约。
如果 Consumer handler 收到消息后同步执行一个耗时数分钟的任务:
- 一个慢任务会阻塞同一分区后面的快任务;
- 处理时间可能超过 Consumer 允许的 poll 间隔,引发 rebalance;
- 旧 Consumer 可能仍在执行,新 Consumer 已经再次收到消息;
- 消息至少投递一次,意味着业务步骤必须自己处理重复执行;
- 排查时要同时还原消息、Consumer、Task 表和业务表的状态。
这说明,即使引入消息队列,它解决的也只是「如何通知 Worker 有工作可做」,不会自动解决「这项工作已经执行到哪里」。
对我们而言,这不是一个「在消息队列后面再加一层 Workflow」的方案。恰恰相反,改造目标之一就是移除 Kafka 长任务执行链,让业务入口直接登记一项可持久化、可恢复的工作。它具体如何被唤醒、调度和接管,放在下篇再展开。
4. 单机并发池控制不了整个集群
假设网页抓取服务最多允许 100 个并发。单个 Pod 配一个容量为 100 的 goroutine 池,看起来解决了问题;部署 5 个 Pod 后,理论并发却变成了 500。
下游容量是集群级事实,单机 semaphore 只能表达进程级限制。更麻烦的是,当第 101 个任务暂时拿不到名额时,它不应该被记作一次业务失败,也不应该消耗一次重试机会。它只是在等待资源。
如果「等待」和「失败」没有分开,流量越高,失败重试越多,重试又制造更多流量,最后形成重试风暴。
5. SSE 断开不会取消生成,但重连后无法续接过程
SSE(Server-Sent Events)是一种服务器持续向浏览器推送文本事件的 HTTP 连接。它适合展示 AI 逐步生成的内容,但网络连接天然不稳定。
用户切到后台、地铁网络切换、网关空闲超时,都可能让 SSE 断开。旧逻辑已经做到「断线不取消生成」:后台任务会继续执行,完成后也会保存最终结果。它没有解决的是重连之后的过程恢复:新连接无法拿回断线期间已经产生的流式内容,用户只能继续等待,直到整项生成完成后读取最终结果。
这次改造因此要同时保留一项旧行为、补上一项新能力:SSE 断开仍然不影响后台生成;客户端重连时,能够先回放当前执行轮次中仍然保留的进度帧,再继续接收新内容。实时推送仍然只是展示通道,数据库中的最终结果仍然是业务事实。
6. 状态开始到处复制,却没有唯一答案
随着补丁增加,同一项工作可能同时出现在:
- 业务对象的终态;
- 旧任务表的处理状态;
- Kafka offset 和消息重试;
- Worker 内存中的当前阶段;
- Redis 中的临时进度;
- SSE 客户端看到的最后一帧;
- 监控系统中的异步投影。
当它们不一致时,系统必须回答「相信谁」。如果没有明确的权威状态,恢复代码只能通过猜测拼出答案。
这些现象指向同一项缺失:系统没有一份独立于进程的执行事实。增加重试入口只能重新发起任务,无法补回已经丢失的执行位置。
7. 把一次重启放到代码行之间观察
旧执行器的核心逻辑可以简化成下面这样:
func Consume(task Task) error {
markProcessing(task.ID)
material, err := prepare(task)
if err != nil {
return markFailed(task.ID, err)
}
transcript, err := transcribe(material)
if err != nil {
return markFailed(task.ID, err)
}
note, err := compose(transcript)
if err != nil {
return markFailed(task.ID, err)
}
return saveAndComplete(task.ID, note)
}
这段代码的正常路径没有明显问题。问题出现在任意两行之间,例如 ASR 已经返回 task_id,进程尚未把它保存为可恢复状态时发生重启。
此时数据库只保留「任务曾经 processing,后来 failed」。外部 ASR 任务是否仍在运行、它的 ID 是什么、额度是否已经占用、后续应该查询还是重新提交,都留在已经消失的调用栈里。
如果希望另一台机器接着执行,它至少需要回答下面几个问题:
| 接手时需要回答的问题 | 通俗地说,需要留下什么 |
|---|---|
| 我接手的是哪一项工作 | 一个不会因为换机器而改变的身份 |
| 前面哪些阶段已经完成 | 每个阶段经过确认的执行结果 |
| 外部 ASR 任务是否已经提交 | 能继续查询原任务的回执和必要参数 |
| 原来的执行者是否仍有权提交结果 | 一份会过期、可以被新执行者替换的执行权证明 |
| 任务现在为何停住,什么时候再继续 | 下一次检查时间,或者正在等待的事件 |
| 这份结果是否还属于业务对象的当前处理轮次 | 能区分新旧处理轮次的标记 |
这些问题暂时不需要对应到具体字段。后文会先解释 Workflow 的基本概念;下篇再跟随一次真实执行,把它们分别对应到实例身份、阶段状态、外部任务检查点、执行租约、恢复条件和业务轮次保护。
从这个角度看,Workflow 改造并不是把长函数拆成四个函数。函数拆分只改变代码组织;把阶段结果、临时执行权和恢复条件保存下来,才改变了服务重启后的行为。
三、Workflow 到底是什么
1. 用旅行计划做一个不严谨但有用的类比
把一次跨城旅行看作一个 Workflow:
- 「去上海出差」是 Workflow 实例;
- 「到机场、安检、登机、入住酒店」是 Step;
- 每一步实际做的事情是 Activity;
- 行程单是 Definition,即预先定义的执行规则;
- 登机牌和酒店订单号是中间 Output;
- 行程 App 中的当前状态是持久化执行状态。
如果负责记录和提醒行程的 App 重启,它不应该把整段行程重置为「尚未出发」。恢复后,它需要知道旅客已经通过安检,下一步应该登机。对应到长任务也是一样:新的执行者读取已经保存的状态,从尚未完成的阶段继续。
类比回系统:
2. 五个必须分清的名词
Workflow Definition:流程定义
Definition 描述「一类工作」的稳定结构。例如,网页内容处理会依次执行预处理、网页抓取、内容生成和收尾。Definition 还会规定每一步的超时、最大重试、失败策略和需要申请的流控资源。
Definition 是模板,不是某一次执行。
Workflow Instance:流程实例
用户提交一个具体链接后,系统创建一条 Instance。它记录本次执行的业务幂等键、输入、每个 Step 的状态、尝试次数、中间输出、租约和最终状态。
Definition 像「菜谱」,Instance 像「桌上这份正在做到第三步的菜」。
Step:持久化边界
Step 用来建立恢复边界。一个 Step 完成并持久化后,系统重启不应该无条件重做它。
好的 Step 通常满足至少一个条件:
- 需要独立重试;
- 需要独立限流;
- 耗时或调用成本需要独立观测;
- 产出可以成为后续恢复依据;
- 需要等待外部事件。
如果把每次数据库查询都拆成 Step,状态写入和调度成本会淹没收益;如果整个生成只有一个 Step,又退化成旧的长函数。
Activity:有副作用的实际动作
Activity 是 Step 中运行的业务代码,例如调用网页抓取、ASR 或 AI,或者把结果写入业务对象。
Activity 必须按「可能至少执行一次」来设计。因为 Worker 可能在外部调用成功后、Step 完成状态落库前崩溃;恢复后,引擎无法凭空知道外部调用是否成功,只能再次执行。因此 Activity 必须幂等,或者用业务 fencing 阻止旧执行覆盖新结果。
例如,Worker A 正在执行「生成内容」这个 Step:
- A 调用 AI 服务并拿到生成结果;
- A 使用当前处理轮次的标记
f1,把结果写入业务对象; - 在 A 把 Step 标记为 completed 之前,Pod 退出了。
数据库里的业务结果已经存在,但 Workflow 只看到这个 Step 尚未确认完成。租约到期后,Worker B 会重新执行同一个 Activity。此时幂等逻辑先检查业务对象:如果 f1 这一轮的结果已经完整保存,就直接复用它并补交 Step 完成状态,不再调用一次 AI。
再考虑另一种并发:A 调用 AI 尚未返回时,用户已经发起新一轮生成,业务对象的轮次标记从 f1 变成 f2。A 的旧结果晚到后,写入条件仍要求轮次等于 f1,因此更新不会命中,也就不能覆盖 f2 这一轮的新结果。这就是 fencing 在这里保护的边界。
把这个问题换成一次 ASR 提交会更容易理解:
Worker A -> ASR:创建转写任务
ASR -> Worker A:创建成功,task_id = t100
Worker A -> Mongo:准备保存 t100……此时 Pod 退出
Worker B -> Mongo:没有找到 task_id
Worker B -> ASR:只能再次创建转写任务
ASR -> Worker B:创建了另一个任务 t101
ASR 服务已经知道 t100 存在,但我们的数据库还不知道。新 Worker 手里没有 task_id,也就无法查询或复用 t100。Mongo 事务只能保证本地写入,不能把第三方服务的「创建任务」一起纳入同一笔事务,所以 Workflow 无法仅凭本地状态补回这段丢失的信息。
如果 ASR 接口接受稳定的幂等键,A 和 B 两次提交可以携带同一个请求标识,第二次调用就返回已有的 t100,而不是再创建 t101。如果下游不支持这种能力,重复创建在极端崩溃窗口中仍然可能发生。这里所说的「至少一次执行」,就是 Activity 为了最终完成,可能被执行一次,也可能因为没有来得及确认而执行多次。
Worker / Engine:执行者与规则执行器
Engine 负责读取状态、认领实例、执行 Step、处理超时和重试、保存结果。Worker 是运行 Engine 的进程角色。Worker 可以消失和替换,Workflow Instance 仍然存在。
3. 一条 WorkflowInstance 内部到底存什么
当前内核的核心模型经过删减后,可以读成下面这样:
type WorkflowInstance struct {
WorkflowID ID
Type Type
BizKey string
DefinitionFingerprint string
Status Status
Steps []StepState
Input map[string]any
Output map[string]any
BusinessPriority BusinessPriority
ClaimableTime Time
ClaimToken string
Wait *WaitState
Resume *ResumeState
RetryCount int
ReplayPreparation ReplayPreparationState
Revision int64
}
type StepState struct {
Name StepName
Status StepStatus
Attempt int
Error string
ElapsedMS int64
TrafficWaitMS int64
TrafficWaits int
Checkpoint any
}
这些字段可以按三类理解:
- 业务身份:
Type、BizKey、Input回答「这是什么工作」; - 控制流:
Status、Steps、Output、Wait、Resume回答「已经做到哪里」; - 并发与演进:
ClaimableTime、ClaimToken、Revision、DefinitionFingerprint回答「谁能继续写,以及当前代码能否解释这份状态」;RetryCount与ReplayPreparation则把「已经开启第几轮 Replay」和「这一轮的业务前置条件是否已经恢复」分开记录。
这里没有保存 goroutine、函数指针或 HTTP 连接。恢复不是还原旧进程的内存,而是让新 Worker 读取这份状态,再由 Definition 计算下一批可运行 Step。
Output 与 Checkpoint 也承担不同职责。Step 完成后供下游消费的产物放入 Output.;Step 尚未完成、但需要跨等待保存的控制位置放入该 Step 的 Checkpoint。例如转录结果属于 Output,正在轮询的 ASR task_id 属于 Checkpoint。
4. Workflow 不是什么
为了避免把所有异步代码都叫 Workflow,可以用下面这张表判断:
| 概念 | 主要回答的问题 | 是否记住步骤进度 | 是否天然跨进程恢复 |
|---|---|---|---|
| 后台 goroutine | 怎样不阻塞当前请求 | 否 | 否 |
| 定时任务 | 什么时候触发一段代码 | 通常否 | 取决于业务实现 |
| 消息队列 | 怎样可靠传递消息 | 只记消费位置 | 否,业务自己实现 |
| 任务队列 | 哪个 Worker 执行哪个任务 | 通常只记整体状态 | 有限 |
| 状态机 | 状态允许怎样迁移 | 是 | 取决于是否持久化和调度 |
| DAG | 步骤之间有哪些依赖 | 描述结构 | 本身不负责 |
| Workflow | 一项多步骤工作怎样可靠推进 | 是 | 是,需要持久化 Store 和恢复协议 |
Workflow 往往会使用队列、状态机和 DAG,但它不等于其中任何一个。
四、Temporal 带来的设计启发
早期设计参考了 Temporal 和 Cadence 一类 durable execution 系统。
Temporal 官方把 Workflow Execution 描述为一种 durable、reliable、scalable 的函数执行。Worker 可以退出,Workflow 的执行事实仍然存在;新的 Worker 可以根据持久化 Event History 重放确定性的 Workflow 代码,再继续产生后续 Command。网络调用、数据库访问、LLM 等非确定性操作则放进 Activity。Temporal Workflow Execution、Workflow Definition、Activities
这给了我们一个非常重要的认识:
Worker 是临时的执行者,Workflow 才是跨越进程生命周期的工作本身。
这句话刚好击中了旧系统的薄弱点。过去,任务的执行位置藏在 goroutine 调用栈里;进程消失,系统就只能猜测发生了什么。Workflow 化之后,进程退出只是执行权发生变化,不再天然等于业务失败。
我们主要借鉴了四类思想:
- 把流程定义与某一次执行分开;
- 把编排与有副作用的 Activity 分开;
- 把等待表达成可持久化状态,而不是让线程一直睡眠;
- 默认 Activity 可能至少执行一次,因此必须围绕幂等和 fencing 设计。
两套系统的恢复机制存在一项重要差异:我们的实现没有采用 Temporal 的 Event History replay。
Temporal 通过 Event History 和确定性 replay 还原 Workflow 状态;我们选择了一套显式持久化状态机,直接保存当前 Step、attempt、output、checkpoint 和等待条件。两者解决相似的问题,但恢复机制并不相同。
从 Temporal 思想到六段运行时协议
如果只把 Temporal 理解成「失败后自动重试」,这次借鉴会显得很浅。重试只是一个动作,持久化执行需要回答的是:当请求重复、Worker 消失、旧执行晚归、下游拥堵、外部任务持续等待以及代码已经升级时,同一项工作怎样继续向唯一终态推进?
我们把这个问题拆成六段协议:
| 协议 | 它必须回答的问题 | 当前实现中的边界 |
|---|---|---|
| 身份协议 | 两次请求是否代表同一项工作 | biz_key 唯一索引与 StartOrResume |
| 所有权协议 | 此刻哪个 Worker 有资格推进 | Claim lease 与 claim_token |
| 进度协议 | 已经完成什么,恢复从哪里继续 | Step、Output、Checkpoint 与原子提交 |
| 等待协议 | 没有 Worker 持有时,工作怎样重新变为可运行 | claimable_time、Signal Inbox 与 generation |
| 容量协议 | 下游不足时,任务应该等待还是失败 | 全局 permit、并发上限与启动速率 |
| 版本协议 | 新代码能否继续执行旧实例 | Definition fingerprint |
这六段协议组合起来,才构成本文所说的 Workflow Kernel:
每段协议都同时规定「谁能写」「按什么条件写」「写到哪里」以及「写到一半退出后由谁接管」。外围的加速、唤醒和实时推送机制可以让它运行得更快,但不会改变这些协议的权威判断。具体组件如何实现这些协议,留到下篇。
用五项系统性质检查设计是否成立
一套 Workflow 实现拥有状态表和重试循环,并不代表它已经具备持久化执行能力。我们用下面五项性质约束内核:
Safety:旧执行不能覆盖新进度
先看 Worker 之间的竞争。Worker A 认领任务时拿到执行凭证 t1,随后因为长时间暂停而没能续租。租约到期后,Worker B 接管同一任务,数据库把当前执行凭证换成 t2。如果 A 此时恢复运行,A 和 B 可能在短时间内同时执行;但它们提交状态时都必须带上自己的凭证。数据库当前只接受 t2,所以 A 携带 t1 的更新不会命中,只有 B 能继续推进 Workflow。这个每次认领都会变化的凭证,就是 claim_token。
claim_token 保护的是同一个 Workflow 在不同 Worker 之间的执行权。业务结果还需要另一层保护:假设旧一轮生成使用轮次标记 f1,用户随后发起重试,业务对象把当前轮次更新为 f2。旧 Worker 即使晚到,也只能携带 f1 写结果;由于业务对象当前已经是 f2,这次写入会被拒绝。这样,旧一轮结果就不会覆盖用户刚启动的新一轮。
Liveness:执行者消失后,工作仍能重新变为可运行
如果 Worker A 在执行中消失,数据库里的 WorkflowInstance 不会随进程一起消失,也不会立刻被判定为失败。A 的执行租约到期后,这条实例会重新满足认领条件,Worker B 可以取得新的执行权,并根据已经保存的 Step 状态继续。
主动等待外部结果时也是如此。例如 ASR 任务尚未完成,Worker 会把下一次查询时间和 task_id 保存下来,然后释放执行权;时间到达后,调度器再把实例恢复为可运行状态,不需要原来的 goroutine 一直存活。
Redis 只帮助 Worker 更快找到这些候选任务。如果 Redis 中的候选记录丢失,调度器仍可以扫描 Mongo 中已经到期的实例并重新放回候选集合。因此,Redis 丢失可能让接管稍晚一些,但不会让这项工作永久消失。
Recoverability:恢复位置与昂贵副作用对齐
ASR task ID、网页抓取结果和扇出游标分别进入 Output 或 Checkpoint。新 Worker 根据已提交的 Step 状态继续,不把一次服务重启放大成整条链路重做。
Operability:拥堵可以被控制,而不必伪装成业务失败
ASR、网页抓取和 AI 各自拥有集群级容量资源。工程师可以降低新请求的启动速度,让任务停在 permit 之前;容量恢复后继续调度,业务状态不需要先被批量改成失败。
Evolvability:持久化实例与代码版本之间有显式契约
Step 名称、顺序和依赖会参与 Definition fingerprint。新 Worker 遇到不兼容的旧实例会停止推进并等待迁移,避免依据数组下标猜测旧进度。
这五项性质对应五类不同的故障。自动重试只能覆盖其中很小的一部分;Workflow Kernel 的价值在于让它们遵循同一套状态转换规则。
为什么我们的业务适合这种思想
「步骤多」不足以成为采用 Workflow 的理由。这类内容处理任务适合 durable execution,是因为它同时满足几个条件:
- 生命周期明显长于一次 HTTP 请求、Kafka handler 或 Pod;
- ASR、抓取和 AI 等外部调用成本高、失败方式不同;
- 很多时间花在等待外部结果,而不是本地计算;
- 业务对象 ID、ASR task ID、外部资源版本等天然形成业务幂等键和 checkpoint;
- 每个阶段需要独立设置超时、重试、限流和诊断信息;
- 下游容量是全局资源,需要在所有 Worker 之间共同控制。
在引入 Workflow 之前,Task 字段、扫描器、心跳、重试和限流补丁已经分别承担了一部分执行语义。改造的作用,是把这些反复出现的语义整理到同一套执行模型中。
五、常见 Workflow 模式
下面这些模式分别处理顺序、状态迁移、依赖、等待、扇出和补偿。区分它们,可以避免用同一种调度方式处理不同问题。
模式一:顺序流程(Sequential Workflow)
后一阶段依赖前一阶段结果,严格按顺序执行:
网页内容处理、图片文字识别、异步转录等主链路,大部分都是这种模式。它简单,也最容易提供明确的恢复边界。
模式二:状态机(State Machine)
状态机描述「当前状态允许转移到哪里」。例如一个 Step:
状态机让异常路径成为显式设计,而不是散落在 if err != nil 中。
模式三:DAG(有向无环图)
DAG 允许没有依赖关系的步骤并行,依赖都完成后再汇合:
「有向」表示依赖有先后方向,「无环」表示不能 A 等 B、B 又等 A。DAG 适合相互独立、并行收益明显的阶段,但会增加并发写入、失败汇合和恢复复杂度。是否使用 DAG,需要由步骤依赖和并行收益决定。
模式四:持久化等待(Durable Wait)
有些任务的大部分时间用于等待外部状态变化,例如等待共享资源就绪、等待 ASR 回调、等待用户选择。
错误做法是让一个 goroutine 睡几个小时。持久化等待会保存「我在等什么」,然后释放 Worker:
等待期间不占用 goroutine,不依赖原 Worker 存活。Signal 只负责唤醒;Activity 醒来后重新读取业务权威状态,而不是盲信一份可能过时的事件 Payload。
模式五:扇出(Fan-out)
一个主任务完成后,需要为很多订阅者创建后续工作:
Fan-out 需要持久化已经处理到哪个游标。进程在第 500 个订阅者处退出后,恢复可以从 Checkpoint 继续,避免从第一个订阅者重新分发。
模式六:Saga 与补偿
跨多个系统的操作通常无法放入一个数据库事务。Saga 把长事务拆成多个本地事务,并为已完成步骤设计反向补偿,例如订房成功、订票失败后取消房间。
当前项目已经实现了一套面向内容生成业务的 Saga,只是补偿粒度放在整个 Workflow 的失败收敛阶段,而不是为每个 Step 分别登记反向操作。
以一项需要占用处理额度的任务为例:开始执行时,前置 Step 幂等地占用额度,并把业务对象标记为生成中;如果后续转写或内容生成最终失败,Engine 不会立即把 Workflow 写成 failed,而是先进入 failing,执行幂等的 FailureFinalizer。这个 Finalizer 会在业务轮次校验通过后把对象收敛为失败,并尝试释放此前占用的额度。Finalizer 返回错误时,Workflow 保持在 failing,新的 Worker 可以继续收敛;Finalizer 返回成功后,Workflow 才提交 failed 终态。
这里还有一层实现边界:当前代码会把“业务对象成功置为失败”作为必须完成的动作,写入失败会让 Finalizer 重试;额度释放失败则记录日志并继续收敛,属于 best-effort 补偿。因此 Saga 协调过程已经存在,但不同补偿动作的可靠性并不完全相同。
人工重试时还要处理补偿的反方向:上一轮失败已经释放额度、把对象置为失败,PrepareReplay 因此会先重新占用额度并恢复生成中状态,再开放失败的 Step。已经完成的 Step 保持完成,执行从需要重做的位置继续。
这符合 Saga 的核心语义:中央编排器协调多次本地状态变化,并在失败时执行明确的业务补偿,使系统最终回到一致状态。当前实现仍有清楚的边界:Kernel 没有提供“每个 Step 注册一个 Compensation,然后按完成顺序自动逆序执行”的通用补偿栈。更准确的说法是,我们实现了业务级、Workflow 粒度的 Saga,而不是任意业务都能直接套用的通用 Saga 引擎。
六、为什么选择本地 Workflow Engine
我们认真考虑过三类方案。
方案 A:继续扩展原 Task 表
给 Task 增加 stage、retry_at、worker_id、heartbeat、更多错误字段,再写更多扫描器,是改动最小的方案。
继续增加字段后,Task 会同时承担:
- 用户看到的业务任务;
- Worker 的执行租约;
- 每个阶段的恢复进度;
- 下游流控状态;
- 监控统计;
- 重试与补偿调度。
每迁移一种业务类型,都需要分别实现一套相似机制。随着类型增加,这些恢复与调度逻辑会在多条链路中重复出现。
方案 B:每个步骤都用 Kafka 串起来
这样天然跨进程,但它与长时间任务之间存在一项直接冲突。网页抓取、ASR 和内容生成可能持续数分钟,耗时上限也不稳定。如果 Consumer handler 在处理期间无法继续调用 poll(),执行时间一旦超过 max.poll.interval.ms,Kafka 会把该 Consumer 视为失效,并触发 Consumer Group rebalance,把分区重新分配给其他成员。Apache Kafka Consumer 配置
一次长任务因此可能影响整个分区:
- 当前分区的后续消息被慢任务阻塞;
- rebalance 会转移分区消费权;
- 旧 Consumer 中的业务调用未必已经停止;
- 如果 offset 尚未提交,新 Consumer 可能再次处理同一条消息;
- 新旧 Consumer 可能同时推进同一项昂贵任务。
不断增大 max.poll.interval.ms 只能推迟 rebalance 的触发时间,也会延长故障 Consumer 被发现和重新分配的时间。Kafka 管理分区消费权;长任务需要单个实例的执行租约。一个任务变慢,不应重新分配整个分区。
除此之外,每个步骤都发 Kafka 消息还会形成消息状态、Step 状态和业务状态三套事实。每一步的至少一次投递仍要求幂等,排查则需要跨 Topic 还原完整时序。对仍位于同一服务内的连续业务函数来说,这会提前引入跨消息的状态协调成本。
我们最终选择移除这条 Kafka 执行链,由持久化 Workflow 直接承担长任务的执行语义。这不仅避开了长任务与 Consumer Group 的语义冲突,也减少了需要对齐的一套中间状态。至于业务请求如何进入 Workflow、Worker 如何取得执行权,属于下篇的实现内容。
方案 C:直接采用成熟 Workflow 平台
Temporal、Cadence 一类平台已经提供持久化历史、Activity 重试、Timer、可视化和跨服务编排。它们尤其适合跨团队、跨服务、持续数天的复杂流程。它们也是这次设计的重要思想来源。
但当时首批迁移仍集中在现有 Go 服务内部,业务能力、Mongo、Redis 和运维体系都已存在。引入新平台不只是增加一个 SDK,还包括部署、容灾、监控、容量规划和团队学习成本。
所以我们选择先构建一个边界明确的 Durable Workflow Kernel:吸收 durable execution 的核心原则,范围限定为当前服务已经出现的执行问题。跨团队通用平台不在本次目标内。
这个选择有明确的退出条件。如果未来主要需求变成跨服务编排、完整事件历史、复杂补偿、超长定时器或大规模版本迁移,那么迁向成熟平台会比继续扩张自研内核更理性。
七、适用性判断
可以用以下问题判断一个异步任务是否需要 Workflow:
- 任务是否包含多个可区分、成本明显的阶段?
- 任务是否可能长于一次进程、消息租约或 HTTP 连接?
- 是否需要从中间步骤恢复,而不是从头重试?
- 是否存在等待外部回调、用户动作或定时器的阶段?
- 是否需要跨实例统一控制下游并发或启动频率?
- 同一业务是否可能被重复提交、重复投递或并发重试?
- 排障时是否需要知道具体卡在哪一步?
如果大部分答案都是「否」,普通函数加任务队列通常更经济。如果多个答案是「是」,继续给 Task 加字段只是在手工长出一个不完整的 Workflow Engine。
八、上篇小结:重新划分事实边界
旧世界里,一项工作主要存在于执行它的代码中:
Workflow 世界里,代码只是执行者,持久化状态才代表这项工作:
这项变化对应了 Temporal 最早带给我们的启发:
进程可以失败,连接可以断开,通知可以丢失;只要执行事实还在,工作就能继续收敛。
下篇会进入实现:我们怎样用 Mongo 保存权威执行状态,用 Redis 做可丢失、可重建的低延迟协调;怎样通过 Outbox、租约、两类 fencing token、幂等 Activity、持久化等待和 best-effort 观测,把这句话变成可运行的系统;以及改造后项目具体获得了什么、又付出了什么代价。