Nodemailer定时任务随机发送重复邮件问题求助
问题分析与解决方案
核心原因:异步竞态条件
你的代码存在异步操作未同步导致的竞态问题:
- 定时任务每2分钟执行一次,每次查询
SEND_FLAG = 'N' AND STATUS ='NOT_SENT'的记录 - 处理邮件时(尤其是生成PDF的分支),
generatePOPDF、transporter.sendMail、runProcedureMYSQL都是异步操作,这些操作完成前,下一次定时任务已经触发 - 此时数据库中对应记录的
SEND_FLAG和STATUS还未更新,导致同一条记录被重复查询、重复发送
另外,每次定时任务都重新创建transporter实例虽然不是重复发送的直接原因,但会造成资源浪费,建议复用。
具体修复步骤
1. 先标记记录为“发送中”,避免重复查询
修改查询逻辑,先将待发送的记录锁定并标记为发送状态,防止下一次定时任务读取到未处理的记录:
let query = `SELECT * FROM EMAIL_NOTIFICATIONS_V WHERE LANGUAGE = 'EN' AND SEND_FLAG = 'N' AND STATUS ='NOT_SENT' FOR UPDATE`; let result = await runQueryMYSQL(query); // 先批量更新状态为SENDING if (result.length > 0) { const notificationIds = result.map(item => item.NOTIFICATION_ID).join(','); await runQueryMYSQL(`UPDATE EMAIL_NOTIFICATIONS SET STATUS = 'SENDING' WHERE NOTIFICATION_ID IN (${notificationIds})`); }
这里用FOR UPDATE行级锁,确保其他事务无法修改这些记录,同时立即更新状态为SENDING,后续定时任务不会再查询到它们。
2. 重构异步代码为顺序执行
将.then()改为await,确保每一条邮件的发送和数据库更新完成后,再处理下一条:
// 复用transporter,不要每次创建 const transporter = nodeMailer.createTransport({ service: 'gmail.com', auth: { user: constantVariables.CREDENTIAL.EMAIL_NOTIFICATION.user, pass: constantVariables.CREDENTIAL.EMAIL_NOTIFICATION.password } }); for (let i = 0; i < result.length; i++) { const item = result[i]; let mailOptions; if (item.EMAIL_CATEGORY_CODE === 'PO_APPROVED_EXT' && item.REFERENCE_ID !== null) { const buffer = await generatePOPDF(item.REFERENCE_ID, item.LANGUAGE); mailOptions = { from: item.FROM_EMAIL_ADDRESS, to: item.TO_EMAIL_ADDRESS, cc: item.CC_EMAIL_ADDRESS || '', subject: item.EMAIL_SUBJECT, html: item.EMAIL_BODY, attachments: [{ filename: `PO-${item.REFERENCE_ID}.pdf`, content: buffer, contentType: 'application/pdf', contentDisposition: `attachment; filename = "PO-${item.REFERENCE_ID}.pdf"` }] }; } else { mailOptions = { from: item.FROM_EMAIL_ADDRESS, to: item.TO_EMAIL_ADDRESS, cc: item.CC_EMAIL_ADDRESS || '', subject: item.EMAIL_SUBJECT, html: item.EMAIL_BODY, }; } // 等待邮件发送完成 try { await transporter.sendMail(mailOptions); await runProcedureMYSQL("EMAIL_NOTIFICATIONS_SP", [ item.NOTIFICATION_ID, null, null, null, null, null, null, null, null, null, null, null, 'Y', 'Submitted', null, null, item.FROM_USER_ID, item.FROM_USER_ID, 'InsertOrUpdateEmailNotification' ]); // 更新最终状态为SENT await runQueryMYSQL(`UPDATE EMAIL_NOTIFICATIONS SET STATUS = 'SENT' WHERE NOTIFICATION_ID = ${item.NOTIFICATION_ID}`); } catch (error) { await runProcedureMYSQL("EMAIL_NOTIFICATIONS_SP", [ item.NOTIFICATION_ID, null, null, null, null, null, null, null, null, null, null, null, 'N', error.message, null, null, item.FROM_USER_ID, item.FROM_USER_ID, 'InsertOrUpdateEmailNotification' ]); // 更新状态为FAILED,方便后续重试 await runQueryMYSQL(`UPDATE EMAIL_NOTIFICATIONS SET STATUS = 'FAILED' WHERE NOTIFICATION_ID = ${item.NOTIFICATION_ID}`); } }
3. 确保定时任务不会重叠执行
添加执行锁,防止上一次任务未完成时,下一次任务重复触发:
let isJobRunning = false; const emailScheduler = () => { let jobs = CronJob.from({ cronTime: `*/${schedulerTime} * * * *`, onTick: async function () { if (isJobRunning) return; isJobRunning = true; try { // 原来的处理逻辑(包含上面的查询、更新、邮件发送代码) } finally { isJobRunning = false; } }, start: false, timeZone: env.TIME_ZONE }) jobs.start() }
验证SMTP服务器是否存在问题
如果修复代码后仍有重复发送,可以通过以下方式排查:
- 在
transporter.sendMail前添加日志,记录每一次发送的NOTIFICATION_ID,对比重复发送的记录是否是同一ID - 测试单次发送邮件,看SMTP是否会返回多次成功响应
- 检查Gmail的发送记录,确认是否是代码重复调用导致的发送,而非SMTP重复投递
内容的提问来源于stack exchange,提问作者Arslan
相关产品推荐
相关产品推荐

