Celery + Redis 深度解读:从 .delay() 看消息的物理生命周期

一行代码背后,Broker 里到底发生了什么

add.delay(2, 3) 调用之后,Redis 里到底多了哪些 key?worker 还没启动时消息去哪了?AsyncResult(task_id).state 返回的 PENDING 究竟是"在排队"还是"根本不存在"?

这些问题只能从 Celery 和 Kombu 的源码里找答案。本文基于 Celery 5.6.2 与 Kombu 5.6.2,逐层拆解消息从 Producer 进程到 Redis broker 的物理路径,并对几个常"被误用的"状态语义给出源码级别的解读。

一、消息入队后的 Redis 物理结构

调 add.delay(2, 3) 后,未启动 worker 时用 redis-cli keys '*' 查 Redis 实例,会看到恰好两个 key:

celery
_kombu.binding.celery

01 物理结构概览

图 1:Producer 进程的三层调用栈(Celery Task API → Celery→Kombu 桥接 → Kombu Producer)将消息写入 Redis 的 LIST 与 SET 两个数据结构。

1.1 celery — 消息队列本体(LIST)

TYPE celery 返回 list。这是 Kombu 的 Redis transport 把 AMQP queue 一对一映射到 Redis LIST 的实现:消息 LPUSH 到头部,BRPOP 从尾部消费。queue key 默认就是 queue 名本身。

LIST 元素是一条完整的 JSON 字符串,结构如下:

{
  "body": "<base64 编码字符串>",
  "content-encoding": "utf-8",
  "content-type": "application/json",
  "headers": { /* 17 个 Celery 任务元数据字段 */ },
  "properties": { /* AMQP 标准属性 */ }
}

1.2 _kombu.binding.celery — 反向路由表(SET)

TYPE _kombu.binding.celery 返回 set。这个 SET 里的元素看起来怪:

celery\x06\x16\x06\x16celery

按源码里 sep = '\x06\x16' 拆开:

[routing_key, pattern, queue] = ["celery", "", "celery"]

含义是:在 exchange celery 上有一个 binding,把 routing_key 为 celery(无 pattern 匹配)的消息投递到 queue celery。

关键源码见 kombu/transport/redis.py L1053-L1061:

def _queue_bind(self, exchange, routing_key, pattern, queue):
    if self.typeof(exchange).type == 'fanout':
        self._fanout_queues[queue] = (
            exchange, routing_key.replace('#', '*'),
        )
    with self.conn_or_acquire() as client:
        client.sadd(self.keyprefix_queue % (exchange,),
                    self.sep.join([routing_key or '',
                                   pattern or '',
                                   queue or '']))

1.3 binding 是 lazy 创建的

redis.py 的 keyprefix_queue = '_kombu.binding.%s' 在 L632 定义。这个 SET 不是 worker 启动时建立,而是 Producer 第一次发消息时通过 _queue_bind 懒加载。

这对 RabbitMQ 设计是个有趣的对照:RabbitMQ 把所有 binding 集中在 broker 端,producer 和 worker 只管发/收;Redis 模式下 metadata 是分布式的——producer 和 worker 各自声明自己需要的部分,第一次发消息时顺便建路由。

1.4 反向索引的查询路径

Worker 拉消息不会查这个 SET(它直接 BRPOP queue),Producer 发消息时才查:

redis.py L1086-L1094 的 get_table:

def get_table(self, exchange):
    key = self.keyprefix_queue % exchange
    with self.conn_or_acquire() as client:
        values = client.smembers(key)
        return [tuple(bytes_to_str(val).split(self.sep)) for val in values]

SMEMBERS _kombu.binding.celery → [("celery", "", "celery")] → 匹配上 → LPUSH celery <msg>。Redis 没有 b+tree 索引,所以反向索引必须用额外的数据结构手工实现——SET 的 O(1) SADD / SISMEMBER 与天然去重特性是这里的关键。

