现象:任务莫名执行两次
上周五深夜,我们的订单系统突然出现大量重复发货通知。排查日志发现,同一个订单的异步任务(Celery + Redis)在短短几秒内被执行了两次,且两次都成功调用了第三方物流API。更诡异的是,这个任务既没有设置重试,也没有人为触发。
定位:从Celery日志到Redis Stream
首先查看Celery worker日志,发现两条相邻的Task accepted记录,任务ID相同但进程PID不同。这基本排除单worker内的重复消费,怀疑是消息队列层面出现了重复投递。
我们用的是Redis Stream作为broker(Celery 5.x支持)。检查Redis中的Stream组状态:
XINFO GROUPS orders_stream
1) 1) "name"
2) "celery"
3) "consumers"
4) (integer) 2
5) "pending"
6) (integer) 0
7) "last-delivered-id"
8) "1699999999999-0"
注意到consumers数量为2,但我们的worker只有一个进程。继续查看消费者详情:
XINFO CONSUMERS orders_stream celery
1) 1) "name"
2) "worker-1"
3) "pending"
4) (integer) 1
5) "idle"
6) (integer) 0
2) 1) "name"
2) "worker-1-stale"
3) "pending"
4) (integer) 0
5) "idle"
6) (integer) 86400
发现一个名为worker-1-stale的消费者,闲置时间长达一天。这通常是因为worker进程被强制kill(如OOM或手动重启)后,Redis Stream中的消费者没有自动清理。当这个僵尸消费者重新活跃(比如网络分区恢复),它会重新读取未确认的消息,导致重复消费。
修复:清理僵尸消费者并强化幂等
第一步:立即清理
# 删除僵尸消费者
XGROUP DELCONSUMER orders_stream celery worker-1-stale
# 确认组状态
XINFO GROUPS orders_stream
第二步:代码层面加幂等保护
在任务入口处添加基于业务ID的去重:
def process_order(order_id):
# 使用Redis SETNX实现简单幂等
key = f"order:{order_id}:processed"
if not redis_client.set(key, "1", nx=True, ex=3600):
logger.info(f"订单{order_id}已处理,跳过")
return
# 实际业务逻辑
call_shipping_api(order_id)
# 业务成功后删除key(可选,根据需求)
第三步:调整Celery配置
# settings.py
CELERY_BROKER_TRANSPORT_OPTIONS = {
'visibility_timeout': 3600, # 设置可见超时,防止僵尸消费者长时间持有消息
'consumer_auto_heartbeat': 10,
}
复盘:教训与最佳实践
- 监控消费者状态:定期检查Stream组的消费者列表,发现长时间idle的消费者及时清理。
- 幂等是底线:任何涉及外部副作用的任务都必须设计幂等,不能依赖消息队列的"至少一次"保证。
- 优雅关闭worker :使用
celery multi stop或发送SIGTERM,避免kill -9导致消费者未清理。 - 测试故障场景:模拟worker宕机重启,验证消息不丢失且不重复。
这次踩坑让我们意识到,消息队列的可靠性不仅仅是"不丢消息",还要考虑"不重复"。在分布式系统中,幂等设计是工程师的基本功。