ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Celery任务积压实战:从雪崩到千万级并发调度系统

Celery任务积压实战:从雪崩到千万级并发调度系统

那天晚上10点,告警群里的消息比大促高峰的优惠券还密集。订单回调任务从3000条积压到300万条,只用了四十分钟,用户积分迟迟不到账,后台从“正常”变成“雪崩”。如果你也跑过Celery分布式任务队列,大概能理解那种感觉:低并发下它温顺得像条狗,一旦流量上来,咬人绝不嘴软。这篇复盘记录的是一次真实的任务积压事故,以及我们如何借这次事故,把Celery从能扛几万条任务的小车,逐步改造成应对千万级并发的任务调度系统。整个过程的调参、拆队列、换架构、补幂等,我会一条条讲清楚,里面的坑你早晚也会踩到。

1. 瓶颈定位:任务积压背后的化学方程式

1.1 Celery Worker并发模型:你以为的“并发”可能是错的

先说一个最容易被误解的点:Celery的并发和你脑子里想的并发,未必是一回事。

Celery的Worker默认使用Prefork模式,也就是启动N个子进程,每个子进程同一时间只能执行一个任务。这里有个关键限制——每个子进程是Python进程,受到GIL约束,CPU密集型任务在单进程内实际上是串行的。所以你把concurrency调到64,不代表一台机器能同时跑64个CPU密集型任务,而是最多有64个进程在抢CPU时间片,真正并行执行的取决于你的CPU核数。

很多人刚接触Celery时有个错觉:任务积压了,就把concurrency翻倍,从8改成16、32,以为万事大吉。结果呢?积压没缓解,机器CPU先拉满,磁盘I/O和数据库连接全被拖垮,整个业务跟着遭殃。

适合IO密集型业务的并发方案还有Eventlet和Gevent,它们用协程模拟并发,一个进程内可以同时挂起几百个协程去等网络响应,比如等外部HTTP接口返回、等数据库查询完成。这种模式下并发数可以拉得很高,几百甚至上千。但也有限制:你用的第三方库必须兼容协程切换,如果一个库内部做了阻塞调用且没有主动让出控制权,协程就退化成同步执行,性能反而更差。

我用一个生活类比帮你理解这三种模式:

  • Prefork像开饭馆请了N个厨师,一个厨师同一时间只做一道菜,优点是互不干扰,一个厨师手抖撒了盐不会影响别的厨师。
  • Eventlet/Gevent像一个厨师同时盯着好几个灶台,哪个锅开了就去处理哪个,优点是效率高,但前提是所有菜都能随时放下、随时拿起来,有些菜一旦下锅就不能离开人。
  • Solo模式是只有一个厨师,所有任务排队来,只适合本地调试,根本不该出现在生产环境。

所以选并发模型之前,先搞清楚你的任务类型:是计算密集(图像转码、视频处理、批量计算)还是IO密集(HTTP回调、发短信、发邮件、同步数据)。这是第一步,也是很多人走错的第一步。

1.2 第一现场:从300万积压里找到真凶

那次事故的排查路径,我到现在还记得。

先看监控,队列积压量直线飙升,Worker存活率正常,没有进程挂掉。接着用Celery自带的命令看Worker到底在执行什么:

celery -A proj inspect active

这个命令能列出每个Worker当前正在执行的任务。结果一出来我就愣住了:队列里积压的明明是订单回调任务,但几乎所有Worker进程都在执行同一个图片转码任务,而且一个转码任务已经跑了三分多钟还没结束。

问题一下就清晰了。虽然当时Worker整体并发不低,但图片转码这类重任务混在订单任务同一个队列里,它们抢占了大量执行单元。更麻烦的是,Celery默认的prefetch机制让每个Worker进程会一次性预取多个任务到本地内存里,重任务卡在前面,后面预取的任务全部处于“已取出但未执行”的锁定状态,其他空闲Worker又分不到这些任务,形成了双重堵塞。

