现象:任务像被黑洞吞了
周三凌晨,监控告警突然炸了:celery 队列积压任务数量从平时的 200 飙到 5000,但 worker 日志里没有任何异常,任务也没有进入 failed 状态。更诡异的是,部分任务执行了,但结果回调(比如写数据库)迟迟没触发。我们检查了 Redis 的 LLEN,发现列表长度在减少,但任务确实"消失"了------既没成功,也没失败。
这已经是本月第二次了。上次我们重启 worker 后恢复,但这次重启无效。我们开始怀疑:任务是不是被多个 worker 重复消费,然后某个 worker 崩溃导致 ack 丢失?
定位:从 RabbitMQ 迁移到 Redis 的隐雷
我们的架构是 Celery 5.2 + Redis 6.2,使用默认的 redis 传输后端。排查分三步:
- 检查 worker 的 prefetch 配置 :默认
worker_prefetch_multiplier=4,每个 worker 会预取 4 个任务到内存。如果 worker 被 kill -9,这 4 个任务就永久丢失------因为 Redis 列表的LPOP是破坏性操作。 - 验证 ack 行为 :用
task_acks_late=True后,任务在 worker 执行完才 ack,但崩溃时任务还在 Redis 里,却会被其他 worker 重复消费。 - 复现现场 :写个脚本模拟 worker 在预取后崩溃,发现任务确实从队列消失,但
unacked哈希表里有残留。
结论:Redis 传输的 LPOP 不是原子的,加上预取机制,导致任务丢失。RabbitMQ 的 ack 机制在 Redis 上被削弱了。
修复:Redis Stream 兜底 + 消费组去重
我们决定不迁移到 RabbitMQ,而是用 Redis Stream 做兜底。核心思路:任务先写入 Stream,worker 通过消费组读取,利用 XACK 保证至少一次投递,配合幂等处理。
具体改造步骤:
-
配置 Celery 使用 Stream 传输 :
broker_url = 'redis://localhost:6379/0',然后设置task_protocol=2(默认就是)。关键是在celery.py中增加:from kombu.transport import redis
启用 Stream 支持(Kombu 4.6+)
app.conf.broker_transport_options = {
'stream': True,
'stream_ack_after': 1, # 处理完立即 ack
'stream_max_len': 10000,
} -
消费组去重 :如果同一个任务被多个 worker 读到,用
task_id做唯一键,在 Redis 里存SET标记。处理前检查:@app.task(bind=True, max_retries=3)
def process_order(self, order_id):
if redis_client.sismember('processed_tasks', self.request.id):
return {'status': 'duplicate'}
# 业务逻辑...
redis_client.sadd('processed_tasks', self.request.id, ex=3600) -
监控未确认消息 :用
XLEN和XPENDING定时检查,超过 5 分钟未确认的自动重新投递。
复盘:教训与最佳实践
这次踩坑让我们深刻理解:Redis 列表做消息队列是脆弱的 。虽然 Stream 也有坑,比如消费组需要手动 XACK,但至少不丢消息。
关键教训:
- 不要依赖默认的 ack 机制 ,尤其在 Redis 传输下,务必设置
task_acks_late=True,并启用task_reject_on_worker_lost=True。 - 幂等设计是兜底 :无论怎么改,业务必须支持重复执行,用
task_id做去重。 - 监控要细化 :除了队列长度,还要看
unacked和pending数量,否则问题会被掩盖。 - 测试要模拟崩溃 :用
kill -9和systemctl stop随机杀 worker,看任务是否丢。
最后,我们的任务丢失率从 0.3% 降到 0,但系统复杂度上升了。如果团队小,建议直接用 RabbitMQ 或更重的消息系统,别在 Redis 上硬扛。