Celery 任务从 Producer 到 Broker 的完整链路

你写 task.delay("hello", 42),然后呢?
这一行调用背后,消息要穿过 6 层抽象、3 次序列化,最终变成 Redis LIST 里的一个 JSON blob。


绝大多数 Celery 用户都会写 task.delay(...),但很少有人真正追过它从 Python 函数调用到 broker 存储介质之间的每一层抽象。这篇文章要做这件事——从源码层面,跟踪一条任务消息从你按下回车的那一刻,到它躺在 Redis LIST 里的那一刻。

我们用的版本是 Celery 5.6.2 和 Kombu 5.6.2(这两个版本在 2026 年初是稳定版,celery/kombu 仓库的 main 分支也基本兼容)。整条链路可以分为六个角色,对应下面这张总览图。

读这张图可以从左到右:

  • 用户代码 一句 task.delay(...) 是旅程的起点。
  • Celery 任务层 在 celery/app/task.py 里,把"用户调用"变成"Celery 内部协议"。
  • AMQP 适配层 在 celery/app/amqp.py 里,把 Celery 的字段映射成 Kombu 能识别的消息格式。
  • Kombu 消息生产者 在 kombu/messaging.py 里,序列化 body 并解决路由。
  • Kombu 传输通道 在 kombu/transport/virtual/base.py 里,把消息组装成 transport 无关的内部字典。
  • Redis 传输与 Broker 在 kombu/transport/redis.py 里,最终 LPUSH 到 Redis。

最值得高亮的是第三格 AMQP 适配层——它是整个链路里"翻译"最重的一段,Celery 的字段模型到 Kombu 的消息模型之间的转换就发生在这里。这也是为什么我把它画成焦橙色(accent)。

接下来的每一节,我们拆开其中一层看。


第一站:Celery 应用层

从 task.delay("hello", 42) 开始追。在 celery/app/task.py 的第 433 行,Task.delay 其实只有一行有效内容:

def delay(self, *args, **kwargs):
    return self.apply_async(args, kwargs)

它把 args/kwargs 原样塞给 apply_async。真正的逻辑从 apply_async 开始——它要处理 countdown、eta、expires、queue、priority 这些选项,要生成 UUID 作为 task_id,要把 root_id 和 parent_id 填上(嵌套任务时用),最后通过 app.send_task(...) 把所有东西转交给 Celery 应用对象。

我们用时序图看一下四个对象之间的调用:

四个泳道:用户代码、Task 实例、Celery 应用、AMQP 适配器。时间从上到下流动。注意第五根箭头是 AMQP 适配器反过来调 send_task_message,这是把消息主体构造完成后再回退一步;调用并没有走远,只是把活儿做完。

读这张图的最关键信息:所有"准备任务元数据"的活儿都在 Celery 应用层做完了,到了 AMQP 适配层手上时,已经是结构化好的字段。


第二站:AMQP 适配层——把字段组装成消息

AMQP 适配层是 Celery 自己的代码,但功能是"桥接"——把 Celery 的字段映射到 Kombu 的消息格式。这一层最关键的方法是 as_task_v2(位于 celery/app/amqp.py 约 320 行)。

三个阶段:

  1. 时间归一化。countdown 转成 ISO 格式的 eta;expires 转成 ISO 字符串或秒数。
  2. 可读化。args/kwargs 被 saferepr 处理成 argsrepr/kwargsrepr——这一步是为了在 worker 端 logging 时能展示出来,不是用于执行的。
  3. 装入三个槽。最终输出一个四元组 (headers, properties, body, sent_event),其中 headers 和 properties 是 dict,body 是一个特殊格式的 tuple。

body 字段的结构是 Celery v2 protocol 规定的:

body = (
    args,                 # (2, 3)
    kwargs,               # {}
    {                     # options (callback/errback 链)
        'callbacks': None,
        'errbacks': None,
        'chain': None,
        'chord': None,
    }
)

这是后面 Kombu 端会反复看到的形状——三元素 tuple,第一个是 args,第二个是 kwargs,第三个是嵌入的 options。

headers 里有一组有趣的字段——lang: "py"、task: "myapp.tasks.add"、id: <UUID>、shadow、eta、expires、group、group_index、retries、timelimit、root_id、parent_id、argsrepr、kwargsrepr、origin、ignore_result、replaced_task_nesting、stamped_headers、stamps。worker 端就是用这些字段来还原任务上下文、决定是否重试、构造日志输出的。

