第 39 章 / 共 57 章 · 约 4 分钟
设计模式:长时间运行的任务
视频转码要几分钟,生成一份年度报表要十几分钟,爬一个网站要几个小时。这类工作不能塞进一次 HTTP 请求里:请求会超时,用户会刷新重试,服务器的连接和线程被长时间占住。通用的做法是异步任务:接口立即接下任务、返回一个任务 id,由后台的 worker 慢慢做,结果通过查询或通知交给用户。
1. 基本流程
- 客户端提交任务,服务端写一条任务记录(状态
QUEUED),把任务放进队列,立即返回202 Accepted和任务 id。 - worker 从队列取任务,把状态改为
RUNNING,开始处理。 - 完成后写入结果(大结果放对象存储),状态改为
SUCCEEDED或FAILED。 - 客户端通过轮询
GET /jobs/{id}、webhook 回调或实时推送得知结果。
图表可横向滚动,展开后可缩放查看。
查看图表源码
flowchart LR
accTitle: 异步任务的基本流程
c["客户端"] -->|"POST /jobs"| api["API 服务"]
api --> db[("任务表:状态和结果位置")]
api --> q["队列"]
api -->|"202 + job_id"| c
q --> w["Worker 池"]
w --> db
w --> s3[("对象存储:结果文件")]
c -.->|"轮询 / webhook / 推送"| db
2. 可靠性要点
- 可见性超时和心跳:worker 取走任务后,任务在队列里暂时不可见;worker 崩溃没确认,超时后任务重新出现,交给别的 worker。任务比超时时间长时,worker 要定期续期(心跳),否则同一个任务会被两个 worker 同时处理。
- 幂等:重新投递一定会发生,任务处理要能安全地重复执行:结果按任务 id 覆盖写,外部副作用带幂等键。
- 重试和死信:失败后按指数退避重试,次数用尽进入死信队列(DLQ),由人排查。区分可重试的错误(超时、限流)和不可重试的错误(输入格式错误)。
- 检查点:几个小时的任务要分段保存进度,失败后从最近的检查点继续,而不是从头开始。
- 拆分:一个大任务拆成很多小任务并行处理,再合并结果。视频按片段转码就是这样。
3. 选择队列
| 选项 | 适合 | 代价 |
|---|---|---|
| SQS / RabbitMQ | 典型的任务队列:逐条确认、可见性超时、延迟、死信 | 不能重放已经处理过的任务 |
| Kafka | 任务量极大、需要按 key 有序或重放 | 单条重试和延迟投递要自己做 |
| Redis 列表 / Stream | 轻量、低延迟 | 受内存限制,持久性弱一些 |
数据库表加 SKIP LOCKED | 量不大、想少一个组件 | 吞吐受数据库限制 |
| 工作流引擎 | 多步骤、长等待、需要定时 | 多一个系统,见多步骤流程 |
4. 扩缩容和公平性
- 按队列深度扩缩容:积压越多,worker 越多;积压为零时缩到最小。按 CPU 扩缩容对 I/O 密集的任务不准。
- 优先级:付费用户、交互式任务放高优先级队列,批量任务放低优先级队列,worker 优先消费高优先级。
- 多租户公平:一个大客户一次提交一万个任务,不能把其他客户饿死。可以按租户分队列轮询,或者限制每个租户同时运行的任务数。
- 资源隔离:GPU 任务、内存大的任务用单独的 worker 池。
5. 常见追问
- 用户提交后关掉页面,任务还会继续吗? 会,任务和请求解耦了;用户回来后按任务 id 查结果,或者收到邮件 / 推送。
- 同一个任务被提交了两次怎么办? 客户端带幂等键提交,服务端发现重复就返回已有的任务 id。
- 怎么取消正在运行的任务? 任务表里标记取消,worker 在检查点之间检查这个标记并主动停止。
- 定时任务(每天凌晨执行)怎么做? 调度器只负责按时把任务放进队列,执行仍然交给 worker;调度器本身要单实例或选主,避免重复触发(见 ZooKeeper / etcd)。
6. 本站用到这个模式的案例
- 案例 7:视频流平台:上传完成后触发转码,按片段拆成 DAG 在大量 worker 上并行。
- 案例 8:网络爬虫:两层 URL 队列负责优先级和对每个网站的礼貌访问,抓取 worker 持续消费。
- 案例 6:通知系统:按优先级分队列,失败按退避重试,最后进入死信队列。
- 案例 10:支付与账本:拿支付服务商的结算文件对账,作为离线批处理任务运行。