二、消息信封:Kombu Message 在 Redis 里的样子

JSON 字符串的顶层是 5 个字段。其中 body、content-type、content-encoding、headers、properties 各承担不同的职责。

02 消息信封

图 2:5 个顶层字段的拆解——body 携带载荷,content-* 标记 MIME 与字符集,headers 装 Celery 元数据,properties 装 AMQP 标准属性。

2.1 body:双重编码的载荷

body 字段是 base64 编码字符串。解码后是 Celery 的标准三元组:

[args, kwargs, embed] = [[2, 3], {}, {"callbacks": null, "errbacks": null, "chain": null, "chord": null}]
索引 含义 示例
[0] args [2, 3] ← add(2, 3)
[1] kwargs {}
[2] embed callbacks / errbacks / chain / chord 元数据

为什么是 base64?见 properties.body_encoding = 'base64'。Kombu 把 Python 对象序列化为 JSON 后再 base64,避免 JSON 里的特殊字符(引号、换行)破坏消息边界。Kombu Message 类定义在 kombu/message.py L18-L79。

2.2 headers:Celery 扩展的 17 个元数据

headers 是 Celery 在 AMQP 标准 headers 上额外塞的元数据,按职责可分为 4 类:

  • identity:task、id、root_id、parent_id
  • debug:argsrepr、kwargsrepr(仅用于日志)
  • origin:origin(producer 进程名 + 主机名)、lang(py)
  • scheduling:eta、expires、retries、timelimit

外加 5 个可选/配置字段:group、group_index、shadow、stamped_headers、stamps、ignore_result、replaced_task_nesting。Worker 端按 headers.task 查注册表定位函数;按 headers.id 让 AsyncResult 反查结果。

2.3 properties:AMQP 标准 + Kombu 的扁平化处理

properties 是 AMQP 标准消息属性,包含 6 个字段:

字段 作用
correlation_id 等于 task_id,用于关联请求与响应
reply_to AsyncResult 回调占位(实际只是 UUID)
delivery_mode 2 = 持久化(写磁盘)
delivery_info {exchange: "",, routing_key: "celery"}
priority 0(0-9,0 最低)
body_encoding base64
delivery_tag Redis transport 模拟 AMQP delivery_tag 的 UUID,Worker 用它做 ACK

注意 Kombu 把 delivery_info 合并到 properties.delivery_info 而不是作为顶层字段——这是 Kombu Message → Redis 字符串的扁平化处理。Kombu Message 类本身有 5 个字段(body / content_type / headers / properties / delivery_info),存到 Redis 时被合并到 5 个顶层字段里。

三、路由查询:消息怎么找到自己的 queue

03 路由查询

图 3:Producer 调 publish(msg, exchange="celery", routing_key="celery") 时,Kombu Channel 通过 SMEMBERS 反查 binding SET 拿到投递目标,再 LPUSH 消息到目标 queue。

完整流程:

  1. Producer → Kombu Channel:publish(msg, exchange="celery", routing_key="celery")
  2. Kombu Channel 自调用:_lookup("celery", "celery")
  3. Kombu Channel → Redis:SMEMBERS _kombu.binding.celery
  4. Redis → Kombu Channel:[("celery", "", "celery")]
  5. Kombu Channel:若 binding 不存在 → 首次时调 SADD _kombu.binding.celery ...(懒加载)
  6. Kombu Channel → Redis:LPUSH celery <msg>
  7. Kombu Channel → Producer:返回 AsyncResult(task_id)

第一次发送时多出一步 SADD 写 binding SET,后续发送只走 SMEMBERS 查表。这跟 RabbitMQ 把 binding 集中在 broker 端的设计完全不同——Redis transport 把 metadata 分布到 producer 和 worker 两端。

四、没有合适 Worker 时,消息会怎样?

把"没有合适的 worker"分成 4 种典型场景,每种对消息的去向、Redis LIST 的变化、Backend 的记录都不同。

