3. 第一个Celery任务
上一节我们已经理解了Celery的“生产者—Broker—消费者—结果后端”四段式结构。本节要把这套结构真正跑起来:写一个最小的任务模块,启动一个Worker进程,从另一个进程里调用任务,再通过结果后端取回执行结果。整个过程会暴露Celery最核心的运行机制——任务不是被“直接执行”的,而是被序列化、投递、领取、执行、回写,最后才被调用方读到。理解这条链路,比记住几个API更重要。
一、准备环境与目录结构
先约定一个最小可运行的工程目录。示例使用Redis同时充当Broker和Result Backend,这是本地开发最省事的组合;生产环境可以把两者拆开,但本节不展开。
celery_demo/
├── tasks.py # 任务定义
├── producer.py # 调用方(生产者)
└── celery_app.py # Celery实例与配置
安装依赖:
pip install celery redis
确保本机Redis已启动,默认监听 127.0.0.1:6379,无密码。若Redis未启动,Worker会反复重连并报 Connection refused,这是新手最常见的第一个坑。
二、创建Celery实例
在 celery_app.py 中创建应用对象。Celery的第一个参数是当前模块名,用于生成默认的任务名前缀;broker 指定消息队列地址,backend 指定结果存储地址。
from celery import Celery
app = Celery(
"celery_demo",
broker="redis://127.0.0.1:6379/0",
backend="redis://127.0.0.1:6379/1",
)
app.conf.update(
task_serializer="json",
result_serializer="json",
accept_content=["json"],
timezone="Asia/Shanghai",
enable_utc=False,
)
这里把Broker放在db0、结果放在db1,是为了在Redis里能直观区分“待执行消息”和“执行结果”两类数据。序列化统一用JSON,避免pickle带来的跨语言与安全风险。
三、定义第一个任务
在 tasks.py 中导入app并注册任务。任务本质上就是一个被 @app.task 装饰的普通函数。
import time
from celery_app import app
@app.task(bind=True, max_retries=3)
def add(self, x, y):
time.sleep(2) # 模拟耗时操作
return x + y
bind=True 让函数第一个参数变成任务实例 self,后续做重试、访问请求上下文时会用到。max_retries=3 表示最多重试3次。函数体里的 time.sleep(2) 是刻意加的,用来观察“调用立即返回、结果稍后才就绪”这一异步特征。
四、启动Worker
在 celery_demo 目录下执行:
celery -A celery_app worker --loglevel=info --concurrency=2
参数含义:-A celery_app 指定应用模块;--loglevel=info 输出任务接收与完成日志;--concurrency=2 启动2个执行槽位。启动成功后终端会打印类似:
[tasks]
. celery_demo.tasks.add
[queues]
. celery exchange=celery(direct) key=celery
[2024-06-01 10:00:00,000: INFO/MainProcess] celery@host ready.
看到 ready. 说明Worker已连上Broker并开始监听队列。此时它处于阻塞等待状态,不会主动做任何事,直到有任务消息到达。
五、调用任务并查看结果
在另一个终端运行 producer.py:
from tasks import add
result = add.delay(3, 5)
print("task_id:", result.id)
print("state:", result.state) # 立即查看,通常是 PENDING
print("result:", result.get(timeout=10))
add.delay(3, 5) 是 add.apply_async(args=(3, 5)) 的简写。它并不会在本进程执行加法,而是把 {"task": "celery_demo.tasks.add", "args": [3, 5], "id": "..."} 序列化后投递到Redis的celery队列,然后立刻返回一个 AsyncResult 对象。
典型输出:
task_id: 8f3c1e2a-...
state: PENDING
result: 8
注意 state 第一次打印是 PENDING,这不是“任务还没开始”,而是“结果后端里还没有这条任务的任何状态记录”。Celery对未知任务统一返回PENDING,这一点很容易被误解。result.get(timeout=10) 会阻塞轮询结果后端,直到拿到返回值或超时。
六、任务生命周期与执行链路
把上面的过程串起来,就是本节最需要建立的心智模型。下图展示了从调用到取回结果的完整链路,以及各阶段任务状态的变化。
七、观察日志与结果判断
调用 add.delay(3, 5) 后,Worker终端会依次出现:
[INFO/MainProcess] Received task: celery_demo.tasks.add[8f3c1e2a-...]
[INFO/ForkPoolWorker-1] Task celery_demo.tasks.add[8f3c1e2a-...] succeeded in 2.01s: 8
这两行日志分别对应“领取”和“完成”两个节点。如果任务抛异常,第二行会变成 raised unexpected: ...,同时结果后端里该任务状态为FAILURE,result.get() 会重新抛出原异常。可以用 result.get(propagate=False) 拿到异常对象而不抛出,便于在调用方做统一错误处理。
判断任务是否真正跑通,不能只看 delay() 有没有报错,而要看三件事:Worker日志里是否出现 Received task 与 succeeded;result.get() 是否返回预期值;Redis db1 中是否存在以任务ID为键的结果记录。三者一致,才说明整条链路是通的。
--loglevel 设为 debug,可以看到消息体内容与结果写入细节;稳定后调回 info,避免日志淹没关键信息。八、常见失败与定位方向
第一个任务跑不起来,通常集中在四类问题:一是Broker地址写错或Redis未启动,Worker启动阶段就会报连接错误;二是任务模块未被Worker发现,日志里 [tasks] 列表为空,此时要检查 -A 指向的模块是否正确导入了任务;三是参数或返回值不可JSON序列化,比如传了datetime对象,会在投递或回写阶段报 EncodeError;四是调用方与Worker使用的Celery配置不一致,导致任务名对不上,消息被投到队列却无人领取。
定位时优先看Worker启动日志中的任务清单和队列名,再看调用后Worker是否打印 Received task。如果消息进了队列但Worker没反应,问题在路由或任务名;如果Worker收到了却执行失败,问题在任务函数本身或序列化。把这条判断顺序固定下来,后续排查复杂任务时会省很多时间。