类似的坑还可以通过Flower页面观察,你会看到某些队列的Active任务一大串,但就是没人消费。直观的监控工具是排查积压的第一步:

celery -A proj inspect stats flower --port=5555 --broker=redis://...

1.3 积压的本质:三个杠杆失衡

积压这件事,说到底就是生产任务的速度大于消费速度。但消费速度不是简单看Worker数量,它由三个杠杆决定:

  • 并发处理单元数(N):Worker进程数、协程数。
  • 单任务平均耗时(T):任务执行得越快,单位时间能消化的任务越多。
  • 有效利用率(U):这个最容易被忽略,它指并发单元真正在干活的比率。长任务阻塞预取队列、外部接口拖慢执行、数据库连接池耗尽,都会让U大幅下降。

用一个简单的产能公式表示:每秒钟能处理的任务数 = N × (1 / T) × U。

举个例子就明白了。假设16个并发单元,任务平均耗时100毫秒,理论上每秒能处理160个任务。但如果队列里混进来一个耗时3秒的重任务,它不仅占用了一个执行单元3秒,还因为prefetch机制锁定了后续若干任务不让其他Worker取走,实际吞吐可能直接跌到每秒40个任务,产能掉到原来的四分之一。

所以解决积压,不是把concurrency无限调大就够了,而是三件事:选对并发模型、降低任务耗时方差、把不同特性的任务拆到不同队列。这三件事做完了,容量问题才算真正解决。

2. 并发模型与执行链路的性能改造

2.1 选对并发池:CPU密集用Prefork,IO密集用Eventlet

改造的第一步,是给不同业务场景匹配不同的并发池。

以我们当时的业务为例,订单回调、短信通知这类任务全是IO密集,它们在等外部服务响应时CPU基本闲着。这类任务如果继续用Prefork,一个Worker进程等HTTP响应3秒,这3秒内这个进程什么也干不了。换Eventlet后,一个进程可以同时发起20个HTTP请求,谁先返回谁先处理,实际吞吐能提升好几倍。

Eventlet配置方式很简单:

celery -A proj worker --pool=eventlet --concurrency=200

或者在配置文件中统一声明:

worker_pool = "eventlet" worker_concurrency = 200

但注意,Eventlet使用协程,对代码有隐藏要求。它会在启动时对标准库做monkey patch,如果你的项目中有某些库不支持协程切换,可能导致任务“假死”或数据错乱。我踩过的一个具体坑是:某个服务在Eventlet模式下运行一个重度使用线程锁的第三方SDK,结果经常卡死无响应。排查了很久才发现是协程切换时锁被占住不放。后来这类任务我全部单独拆到Prefork队列里,用进程隔离来保证安全。

建议你先做一个基准验证:打个小流量,把任务的执行时间、内存增长、异常率记录下来,对比Prefork和Eventlet的差异,再决定是否全量切换。别一上来就几百个协程,监控告警没到位前,协程会把你系统的问题放大十倍。

2.2 prefetch multiplier:差一个字段,积压差三倍

这是Celery调参里性价比最高的一步。

Celery默认的worker_prefetch_multiplier是4,含义是每个Worker进程一次预取4个任务到本地队列。这个机制在任务执行时间均匀时没问题,但一旦任务耗时方差大,它就会成为灾难——一个耗时长的任务排在本地队列前面,后面3个任务被锁住无法被其他空闲Worker消费,产生“活锁”。

解决办法就是把预取数量降到1:

worker_prefetch_multiplier = 1

或者启动时加参数:

celery -A proj worker --concurrency=16 -Ofair

-Ofair在Celery中会让Worker转向公平模式,等价于每个进程一次只取一个任务,执行完再取下一条。这样一来,某个重任务只会锁住它自己,后面的任务能被其他Worker及时分走。