04 场景对比

图 4:4 个场景的归宿对比——A 与 C 消息永久堆积在 LIST;B 消息被 reject 并写入 Backend FAILURE;D 消息在 broker 等 worker 槽位。

4.2 场景 A:完全没有 Worker 在跑

消息在 LIST celery 里永久残留。

源码 grep EXPIRE / expire_at 在 kombu/transport/redis.py 里没有任何对消息本身设置 TTL 的逻辑。expires 配置项是结果过期时间(Backend 的 TTL),不是 broker 消息的 TTL。LLEN 看一周后还是 1。

4.2 场景 B:Worker 在跑但没注册这个 task 名

消息被静默吞掉,Backend 写一条 NotRegistered 失败记录。

源码在 celery/worker/consumer/consumer.py L596-L625:

def on_unknown_task(self, body, message, exc):
    error(UNKNOWN_TASK_ERROR, ...)
    ...
    message_reject_log_error(logger, self.connection_errors)
    self.app.backend.mark_as_failure(
        id_, NotRegistered(name), request=request,
    )

reject_log_error → reject(requeue=False) → redis.py L390:

def reject(self, delivery_tag, requeue=False):
    if requeue:
        self.restore_by_tag(delivery_tag, leftmost=True)
    else:
        self._remove_from_indices(delivery_tag).execute()

_remove_from_indices 是 pipeline 操作:zrem unacked_index, hdel unacked。消息不回 broker(不回 LIST celery)。Backend 写入 SETEX celery-task-meta-<id> status=FAILURE。

4.3 场景 C:Worker 在跑但没订阅这个 queue

和场景 A 一样,消息永久堆积。Worker 启动时用 -Q other_queue 覆盖默认订阅。如果没有任何 worker 订阅 celery 这个 queue,LIST 一直涨。监控用 LLEN celery——一直涨 = 有 worker 没订阅/没启动/没注册。

4.4 场景 D:Worker 满载

消息在 broker 等待,prefetch 限制 worker 不再拉新消息。源码在 worker/consumer/consumer.py 通过 QoS(quality of service)控制:worker_prefetch_multiplier 默认 4,concurrency=1 时 prefetch 窗口 = 4 条。Worker 启动后预先 BRPOPLPUSH 4 条消息到 unacked,unacked 满了不再 BRPOP。某个 ForkWorker 子进程执行完 → 主进程发现空位 → 再拉一条。

4.5 场景对比表

场景 消息最终去哪 Redis LIST 变化 Backend 记录
A. 无 Worker 永久堆在 broker 长度持续增长 无
B. Worker 未注册任务 被 reject 丢掉 不变(消息已不在 broker) FAILURE / NotRegistered
C. Worker 未订阅 queue 永久堆在 broker 长度持续增长 无
D. Worker 满载 在 broker 等 长度 < prefetch × worker 数 无(未消费)

现实中最容易踩的 3 个坑:消息堆积无感知(必须监控 LLEN);Unknown Task 静默失败(建议监听 task_unknown signal);Prefetch 限制的"持有"问题(D 场景下,prefetched 消息已在 unacked,崩溃可能丢消息)。

五、任务状态到底记在哪?PENDING 的真相

任务发到 broker 后,Backend 里没有这个任务的任何记录——broker 里只有消息本身的物理位置,没有"状态"字段。

5.1 三个记录位置的真相

Broker(Redis db 0)— 隐式状态

没有显式的"状态"字段,但位置本身就是状态:

物理位置 含义 状态含义
LIST celery 消息从未被 Worker 拉走 等待消费
LIST unacked 消息被 BRPOPLPUSH 但还没 ACK 处理中
不在 Broker 消息已被 ACK 或 reject 已结束

Backend(Redis db 1)— 完全没有记录

任务发到 Broker 后,db 1 不会有任何 key。源码见 celery/backends/base.py L1094-L1099:

