任务队列解决什么
一个接口里要发邮件、生成报表、压缩图片,用户等得心焦,还容易被超时打断。任务队列把这类耗时操作从请求链路里拆出去,让接口先返回「已受理」,后台慢慢干。Celery 就是 Python 生态里最流行的任务队列框架。
Celery 的架构由三部分组成:生产者(业务代码把任务丢进队列)、Broker(消息中间件,负责暂存任务,常见用 Redis 或 RabbitMQ)、Worker(真正执行任务的进程)。任务在队列里排队,多个 Worker 并发消费,天然支持水平扩展。
什么时候该上任务队列?判断标准很简单:这个操作能不能异步?如果用户不需要等它的结果,而且它比较耗时,就值得拆成任务。比如发验证码邮件、导出大数据、视频转码、定时清理数据。反过来,用户要立刻看到结果的操作(比如登录校验),不应该进队列。
引入队列也带来代价:多了一个中间件要运维,任务执行的时序变复杂,失败重试要自己设计。小项目先别急着上,等真的出现「接口太慢」「任务超时」再引入不迟。
任务队列的本质是「把时间从请求里偷出来」。
- BrokerRedis 或 RabbitMQ 做消息中间件。
- 任务@app.task 定义可异步执行的函数。
- Worker后台进程并发消费任务。
- 定时beat 调度周期任务。
安装与基础配置
Celery 本体加一个 Broker 的适配库就够起步。Redis 做 Broker 最简单,一台机器就能跑。
pip install celery redis
新建一个 celery_app.py,把应用对象建好。这里指定 Redis 做 Broker,同时也用它存任务结果。
from celery import Celery
app = Celery(
"tasks",
broker="redis://localhost:6379/0",
backend="redis://localhost:6379/1"
)
app.conf.timezone = "Asia/Shanghai"
app.conf.task_serializer = "json"
app.conf.result_serializer = "json"
时区要显式配成 Asia/Shanghai,否则定时任务按 UTC 走,会比本地时间差 8 小时。序列化统一用 JSON,跨语言调试都方便。
定义与调用任务
在 tasks.py 里用 @app.task 装饰一个函数,它就变成了可异步执行的任务。任务里写真实业务:发邮件、生成报表、调第三方接口。
from celery_app import app
import time
@app.task
def send_welcome_email(user_id):
# 模拟发邮件耗时
time.sleep(2)
print(f"欢迎邮件已发送给用户 {user_id}")
return {"user_id": user_id, "status": "sent"}
业务代码里调用时不用直接执行,而是用 .delay 把任务丢进队列,立刻返回一个 AsyncResult 对象,接口马上就能响应。
# views.py
def register(request):
# 业务处理
user_id = create_user(request)
# 异步发邮件,不阻塞响应
send_welcome_email.delay(user_id)
return {"status": "ok", "user_id": user_id}
启动 Worker 的命令:celery -A tasks worker --loglevel=info。Worker 起来后,队列里的任务会被逐个消费。开多个 Worker 进程或加多台机器,任务吞吐量就上来了。
结果存储与状态查询
任务执行完的结果存在 backend(Redis)里,业务方可以用任务 ID 轮询状态,拿到返回值。
result = send_welcome_email.delay(42)
# 查看任务是否完成
print(result.ready()) # False / True
print(result.state) # PENDING / SUCCESS / FAILURE
# 阻塞等结果(一般不在 Web 请求里用)
value = result.get(timeout=10)
print(value) # {"user_id": 42, "status": "sent"}
Web 请求里别用 get 阻塞等结果,那就失去异步的意义了。正确姿势是前端先拿到 task_id,再用轮询接口查询状态,状态好了再展示结果。result_expires 要配一个合理值,默认一天后结果自动过期,避免 Redis 里堆一堆没人要的结果。
定时任务与队列分流
定时任务(比如每天凌晨清理缓存、每周生成报表)用 Celery beat 调度。beat 是一个独立的调度进程,到点把任务推进队列。
from celery.schedules import crontab
app.conf.beat_schedule = {
"cleanup-every-midnight": {
"task": "tasks.cleanup_expired",
"schedule": crontab(hour=0, minute=30),
},
"report-every-monday": {
"task": "tasks.weekly_report",
"schedule": crontab(day_of_week="mon", hour=9),
},
}
启动命令是 celery -A tasks beat。注意 beat 和 worker 是两个进程,要分别启动,也可以用 beat 内嵌到 worker 的方式减少运维。
任务多了以后,可以把任务按类型分到不同队列,用不同优先级的 Worker 处理。比如发邮件的任务慢但不紧急,放 low 队列;图片转码任务吃 CPU,单独一个队列限并发。
app.conf.task_routes = {
"tasks.send_welcome_email": {"queue": "email"},
"tasks.transcode_video": {"queue": "heavy"},
}
# worker 按队列消费
# celery -A tasks worker -Q email --loglevel=info
任务重试与常见坑
| 坑 | 后果 | 应对 |
|---|---|---|
| 第三方接口偶发失败 | 任务失败丢数据 | bind=True 用 self.retry 重试 |
| 任务幂等没设计 | 重试产生重复数据 | 任务里做幂等校验 |
| 大任务没有超时 | Worker 被拖死 | 配 task_time_limit |
| 代码改了 Worker 没重启 | 跑的是旧代码 | 发布后记得重启 Worker |
@app.task(bind=True, max_retries=3, default_retry_delay=30)
def call_third_party(self):
try:
api_call()
except ConnectionError as exc:
raise self.retry(exc=exc)
@app.task(time_limit=600, soft_time_limit=540)
def heavy_job():
# 超过 540 秒软超时,600 秒硬超时
pass
任务必须可重试,且重试后不会产生脏数据。
Celery 实战清单
- 耗时且不需要同步结果的操作才进队列。
- Broker 用 Redis 起步,量大再换 RabbitMQ。
- 结果存储设 result_expires,避免堆积。
- 定时任务用 beat,注意时区配置。
- 任务必须幂等,配好重试与超时。
队列用好了,接口快一倍,系统稳一倍。