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端的几千次循环提交操作一样会卡。正确的流程是:
- Web接口只提交一个最轻量的根批量Celery任务,立刻返回响应,不做任何循环操作
- 所有用户遍历、任务拆分、邮件内容生成的逻辑,全部放在这个根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
相关产品推荐
相关产品推荐