def _get_task_meta_for(self, task_id):
    meta = self.get(self.get_key_for_task(task_id))
    if not meta:
        return {'status': states.PENDING, 'result': None}
    return self.decode_result(meta)

Backend 没记录时返回 PENDING 是默认值,不是真的"待执行"。

Producer 进程内存 — 只有引用无状态

AsyncResult 不缓存状态,每次访问都查 Backend。Backend 没记录 → 永远显示 PENDING。

5.2 PENDING 是个"谎言"

05 状态机

图 5:PENDING 不是状态,是 Backend 查不到记录时的默认值。STARTED 仅在 track_started=True 时由 Worker 写入,FAILURE 可触发 RETRY 重新入队。

实际的状态机:

[Producer]              [Worker 端]                  [Backend]
   │
   │ publish
   ▼
Broker LIST celery ──┐
                     │
   │ BRPOPLPUSH
   ▼
Worker unacked LIST
                     │
                     │ Task.run() 开始
                     │ (track_started=True)
                     ├─ mark_as_started ───────► celery-task-meta-* (status=STARTED)
                     │
                     │ Task.run() 返回
                     ├─ mark_as_done ───────────► celery-task-meta-* (status=SUCCESS)
                     │
                     │ Task.run() 抛异常
                     └─ mark_as_failure ────────► celery-task-meta-* (status=FAILURE)

关键洞察:

  • Backend 写入是 Worker 端的副作用,不是 Producer 的设计目标
  • Producer 端的 PENDING 是个"谎言"——Backend 查不到时返回的默认值,不是真实状态
  • STARTED 是可选的——需要 task_track_started=True 才记录

最反直觉的 3 点:

  1. state=PENDING 不是"待执行",是"Backend 查不到记录"。AsyncResult("不存在的uuid").state 也等于 PENDING。
  2. 任务从 Producer 到 Backend 是个延迟路径——.delay() 后立刻 result.get(timeout=1) 不会更快拿到结果,因为结果还没写到 Backend。
  3. Broker 里的"状态"是物理位置,不是字段。这跟 RabbitMQ 的设计不同(RabbitMQ 有 x-message-ttl、优先级等 header),Redis transport 把这些都去掉了换来简单性。

3 个状态查询入口的对比:

查询方式 数据源 可靠性
AsyncResult(id).state Backend 假阳性(PENDING 不可区分)
redis-cli LLEN celery Broker 看不到"已经 ACK 的"历史
Worker events(Flower) Worker 进程 需要 -E 参数启动

六、task_track_started=True:让 STARTED 不再沉默

6.1 4 种配置入口

入口 代码
全局默认 app.conf.task_track_started = True
per-task override @app.task(t.bind=True, track_started=True)
per-call override task.apply_async(args, _track_started=True)
CLI flag --track-started ⚠️ 不存在,会被忽略

注意第一个方法有个坑:track_started 是 Task 类属性,装饰时绑定,运行时改 app.conf.task_track_started 不会回写到已装饰的 task 实例。要么改在 import 之前,要么用 per-task override。源码见 celery/app/task.py L257-L258 与 L329(from_config 映射)。

6.2 开启前后的时序对比

06 时序对比

图 6:左侧 track_started=False 时 Backend 只在 Task.run() 完成后写一次 SUCCESS;右侧开启后在 .t2 多一次 SETEX 写入含元数据的 STARTED。

默认(track_started=False):

.t0  Producer ──delay()──►  Broker LIST celery
.t1  Worker BRPOPLPUSH  ──►  unacked LIST
.t2  Task.run() 开始
.t3  Task.run() 完成       ──►  Backend: SETEX status=SUCCESS

开启后(track_started=True):