多说一句,这里有一个取舍。对于一些执行时间本身就非常稳定、量又特别大的纯计算任务,比如固定格式的数据清洗,预取4个其实能减少Worker和Broker之间的频繁通信,提高整体吞吐。所以prefetch_multiplier不是越小越好,而是要看任务耗时分布。我的经验是:凡是任务耗时差异大、有外部依赖的,一律设成1;纯本地计算、时长均衡的,可以维持4甚至更高。

那次调整后,我们的订单任务积压峰值从45秒下降到了7秒,效果立竿见影。

2.3 ack策略、time_limit与僵尸任务治理

任务积压还有一种隐蔽来源——僵尸任务。任务执行到一半,进程被系统杀掉或者依赖的数据库连接断开,任务既不成功也不失败,卡在Worker里出不来,一直占着执行单元。

僵尸任务需要三个维度来治理。

第一,延迟确认(acks_late)。默认情况下,Celery在Worker收到任务后就立即向Broker确认消息,如果后续处理过程崩溃,任务就丢了。开启延迟确认后,任务执行成功才确认消息,执行过程中Worker挂了,任务会被重新投递给其他Worker:

task_acks_late = True task_reject_on_worker_lost = True

第二,设置超时时间,防止任务无限期执行。Celery提供了两层限制:

task_time_limit = 600 # 硬超时,超过直接杀进程 task_soft_time_limit = 480 # 软超时,抛异常让你主动清理

软超时应该在任务内部捕获处理:记录日志、保存现场、主动释放资源,然后抛出明确错误。硬超时是最后防线,防止极端情况把Worker拖死。

第三,定期重启Worker。长时间运行的Python进程容易累积内存碎片,尤其是任务里用到Pandas、OpenCV这类重量级库时,内存会一直缓慢上涨。给Worker加一个max_tasks_per_child参数,让子进程处理满一定数量后自动回收重建:

worker_max_tasks_per_child = 200

这个参数的实战价值我体会很深。之前某个使用图像识别库的Worker进程内存从1GB涨到5GB直到OOM,加了这个参数后,子进程每处理200个任务就重新fork一次,内存轻松维持在一个稳定水位。

还有Redis Broker特有的visibility_timeout。Redis作为Broker时,任务被取走后会有一个不可见时间窗口,默认3600秒。如果任务真实执行时间超过这个值,任务会被重复投递,造成重复消费。这个值要设成“单任务最大耗时的两倍以上”,但又不能设太大,太大了Worker崩掉后消息要等很久才能被重新消费。

broker_transport_options = {"visibility_timeout": 7200}

2.4 队列拆分的核心套路:按“能不能等”分桶

你迟早会走到这一步:所有任务混在默认队列里,一定是灾难。按“能不能等、延误了代价多大”把任务分桶,是所有Celery生产系统的必经之路。

我们最终的队列拆分方案是这样的:

  • transaction:订单支付回调、积分变更、优惠券发放,实时性要求极高,单独队列,资源优先保障。
  • media:图片压缩、视频转码、文件处理,耗时差异大,独立队列,避免拖累别的任务。
  • notification:短信、Push推送、邮件,有外部依赖,允许一定延迟,独立队列,量最大。
  • sync:数据同步、报表统计、异步导入导出,实时性要求低,可以用低优先级Worker慢慢跑。

代码层面用Kombu声明队列和路由:

from kombu import Queue task_queues = ( Queue("transaction", routing_key="transaction.#"), Queue("media", routing_key="media.#"), Queue("notification", routing_key="notification.#"), Queue("sync", routing_key="sync.#"), ) task_routes = { "apps.order.tasks.handle_paid": {"queue": "transaction"}, "apps.media.tasks.generate_thumb": {"queue": "media"}, "apps.notify.tasks.send_push": {"queue": "notification"}, "apps.sync.tasks.refresh_report": {"queue": "sync"}, }

启动Worker时按队列分别部署,资源分配可以独立控制:

celery -A proj worker -Q transaction --concurrency=32 celery -A proj worker -Q media --concurrency=16 celery -A proj worker -Q notification --concurrency=200 --pool=eventlet

这套模式最核心的价值不是优先级,而是故障隔离。媒体队列里有一个任务卡死了,最多拖垮一组media的Worker,交易队列完全不受影响。容量规划也可以分别做:notification量大就多开几个Worker,transaction量小但要求响应快,就单独保证常驻资源。

3. 千万级并发的架构演进与扩容路线

3.1 从单集群到多集群:流量不再集中在一个锅

队列拆分解决了任务混合的问题,但当总流量上升到千万级时,单套Celery集群的瓶颈开始暴露:Broker的连接数、Redis的带宽、任务状态记录的写入量,每一项都会成为新的天花板。

这个阶段的演进方向是拆集群。

我们按业务域拆成了几个相互独立的Celery集群:交易集群、媒体集群、通知集群、同步集群。每个集群拥有独立的Broker实例、独立的Worker组、独立的监控告警。架构上看起来就像多个互不干扰的“流水车间”,其中一个车间出问题,不会传导到其他车间。

跨集群的调用不要直接操作对方的Worker,而是通过统一的任务入口或事件总线来转发。比如同步集群需要触发一次交易集群的数据变更任务,做法是把一个事件写入公共事件队列,交易集群订阅它再创建自己的任务。这样既解耦了调用,又方便做流量控制和审计。

多集群后还有个好处是灰度发布。每次改动Celery配置、升级依赖版本,先在同步集群上跑一天,确认稳定后再推给交易集群和通知集群。这比在生产环境直接改配置稳妥得多。

3.2 容量规划只需要一张“计算表”:TPS × 耗时 ÷ 并发度

千万级并发听起来吓人,但落到容量规划上,其实是一个很简单的乘法除法题。

先算峰值每秒任务数。假设我们的通知任务峰值QPS是2000,平均单任务耗时200毫秒。系统里同时存在的“在途任务”数量(利特尔定律,系统内的平均任务数等于到达率乘以平均服务时间)为:

2000 × 0.2 = 400 个并发处理单元

每个Worker实例如果配置concurrency=16,则需要:

400 ÷ 16 = 25 个Worker实例

但这是理论值,还要考虑两个余量:一是任务耗时会有波动,外部接口变慢10%就会让产能下降;二是Worker自身要处理心跳、连接维护、日志写入。我们通常再加30%的余量,也就是实际部署30-40个Worker实例。这个数字并不过分,千万级并发从来不是靠一台机器硬扛,而是靠足够多的Worker水平扩展。

扩容后的压测节奏也很关键。先小步调大并发:从10个Worker起步,观察任务P99执行时间、Broker的连接数、Redis的内存增速,确认指标稳定后再继续加,直到P99开始上扬,才说明接近这个Broker和Worker组合的上限。

生产环境建议用Kubernetes管理Worker,按队列积压数量自动扩缩容:积压超过阈值就多拉起几个Worker实例,积压消下去就回收。这种弹性伸缩比人工调concurrency要省心太多。

3.3 Broker与结果后端的选型教训:Redis不是万能垃圾桶

先明确一个原则:Broker负责消息流转,Backend负责结果存储,两者职责完全不同。

Redis作为Broker确实简单,开箱即用,团队上手快。但到了千万级任务量,Redis的两个问题会被放大:一是所有任务状态都写在内存里,积压一大内存立刻吃紧;二是可见性超时机制依赖任务状态过期,超时配置不合理就会重复消费。

我们后期把核心交易集群的Broker从Redis迁到了RabbitMQ,原因是RabbitMQ的消息确认、持久化、prefetch控制都更严格,尤其适合对可靠性要求极高的支付回调类任务。两种Broker的对比大致如下:

