FastAPI 的 BackgroundTasks 适合“响应返回后、同一进程内完成、失败可容忍或可补偿”的小任务;Celery 适合需要独立 worker、队列路由、重试和水平扩展的离散任务;可恢复工作流适合跨分钟或跨天、需要等待、审批、定时和明确状态历史的业务。正确选型先看持久性与失败语义,而不是代码行数。
为什么“放到后台”不是一个完整需求?
开发者常把三类问题都叫后台任务:
- HTTP 响应不必等待的收尾动作;
- 跨进程执行的异步任务;
- 跨多个步骤、时间和外部系统的持久工作流。
它们的共同点只是“不在当前请求里同步等待”,可靠性契约完全不同。FastAPI Background Tasks 文档说明任务会在响应返回后执行,并明确建议重计算或无需共享当前进程内存的工作考虑 Celery 等更大的工具。它没有承诺任务已经持久化、进程崩溃后恢复或只执行一次。
选型前先回答五个问题:
- 接口返回 202 后,任务丢失是否可以接受?
- 应用进程在下一毫秒退出时,谁负责恢复?
- 同一个任务执行两次会发生什么?
- 任务需要等待多久,是否跨部署或跨天?
- 调用方怎样查询状态、取消、重试和看到失败原因?
如果这些问题没有答案,“异步”只是在隐藏失败。
三种方案的边界是什么?
| 维度 | FastAPI BackgroundTasks |
Celery 任务队列 | 可恢复工作流 |
|---|---|---|---|
| 执行位置 | Web 应用进程 | 独立 worker,可跨主机 | 工作流 worker + 持久状态 |
| 入队持久性 | 通常没有独立持久队列 | 由 broker 与发布配置决定 | 工作流事件/状态通常持久化 |
| 适合时长 | 短小收尾动作 | 秒到小时的离散任务 | 多步骤、等待、定时、跨天 |
| 重试 | 业务自己实现 | 原生任务重试与路由 | 步骤级重试、等待与恢复 |
| 编排 | 顺序追加,能力有限 | chain/group/chord 等 Canvas | 状态机、DAG、signal/审批 |
| 部署耦合 | 与 API 同生共死 | API 与 worker 解耦 | 执行与工作流状态解耦 |
| 运维成本 | 低 | 中:broker、worker、监控 | 中到高:工作流服务与模型 |
| 主要风险 | 响应已成功但任务未完成 | 重复交付、毒任务、队列积压 | 历史兼容、状态膨胀、错误编排 |
“工作流”不是特指某个产品。它指业务进度被显式建模和持久化,进程重启后可以从已提交状态继续。选用自建状态机、DAG 调度器或 durable execution 平台时,仍要验证其真实保证。
什么时候应该使用 BackgroundTasks?
适合条件是:任务很短,只依赖当前应用可访问的资源,失败影响较低,而且有其他机制最终修复或用户可以安全重试。例如:
- 响应后写一条非关键审计辅助记录;
- 触发一个可丢失的缓存预热;
- 对已经持久化的业务状态发送“尽力而为”通知;
- 清理一个短生命周期的本地临时文件。
下面是最小示例:
from fastapi import BackgroundTasks, FastAPI, status
app = FastAPI()
def warm_cache(item_id: str) -> None:
cache_service.warm(item_id)
@app.post("/items/{item_id}/refresh", status_code=status.HTTP_202_ACCEPTED)
async def refresh_item(item_id: str, tasks: BackgroundTasks):
tasks.add_task(warm_cache, item_id)
return {"accepted": True, "itemId": item_id}
这段示例只表达“响应后调用”,没有持久化语义。客户端收到 202 时,warm_cache 可能尚未开始;进程滚动重启、机器宕机或函数抛错都可能让它不完成。
Starlette Background Tasks 文档还说明多个任务按顺序执行;如果一个任务抛出异常,后续任务不会执行。因此不要把一组必须全部完成的业务副作用简单堆进 BackgroundTasks。
BackgroundTasks 最危险的误用是什么?
把业务提交放到响应之后
错误思路:
@app.post("/orders", status_code=202)
async def create_order(payload, tasks: BackgroundTasks):
tasks.add_task(save_order_and_charge, payload)
return {"accepted": True}
接口已经告诉用户“已接受”,但订单甚至还没有持久化。若进程退出,系统没有任何记录可以恢复。更稳妥的顺序是先在数据库原子写入订单/作业状态,再异步处理。
在 async 函数里执行阻塞工作
async def 不会自动让 CPU 密集计算或同步 SDK 变为非阻塞。长时间阻塞事件循环会拖慢整个 worker。短同步函数可以交由合适的线程路径;重计算、图像处理、模型推理或大文件转换通常应离开 Web 进程。
依赖请求作用域对象
响应结束后,请求事务、临时文件、数据库 session 或上下文变量可能已经关闭。后台函数应接收稳定 ID,并重新获取所需资源,而不是捕获一个即将失效的 ORM 对象。
把异常只写进日志
如果没有状态表、指标或告警,用户看到 202,团队只在偶然查日志时发现失败。对业务重要的任务,必须有可查询状态和明确 owner。
什么时候应升级到 Celery?
当任务需要独立进程、多个 worker、队列路由、并发控制或标准化重试时,Celery 是典型选择。Celery 5.6 Tasks 文档强调任务应尽量幂等;当启用较晚 acknowledgment 或 worker 故障重投时,同一消息可能再次执行。
常见适用场景:
- 邮件、webhook、批量导入和文档转换;
- CPU 或内存负载不应影响 API 延迟的任务;
- 需要按租户、优先级或资源类型路由;
- 需要控制 retry/backoff、soft/hard time limit;
- 需要独立扩缩容和 worker 维护窗口。
一个安全的 FastAPI → Celery 边界只传递稳定标识:
@app.post("/exports", status_code=202)
async def create_export(request: ExportRequest):
job = await jobs.create(
kind="export",
state="queued",
input_ref=request.input_ref,
)
build_export.apply_async(
args=[str(job.id)],
task_id=str(job.dispatch_id),
)
return {"jobId": str(job.id), "state": "queued"}
示例省略了事务边界。若数据库提交成功而 broker 发布失败,作业会永远停在 queued;若消息先发布而事务回滚,worker 又找不到作业。生产系统应使用 transactional outbox、可靠发布表或等价对账机制,把“业务状态已提交”和“消息最终可见”连接起来。
Celery 任务怎样设计才可重试?
可靠任务的核心不是 autoretry_for,而是副作用幂等。
使用业务幂等键
为每个外部副作用建立稳定键,例如 export:{job_id}:upload、invoice:{invoice_id}:send。在执行前原子声明,完成后记录结果。重试应读取既有结果,而不是再次扣款、发送或创建资源。
区分瞬时错误和永久错误
- 连接超时、429、暂时 5xx:有限次数重试并加入抖动;
- 参数无效、权限不足、资源永久不存在:直接失败;
- 未知异常:进入人工检查或隔离队列,避免无限重试。
设置超时和资源边界
调用外部服务必须有 connect/read timeout。任务应限制运行时间、内存和输入大小;超时后仍要确认下游是否已经提交,不能把“本地没收到响应”当作“远端没执行”。
让状态转换使用 CAS
worker 从 queued 改为 running、succeeded 或 failed 时使用 revision/条件更新,避免迟到的旧执行覆盖新一轮状态。这与 FastAPI、MongoDB 与 Redis 原子双语发布中的 revision-safe 发布原则相同。
什么时候任务队列仍然不够?
Celery 可以通过 Canvas 组合 chain、group 和 chord,但如果业务包含长期等待、外部审批、人工输入、定时器、补偿和多个系统,单纯的任务链会把状态藏在 broker、result backend 和回调之间。
考虑可恢复工作流的信号包括:
- 一次运行持续数小时或数天;
- 中间要等待 webhook、用户批准或特定时间;
- 每一步有不同 retry、timeout 和补偿策略;
- 需要从“第 7 步”恢复,而不是重跑全部任务;
- 产品要展示完整进度和人工处理入口;
- 业务要求可审计的状态历史。
Temporal 的 Durable Execution 文档解释了通过持久事件历史恢复工作流执行的模型。其他工作流引擎实现方式不同,不能因为都叫 durable 就假设保证相同。评估时应验证状态存储、determinism 限制、版本兼容、重试、signal 和灾难恢复。
一个可靠的 202 API 应该怎么设计?
HTTP 202 表示请求被接受处理,不表示处理成功。推荐把作业当作一等资源:
POST /exports
Idempotency-Key: 8b3f...
202 Accepted
Location: /exports/job_123
{"jobId":"job_123","state":"queued"}
查询资源:
{
"jobId": "job_123",
"state": "running",
"revision": 4,
"progress": {"completed": 18, "total": 40},
"result": null,
"error": null
}
设计要点:
Idempotency-Key与已认证用户、route 和规范化输入绑定;- 相同意图重放返回同一
jobId; - 状态只通过允许的转换前进;
- 进度是提示,不代替最终结果;
- 错误返回稳定 code 与可操作说明,不泄露内部堆栈;
- 取消是条件状态转换,不是简单 kill;
- 结果设置过期与访问控制。
实时 UI 可以结合 FastAPI SSE 生产指南推送状态,但 SSE 连接不能成为状态源;断线后客户端必须能通过 job resource 重建。
定时任务该放在哪里?
定时只是触发方式,不决定执行可靠性。
- 单实例、可丢失的维护动作可以用简单 scheduler;
- Celery Beat 适合向 Celery 队列周期投递任务,但要保证同一 schedule 只有一个有效调度者;
- 跨步骤、依赖前序产物或需要补跑的任务更适合 DAG / workflow;
- 云平台定时器也应调用幂等入口,而不是把全部业务逻辑塞进 cron shell。
可将周期自动化交给 Cronova 这类工作流工具进行产品化管理,但仍要独立验证:重复触发怎样去重、错过窗口怎样补跑、时区与 DST 如何处理、任务升级后历史运行怎样解释。
如何从 BackgroundTasks 平滑升级?
阶段一:先创建 Job 模型
即使仍在同进程执行,也先把 queued/running/succeeded/failed、输入引用、幂等键和 revision 写入数据库。这样 UI、审计和恢复边界先稳定。
阶段二:抽出执行函数
让执行器只接收 job_id,自行加载输入、抢占执行权、写状态。不要依赖 FastAPI Request、全局可变对象或未提交事务。
阶段三:替换 dispatch
把 background_tasks.add_task(run_job, job_id) 替换为 Celery publish 或 workflow start。API 合约与 job 状态不变,迁移风险集中在 dispatch 层。
阶段四:加入 outbox 与 reconciliation
定期扫描“数据库已提交但未投递”“运行超时”“完成事件缺失”的记录。恢复程序必须幂等,并使用原始 job / revision 语义。
可观测性最低要求
无论选哪种方案,都应记录低敏感、可关联的控制面数据:
job_id、task_id、workflow_id;- task kind、队列、attempt、状态、duration;
- retry reason code 与下次计划时间;
- queue latency 与运行 latency;
- worker 心跳和积压深度;
- 最终失败 owner 与处理结果。
不要默认记录完整 payload、prompt、文件内容、token 或第三方响应。Trace 传播要允许跨 API、broker 和 worker 关联,但不能用用户数据作为 span 名称或高基数 label。
常见失败模式
API 返回 202,任务完全消失
通常是使用进程内后台任务承载关键业务,或数据库与 broker 双写没有 outbox。先查是否存在持久 Job,再查 dispatch 记录;没有 Job 就无法可靠恢复。
同一通知或扣款执行两次
任务系统的 retry 不是 bug,副作用缺少幂等边界才是根因。使用业务键、唯一约束和下游 idempotency key。
队列越来越长,但 worker CPU 不高
可能是外部依赖超时、prefetch 不合理、单个毒任务重试、锁竞争或路由错误。分别观察 queue latency、active/reserved、外部调用耗时和 retry 分布。
工作流升级后历史运行无法恢复
长工作流会跨代码版本。需要版本化 workflow definition、兼容历史事件,或通过显式迁移/continue-as-new 切换;不能假设所有运行都使用最新代码重新开始。
FAQ
BackgroundTasks 会自动重试吗?
不会提供独立队列式的持久重试保证。函数异常需要应用自己观测和处理;进程退出后也没有自动恢复契约。
Celery 能保证 exactly once 吗?
不要把它设计成 exactly-once 副作用执行器。消息确认、worker 故障、publish 重试和外部超时都可能产生重复或不确定状态。用 at-least-once 思维设计幂等任务与对账。
使用 Redis 就一定要选 Celery 吗?
不一定。Redis 可以作为 Celery broker,也可以服务缓存、Stream 或其他队列模式。技术组件相同不代表语义相同;先定义任务与恢复契约,再选实现。
小团队是否应该直接上工作流引擎?
如果业务确实有跨天等待、审批和多步骤恢复,早建模状态可能比维护回调链更简单;如果只是短邮件或缓存预热,引入工作流平台会增加不必要的运维面。
最终选型清单
- [ ] 关键业务状态在返回 202 前已持久化。
- [ ] 进程退出后的恢复责任明确。
- [ ] 重复执行不会重复产生副作用。
- [ ] 数据库与 broker 双写有 outbox 或对账。
- [ ] retry、timeout、取消和永久失败分别定义。
- [ ] 用户能查询稳定 Job 状态。
- [ ] 长等待与审批使用显式工作流状态。
- [ ] 定时触发具备时区、补跑与单调度者策略。
- [ ] worker、队列和外部依赖可观测。
- [ ] 日志不包含凭据和敏感 payload。
最简单的可靠决策是:可丢失的小收尾用 BackgroundTasks;需要独立执行与重试的离散工作用 Celery;需要长期状态、等待和编排的业务用可恢复工作流。任何关键任务都先持久化意图,再返回 202。