properties 字段相对简单:correlation_id(一般等于 task_id)、reply_to(一个 UUID,用作 result backend 的 reply queue 标识)、后面 transport 层会再往里塞 delivery_mode、delivery_info、delivery_tag、body_encoding。

这一层的输出就是一个结构完整的元组,可以被任何 transport 直接序列化发送。


第三站:Kombu Producer 层——消息元组变成可投递的字典

接下来控制权交给 Kombu。AMQP.send_task_message 最后会调 producer.publish(body, ...)。这一步位于 kombu/messaging.py 的第 122 行 Producer.publish。

整个 publish 分三个阶段:

  1. 解析路由。_delivery_details() 把 exchange 和 delivery_mode 解析出来:默认 exchange=''(AMQP 的 default exchange,消息直接走 routing_key 命名的 queue)、delivery_mode=2(persistent,写磁盘)。
  2. 序列化 body(核心)。_prepare() 把 body 这个 Python tuple 序列化为 JSON 字节。这是 Kombu 默认的 serializer='json'。Celery 在调用 publish 时把 serializer 传过来了。
  3. 委托 transport。_publish() 在 channel.prepare_message() 里把字节、headers、properties 打包成一个 dict,然后调 channel.basic_publish()。

这张图下半部分有个 callout 框:为什么要 base64?答案是——Kombu 的 Redis transport 在 Channel._put 里要把整个 message dict 再做一次 JSON 序列化塞进 Redis。dict 里 body 字段必须是 ASCII 字符串才能塞进 JSON,所以 Kombu 在 base class 里加了一道 base64 编码:

(2, 3)                                       ← Python tuple
   ↓ json.dumps().encode("utf-8")
b'[2, 3]'                                    ← JSON 字节
   ↓ base64.b64encode()
'WzIsIDNd'                                   ← ASCII 字符串

编码方式会被记在 properties['body_encoding']='base64' 里,worker 端按这个标记做反向解码。

这一层的输出是一个 dict,结构是 {body, content-type, content-encoding, headers, properties}——transport 无关的"内部消息结构"。


第四站:Kombu Transport 层——从 dict 到 Redis LPUSH

上一站输出的 dict 进入 transport 层。这一层有两段:基类在 kombu/transport/virtual/base.py(所有 transport 共享),具体实现在 kombu/transport/redis.py(Redis transport)。

三步加工:

  1. prepare_message(基类)组装最终消息字典。{body, content-type, content-encoding, headers, properties}——这是 Kombu 给所有 transport 准备的统一结构。
  2. basic_publish(基类)走 _inplace_augment_message:加上 delivery_tag(UUID,每条消息唯一)、body_encoding="base64"、delivery_info={exchange, routing_key}。然后做路由决策——有 exchange 走对应类型的 deliver 方法,没有 exchange 走 _put(routing_key, message)。
  3. Channel._put(Redis 实现)双重序列化:client.lpush(_q_for_pri(queue, pri), dumps(message))。dumps 在这里做的是整个 message dict 的 JSON 序列化——把刚才 dict 变成字符串存进 Redis LIST。

_q_for_pri(queue, pri) 是 Redis transport 的一个小巧设计:如果消息带优先级,queue 名会变成 celery\x06\x160(优先级 0)、celery\x06\x169(优先级 9)这种用 \x06\x16 分隔的形态。worker 启动时会按优先级依次消费这些 LIST。

最终 Redis LIST 里的字节流长这样(伪码示意):

b'{"body": "WzIsIDNd",
    "content-type": "application/json",
    "headers": {"task": "myapp.tasks.add", "id": "550e...", ...},
    "properties": {"correlation_id": "550e...", "delivery_info": {"routing_key": "celery"}, ...}}'

这条消息到了 broker 里,旅程结束。

整条链路里 Celery/Kombu 共同做了两次序列化:第一次在 Kombu Producer 层把 body 转 JSON 字节 + base64,第二次在 Redis Transport 层把整个 dict 转 JSON 字符串。


消息解剖:到了 Redis 里到底是什么样

为了把这两次序列化讲清楚,我做了一张完整解剖图。

