03-任务执行架构

任务执行架构

有了“任务对象”和“消息队列”之后,下一步就是回答一个更工程化的问题:

这套系统到底应该拆成哪些角色,它们分别负责什么?

如果这一步没有想清楚,最后常见的结果就是:

  • API 什么都做
  • Worker 什么都知道
  • 状态分散在各处
  • 任务失败以后没人知道该去哪看

所以这一篇讲的是“职责分层”。

一个够用的基础架构

对于大多数中后台和 AI 场景,一个足够清晰的任务执行架构通常长这样:

flowchart LR
	C[Client] --> API[API / Task Controller]
	API --> DB[(Task DB)]
	API --> MQ[Message Queue]
	MQ --> W1[Worker Pool A]
	MQ --> W2[Worker Pool B]
	W1 --> DB
	W2 --> DB
	W1 --> RS[(Result Store)]
	W2 --> RS
	DB --> Query[Query API]
	Query --> C
	W1 --> Notify[Callback / SSE / WebSocket]
	W2 --> Notify
	Notify --> C

这套图里每个角色都应该职责单一。

每一层分别做什么

1. API / Task Controller

这一层只负责:

  • 校验参数
  • 创建任务记录
  • 返回 taskId
  • 把任务投递到队列

不应该承担真正的长耗时执行。

2. Task DB / State Store

这一层负责记录:

  • 任务状态
  • 当前步骤
  • 进度
  • 执行次数
  • 失败原因
  • 最终结果索引

你可以把它理解为“系统对外的真相来源”。

3. Message Queue

负责:

  • 缓冲任务
  • 解耦 API 与 Worker
  • 控制消费节奏
  • 配合重试和死信

4. Worker Pool

Worker 是真正干活的地方,它负责:

  • 拉任务
  • 执行业务步骤
  • 更新状态
  • 处理失败重试

如果任务类型不同,还可以拆成多个池子,比如:

  • ocr-worker
  • embedding-worker
  • notify-worker

5. Result Store

如果任务结果是大文件、大文本、图片、视频,不建议直接塞进任务表,而是放到对象存储或专门结果表里。

6. Query / Notify

任务做完以后,前端要么主动查,要么被动接收通知:

  • 轮询查询
  • SSE 推送
  • WebSocket 推送
  • 回调 webhook

为什么要这样拆

原因 1:快路径和慢路径分离

API 层是“快路径”,目标是尽快返回。

Worker 层是“慢路径”,目标是稳定完成任务。

把快路径和慢路径混在一起,系统的吞吐和稳定性都会变差。

原因 2:执行能力可以独立扩容

如果 OCR 很慢,那你就扩 OCR Worker;如果通知很多,就扩通知 Worker。

如果都堆在 API 里,就只能整体扩容,成本高,也不精准。

原因 3:状态与执行分离后,系统更可恢复

任务执行到一半挂掉时,只要状态存储还在,你就知道:

  • 它卡在哪一步
  • 是否可以重试
  • 是否需要人工介入

一个标准执行流程

sequenceDiagram
	autonumber
	participant C as Client
	participant API as API
	participant DB as Task DB
	participant MQ as MQ
	participant W as Worker
	participant R as Result Store

	C->>API: POST /tasks
	API->>DB: insert CREATED
	API->>MQ: publish task_id
	API-->>C: 202 Accepted
	W->>MQ: consume task_id
	W->>DB: update RUNNING
	W->>R: write result artifact
	W->>DB: update SUCCESS + result_ref
	C->>API: GET /tasks/{id}
	API->>DB: query task
	API-->>C: status / progress / result

这个流程里有一个非常重要的细节:

API 返回成功,并不代表任务成功;它只代表“任务已被系统受理”。

这也是为什么长任务接口通常返回 202 Accepted,而不是直接返回最终结果。

一个简单案例:OCR 解析平台

假设你在做合同 OCR 系统,可以按下面思路拆:

API 层

  • 接收文件地址
  • 创建 contract_parse 任务
  • 返回任务 ID

Worker 层

  • download-worker:下载文件并做预处理
  • ocr-worker:调用 OCR 服务
  • extract-worker:提取字段
  • persist-worker:写结果与索引

状态层

  • 保存当前步骤
  • 保存任务进度
  • 保存失败原因

查询层

  • GET /tasks/{id} 查看状态
  • GET /tasks/{id}/result 查看最终结果

这样一拆,你会发现系统职责非常清楚,任何问题都更容易定位。

最小可落地版本怎么做

如果你不是在搭超大平台,而是先做一个够用版本,最小架构可以是:

  1. 一个 API 服务
  2. 一个 MySQL 任务表
  3. 一个 Redis 队列
  4. 一个 Worker 进程
  5. 一个轮询查询接口

先把这条链路跑通,再考虑:

  • 多 worker
  • 优先级队列
  • DAG 编排
  • 回调通知
  • 监控告警

常见反模式

1. API 同时负责创建和执行

短期看省事,长期看会非常难扩展。

2. Worker 直接决定前端展示逻辑

Worker 应该更新状态,不应该知道页面怎么画。

3. 结果只存在内存里

Worker 重启以后结果就丢了,任务查询也没法做。

这一篇要记住的核心点

  1. 长任务架构的基本角色是:API、状态存储、队列、Worker、结果存储、查询/通知
  2. API 是快路径,Worker 是慢路径,这两层一定要分开
  3. 状态存储是系统真相来源,前端展示和排障都要围绕它展开

下一篇进入更复杂但也更有价值的部分:当一个任务不是线性步骤,而是一个依赖图时,该怎么做任务编排。

github