You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Nodemailer发信后未释放TCP端口致端口耗尽服务故障排查

故障根因诊断

核心问题是TCP连接和通道资源被无限制重复创建,且没有可靠的回收机制,直接导致临时端口耗尽:

  • 每次CRON任务触发SubscribeMails时,都会调用nodemailer.createTransport()新建独立的SMTP连接池实例。即使开启了pool参数,旧实例没有被正确回收,每个实例都会持有独立的TCP连接组,多轮任务跑下来连接数线性累积,直到占满所有临时端口。你之前添加的transporter.close()逻辑无法稳定生效:channel.checkQueue是异步操作,判断队列消息数为0的瞬间,可能还有未完成的sendMail回调在执行,甚至下一轮CRON任务已经启动并新建了实例,旧实例的连接直接变成孤儿连接;频繁调用close还会产生大量处于TIME_WAIT状态的TCP连接,Windows默认TIME_WAIT时长为2分钟,这部分端口不会被立刻释放,反而会加速端口耗尽。
  • 每次执行PublishMails、SubscribeMails时都会调用connection.createChannel()新建RabbitMQ AMQP通道,通道关闭逻辑不可靠,AMQP底层基于TCP实现,大量未正常关闭的通道同样会占用临时端口。
  • 移除maxConnections配置属于错误操作:Nodemailer连接池默认最大连接数为5,删除自定义配置后若没有显式限制,批量发信场景下会无限制新建SMTP连接,进一步加快端口消耗速度。maxMessages设为infinity本身不会直接导致泄漏,但会让单连接长期持有不释放,叠加TIME_WAIT机制会进一步压缩可用端口空间。
  • SubscribeMails存在提前resolve的逻辑问题:调用channel.consume后立刻执行resolve(true),导致Promise链判定任务已完成,实际上消费逻辑还在后台异步运行,下一轮CRON触发时上一轮的连接、通道、消费逻辑都未释放,直接造成资源重复叠加创建。
  • CRON任务没有加执行锁,上一轮任务未完成时下一轮任务会直接启动,进一步放大资源泄漏问题。
分步修复方案

1. 全局复用长连接实例,禁止重复创建

服务启动阶段一次性初始化SMTP传输实例、RabbitMQ通道,全局复用,禁止在每次任务执行时重复创建:

let globalMailTransporter = null;
let globalRabbitChannel = null;

// 初始化SMTP邮件传输器
function initMailTransporter() {
    const templateOptions = {
        viewEngine: {
            extname: '.html',
            layoutsDir: 'views/email/',
            defaultLayout: 'Email_Template'
        },
        viewPath: 'views/email',
        extName: '.html'
    };

    globalMailTransporter = nodemailer.createTransport(smtpTransport({
        host: 'XXX.XXX.XXX.XX',
        port: 25,
        pool: true,
        maxConnections: 10, // 显式限制最大SMTP连接数,根据邮件服务器承载能力调整,建议不超过20
        maxMessages: 100, // 单连接累计发送100封邮件后自动轮换,避免单连接僵死
        socketTimeout: 30000, // 30秒无响应自动回收僵死连接
    }));

    globalMailTransporter.use('stream', require('nodemailer-dkim').signer({
        domainName: 'XXX.com',
        keySelector: 'main',
        privateKey: 'XXXX'
    }));
    globalMailTransporter.use('compile', hbs(templateOptions));
}

// 初始化RabbitMQ通道
async function initRabbitChannel() {
    const conn = global.RabbitMQConnection;
    globalRabbitChannel = await conn.createChannel();
    await globalRabbitChannel.assertQueue(config.RabbitMQ.Queue_EmailQueue, {durable: true});
    await globalRabbitChannel.prefetch(5); // 控制消费并发,同时最多处理5封邮件
    return globalRabbitChannel;
}

// 服务启动时执行一次初始化
initMailTransporter();
initRabbitChannel().catch(err => {
    console.error('RabbitMQ通道初始化失败:', err.stack);
    process.exit(1);
});

2. 重写邮件入队逻辑,复用全局通道

发布邮件到队列时复用全局通道,所有消息确认写入队列后再返回结果,禁止在循环中提前关闭通道:

const PublishMails = async (mailObj) => {
    if (!Array.isArray(mailObj) || mailObj.length === 0) {
        return {
            Result: false,
            Message: "No mails were received - Mails Not Published."
        };
    }

    const channel = globalRabbitChannel;
    for (const mailItem of mailObj) {
        const mailData = {
            from: 'XXX@XXX.com',
            to: mailItem.Email,
            firstName: mailItem.FirstName,
            login: mailItem.Login,
            email: mailItem.Email,
            mailID: mailItem.MailID,
            sendID: mailItem.SendID,
            subject: "Email Message",
            template: 'EmailTempLate'
        };
        channel.sendToQueue(
            config.RabbitMQ.Queue_EmailQueue,
            Buffer.from(JSON.stringify(mailData)),
            {persistent: true, contentType: 'application/json'}
        );
    }

    // 等待所有消息确认写入队列再返回
    await channel.waitForConfirms();
    return {
        Result: true,
        Message: "All mails successfully published."
    };
}

3. 启动常驻消费者,禁止重复绑定消费逻辑

服务启动时启动唯一的常驻消费者,不要在每次CRON任务中重复创建消费者:

function startMailConsumer() {
    const channel = globalRabbitChannel;
    channel.consume(config.RabbitMQ.Queue_EmailQueue, async (data) => {
        if (data === null) return;
        try {
            const mail = JSON.parse(data.content.toString());
            await globalMailTransporter.sendMail({
                from: mail.from,
                to: mail.to,
                subject: mail.subject,
                template: mail.template,
                context: {
                    FirstName: mail.firstName,
                    Email: mail.email,
                    MailID: mail.mailID,
                    SendID: mail.sendID,
                }
            });
            channel.ack(data);
        } catch (err) {
            console.error('邮件发送失败:', err.stack);
            // 失败最多重试3次,超过则丢弃或转入死信队列,避免无限重试占用资源
            const retryCount = (data.properties.headers['x-retry-count'] || 0) + 1;
            if (retryCount > 3) {
                channel.nack(data, false, false);
                return;
            }
            data.properties.headers['x-retry-count'] = retryCount;
            channel.nack(data, false, true);
        }
    });
}

// 服务启动时执行一次
startMailConsumer();

4. 调整CRON任务逻辑,增加执行锁

CRON任务仅负责拉取待发邮件入队,不处理消费逻辑;增加任务锁避免上一轮任务未完成时重复启动:

let cronTaskRunning = false;
const MailerCronJob = new CronJob({
    cronTime: '58 * * * *',
    onTick: async () => {
        if (cronTaskRunning) {
            console.log('上一轮发信任务未完成,跳过本次执行');
            return;
        }
        cronTaskRunning = true;
        try {
            const mailList = await modelPC.GetMails();
            const publishResult = await PublishMails(mailList);
            if (!publishResult.Result) {
                console.error('邮件入队失败:', publishResult.Message);
            }
            // 轮询等待队列消费完成
            const waitQueueEmpty = () => {
                globalRabbitChannel.checkQueue(config.RabbitMQ.Queue_EmailQueue, (err, queueInfo) => {
                    if (err) {
                        console.error('队列状态检查失败:', err.stack);
                        cronTaskRunning = false;
                        return;
                    }
                    if (queueInfo.messageCount === 0) {
                        cronTaskRunning = false;
                        console.log("Completed Successfully");
                        return;
                    }
                    setTimeout(waitQueueEmpty, 1000);
                });
            };
            waitQueueEmpty();
        } catch (err) {
            cronTaskRunning = false;
            console.log("Failed");
            console.log(err);
        }
    },
    start: false
});
MailerCronJob.start();

5. Windows系统TCP参数优化

用管理员权限执行以下PowerShell命令,调整系统TCP配置,扩大临时端口范围、缩短TIME_WAIT等待时长,执行完成后重启服务器生效:

# 扩大动态端口范围为1025-65535
netsh int ipv4 set dynamicport tcp start=1025 num=64511
# 将TCP TIME_WAIT等待时间调整为30秒(默认120秒)
reg add "HKLM\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters" /v TcpTimedWaitDelay /t REG_DWORD /d 30 /f
验证标准
  • 服务稳定运行后,执行netstat -ano | findstr /i ":25"查看SMTP连接数,稳定维持在配置的maxConnections数值附近,不会随任务执行次数持续增长
  • 应用内存占用稳定,不会随任务轮次持续攀升
  • 大批量发信任务执行过程中,TCP临时端口占用数稳定在合理区间,不会出现端口耗尽报错

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.27 22:06:51