FastAPI 的 BackgroundTasks 适合“响应返回后、同一进程内完成、失败可容忍或可补偿”的小任务;Celery 适合需要独立 worker、队列路由、重试和水平扩展的离散任务;可恢复工作流适合跨分钟或跨天、需要等待、审批、定时和明确状态历史的业务。正确选型先看持久性与失败语义,而不是代码行数。

为什么“放到后台”不是一个完整需求?

开发者常把三类问题都叫后台任务:

  1. HTTP 响应不必等待的收尾动作;
  2. 跨进程执行的异步任务;
  3. 跨多个步骤、时间和外部系统的持久工作流。

它们的共同点只是“不在当前请求里同步等待”,可靠性契约完全不同。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}:uploadinvoice:{invoice_id}:send。在执行前原子声明,完成后记录结果。重试应读取既有结果,而不是再次扣款、发送或创建资源。

区分瞬时错误和永久错误

  • 连接超时、429、暂时 5xx:有限次数重试并加入抖动;
  • 参数无效、权限不足、资源永久不存在:直接失败;
  • 未知异常:进入人工检查或隔离队列,避免无限重试。

设置超时和资源边界

调用外部服务必须有 connect/read timeout。任务应限制运行时间、内存和输入大小;超时后仍要确认下游是否已经提交,不能把“本地没收到响应”当作“远端没执行”。

让状态转换使用 CAS

worker 从 queued 改为 runningsucceededfailed 时使用 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
}

设计要点:

  1. Idempotency-Key 与已认证用户、route 和规范化输入绑定;
  2. 相同意图重放返回同一 jobId
  3. 状态只通过允许的转换前进;
  4. 进度是提示,不代替最终结果;
  5. 错误返回稳定 code 与可操作说明,不泄露内部堆栈;
  6. 取消是条件状态转换,不是简单 kill;
  7. 结果设置过期与访问控制。

实时 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_idtask_idworkflow_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。