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

图 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 各承担不同的职责。

图 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

图 3:Producer 调 publish(msg, exchange="celery", routing_key="celery") 时,Kombu Channel 通过 SMEMBERS 反查 binding SET 拿到投递目标,再 LPUSH 消息到目标 queue。
完整流程:
- Producer → Kombu Channel:
publish(msg, exchange="celery", routing_key="celery") - Kombu Channel 自调用:
_lookup("celery", "celery") - Kombu Channel → Redis:
SMEMBERS _kombu.binding.celery - Redis → Kombu Channel:
[("celery", "", "celery")] - Kombu Channel:若 binding 不存在 → 首次时调
SADD _kombu.binding.celery ...(懒加载) - Kombu Channel → Redis:
LPUSH celery <msg> - Kombu Channel → Producer:返回
AsyncResult(task_id)
第一次发送时多出一步 SADD 写 binding SET,后续发送只走 SMEMBERS 查表。这跟 RabbitMQ 把 binding 集中在 broker 端的设计完全不同——Redis transport 把 metadata 分布到 producer 和 worker 两端。
四、没有合适 Worker 时,消息会怎样?
把"没有合适的 worker"分成 4 种典型场景,每种对消息的去向、Redis LIST 的变化、Backend 的记录都不同。

图 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 是个"谎言"

图 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 点:
state=PENDING不是"待执行",是"Backend 查不到记录"。AsyncResult("不存在的uuid").state也等于 PENDING。- 任务从 Producer 到 Backend 是个延迟路径——
.delay()后立刻result.get(timeout=1)不会更快拿到结果,因为结果还没写到 Backend。 - 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 开启前后的时序对比

图 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 实现 |
本文来自博客园,作者:GreeneGe,转载请注明原文链接:https://www.cnblogs.com/greene/p/22923914

浙公网安备 33010602011771号