从底向上看:

  • 最底层:Redis LIST 里的字节流——一个 JSON 序列化后的字符串。
  • 第二层:worker 端 kombu.serialization.loads() 反序列化得到的 dict——{body, content-type, content-encoding, headers, properties}。
  • 第三层:dict 的三个字段。headers 装元数据、properties 装 routing 信息、body 装任务参数。
  • 第四层:body 字段本身是字符串 "WzIsIDNd"(base64 编码)。worker 再 base64 解码,得到 JSON 字符串。
  • 第五层:JSON 反序列化,得到 Python tuple (args, kwargs, options)。
  • 最顶层:tuple 解构出真正的 (2, 3)、{}、{callbacks, errbacks, chain, chord},传给 task 的 run() 方法。

这就是为什么 body 字段看起来"是 base64"——它是为了让整个 dict 能用 JSON 序列化,必须保证所有字段都是 ASCII 字符串。


几个值得记住的设计决策

走完这条链路之后,有几个设计选择值得记住,因为它们直接影响你写代码的方式:

1. 默认 exchange 是空字符串

Celery 默认不创建自定义 exchange,而是用 AMQP 的 default exchange(exchange='')。这意味着所有任务都直接走 routing_key 命名的 queue,根本没有 topic exchange 的灵活路由能力。如果你的项目里有"按业务类型分发到不同 worker"的需求,要显式在 app.conf.task_default_exchange 里配上 exchange,否则路由直接退化成"全部塞进 celery 这一个 queue"。

2. 默认 delivery_mode=2(持久化)

Kombu 默认会把消息标记为 persistent,broker 端(Redis 或 RabbitMQ)理论上应该写到磁盘。但 Redis 的持久化配置跟 broker 没关系是另一回事——Redis 要开 AOF 或者 RDB 才会真正落盘。这意味着Celery 默认配置不等于消息真正持久化,需要 broker 端一起配。

3. body 字段的双重编码是为了 JSON 兼容性

Celery/Kombu 的 Redis transport 整体走 JSON。如果 body 直接是字节(tuple 序列化后的结果),塞不进 JSON 里。base64 把任意字节转成可打印 ASCII 字符串,这是工程妥协,而不是某种精妙设计。代价是 body 字段的体积大约膨胀 33%(base64 的固定开销)。

4. v2 protocol 是 tuple 不是 dict

Celery 默认走 v2 protocol,body 是 (args, kwargs, options) 三元 tuple 而不是 dict。这跟早期 v1 protocol 的 {'args': ..., 'kwargs': ..., 'callbacks': ...} 不兼容。如果你的项目历史比较长,可能会碰到 v1 协议残留,可以检查 app.conf.task_protocol。

5. Kombu 的 Channel 层是 transport 抽象的关键

Kombu 之所以能同时支持 Redis、RabbitMQ、SQS、文件系统作为 broker,靠的就是把"消息组装"和"消息序列化发送"拆成两层。kombu/transport/virtual/base.py 里的 prepare_message 和 basic_publish 是所有 transport 共享的;具体传输逻辑交给 kombu/transport/{redis,amqp,sqs,filesystem}/... 实现。Celery 之所以能用一个 broker URL 切到不同 broker,就是 Kombu 的这一层在起作用。


小结:然后呢?

一条 task.delay(args) 调用,从 Python 函数走到 Redis LIST,一共要穿过:

用户代码 → Celery 任务层 → AMQP 适配层 → Kombu Producer → Kombu Channel → Redis 传输

六层抽象、9 次函数调用、3 次序列化(json.dumps、base64.encode、json.dumps 第二次)、1 次 Redis LPUSH。

理解了这条链路,对调试很有用:

  • 任务 PENDING 但 worker 不消费?大概率在 broker 那头——检查 Redis LIST 长度、看 Kombu 的消息结构。
  • worker 收到但参数错乱?多半在 body 解码环节——检查 properties.body_encoding,或显式传 serializer='json'/'pickle'。
  • 多 worker 抢同一个任务?一定是 routing 问题——检查 task_default_queue、task_routes、worker -Q 参数。

但这篇文章只追了上半段——消息如何到达 broker。下半段同样精彩:worker 是怎么从 LIST 里 BRPOPLPUSH 出来的?怎么变成 Request 对象?怎么决定优先级、ETA、retry?怎么进入 prefork 进程池执行?……那是另一篇文章的事了。


参考

posted @ 2026-09-10 15:28  GreeneGe  阅读(5)  评论(0)    收藏  举报