对比项RabbitMQRedis
消息可靠性高,支持持久化和完整ack机制中等,依赖visibility_timeout机制
队列管理能力支持队列优先级、长度限制、死信队列依赖内存管理,积压成本高
消费者prefetch控制原生支持,精确到每个消费者通过Celery参数间接控制
运维复杂度高,组件多,需精心维护低,大部分团队已熟悉
适合场景高可靠、多队列、大规模生产环境中小规模、快速迭代期

结论是:如果任务量在百万级以内,Redis完全够用;如果朝千万级走,RabbitMQ更适合核心业务,Redis可以保留给结果存储和对可靠性要求没那么高的旁路任务。

结果后端这块,最大的教训是——别把所有任务的执行结果都往Redis里写。默认情况下Celery会把每个任务的结果写到Backend,如果你的业务根本不需要读结果,这个写入纯属浪费。设置:

task_ignore_result = True

需要保留结果的任务再单独开启。如果确实需要保留结果,一定设置过期时间,防止Redis被历史结果占满:

task_store_eager_result = False result_expires = 3600

我们当年没设result_expires的后果是,任务结果在Redis里堆了三天,内存占用飙到90%,Redis进入OOM保护,部分写入开始报错,最后把依赖Redis的任务链路全部拖垮。这件事之后,我把所有Redis相关的过期策略全部梳理了一遍,才明白“结果该丢就丢”这个道理。

4. 高并发稳定性治理与踩坑实录

4.1 at-least-once语义与幂等设计

讲一个残酷的现实:Celery的消息投递是at-least-once语义,也就是说同一个任务,在极端情况下会被执行不止一次。Worker在处理任务过程中崩溃、网络抖动导致ack丢失、Broker重启、超时重复投递——任何一个环节出问题,你的任务都会跑第二次。

如果你在任务里做了“发短信”“加积分”“扣库存”这类操作,重复执行就是生产事故。所以所有写操作类任务,必须做幂等。

幂等最简单的做法:给每条业务消息生成唯一事件ID,任务执行时先查这个ID是否处理过。

@task(bind=True, acks_late=True, max_retries=5) def grant_user_points(self, user_id, points, event_id): # 幂等检查:同一事件ID只处理一次 if PointEvent.objects.filter(event_id=event_id).exists(): return "duplicated" with transaction.atomic(): user = User.objects.select_for_update().get(id=user_id) user.points += points user.save(update_fields=["points"]) PointEvent.objects.create( event_id=event_id, user_id=user_id, points=points, )

这里的技巧是:把“检查是否处理过”和“写入处理记录”放在同一个数据库事务里,并且用select_for_update锁住目标行,防止并发时两个线程同时通过检查。事件ID作为数据库唯一键再兜底一层,即使业务代码出了问题,数据库也会拒绝重复记录。

这句话我要重复三遍:就算你现在觉得“我们业务不会重复”,也一定要写幂等。因为重试机制和网络抖动一定会让你看到重复的那天。

4.2 重试风暴与毒丸任务:重试要有“阻尼”

高并发下另一个容易翻车的是重试策略。

场景是这样的:一个外部短信接口短时故障,队列里同时有1万个短信任务在等它返回。如果你没有退避策略,1万个任务会在同一轮失败后立刻重试,下一秒钟对外部接口发起1万次请求,直接把接口打死,然后下一批继续打,形成重试风暴。

Celery自带了一套还算好用的重试控制,关键在于退避和抖动:

@task( bind=True, max_retries=5, acks_late=True, autoretry_for=(TimeoutError, ConnectionError), retry_backoff=True, retry_backoff_max=600, retry_jitter=True, ) def send_sms(self, mobile, content): try: sms_client.send(mobile, content) except Exception as exc: # 自定义重试,保留现场便于排查 logger.warning("sms send failed: %s", exc) raise self.retry(exc=exc, countdown=min(60 * (2 ** self.request.retries), 600))

