第8章:Celery结果后端 Result Backend 基础

发布时间:2026/9/4 9:05:09
第8章:Celery结果后端 Result Backend 基础 0. 上一章思考题参考答案思考题 1Worker 崩溃场景下RabbitMQ 的 Ack 是「已确认才移除」——配合早确认收到即 ack则消息不丢但可能执行到一半崩溃任务被确认后不再重投 → 实际是「执行结果丢失」配合晚确认acks_late则崩溃时未确认的消息会重新投递→ 更容易重复执行。Redis 的可见性超时是「超时未确认就重新可见」→ Worker 崩溃后消息要等超时窗口过去才重投更慢但也会重复若 Worker 活着但任务超过超时同样重复。结论RabbitMQ晚确认防丢但防不了重Redis 靠超时兜底重复与延迟并存。两者都要求幂等。思考题 2交换机模型下路由规则哪个任务进哪个队列声明在 Broker 的绑定上生产者代码只带一个 routing_key改路由不用改代码。Redis 没有这层抽象要按业务分队列就得在生产者代码里写死队列名apply_async(queuesms)或自建映射表——路由逻辑与业务代码耦合改路由要改代码、重新发布。1. 项目背景财务对账任务导出 30 万订单的 CSV上线后前端同事天天来问「好了没好了没」小周查了一下任务平均跑 8 分钟前端只能干等或靠轮询猜。更糟的是Redis 这几天内存一路涨到 90%——排查发现celery-task-meta-*开头的键有 200 多万个全是任务结果。默认result_expires是 1 天celery/app/defaults.py每天几十万任务的结果在 Redis 里躺满 24 小时谁都不看纯占内存。还有个隐藏炸弹同事写了个AsyncResult(task_id).get()没设超时。任务因为路由错误永远 PENDING这个get()就永远阻塞把 FastAPI 的 worker 线程池一个一个挂死接口开始假死。复盘时大家才意识到结果不是免费的——它要存储、要过期、要查询是一套完整的生命周期而不是「返回值自动出现」那么简单。需求导出对账单任务的进度查询 前端 → GET /api/statement/task_id → 返回 {state, progress, result_url} ↑ AsyncResult(task_id).state / .info ↑ Redis: celery-task-meta-task_id30 分钟后过期2. 项目设计场景周会上前端抱怨「查不到进度」小周甩出 Redis 内存图。小胖结果不就是函数返回值吗任务里return csv_url前端直接拿多简单。搞什么 Backend、AsyncResult绕来绕去的。小白小胖你说的是同一进程里的返回值。任务在 Worker 进程执行前端在 Web 进程等待返回值靠什么穿越进程边界还有AsyncResult.get()和.state和.ready()到底查的是同一份数据吗EagerResult 又是什么大师两个问题一起答。第一任务的返回值会由 Worker 写进Result Backend——一个独立的存储Redis/数据库/RPC键就是任务 ID。Backend 是「返回值与状态的记账簿」和 Broker 是两码事第 1 章说过传菜口不记账。第二AsyncResultcelery/result.py:70就是查询这份记账的客户端它的方法分两类状态类state、ready、successful、failed、status和取值类get、result、traceback。EagerResultcelery/result.py:1026是task_always_eager同步执行时的「本地结果」不经过 Backend。记住AsyncResult只是「带着任务 ID 去 Backend 查数」它不是结果本身。技术映射AsyncResult 银行小票上面只有流水号Backend 银行核心系统真正的账本get() 拿小票去柜台查「这笔到账没」。小白那 Backend 怎么选我看到有 Redis、RPC、数据库三种还有 S3 之类的。我们 Redis 都快被celery-task-meta-*撑爆了这个怎么治大师先说选型Redis Backendcelery/backends/redis.py:205——快、简单适合通用场景但结果大时占内存RPC Backendcelery/backends/rpc.py:154——结果存在 Broker 的「回复队列」里查一次就删省存储但只能查一次多端重复查会查不到数据库 Backendcelery/backends/database/——结果落表可审计可回溯但性能最差表会膨胀。我们选 Redis然后治内存①result_expires从默认 1 天调成 30 分钟结果只在「还有人关心」的窗口内有效② 不需要结果的任务开ignore_resultTrue连键都不写③ 全局兜底task_ignore_result。三招下来键数量直接降一个数量级。小胖还有个问题我上次get()等了 10 分钟任务还是 PENDING页面都卡死了。这有救吗大师get()必须带超时——get(timeout5)超时抛TimeoutError让调用方决定重试还是放弃。另外get()还有一个隐藏参数propagate任务失败时get()默认把异常重新抛给调用方不想炸就propagateFalse拿result自己判断。前端同事抱怨的「卡死」一半是没设 timeout一半是任务根本没进队PENDING 是默认假设状态第 15 章故障排查会专门讲「PENDING 之谜」。技术映射get(timeout5) 等电梯最多等 5 秒超时走楼梯不带 timeout 死等一部永远不来的电梯。小白最后一个问题我听说 chord 工作流必须有 Backend为什么大师因为 chord 的「并行组完成后精确触发一次回调」靠的是计数器每个子任务完成后去 Backend 递增计数最后一个完成者触发 body。这个计数器就存在 Backend 里celery/backends/base.py的 chord 逻辑。没有 Backend计数器没地方放chord 直接报错。所以配 chord 前先确认 Backend 可写第 19 章正式玩 chord。3. 项目实战3.1 环境准备沿用现有环境Redis 同时当 Broker 和 Backend。启动 Redis 后确认 1 号库用于结果存储。dockercompose-fdocker/docker-compose.yml up-dredis3.2 分步实现步骤 1定义导出对账单任务返回结果 URL目标任务返回值经过 Backend 供前端查询。# order_tasks.py 追加app.task(nameorders.export_statement,bindTrue)defexport_statement(self,date_str:str)-str:导出对账单模拟 30 万订单 CSV 生成返回下载 URL。importtime time.sleep(3)# 模拟导出耗时urlfhttps://fs.xx.com/statement/{date_str}.csvprint(f[export]{date_str}完成:{url})returnurl# 返回值 → 写进 Backend步骤 2配置结果过期与忽略策略目标30 分钟过期 不需要结果的任务不写 Backend。# celeryconfig.py 追加result_backendredis://localhost:6379/1# 结果库独立避免与队列争内存result_expires1800# 结果 30 分钟过期秒task_ignore_resultFalse# 全局默认仍写结果# 不需要结果的任务短信、审计日志单独声明app.task(nameorders.send_order_sms,ignore_resultTrue)defsend_order_sms(order_id:int)-bool:...步骤 3实现进度查询接口HTTP目标前端通过GET /api/statement/task_id拿到状态与结果。# web_app.py基于第 3 章 http.server 扩展fromcelery.resultimportAsyncResultfromorder_tasksimportappdefstatement_view(self,task_id:str):rAsyncResult(task_id,appapp)# 带着任务 ID 去 Backend 查账body{task_id:task_id,state:r.state,# PENDING/SUCCESS/FAILURE...ready:r.ready(),# 是否出最终结果successful:r.successful(),result:r.resultifr.ready()elseNone,error:r.tracebackifr.failed()elseNone,# 失败时的堆栈摘要}self._respond(200,body)步骤 4全流程验证celery-Aorder_tasks worker--loglevelinfo--poolsolo python web_app.py# 终端 C发起导出任务celery-Aorder_tasks call orders.export_statement--args[2026-08-23]# 得到 task_id 后查询状态curlhttp://127.0.0.1:8000/api/statement/task_id# 3 秒内查{state: PENDING, ready: false, ...}未写完前可能 STARTED/SUCCESS# 执行完查{state: SUCCESS, ready: true, result: https://fs.xx.com/statement/2026-08-23.csv}步骤 5观察结果键与过期目标验证「结果不是免费的」——键存在且会过期。dockerexecdocker-redis-1 redis-cli-n1KEYScelery-task-meta-*dockerexecdocker-redis-1 redis-cli-n1TTL celery-task-meta-task_id# 预期 1800 附近递减运行结果文字描述结果键带 1800 秒 TTL短信任务因ignore_resultTrue不产生任何键——这正是「省内存」的直观证明。步骤 6对比 RPC Backend——「结果只能查一次」的真实体验目标理解三种 Backend 的语义差异选型不再拍脑袋。# rpc_demo.py临时切 RPC Backend 验证fromceleryimportCelery appCelery(rpc_demo,brokerredis://localhost:6379/0,backendrpc://)# RPC结果存在 Broker 的回复队列查一次即删app.taskdefadd(x,y):returnxy radd.delay(1,2)importtime;time.sleep(2)print(第一次查:,r.get(timeout5))# 3time.sleep(1)print(第二次查:,r.get(timeout5))# 可能返回 None结果已被删除运行结果文字描述第一次get正常拿到 3间隔后再查结果为 None——RPC Backend 的结果是一次性的。所以它适合「回调方只查一次」的场景如 chord 的 body 取结果不适合「任务中心多次展示」。数据库 Backend 反之落表永久可查适合审计第 23 章深入。步骤 7封装全公司统一的safe_get查询工具目标把「超时 propagate 决策 状态区分」三件事收进一个函数消灭裸get()。# result_utils.pyfromcelery.resultimportAsyncResultfromorder_tasksimportappclassTaskNotFound(Exception):...defsafe_get(task_id:str,timeout:float5.0):统一的结果查询带超时、不抛业务异常、区分三类结局。rAsyncResult(task_id,appapp)ifr.statePENDINGandnotr.ready():# 快速失败不阻塞调用线程PENDING 之谜交给第 15 章排查raiseTaskNotFound(f任务{task_id}不存在或未入队请先确认投递成功)ifr.ready():ifr.successful():return{ok:True,result:r.result}return{ok:False,state:r.state,error:str(r.traceback).splitlines()[-3:]}# 未完成给状态让前端轮询return{ok:None,state:r.state}# web_app.py 中使用bodysafe_get(task_id,timeout5)运行结果文字描述任务不存在/未入队时立刻抛 TaskNotFound而不是傻等任务失败时返回结构化错误而非炸掉接口未完成时返回状态交给前端轮询。上线后「接口假死」类工单归零。3.3 可能遇到的坑及解决方法坑现象解决get()永久阻塞任务 PENDING调用方线程挂死必须get(timeout...)排查任务是否真的入队第 15 章结果查不到state返回 PENDING但任务明明跑完了结果已过期result_expires 太短或 Backend 与 Worker 配置不一致Redis 内存暴涨celery-task-meta-*键堆积result_expires调短 ignore_result/task_ignore_resultget()突然抛异常任务失败异常被 propagate 回来propagateFalse或先判failed()再取tracebackchord 报No result backend没配 Backend 就用了 chordchord 前必须确认result_backend可写RPC Backend 二次查询为 None结果查一次即删需要多次查询的场景换 Redis/数据库 Backend结果里塞大 JSON 导致 Redis 大键导出任务把整包数据写进结果内存抖动结果只放摘要与文件 URL数据落对象存储3.4 完整代码清单与测试验证清单order_tasks.pyexport_statement ignore_result 任务、celeryconfig.py过期策略、web_app.py查询接口、result_utils.pysafe_get。附结果治理三件套口诀短过期、能忽略就忽略、查询必带超时。治理基线配置可直接拷进 celeryconfig.pyresult_backendredis://localhost:6379/1# 独立库result_expires1800# 30 分钟过期task_ignore_resultFalse# 全局默认写结果# 不需要结果的任务在装饰器上单独声明 ignore_resultTrue测试验证# tests/test_result_backend.pyfromcelery.resultimportAsyncResultfromorder_tasksimportapp,export_statement app.conf.task_always_eagerTruedeftest_export_returns_url():rexport_statement.apply(args[2026-08-23])assertr.successful()andstatement/2026-08-23.csvinr.resultdeftest_eager_result_ready_and_get():rexport_statement.apply(args[2026-08-24])assertr.ready()assertr.get(timeout1,propagateFalse).endswith(.csv)deftest_ignore_result_task_has_flag():fromorder_tasksimportsend_order_smsassertsend_order_sms.ignore_resultisTruedeftest_result_expires_configured():assertapp.conf.result_expires1800python-mpytest tests/test_result_backend.py-v# 4 passed4. 项目总结4.1 优点 缺点维度Redis BackendRPC Backend数据库 Backend查询性能快快慢落表结果可查次数多次TTL 内一次查即删多次/永久存储成本内存极低磁盘可审计弱无强SQL 可回溯适合通用默认一次性回调审计合规选型顺口溜查询勤用 Redis一次用完上 RPC审计合规走数据库文件大内容别进 Backend——结果里只放「轻量值 文件 URL」这是第 23 章进阶前必须刻进肌肉记忆的原则。4.2 适用场景适用① 需要进度/结果查询的导出类任务② chord 等依赖 Backend 的工作流③ 前端「提交后轮询」的异步交互④ 任务中心/管理后台需要展示任务结局的场景配合第 10 章状态机。不适用① 结果超大大 JSON/文件内容直接存 Backend 会撑爆内存应存文件系统只返回 URL② 不需要结果的纯通知任务开 ignore_result③ 强审计场景直接用数据库 Backend 而不是 Redis④ 查询方多而结果有效期极短的场景RPC Backend 只能查一次会互相「抢结果」。4.3 注意事项result_expires单位是秒默认 1 天celery/app/defaults.py调短前确认「最长查询窗口」。get()永远带 timeout 与 propagate 决策这是调用方的基本功。Backend 与 Broker 用同一个 Redis 时库号要分开否则键混在一起内存与排障互相干扰。ignore_resultTrue后AsyncResult.get()永远返回 None——别在需要结果的流程里误开。结果查询接口要防止「任务 ID 枚举」任何调用方凭 task_id 就能读到别人的结果内容生产需加鉴权与白名单。结果序列化与任务序列化是两套配置result_serializervstask_serializer切 JSON 时两处都要检查漏一处就会「任务能跑、结果读不出」。4.4 常见踩坑经验3 个生产故障故障Redis OOM影响全部队列投递。根因结果键 200 万result_expires保持默认 1 天且无 ignore_result。对策过期调 30 分钟 通知类任务全开 ignore_result。教训结果存储是 Broker 的同池邻居一家爆仓两家遭殃。故障报表接口周期性假死。根因get()无超时路由错误的任务永远 PENDING线程池被逐个挂死。对策统一封装safe_get(task_id, timeout5)。教训异步查询的每个阻塞点都要有逃生门。故障结果「时有时无」。根因两个微服务一个用 Redis Backend 一个没配查到了对方的键找不到自己的。对策Backend 配置纳入服务模板celery report指纹校验。教训Backend 配置不一致 各查各的账本。4.5 思考题ignore_resultTrue与result_expires1800有什么区别为什么说「ignore_result 是从源头省expires 是事后回收」chord 的计数器为什么必须放在 Backend如果子任务执行期间 Backend 挂了chord 会怎样提示body 触发、join 超时答案见第 9 章开头的「上一章思考题参考答案」。延伸阅读与资源Java 工程师进阶从 JVM 生产排障到OpenJDK原理NumPy 从入门到生产落地全链路实战指南科学计算/向量化Redis 8 实战精讲从 CRUD 到源码构建高可用缓存系统Redis 实战修炼与原理进阶Python 3实战精进从脚本到高并发订单引擎python入门Rquests从菜鸟脚本到企业级SDK的网络实战圣经Milvus向量数据库实战修炼从 0 到 1精通向量检索与生产落地MongoDB 实战进阶与内核修炼后端工程师的 AI 转型第一课Ollama 与私有化大模型实战10倍开发者的 Dify 魔法书从零构建全栈 AI 应用后端工程师转型AI第一课-Ollama 与私有化大模型实战大型语言模型(LLM) vLLM 高性能推理落地实战Agent开发之LlamaIndex 实战修炼与源码进阶大语言模型Transformers 实战修炼与源码剖析