任务调度引擎:Celery架构与部署

说到自动化运维,任务调度是绕不开的核心环节。你想想看,半夜三点爬起来执行脚本?那还不如让系统自己干活。Celery 就是干这个的——一个分布式任务队列,说白了就是「把任务扔进去,让工人排队干活」。

我个人习惯把 Celery 比作一个快递分拣中心。你(生产者)把包裹(任务)扔进传送带(消息队列),分拣员(Worker)按地址(路由)分发,快递车(结果后端)把签收单(结果)送回。嗯,这个比喻虽然糙,但道理不糙。

Celery 的核心架构

先看一张图,这是我画的核心架构图。别嫌丑,逻辑是清楚的。

生产者 (你的代码) 消息队列 (Redis/RabbitMQ) Worker 1 Worker 2 Worker N 结果后端 发送任务 Celery 核心架构图

这张图里,生产者就是你的 Django 或 Flask 应用。消息队列我用 Redis 比较多,轻量、快。Worker 是真正干活的进程,可以开多个。结果后端存返回值,方便你查任务跑完了没。

核心组件一览:

  • 生产者:发起任务的应用代码
  • 消息队列:任务暂存区,推荐 Redis 或 RabbitMQ
  • Worker:执行任务的进程,可水平扩展
  • 结果后端:存储任务执行结果,可选 Redis、数据库等

部署实战:从零跑通 Celery

我记得第一次部署 Celery 时踩了个坑——忘记启动 Worker,结果任务全堆在队列里,我还纳闷怎么没反应。后来养成了习惯:先看 Worker 日志,再谈其他。

下面是最简部署步骤。假设你用 Redis 做消息队列。

# 1. 安装依赖
pip install celery redis

# 2. 创建 tasks.py
from celery import Celery

app = Celery('tasks', broker='redis://localhost:6379/0',
             backend='redis://localhost:6379/0')

@app.task
def add(x, y):
    return x + y

# 3. 启动 Worker(终端执行)
celery -A tasks worker --loglevel=info

# 4. 调用任务(Python 交互环境)
from tasks import add
result = add.delay(4, 5)
print(result.get())  # 输出 9

嗯,这里要注意:delay() 是异步调用,get() 会阻塞等待结果。生产环境别滥用 get(),容易把性能拖垮。

我的小技巧: 启动 Worker 时加上 --concurrency=4 参数,可以控制并发数。我一般根据 CPU 核数来设,比如 4 核机器就设 4。设太多反而上下文切换开销大。

定时任务与周期任务配置

运维里最常用的就是定时任务。比如每天凌晨备份数据库、每小时清理日志。Celery 的 Beat 组件就是干这个的。

Beat 像一个闹钟,到点就自动把任务塞进队列。Worker 只管闷头干活,不用关心时间。

配置周期任务

有两种方式:硬编码在代码里,或者动态从数据库读取。我建议小项目用硬编码,大项目用数据库动态配置。

# 方式一:硬编码(适合小项目)
from celery import Celery
from celery.schedules import crontab

app = Celery('tasks', broker='redis://localhost:6379/0')

app.conf.beat_schedule = {
    'backup-db-every-day': {
        'task': 'tasks.backup_database',
        'schedule': crontab(hour=3, minute=0),  # 每天凌晨3点
        'args': (),
    },
    'clean-logs-every-hour': {
        'task': 'tasks.clean_logs',
        'schedule': 3600.0,  # 每3600秒执行一次
        'args': (),
    },
}

@app.task
def backup_database():
    print("备份数据库...")

@app.task
def clean_logs():
    print("清理日志...")

启动 Beat 组件:

celery -A tasks beat --loglevel=info

注意,Beat 和 Worker 是两个独立进程。我习惯用 Supervisor 同时管理它们,省心。

我曾经踩过的坑: Beat 进程挂了,任务就不触发了。但 Worker 还在傻等。所以一定要给 Beat 加监控告警。我后来用 systemd 做了自动重启,才睡上安稳觉。

crontab 表达式详解