retry_backoff=True让重试间隔指数增长,第一次1秒,第二次2秒,第三次4秒。但所有任务同时指数增长会出现“惊群”,所以还要配合retry_jitter=True给每个任务的重试间隔加一点随机抖动,把重试请求均匀打散。这个细节是防止二次雪崩的关键。

还有一个毒丸任务问题:某些任务因为数据本身有缺陷,重试多少次都必败(比如一条格式非法的手机号)。如果让它无限重试,它会一直占着队列资源,还不断污染日志。我们的做法是max_retries控制次数,重试耗尽后任务进入失败状态,再由一个定时补偿任务扫描失败记录表,人工介入或者跳过。

4.3 心跳、连接池与“失踪”的Worker

千万级任务量下,Worker和Broker之间的连接管理也是一种隐性风险。

当时我们遇到过最诡异的一次故障:所有Worker看起来都在运行,但任务就是不消费。排查到最后发现是Worker和Redis之间的连接池被占满了——某个队列的积压量太大,Worker不断地从连接池取连接、等响应,一直占着连接不放,新的连接请求全部超时,整体陷入假死。

这个问题的应对思路有几层。其一,给Broker连接设置心跳和超时,让无响应的连接能被主动清理:

broker_transport_options = { "heartbeat": 60, "socket_timeout": 30, }

其二,把积压控制在上游。在任务入库时加一个总量限制,超过阈值直接报警,而不是让Broker无限堆积。其三,大规模部署时别依赖Flower的实时页面——Flower在Worker数量很多时可能误报“离线”,实际Worker只是短暂心跳延迟。最终监控以Prometheus指标为基准,Flower只做人工排查时的辅助工具。

Worker“失踪”的另一个常见原因是网络分区。Worker与Broker之间的网络抖动超过重连阈值后,Worker会进入无限重连状态。此时日志可能一直打connection lost,但任务全部卡住。我的经验是:单独留一组监控任务,每分钟往队列里发一条探活消息,设定超时时间,连续三次探活失败就触发Worker集群重启,把“无声故障”变成“自动恢复”。

4.4 从实战中总结的调优检查清单

把前面所有经验汇成一张清单,上线前逐项过一遍,能避开大部分生产事故:

检查项推荐配置备注
并发池选择CPU密集用Prefork,IO密集用Eventlet按任务特性分池,不要一锅端
prefetch_multiplier设为1(耗时差异大的场景)公平调度,避免长任务锁队列
acks_lateTrue执行成功才确认
任务超时task_time_limit + soft_time_limit防止僵尸任务卡死Worker
子进程回收max_tasks_per_child=200防止内存泄漏累积
visibility_timeout单任务最大耗时×2以上避免重复消费
结果保存尽量ignore_result,result_expires设短防止Redis内存爆炸
重试策略retry_backoff + retry_jitter防止重试风暴
队列拆分按业务域多队列隔离隔离故障,独立扩缩容
幂等设计唯一事件ID + 数据库唯一键应对at-least-once语义
Broker选型核心业务用RabbitMQ,量小用Redis可靠性需求决定
监控告警队列积压量、失败率、P99耗时覆盖无声故障

最后再说点个人的真心话。我见过太多项目在并发没上去的时候,Celery一切都岁月静好,上线第一周就炸了。分布式任务队列设计的本质,是提前接受“失败一定会发生”这个事实,然后把失败变成系统设计的一部分。你能不能从容应对千万级并发,不在于某个参数调得多完美,而在于当任务开始积压、队列开始告警、某个依赖开始抖动时,你的系统是否有一套完整的降级、隔离、重试、幂等的应对预案。那次事故之后,我把所有生产环境的Celery配置全部按上述清单过了一遍,又花了一个月时间把任务系统搬上容器,用队列积压量做了自动扩缩容,才终于敢在高峰期睡个踏实觉。希望这篇文章能帮你把该踩的坑提前踩完。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进