.t0  Producer ──delay()──►  Broker LIST celery
.t1  Worker BRPOPLPUSH  ──►  unacked LIST
.t2  Task.run() 之前      ──►  Backend: SETEX status=STARTED (含 pid + hostname)
.t3  Task.run() 开始
.t4  Task.run() 完成       ──►  Backend: SETEX status=SUCCESS (覆盖 STARTED)

差异:在 .t2 多了一次 Backend 写入。源码见 celery/app/trace.py L357-L358(条件判断)与 L460-L470(实际写入)。

STARTED 状态包含 pid + hostname 元数据,可追溯"是哪个 worker 进程跑的":

{
    'status': 'STARTED',
    'result': {'pid': 19066, 'hostname': 'celery@host.local'},
    'task_id': '...'
}

6.3 何时该开

推荐开启:任务 >1s(STARTED→SUCCESS 窗口可观测,做"卡死检测");需要看 worker 负载分布(通过 STARTED 的 hostname 统计);debug 场景(想知道任务"已开始但还没结束"的状态)。

不推荐开启:任务 <100ms(窗口太短);超高吞吐(每秒>1000 任务,多一次 Redis 写是负担);ignore_result=True(会被 trace.py 里的条件跳过,写了也白写)。

实际生产建议:保持默认 False 就够了。需要观测时用 per-task override(针对关键任务开启),不要全局开启。

七、写在最后:3 个生产建议

监控 broker 队列长度

A 与 C 场景下,消息会一直堆到 Redis OOM。生产必加监控:

redis-cli LLEN celery
redis-cli info memory | grep used_memory

Celery 5.x 的 -E(events)模式下,可以用 Flower 或 Prometheus exporter 监控 task-received 与 task-succeeded 速率——received > succeeded 持续 N 分钟说明有积压。

给任务设过期

task.apply_async(args, expires=3600) 给单条任务设过期;app.conf.task_default_expires = 3600 全局生效。注意:expires 不是 broker TTL——它是 header 里的字段,Worker 拉取时才检查。如果 Worker 一直没起,expires 字段存在但消息不会自动消失。

用 Worker events 而非 Backend 轮询

Backend 查询昂贵且不可靠(PENDING 假阳性)。Worker events 实时、可靠、能反映 worker 进程真实负载。业务查结果用 Backend(注意 PENDING 假阳性);broker 监控只用于容量预警。


附录:关键源码索引

Kombu(v5.6.2)

文件 行号 用途
kombu/transport/redis.py L632 keyprefix_queue = '_kombu.binding.%s'
kombu/transport/redis.py L634 sep = '\x06\x16'
kombu/transport/redis.py L386 def ack(self, delivery_tag)
kombu/transport/redis.py L390 def reject(self, delivery_tag, requeue=False)
kombu/transport/redis.py L1053-L1061 _queue_bind 实现(binding SET 写入)
kombu/transport/redis.py L1086-L1094 get_table 实现(反向路由表查询)
kombu/entity.py L327-L374 binding 类
kombu/entity.py L670-L672 Queue.queue_bind
kombu/message.py L18-L79 Message 类定义

Celery(v5.6.2)

文件 行号 用途
celery/result.py L442-L446 _get_task_meta 实现
celery/result.py L477-L512 state property
celery/backends/base.py L176 mark_as_started
celery/backends/base.py L181 mark_as_done
celery/backends/base.py L188 mark_as_failure
celery/backends/base.py L290 mark_as_revoked
celery/backends/base.py L1094-L1099 _get_task_meta_for(PENDING 默认值)
celery/app/defaults.py L310 track_started=Option(False, ...) 默认值
celery/app/task.py L257-L258 track_started = None
celery/app/task.py L329 ('track_started', 'task_track_started') from_config 映射
celery/app/trace.py L357-L358 track_started 计算条件
celery/app/trace.py L460-L470 STARTED 状态实际写入位置
celery/worker/consumer/consumer.py L596-L625 on_unknown_task 实现
posted @ 2026-09-10 16:49  GreeneGe  阅读(19)  评论(0)    收藏  举报