You need to enable JavaScript to run this app. Celery异步批量发送通知邮件时Gunicorn临时阻塞的排查与实现
优惠活动
大模型
产品
解决方案
定价
更多

Celery异步批量发送通知邮件时Gunicorn临时阻塞的排查与实现

数千用户批量通知邮件的正确实现方案

问题本质

遇到的Gunicorn停顿核心原因,根本不是邮件发送慢,而是Web请求处理流程被几千次循环操作完全占满:

  • 当前的循环跑在Gunicorn的同步处理流程里,每一次迭代都要在Web进程内完成3类操作:获取Flask应用上下文、逐封渲染带独立退订链接的邮件HTML、逐次向Celery Broker提交序列化后的任务消息。
  • 当前用的是Gunicorn默认的sync同步worker模型,单worker同一时间只能处理一个请求,遇到上述操作里的IO等待(模板读盘、和Broker的网络通信)时,worker会完全挂起不处理其他新请求,最终表现为数秒的临时停顿——哪怕CPU没有跑满,因为整个过程绝大多数时间在等IO,不是CPU密集型负载。
  • BCC密送方案不适用的原因:每个用户需要独立的UUID退订链接,意味着哪怕用BCC也要逐人渲染差异化邮件内容,加上主流邮箱服务商对单封邮件的收件人有硬上限(通常单封允许的收件人不超过100),根本覆盖不了数千量级的发送需求。

落地实现逻辑

整个方案的核心原则是:Web进程只做最轻量的请求响应,所有耗时的批量操作全部下沉到独立的Celery任务链路里,不占用Web worker资源

1. 彻底把批量调度逻辑移出Web上下文

原写法里的用户遍历、任务提交全在Web请求流程中,哪怕Celery发邮件再快,Web端的几千次循环提交操作一样会卡。正确的流程是:

  1. Web接口只提交一个最轻量的根批量Celery任务,立刻返回响应,不做任何循环操作
  2. 所有用户遍历、任务拆分、邮件内容生成的逻辑,全部放在这个根Celery任务里执行

参考代码实现:

# 修正后的Web路由逻辑,不再遍历用户列表
@app.route('/trigger/user-notify')
def trigger_notify():
    # 仅传递基础参数给根批量任务,立刻返回
    send_batch_notify_task.delay(
        template='users/email/new_data',
        subject='New email'
    )
    return "批量发送任务已受理", 202

@celery.task
def send_batch_notify_task(template, subject, page_size=100):
    # 整个批量调度逻辑在Celery Worker内运行,不占用Web资源
    current_page = 1
    while True:
        # 分页拉取用户,禁止一次性用User.query.all()加载全量数据,避免内存溢出
        user_batch = User.query.paginate(page=current_page, per_page=page_size).items
        if not user_batch:
            break
        for user in user_batch:
            # 每个用户的独立UUID退订链接在这里生成
            unsubscribe_param = {'unsubscribe_url': f'/unsubscribe/{user.uuid}'}
            # 提交单封邮件的发送任务,保留原有的延迟配置
            send_async_email_task.apply_async(
                args=[user.email, subject, template, unsubscribe_param],
                countdown=600
            )
        current_page += 1

2. Celery链路的配套优化

  • 开启Celery结果忽略配置CELERY_IGNORE_RESULT = True:发邮件的任务不需要把执行结果回传给Web端,能大幅减少Broker的序列化、网络传输开销。之前试开这个配置没效果,是因为瓶颈一直没在Web端的循环上,不在Broker侧。
  • 把原send_email_celery里的模板渲染、Flask上下文获取逻辑,全部移到单封邮件的Celery任务内部:不要在Web端渲染好整封邮件的HTML再序列化传给Celery,数千封带个性化内容的HTML序列化会占大量Broker内存,本身也是之前Web阻塞的诱因之一。
  • 给单封邮件发送任务配置合理的速率限制,避免短时间打满邮件服务的连接数、Broker的带宽。

3. Gunicorn侧的配置适配

之前用gevent worker解决问题的方向是对的,原因和配置细节如下:

  • 原来的sync同步worker模型,遇到IO等待时会完全挂起整个请求流程,没法处理其他新请求。换成gevent异步worker后,遇到IO等待时worker可以切换去处理其他Web请求,不会出现整段停顿。
  • 对应的启动命令调整:
gunicorn --error-logfile gunicorn-error.log --timeout 600 --max-requests 500 --max-requests-jitter 50 --workers 5 --worker-class gevent --bind localhost:8080 app:app
  • 注意点:gevent模式需要在应用入口的最开头做monkey patch,否则IO的异步切换不生效,还是会出现阻塞:
# app.py 最开头的位置
from gevent import monkey
monkey.patch_all()

过往踩坑的原因复盘

  • 为什么把循环迁移到Celery路由里还是会卡:如果Web端还是要在循环里逐次提交几千个任务给Celery,哪怕Celery执行再快,Web端也会被几千次的序列化、网络提交操作占住,和Celery本身的运行速度没关系。
  • 为什么换RabbitMQ/Redis、开结果忽略还是卡:瓶颈从来不在Broker的性能上,而在Web worker被循环操作完全占满了。
  • 为什么top看到CPU没满还是会停顿:整个过程的耗时基本都在IO等待(读模板文件、和Broker网络通信),同步worker在等IO的时候不会做其他事,自然表现为卡但CPU不高。

内容的提问来源于stack exchange,提问作者anvd

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.10.31 07:36:05