问题1:celery job无法被worker正确接收
线上同一台机器部署了两套服务,分别启动了Celery应用,选取Rabbitmq作为Broker,其中两套服务共用同一个Broker。结果导致:服务B的任务被服务A的Celery received,于是可能会报错:
Received unregistered task of type 'apps.tasks.scheduled_job.calc_post_score'
消息队列
首先了解一下AMQP的几个核心定义:
AMQP,即Advanced Message Queuing Protocol(高级消息队列协议)
参考:celery文档
-
message 消息,由headers和body组成,例如celery中消息格式:
{ 'task': 'myapp.tasks.add', 'id': '54086c5e-6193-4575-8308-dbab76798756', 'args': [4, 4], 'kwargs': {} } -
producer 生产者,发送信息的客户端通常称为发布者或生产者
-
consumer 消费者,接收消息的实体
-
broker 消息中间件,负责将信息从生产者路由到消费者,可以是redis或rabbitmq(官方推荐)
-
channel 信道,建立在connection连接之上
-
exchanges 交换机,负责接收生产者发送的消息,并将其路由到相应的队列。支持3种类型:
Direct、Topic、Fanout,不是所有传输都支持这3种 -
queues 队列,存储消息的地方,消费者会从队列中获取消息
-
routing keys 路由键,exchange会根据路由键将消息路由到对应的队列
主要 Exchange 类型
1. Direct Exchange (直接交换)
- 路由机制:完全匹配 routing key
- 特点:
- 消息会被发送到 binding key 与 routing key 完全匹配的队列
- 一对一的消息传递
- 默认的 Exchange 类型
- 使用场景:点对点精确消息传递
- 示例:
task_default_exchange = 'direct_exchange'
2. Topic Exchange (主题交换)
- 路由机制:模式匹配 routing key
- 特点:
- 使用通配符匹配 routing key (
*匹配一个单词,#匹配零个或多个单词) - 支持灵活的多对多消息传递
- 使用通配符匹配 routing key (
- 使用场景:基于模式的消息路由,复杂的发布/订阅模式
- 示例:
routing_key='stock.us.nyse.#'可以匹配stock.us.nyse.ibm等
3. Fanout Exchange (扇出交换)
- 路由机制:不处理 routing key
- 特点:
- 将消息广播到所有绑定的队列
- 忽略 routing key
- 性能最高
- 使用场景:广播消息,发布/订阅模式
- 示例:实时通知所有订阅者
4. Headers Exchange (头交换)
- 路由机制:基于消息头属性匹配
- 特点:
- 不依赖 routing key,而是匹配 headers 属性
- 可以使用
x-match参数指定 all(全部匹配)或 any(任一匹配)
- 使用场景:基于消息属性而非 routing key 的路由
- 示例:根据消息的元数据进行路由决策
区别对比
| 特性 | Direct Exchange | Topic Exchange | Fanout Exchange | Headers Exchange |
|---|---|---|---|---|
| 路由依据 | 精确 routing key | 模式匹配 routing key | 无 | 消息头属性 |
| 通配符支持 | 否 | 是 (*, #) | 否 | 否 |
| 性能 | 高 | 中 | 最高 | 低 |
| 典型应用 | 点对点消息 | 灵活路由 | 广播 | 基于属性路由 |
| 是否默认 | 是 | 否 | 否 | 否 |
在 Celery 中,可以通过以下配置设置 Exchange:
app.conf.task_queues = (
Queue('default', Exchange('default', type='direct'), routing_key='default'),
Queue('video', Exchange('media', type='topic'), routing_key='media.video.#'),
)
app.conf.task_default_exchange = 'default'
app.conf.task_default_exchange_type = 'direct'
app.conf.task_default_routing_key = 'default'
实际应用建议
- 默认情况:使用 direct exchange 满足大多数基本任务需求
- 复杂路由:需要灵活路由时使用 topic exchange
- 广播通知:使用 fanout exchange 向多个消费者发送相同消息
- 高级路由:基于消息属性而非 routing key 时使用 headers exchange
正确选择 Exchange 类型可以显著提高消息系统的效率和灵活性。
任务发送接收流程
AMQP通信流程
---
config:
theme: forest
themeVariables:
primaryColor: "#ffa600"
---
graph LR
subgraph Producer
A[Producer1]
B[Producer2]
C[Producer3]
end
subgraph Channel
CH1[Channel1]
CH2[Channel2]
CH3[Channel3]
end
subgraph Exchange
E1[Exchange1]
E2[Exchange2]
end
subgraph Queue
Q1[Queue1]
Q2[Queue2]
Q3[Queue3]
end
subgraph Consumer
X[Consumer1]
Y[Consumer2]
Z[Consumer3]
end
A -->|send msg| CH1
B -->|send msg| CH2
C -->|send msg| CH3
CH1 --> E1
CH2 --> E1
CH3 --> E2
E1 -->|routingkey1| Q1
E1 -->|routingkey2| Q2
E2 -->|routingkey3| Q3
Q1 -->|pick up| X
Q2 -->|pick up| Y
Q3 -->|pick up| Z
解决方案
问题1原因:
exchange无法将job路由到对应的队列,或者说因为没有指定队列,导致队列中的job共享而无法找到。
解决方法就是初始化时指定队列名。
以下展示了Flask中如何配置队列和交换机
通过工厂函数初始化celery app,配置了queue之后,任务函数也必须指定一个队列名,或者设置一个task_default_queue默认队列,就不用在每个任务函数装饰器中使用 queue 参数指定队列了
# 配置queues
celery.conf.task_queues = (
Queue(
"game_community_default",
Exchange("game_community_default"),
routing_key="game_community_default",
),
)
celery.conf.task_default_queue = "game_community_default"
celery.set_default()
celery.autodiscover_tasks(["apps.tasks"])
使用celery的注意点
task_acks_late
设置为True表示任务在执行完成之后才确认,如果Worker进程突然退出或被kill掉也会立即确认;如果为False表示执行之前就确认(默认值)
task_reject_on_worker_lost
设为False, worker进程崩掉之后将重新加入worker,默认值False,可能会造成循环执行
task_always_eager 设为True可用于本地同步调试,而不经过broker
周期任务无法被正确识别?
使用include指定任务模块(和import语义相同)
使用beat_schedule参数指定周期任务字典配置,比如:
"""手动导入任务,否则可能无法识别"""
INCLUDES = "apps.tasks.scheduled_job"
BEAT_SCHEDULES = {
# 更新文章热度值
"calc_post_score": {
"task": "apps.tasks.scheduled_job.calc_post_score",
"schedule": crontab("0", "1,7,13,19"),
"args": (),
},
# 更新评论热度值
"calc_comment_score": {
"task": "apps.tasks.scheduled_job.calc_comment_score",
"schedule": crontab("30", "1,7,13,19"),
"args": (),
},
}
celery如何访问Flask App上下文
def init_app(app: Flask, config) -> Celery:
class FlaskTask(Task):
def __call__(self, *args: object, **kwargs: object) -> object:
with app.app_context():
return self.run(*args, **kwargs)
celery = Celery(app.name, task_cls=FlaskTask)
celery.task_cls = FlaskTask
celery.config_from_object(config)
# 配置queues
celery.conf.task_queues = (
Queue(
"game_community_default",
Exchange("game_community_default"),
routing_key="game_community_default",
),
)
celery.conf.task_default_queue = "game_community_default"
celery.set_default()
celery.autodiscover_tasks(["apps.tasks"])
app.extensions["celery"] = celery
return celery
另外,使用 @celery_app.task 来装饰任务函数需要访问 celery_app 对象,而使用工厂模式则无法访问 celery_app对象,用@shared_task来替代。
调用 celery_app.set_default() 使得@shared_task 装饰器可以访问任何current app,这与 Flask 的蓝图和应用程序上下文概念类似。
Task states
| 状态 | 说明 |
|---|---|
| PENDING | 任务已被添加到任务队列中,等待执行 |
| STARTED | 任务已开始执行 |
| RETRY | 任务已失败并且正在尝试重新执行 |
| FAILURE | 任务执行失败 |
| SUCCESS | 任务执行成功 |
| REVOKED | 任务已被撤销 |
自定义状态
@app.task(bind=True)
def upload_files(self, filenames):
for i, file in enumerate(filenames):
if not self.request.called_directly:
self.update_state(state='PROGRESS',
meta={'current': i, 'total': len(filenames)})
问题2:Apscheduler周期任务重复执行
多进程环境下,比如Gunicorn多个worker部署,Apscheduler周期任务会重复执行多次
这是Apscheduler3的一个issue,不知道后续版本会不会优化
解决方案
- 尝试过设置gunicorn worker数=1,无法解决
- redis分布式锁,执行之前尝试获取锁,成功才执行,注意最后无论执行是否成功都要释放锁
问题3: Apscheduler定时任务不执行
Apscheduler实例和celery都是跟随Flask应用同时启动的,线上通过docker部署将会有3个容器:flask主应用、celery worker、celery beat,定时任务就会被celery worker容器中的apscheduler消费掉,导致主应用中找不到对应的jobid
解决方案
启动celery时不启动apscheduler
使用Apscheduler的注意事项
- 多进程启动scheduler要加锁,否则可能重复执行,并且尽量保证业务上的操作幂等
- 加锁后仍会报错:apscheduler.jobstores.base.JobLookupError: 'No job by the id of aps_job:pub_bp:218 was found',是因为多个进程同时处理同一个job,某个进程拿到锁执行完后会删除job,其他进程仍会自动执行remove_job操作,而job_id被主进程已经消费了,所以会出现其他进程找不到job_id的情况
- 配置参数
# 启用api能力 SCHEDULER_API_ENABLED = True # 任务存储,建议持久化 SCHEDULER_JOBSTORES = {"default": SQLAlchemyJobStore(url=SQLALCHEMY_BINDS["main"])} SCHEDULER_EXECUTORS = { "default": ThreadPoolExecutor(20), "processpool": ProcessPoolExecutor(10), } SCHEDULER_JOB_DEFAULTS = { "coalesce": False, # 设为True,当一个job累积多次未执行,只会执行最后一次 "replace_existing": True, # 当job持久化存储时,必须设为true,否则系统重启时会创建新的副本 "max_instances": 20, # 每个job的并发数 "misfire_grace_time": 600 # 如果因宕机等其他原因造成job未执行,600秒内满足执行条件时将恢复执行 }
扩展
如何设计一个Scheduled Job
cron表达式
| 属性 | 英文名 | 值 |
|---|---|---|
| 分 | minute | 0-59 |
| 时 | hour | 0-23 |
| 日 | day_of_month | 1-31 |
| 月 | month_of_year | 1-12 |
| 周 | day_of_week | 0-6 |
| 开始时间(可选) | start_time | |
| 截止时间(可选) | end_time |
周期任务表 periodic_task
| column | type | description |
|---|---|---|
| name | str | 任务名 |
| description | str | 备注 |
| args | json / jsonb | 任务函数参数 |
| last_run_time | datetime | 最后一次运行时间 |
| trigger | str | contab触发参数 |
| status | int | 执行状态 |
| enabled | int | 任务启用 / 关闭 |
| job_id | str | 任务唯一id |
| result | str / json | 执行结果 |
| log_path | str | 日志路径 |