批量发送邮件时出现内存泄漏问题排查求助
首先,结合你的Rails 4.1.6 + Passenger + Sucker Punch技术栈,以及代码实现来看,内存泄漏和渲染时间逐封增加的问题大概率和后台常驻线程的对象引用累积、循环中的资源未及时回收有关,下面具体拆解:
一、可能的核心原因
1. Sucker Punch Worker的实例变量持有问题
Sucker Punch的Worker是常驻单实例线程,也就是说每次任务都会复用同一个Worker对象。你代码里用到的@download_url、@subscriber、@message这些实例变量,会一直绑定在Worker实例上,任务结束后也不会被GC回收——如果这些变量关联了ActiveRecord对象(比如Message、Subscriber),会导致整个对象链都无法被释放,内存自然越积越多。
2. 循环中ActiveRecord对象的批量加载与未回收
你直接用messages.each遍历所有消息,一次性加载了所有Message对象到内存中。当发送量超过1000封时,这些对象会长期占用内存,而且每处理一封邮件时,create_track_emails里又会循环创建大量TrackEmail实例,进一步加剧内存压力。
3. 邮件模板渲染的上下文污染
在后台任务中调用Action Mailer的mail方法时,视图渲染的上下文(比如实例变量、模板缓存)可能没有被正确重置。因为Worker线程是常驻的,每次渲染模板时,之前的上下文数据可能没有被清理,导致渲染逻辑越来越复杂,时间逐步增加。
4. 缺乏主动GC触发与资源清理
Rails的GC默认是按需触发的,但在后台循环任务中,如果对象创建速度远快于GC回收速度,就会导致内存堆积。而且邮件发送后,Mail对象、模板渲染的中间对象也没有被显式清理。
二、具体排查与修复步骤
1. 替换实例变量为局部变量
把Worker中的实例变量全部改成局部变量,避免常驻线程持有对象引用:
def email_blast(message, child_message_id, resend) # 用局部变量代替实例变量 download_url = message.doc_file_size ? message.doc.url : '' subscriber = message.subscriber current_message = message send_message(current_message, child_message_id, resend, download_url, subscriber) end def send_message(current_message, child_message_id, resend, download_url, subscriber) messages = child_message_id ? Message.where(id: child_message_id) : Message.where(parent_message_id: current_message.id) messages = messages.joins(:pending_messages).uniq if resend && child_message_id.nil? subject = 'New Message' type = current_message.type.split('::').second || current_message.type.split('::').first # 后续逻辑全部使用局部变量 messages.find_each(batch_size: 50) do |message| send_mail(message.all_email_addresses, action_name, subject, message, type, download_url, subscriber) end end
2. 分批处理ActiveRecord对象
用find_each代替each,分批加载Message对象,让GC能及时回收每一批处理完的对象:
# 替换messages.each为find_each,设置合理的批量大小(比如50) messages.find_each(batch_size: 50) do |message| send_mail(...) end
同时,create_track_emails里的循环创建TrackEmail可以改成批量创建,减少实例化次数:
def create_track_emails(smtp_message_id, status, description, emails, model, type) id = type == 'Logon' ? model.id : model.to_id # 先批量生成属性数组,再一次性创建 track_email_attrs = emails.map do |email| { email_address: email, status: status, status_description: description, smtp_message_id: smtp_message_id, subscriber_id: model.subscriber_id, user_id: id, message_type: type } end model.track_emails.create!(track_email_attrs) end
3. 清理邮件渲染上下文
在每次发送邮件后,尝试清理Action Mailer的视图缓存,避免上下文污染:
def send_mail(emails, template, subject, model, type, download_url, subscriber) # 用局部变量传递模板所需数据,避免依赖实例变量 response = mail( to: emails, subject: subject, template_name: template, locals: { download_url: download_url, subscriber: subscriber, model: model } ).deliver! # ... 后续逻辑 ensure # 清理视图缓存(测试环境先验证效果) ActionView::LookupContext::DetailsKey.clear if defined?(ActionView::LookupContext::DetailsKey) end
另外,确保模板中只使用局部变量,不要依赖Worker的实例变量。
4. 监控内存与GC状态
- 在Heroku上查看dyno的内存使用曲线,确认是否是内存持续增长导致的性能下降;
- 在测试环境中,在循环前后加入内存监控代码,定位哪一步内存增长最快:
# 循环前记录内存 before = Process.memory_mb messages.find_each do |message| send_mail(...) # 每处理10封记录一次 puts "Memory after 10 emails: #{Process.memory_mb}" if (messages.index(message) + 1) % 10 == 0 end # 循环后记录 puts "Total memory growth: #{Process.memory_mb - before} MB"
- 可以在循环中主动触发GC(生产环境谨慎,先测试):
messages.find_each(batch_size: 50) do |message| send_mail(...) GC.start if (messages.index(message) + 1) % 50 == 0 end
5. 检查Passenger与Sucker Punch的线程配置
- Passenger的
passenger_max_pool_size和passenger_min_instances配置是否合理,避免和Sucker Punch的worker线程数冲突; - Sucker Punch的worker线程数不要设置过高,避免内存占用过高:
# 在config/initializers/sucker_punch.rb中配置 SuckerPunch.configure do |config| config.num_workers = 2 # 根据dyno内存调整,默认是2 end
三、额外排查点
- 检查模板中的逻辑:如果模板中有查询数据库、处理大量数据的逻辑,比如调用
model.all_email_addresses时是否每次都执行SQL查询,是否可以提前预加载; - 测试小批量邮件:先发送10封、100封,观察内存和渲染时间的变化,确认问题是否是循环累积导致的;
- 用
ObjectSpace工具(测试环境)检查哪些对象数量持续增长:
# 处理前记录 before_count = ObjectSpace.each_object(Message).count # 处理后记录 after_count = ObjectSpace.each_object(Message).count puts "Unreleased Message objects: #{after_count - before_count}"
内容的提问来源于stack exchange,提问作者Andy Simon