Celery 的 crontab 和 Linux 的 crontab 很像,但更灵活。看这个表格:

表达式 含义 示例
crontab() 每分钟执行 每分钟
crontab(minute=0, hour=0) 每天午夜 00:00
crontab(minute=0, hour='*/2') 每2小时 0点、2点、4点...
crontab(minute=30, hour=6, day_of_week=1) 每周一6:30 周一 06:30
crontab(minute='*/15') 每15分钟 0、15、30、45分

我个人习惯用 crontab 而不是 timedelta,因为 crontab 更精确。比如「每天凌晨3点」用 timedelta 很难表达,但 crontab 一行搞定。

任务链与错误重试机制

实际运维中,任务往往不是孤立的。比如:先下载文件,再解压,最后入库。这就需要用任务链把它们串起来。

任务链(Chain)

Celery 提供了 chain 方法,让任务按顺序执行。前一个任务的返回值,自动传给下一个任务。

from celery import chain

@app.task
def download_file(url):
    print(f"下载 {url}")
    return "/tmp/file.zip"

@app.task
def unzip_file(filepath):
    print(f"解压 {filepath}")
    return "/tmp/extracted"

@app.task
def import_data(dir_path):
    print(f"导入 {dir_path}")
    return "success"

# 任务链:下载 -> 解压 -> 导入
result = chain(
    download_file.s("http://example.com/data.zip"),
    unzip_file.s(),
    import_data.s()
)()

这里 .s() 是签名方法,相当于给任务预填参数。链式调用时,前一个任务的返回值会自动填充到后一个任务的第一个参数位置。

任务链的几种变体:

  • Chain:顺序执行,一个接一个
  • Group:并行执行,所有任务同时跑
  • Chord:先并行,再汇总(类似 map-reduce)
  • Canvas:以上三者的组合,灵活度最高

错误重试机制

任务总有失败的时候。网络超时、资源不足、数据异常……我见过最离谱的是磁盘满了,任务写日志都写不进去。

Celery 内置了重试机制,用 retry 参数控制。

@app.task(bind=True, max_retries=3, default_retry_delay=60)
def send_alert(self, message):
    try:
        # 模拟发送告警
        print(f"发送告警: {message}")
        # 假设这里抛异常
        raise ConnectionError("网络不通")
    except Exception as exc:
        # 重试,指数退避
        countdown = 2 ** self.request.retries * 10
        raise self.retry(exc=exc, countdown=countdown)

关键参数说明:

参数 含义 建议值
max_retries 最大重试次数 3-5 次
default_retry_delay 默认重试间隔(秒) 60-300
countdown 自定义重试间隔 指数退避:2^retries * 10
autoretry_for 自动重试的异常类型 (ConnectionError, TimeoutError)

我的经验: 重试一定要加指数退避。第一次等10秒,第二次等20秒,第三次等40秒。别一窝蜂重试,那叫「雪崩式自杀」。我曾经见过一个项目重试间隔设了1秒,结果把数据库打挂了。

避坑指南:重试的陷阱

说几个我踩过的坑:

  • 幂等性:重试的任务必须是幂等的。比如「扣钱」任务重试两次,用户就被扣了两次钱。我一般用唯一ID去重。
  • 死信队列:重试超过最大次数后,任务会进入死信队列。记得监控死信队列,否则失败任务就石沉大海了。
  • 任务超时:设置 soft_time_limittime_limit,防止任务卡死。我习惯设 soft 为 300秒,hard 为 600秒。
@app.task(
    bind=True,
    max_retries=3,
    autoretry_for=(ConnectionError, TimeoutError),
    soft_time_limit=300,
    time_limit=600
)
def robust_task(self):
    # 这个任务会自动重试网络类异常
    pass

嗯,到这里 Celery 的核心用法基本讲完了。任务调度这东西,上手容易,但想跑得稳,得把重试、监控、幂等这些细节都考虑进去。我每次搭建新系统,都会先画架构图,再写代码——图清楚了,代码就不会乱。