给订单确认邮件附上发票PDF以后,单封邮件测试完全正常,但生产Cron每隔几小时被OOM Killer杀掉。原因不是某一张PDF特别大,而是批量发送循环中PDF二进制、Transport和订单对象一直被引用,垃圾回收无法及时释放;失败重试时还会再次生成附件。

先确认是哪一个进程涨内存

ps -eo pid,etime,rss,cmd --sort=-rss | grep -E "cron:run|consumers:start"
journalctl -k | grep -i -E "out of memory|killed process" | tail

在发送循环的关键节点记录 memory_get_usage(true) 和 memory_get_peak_usage(true),同时记录订单ID而不是客户邮箱。若每处理一单RSS都只增不减,基本可以确定有长生命周期引用。

$this->logger->info("mail memory", [
    "order_id" => $order->getEntityId(),
    "current" => memory_get_usage(true),
    "peak" => memory_get_peak_usage(true)
]);

不要在邮件Observer里做所有重活

更稳的做法是:订单事件只发布一个包含order_id的队列消息;Consumer加载订单、生成一次PDF、发送邮件后结束该消息上下文。Consumer设置合理max-messages,让进程定期退出,由Supervisor重新拉起。

bin/magento queue:consumers:start order.email.attachment --max-messages=100

如果暂时不能拆队列,至少让Cron按小批次处理,并在每次迭代结束unset大对象。对DomPDF、TCPDF一类库,还要清理临时图片和字体缓存。unset不是万能药,但能消除明显的变量引用。

附件不要重复生成和重复编码

  • PDF生成一次写入临时文件,邮件发送时按流读取
  • 不要同时保存原始二进制、base64字符串和多个附件对象
  • 使用确定的幂等键order_id + template + version,防止重试重复生成
  • 发送成功后记录状态;失败只重试未成功的步骤
  • 临时文件用try/finally删除,避免磁盘慢慢占满
try {
    $path = $pdfService->renderToTempFile($order);
    $sender->sendWithAttachment($order, $path);
} finally {
    if (isset($path) && is_file($path)) { @unlink($path); }
}

TransportBuilder也可能被错误复用

自定义插件若把附件数组保存在单例或共享属性中,下一封邮件会继续携带上一封附件,内存和数据安全都会出问题。每次创建Transport后清空附件集合,并写测试确认两封不同订单邮件不会交叉。

上线前做压力验证

  • 用脱敏测试数据连续发送至少实际批次两倍的邮件
  • 绘制每封后的RSS,允许锯齿波动但不能持续单调增长
  • 模拟SMTP失败,确认重试不会重复附件或重复邮件
  • 确认临时目录在成功、异常和进程终止后都有清理策略
  • 核对Consumer退出并重启后不会重复处理已成功订单
把memory_limit从768M调到2G只能推迟崩溃。如果每封邮件都泄漏几MB,进程迟早还会被杀。