0. 上一章思考题参考答案
思考题 1:celery/app/ 管的是 App 生命周期——配置、任务注册表、消息路由,随进程常驻;celery/worker/ 管的是消费运行时——谁在消费、怎么确认、请求上下文怎么包装,生命周期随 Worker 启停;celery/backends/ 管的是结果存储——状态与返回值落哪、何时过期。三者生命周期完全不同(配置要稳定、消费要可控、结果要过期),拆开才能独立演进与运维。
思考题 2:-e 是 editable(可编辑)模式,pip 在 site-packages 里写入一个指向仓库的路径钩子(Windows 下是 __editable__.celery...pth 文件)。Python 导入 celery 时按该路径直接找到仓库里的源码,因此改仓库文件立即生效、无需重装。
1. 项目背景
上一章环境跑通后,leader 给小周派了第一个正经需求:下单成功后在 2 秒内给用户发一条订单确认短信,但下单接口必须在 50ms 内返回。小周第一反应是加个线程池,在接口里 threading.Thread(target=send_sms, args=(order_id,)).start()。上线的第一个晚上就出事了:服务重启时丢了几百条短信(线程里的任务随进程蒸发),短信网关限流时线程池被打爆,主进程线程数飙到 800,接口开始排队,最终还是卡了用户。
小周这才意识到:「把代码放到别的线程跑」不等于「异步」。异步系统的第一性原理是:任务要和调用方进程隔离,并且任务本身要被持久化、可确认、可重试。而这正是「把函数升级成 Task」的意义。
那问题来了:@app.task 到底对一个函数做了什么?为什么调 task.delay(...) 之后,接口立刻返回,而 send_order_sms 真的在 2 秒后执行了?本章用一个小而完整的例子,把「函数 → 分布式执行单元」的变身过程拆开看。
下单接口(<50ms 返回)
│ send_order_sms.delay(order_id)
▼
消息 → Redis(Broker)→ Worker 拾取 → 包装成 Request → 执行函数体(约 2s)→ (结果后端)
2. 项目设计
场景:小周把自己的线程池方案讲给大师和小胖听。
小胖:线程池咋了?我吃火锅的时候,锅底和配菜不是同步上的——锅底先上,涮肉慢慢来,这不就是异步吗?你那个 Thread(...).start() 我觉得挺好,简单!
小白:但是小周你也说了,进程一重启,线程里的任务全没了。我还在想另一个问题:Worker 收到任务的时候,怎么知道要执行哪个函数? 函数在 Web 进程里是一个内存对象,到了 Worker 进程里,同一个函数对象并不存在——那消息里到底传的是什么?
大师:小白问到了核心。先回答小胖:线程池里「任务」的生命周期等于进程的生命周期,进程挂了任务就蒸发;而 Celery 里任务被序列化成一条消息,落到 Broker 里,Worker 挂了消息还在,换台机器照样执行。这就叫「任务的持久化」。再回答小白:消息里传的从来不是函数,而是函数的「名字」。send_order_sms 在 Web 进程里被 @app.task 装饰时,Celery 做三件事:
- 把函数包装成一个
celery.app.task.Task实例; - 按「模块名 + 函数名」算出任务名(如
tasks.send_order_sms)并注册进app.tasks注册表; - 在 Task 实例上挂
delay()、apply_async()等方法。
Worker 进程启动时也会加载同样的模块、执行同样的注册——所以 Worker 拿到消息里的任务名,到自己的注册表里一查,就找到那个函数了。两边代码一样,注册表就能对上暗号。
技术映射:任务名 = 菜单上的菜名(跨进程的稳定标识);任务函数体 = 后厨菜谱(每个厨房都有一份);消息 = 写有菜名的点菜单。
小周:那我调 delay(order_id) 的时候,消息里是怎么装我的参数的?order_id 是 Python 对象,Worker 那边拿到的是同一个对象吗?
大师:消息体里放的是 args 和 kwargs 的序列化结果——默认 JSON。所以参数必须是 JSON 可序列化的:数字、字符串、列表、字典都行,ORM 对象、socket、文件句柄一概不行(第 6 章讲参数契约)。Worker 反序列化后重建参数,再包进一个叫 Request 的上下文对象里(里面带任务 ID、重试次数、投递信息等),最后才调用你的函数。所以你在任务函数里能拿到 self.request.id,就是这个 Request。这一套「消息 → Request → 函数」的链路,源码在 celery/worker/request.py 和 celery/app/trace.py,第 34 章精读。
小胖:那 delay() 和 apply_async() 有啥区别?我百度看到俩都能发任务,一个带个框一个不带?
大师(笑):delay(*args, **kwargs) 就三行代码——return self.apply_async(args, kwargs)(celery/app/task.py:534)。delay 是 apply_async 的语法糖,只管传参,不管别的选项;要加 countdown、queue、expires 这些控制项,就得用 apply_async(..., countdown=10, queue='sms')。另外还有个 apply()——它是同步执行,不经过 Broker,直接在当前进程跑函数(测试和调试用的,别在生产用)。三个方法的边界:delay 传参、apply_async 传参+选项、apply 同步执行。
技术映射:delay = 只点菜不交代(默认做法);apply_async = 点菜 + 交代口味忌口(队列、延时);apply = 当着你的面现炒(同步)。
3. 项目实战
3.1 环境准备
沿用第 2 章环境(源码可编辑安装 + Redis)。新增依赖:无(HTTP 演示用 Python 标准库)。
docker compose -f docker/docker-compose.yml up -d redis # 已启动可跳过
cd examples/tutorial
3.2 分步实现
步骤 1:定义第一个业务任务 send_order_sms
目标:用 @app.task 把「发短信函数」升级为「可跨进程执行的任务」。
# examples/tutorial/order_tasks.py
import time
from celery import Celery
app = Celery('order_tasks', broker='redis://localhost:6379/0')
@app.task(name='orders.send_order_sms') # 显式命名,防止模块改名后任务名漂移
def send_order_sms(order_id: int) -> bool:
"""模拟调用短信网关:耗时约 2 秒,返回是否发送成功。"""
time.sleep(2) # 模拟三方网关 I/O
print(f"[SMS] 订单 {order_id} 的确认短信已发出")
return True
关键点:显式
name='orders.send_order_sms'——任务名是跨进程契约,模块路径变了名字可能漂移,显式命名是生产规范(第 6、28 章扩展)。命名建议遵循三段式约定:<业务域>.<动作>,如orders.send_order_sms、billing.gen_invoice;跨团队共享的任务再加团队前缀,如promo.orders.issue_coupon。任务名一旦对外发布就不要改,第 4 章讲过「在途消息」会因改名而NotRegistered。
步骤 1.5:任务多模块时,用 autodiscover_tasks 自动发现
目标:任务分散在多个业务模块时,一键注册,告别手工维护 import 清单。
# order_tasks.py 中追加(模块拆分场景)
app.autodiscover_tasks(['order', 'billing'], related_name='tasks')
# 等价于自动 import order.tasks、billing.tasks,并注册其中的任务
说明:autodiscover_tasks(源码在 celery/app/base.py)会按「包名.related_name」批量 import 任务模块,业务模块约定统一叫 tasks.py 即可被自动发现。它是第 27 章 Django 集成的标配姿势;注意它静默失败——路径写错不报错,排查时先打印 app.tasks.keys() 核对。
步骤 2:用「下单接口」演示同步与异步的差别
目标:同一个接口,对比内联调用(阻塞 2 秒)与 delay()(毫秒返回)。
# examples/tutorial/web_app.py
import json, time
from http.server import BaseHTTPRequestHandler, HTTPServer
from order_tasks import app, send_order_sms
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path == '/api/orders':
order_id = int(time.time() * 1000) % 1000000
t0 = time.time()
send_order_sms.delay(order_id) # 异步:只发消息,立即返回
cost = round((time.time() - t0) * 1000, 1)
self._respond(200, {"order_id": order_id, "async_cost_ms": cost})
else:
self._respond(404, {"error": "not found"})
def _respond(self, code, body):
data = json.dumps(body).encode()
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
if __name__ == '__main__':
HTTPServer(('127.0.0.1', 8000), Handler).serve_forever()
步骤 3:启动 Worker,用 curl 验证「接口快、任务慢」
目标:验证 Web 侧 50ms 内返回,Worker 侧约 2 秒后完成。
# 终端 A:启动 Worker(Windows 加 --pool=solo)
celery -A order_tasks worker --loglevel=info --pool=solo
# 终端 B:启动下单接口
python web_app.py
# 终端 C:发一个下单请求
curl -X POST http://127.0.0.1:8000/api/orders
运行结果(文字描述):
curl 响应:{"order_id": 123456, "async_cost_ms": 3.2} # 接口 3 毫秒返回,远小于 50ms
终端 A Worker 日志:
[INFO/MainProcess] Task orders.send_order_sms[...] received
[SMS] 订单 123456 的确认短信已发出 # 2 秒后才打印
[INFO/MainProcess] Task orders.send_order_sms[...] succeeded in 2.003s
步骤 4:用 AsyncResult 查询任务状态
目标:给调用方一个「查单」的口子,验证第 1 章「Backend 记账」的闭环。先给 App 配上结果后端。
# order_tasks.py 中修改:
app = Celery('order_tasks', broker='redis://localhost:6379/0',
backend='redis://localhost:6379/1')
# 终端 C:
celery -A order_tasks call orders.send_order_sms --args='[789]'
celery -A order_tasks result <任务ID>
运行结果:
result 输出:True # 任务执行成功,返回值可查(结果存在 Redis 1 号库,30 分钟过期)
步骤 5:用 apply() 与 apply_async(countdown=...) 体验另两种调用
目标:对比三种调用方式的语义差异。
# examples/tutorial/call_demo.py
from order_tasks import send_order_sms
r1 = send_order_sms.apply(args=(1,)) # 同步执行,阻塞 2 秒,适合测试
r2 = send_order_sms.apply_async(args=(2,), countdown=5) # 5 秒后执行,可加 queue/expires 等选项
print("同步结果:", r1.get(), "| 异步任务ID:", r2.id)
python call_demo.py
# 预期:先阻塞 2 秒打印 [SMS] 订单 1 ...,再打印同步结果与异步任务 ID;
# Worker 日志约 5 秒后出现订单 2 的执行记录。
同步与异步的适用边界小结:① 需要立刻拿到结果才能继续 → 走同步(REST 调用,或
apply()仅限测试调试);② 结果几秒后才用 → 异步 +AsyncResult查询;③ 只发不管结果 → 异步 +ignore_result。选择标准一句话:调用方能不能等、结果要不要拿。拿不准时优先异步 + 查询,因为它对调用方最无侵入。
3.3 可能遇到的坑及解决方法
| 坑 | 现象 | 解决 |
|---|---|---|
NotRegistered: 'tasks.send_order_sms' | 任务名对不上 | 显式命名后必须完全一致;Worker 重启加载最新模块 |
| 任务函数里传了 ORM 对象 | 序列化报 TypeError: Object of type Order is not JSON serializable | 只传主键/ID,对象由 Worker 进程自己查库(第 6 章) |
AsyncResult.get() 一直 PENDING | 忘了配 backend | Celery(..., backend=...) 或 app.conf.result_backend = 'redis://...' |
| Web 进程 import 任务模块时触发副作用 | 接口启动变慢 | 任务模块只定义任务,不执行业务初始化;import order_tasks 放在接口入口处 |
| Windows 下 curl 没输出 | 可能是启动顺序问题 | 先 Worker 再 Web;Worker 用 --pool=solo |
autodiscover_tasks 没找到任务,也不报错 | 模块路径或 related_name 写错,静默失败 | 用 app.autodiscover_tasks(['order'], force=True) 并打印 app.tasks 核对 |
3.4 完整代码清单与测试验证
清单:order_tasks.py(任务定义 + 配置)、web_app.py(下单接口)、call_demo.py(调用对比),共 3 个文件。生产规范:任务定义与调用方分属不同模块,调用方只 import 不 from ... import *。
测试验证(单元级,用 task_always_eager 让任务在测试进程内同步执行):
# tests/test_order_sms.py
from unittest import mock
from order_tasks import app, send_order_sms
app.conf.task_always_eager = True # 测试模式:不发 Broker,直接同步执行
def test_send_order_sms_returns_true():
assert send_order_sms.run(order_id=1) is True # run() 是任务函数体
def test_send_order_sms_registered_with_explicit_name():
assert 'orders.send_order_sms' in app.tasks
@mock.patch('order_tasks.send_order_sms.delay')
def test_web_calls_delay_once(mock_delay):
from web_app import Handler # 触发模块加载
mock_delay.assert_called_once_with(123456) # 调用方只走 delay,不直接执行
python -m pytest tests/test_order_sms.py -v # 3 passed
4. 项目总结
4.1 优点 & 缺点
| 维度 | Celery Task(消息驱动执行单元) | 线程池 + Thread().start() |
|---|---|---|
| 持久性 | 消息落 Broker,进程崩溃可恢复 | 任务随进程蒸发 |
| 扩展性 | 任务可跨机器执行,加 Worker 即扩容 | 受限于单进程线程数 |
| 可观测性 | 有任务 ID、状态机、事件 | 只能打日志 |
| 触发方式 | 消息驱动,无共享内存 | 共享内存、GIL 限制 |
| 缺点 1 | 参数必须可序列化 | 参数随便传 |
| 缺点 2 | 有重复执行可能,需幂等 | 天然不重复 |
| 缺点 3 | 引入中间件运维成本 | 零依赖 |
4.2 适用场景
- 适用:① 通知类(短信/邮件/推送)任务;② 需要跨进程解耦的业务动作;③ 需要重试与状态追踪的写操作;④ 定时任务(配合 Beat,第 13 章)。
- 不适用:① 必须在请求内拿到结果才能继续的强同步流程;② 任务参数含不可序列化对象(需先改造为 ID 引用);③ 极端低延迟微任务(进程间通信开销大于收益)。
4.3 注意事项
- 任务名一旦对外发布(跨团队调用、线上消息在途),改名要灰度兼容,否则在途消息将
NotRegistered。 apply()同步执行绕过 Broker,千万别在 Web 请求里用它「图省事」。- 结果后端不是免费的:每个任务结果占 Redis 键,记得
result_expires(默认 24 小时,第 8 章调优)。
4.4 常见踩坑经验(3 个生产故障)
- 故障:升级任务模块后,消息报
NotRegistered。根因:在途消息里的任务名还是旧名,而新 Worker 只注册了新名。对策:显式任务名 + 双版本窗口期同时注册。教训:任务名是跨版本契约,轻易不改。 - 故障:接口偶发 2 秒慢请求,定位发现任务函数里有人直接
requests.get调短信网关。根因:把业务逻辑写进了「接口内联调用」分支。对策:代码评审强制接口只允许 delay/apply_async。教训:异步接口不允许同步调用远程服务。 - 故障:
get()卡死超时,排查发现把 ORM 对象传进了任务参数。根因:参数被 pickle 时拖进整个 Session。对策:只传主键,Worker 内重新查询。教训:任务参数必须可序列化、要最小化。
4.5 思考题
delay()与apply_async()的区别是什么?为什么说「delay 只适合最简单的调用」?(提示:看celery/app/task.py:534的实现)- 如果两个 Worker 同时消费同一个队列,任务 A 的
countdown=10会保证「不早于 10 秒执行」吗?它是怎么被实现的?(提示:任务消息是立即投递的,延迟是在哪一侧实现的?)
答案见第 4 章开头的「上一章思考题参考答案」。
延伸阅读与资源
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 实战修炼与源码剖析

1万+

被折叠的 条评论
为什么被折叠?



