异步任务与 Celery:处理耗时的后台任务
📂 所属阶段:第五阶段 — 高级进阶(性能与架构)
🔗 相关章节:Redis 集成 · RESTful API 开发
0. 开头:从道满博客的上传卡顿说起
上周写后台图片上传逻辑时踩了个小坑:
测试用一张 iPhone 直出的 12MB HEIC 转成 PNG 后提交,前端足足转了 3 秒多圈才拿到保存链接,差点以为接口挂了。查日志才发现,压缩处理 是整个流程里的「时间刺客」—— PIL 处理这张图要近 2.8 秒。
如果把所有耗时操作(压缩、邮件、批量导出、第三方 API 拉取)都堆在 Web 请求里,用户会看到无数个「加载中」,服务器也会因为阻塞的请求慢慢耗尽资源。
这时候就该请 异步任务队列 Celery 出场了。
1. 后台任务的核心价值
一句话总结:把「用户必须等结果」的同步流程,拆成「先返回操作确认,后台偷偷干活」的异步流程。
比如刚才的图片上传,改造后的流程是这样的:
flowchart LR
A[用户上传原图] --> B{Web服务器接收}
B --> C[保存临时原图]
C --> D[提交「压缩任务」到消息队列]
D --> E[立即返回「任务ID + 处理中状态」给用户]
F[Celery Worker 监听队列] --> G[拿到任务ID执行压缩]
G --> H[删除临时图/更新数据库]
E --> I[前端用任务ID轮询状态接口]
H --> J[轮询接口返回「完成状态 + 压缩后链接」]
对比改造前,核心体验提升是从「阻塞等待3秒」变成「100ms内收到提交反馈」;对服务器来说,Web 进程也能快速释放去接下一个请求。
2. 基础环境搭建
我们选择 Redis 作为 消息中间件(Broker) 和 结果存储(Backend),原因很简单:大多数项目已经用 Redis 做缓存了,不需要额外引入 RabbitMQ/Kafka 增加复杂度。
2.1 安装依赖
假设你已经有了 Python 虚拟环境,直接执行:
pip install celery redis pillow flask-mail # 额外加了本次示例用到的PIL和Flask-Mail
2.2 最小化 Celery 配置
新建 app/celery_app.py,把 Celery 实例单独抽出来(避免循环导入,生产环境也建议这样做):
# app/celery_app.py
from celery import Celery
# 1. 初始化 Celery 核心实例
celery_app = Celery(
main="daoman", # 项目名,用于日志/任务命名空间
broker="redis://localhost:6379/0", # Broker:存待处理的任务
backend="redis://localhost:6379/1", # Backend:存任务执行结果/状态
)
# 2. 补充通用配置(生产环境建议用单独的config类加载)
celery_app.conf.update(
# 序列化格式:用 JSON 兼容性最好,但不能传函数/类
task_serializer="json",
result_serializer="json",
accept_content=["json"],
# 时区设置:和项目时区保持一致
timezone="Asia/Shanghai",
enable_utc=False,
# 任务追踪:开启后能看到任务是否「已开始」
task_track_started=True,
# 超时保护:避免某个任务卡死Worker
task_time_limit=300, # 硬超时:5分钟后强制杀掉Worker进程
task_soft_time_limit=270, # 软超时:4.5分钟后抛出异常,可捕获处理
)
3. 定义可复用的后台任务
Celery 任务本质是加了装饰器的普通函数,我们可以把常见的耗时操作封装成独立模块。
3.1 图片压缩任务
新建 app/tasks/image_tasks.py,加一些容错处理(比如捕获打不开的图片、删除失败的提示)会更实用:
# app/tasks/image_tasks.py
import os
from PIL import Image
from PIL.UnidentifiedImageError import UnidentifiedImageError
from app.celery_app import celery_app
@celery_app.task(bind=True, name="tasks.compress_image") # bind=True 可以获取 self(当前任务实例)
def compress_image(
self,
temp_path: str,
output_path: str,
quality: int = 85,
max_width: int = 1920,
max_height: int = 1080,
):
"""后台压缩图片:统一转JPG、限制尺寸、删除临时图"""
try:
# 1. 读取并转换格式
with Image.open(temp_path) as img:
# 只保留RGB(丢弃Alpha通道,适配JPG)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
# 等比例缩放(不超过最大宽高)
img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
# 保存压缩后的图片
img.save(output_path, "JPEG", quality=quality, optimize=True)
# 2. 压缩成功后删除临时原图
if os.path.exists(temp_path):
os.remove(temp_path)
# 3. 返回结果(会存到Backend)
return {"status": "success", "path": output_path}
except UnidentifiedImageError as e:
# 捕获非图片格式的错误
self.retry(exc=e, countdown=5, max_retries=2) # 5秒后重试,最多2次
except Exception as e:
# 捕获其他错误(比如磁盘满、权限不足)
return {"status": "error", "msg": str(e)}
3.2 欢迎邮件任务
新建 app/tasks/mail_tasks.py,注意邮件实例要从扩展里导入:
# app/tasks/mail_tasks.py
from flask import current_app # 生产环境要确保Flask应用上下文可用!
from flask_mail import Message
from app.celery_app import celery_app
from app.extensions import mail
@celery_app.task(name="tasks.send_welcome_email")
def send_welcome_email(user_email: str, username: str):
"""后台发送注册欢迎邮件(带HTML更好看,但这里简化)"""
try:
# 生产环境需要手动激活Flask应用上下文,因为Worker和Web进程是分离的
with current_app.app_context():
msg = Message(
subject=f"🎉 欢迎来到{current_app.config['SITE_NAME']}!",
recipients=[user_email],
body=f"亲爱的 {username},\n\n感谢注册道满博客!\n\n你可以开始发布文章、评论互动了~\n\n道满博客团队",
)
mail.send(msg)
return {"status": "success", "to": user_email}
except Exception as e:
return {"status": "error", "msg": str(e)}
4. 在 Web 路由里调用任务
调用 Celery 任务不能用 function(),必须用 function.delay() 或 function.apply_async():
delay():简化版,只支持传位置参数和关键字参数
apply_async():完整版,支持设置倒计时、重试策略、队列等
4.1 提交压缩任务
假设你有一个上传蓝图 app/routes/upload.py:
# app/routes/upload.py
import os
from flask import Blueprint, request, jsonify, current_app
from flask_login import login_required, current_user
from app.tasks.image_tasks import compress_image
upload_bp = Blueprint("upload", __name__, url_prefix="/api/upload")
@upload_bp.route("/avatar", methods=["POST"])
@login_required
def upload_avatar():
# 1. 接收并保存临时原图
file = request.files.get("avatar")
if not file or not file.filename:
return jsonify({"code": 400, "msg": "请上传头像"}), 400
# 生成临时文件名(用UUID更安全)
import uuid
temp_name = f"temp_{current_user.id}_{uuid.uuid4().hex}{os.path.splitext(file.filename)[0]}.png"
temp_path = os.path.join(current_app.config["TEMP_UPLOAD_FOLDER"], temp_name)
file.save(temp_path)
# 2. 生成压缩后的文件名
compressed_name = f"avatar_{current_user.id}_{uuid.uuid4().hex}.jpg"
compressed_path = os.path.join(current_app.config["AVATAR_UPLOAD_FOLDER"], compressed_name)
# 3. 提交后台压缩任务(用delay简化调用)
task = compress_image.delay(
temp_path=temp_path,
output_path=compressed_path,
quality=90, # 头像质量可以稍高
max_width=256,
max_height=256,
)
# 4. 立即返回任务ID和临时状态
return jsonify({
"code": 200,
"msg": "头像上传中,请稍后刷新",
"data": {"task_id": task.id, "state": task.state},
})
4.2 查询任务状态
前端可以拿到任务ID后,每 200ms-1s 轮询一次这个接口:
# app/routes/task.py
from flask import Blueprint, jsonify
from app.celery_app import celery_app
task_bp = Blueprint("task", __name__, url_prefix="/api/task")
@task_bp.route("/<task_id>", methods=["GET"])
def get_task_status(task_id):
# 用AsyncResult获取任意任务的状态(不管是哪个模块的)
task = celery_app.AsyncResult(task_id)
response = {
"code": 200,
"data": {
"task_id": task.id,
"state": task.state, # PENDING/STARTED/SUCCESS/FAILURE/RETRY
},
}
# 只有任务完成(SUCCESS/FAILURE)才返回结果
if task.ready():
response["data"]["result"] = task.result
return jsonify(response)
5. 启动 Celery 相关服务
注意:Worker 和 Web 进程必须分开启动,不能在同一个终端里。
5.1 开发环境启动 Worker
# Windows 用户注意:Celery 5.x 官方不支持 Windows,建议用 WSL2 或 Docker
# 如果非要在 Windows 试,可以降级到 Celery 4.4.7 + gevent
celery -A app.celery_app worker --loglevel=info --concurrency=2
-A:指定 Celery 实例的位置(module:instance 格式,默认找 module 里的 celery_app)
--loglevel=info:输出详细日志(方便调试)
--concurrency=2:启动 2 个 Worker 进程(根据CPU核数调整,一般是核数的1-2倍)
5.2 生产环境启动 Worker
生产环境不能直接用终端启动,建议用 systemd(Linux)或 Docker 守护进程化。
这里给一个简单的 Docker Compose 片段(如果没用 Docker,参考官方文档配置 systemd):
# docker-compose.yml
version: '3.8'
services:
# 你的 Web 服务和 Redis 服务...
celery-worker:
build: .
command: celery -A app.celery_app worker --loglevel=warning --concurrency=4
volumes:
- ./uploads:/app/uploads # 挂载上传文件夹
depends_on:
- redis
environment:
- FLASK_ENV=production
6. 快速回顾 Celery 核心流程
一张极简的清单图(文字版)就能记住:
Celery 极简流程
1. 准备环境:安装 celery + redis
2. 抽离配置:单独建 celery_app.py
3. 封装任务:用 @celery_app.task 装饰普通函数
4. 提交任务:用 task.delay() / apply_async()
5. 启动监听:终端/守护进程启动 Worker
6. 查询结果:用 AsyncResult(task_id)
7. 适用场景避坑
✅ 强烈建议后台化的场景:
- 图片/视频/音频等媒体处理
- 批量数据导出(Excel/PDF)
- 邮件/SMS/推送通知
- 耗时超过 1s 的第三方 API 调用
- 后台数据统计/清洗
❌ 不要用后台任务的场景:
- 必须立即返回结果的操作(比如登录验证、简单查询)
- 逻辑非常简单、耗时 < 50ms 的操作(任务提交到 Redis 也有开销)
🔗 扩展阅读