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

node-schedule定时任务重复执行:生产环境邮件重复发送问题排查

生产环境下node-schedule重复发送邮件问题排查

我是Web开发新手,开发了一款支持用户创建群组、开展每周视频会议的Web应用。通过node-schedule实现了两个核心功能:

  • 会议前24小时向用户发送提醒邮件
  • 会议结束后发送调研邮件

该功能在测试环境运行完全正常,但部署到生产环境后,每位用户都会收到2份提醒邮件和2份调研邮件。

设计逻辑

父定时任务每天凌晨0点执行,筛选出次日有会议的群组,为其调度两个子任务:

  1. 会议前24小时的提醒邮件
  2. 会议结束(按会议时长计算结束时间)后的调研邮件

举个例子:如果群组1在周二13:30有60分钟会议,周一0点执行的父任务会调度:

  • 周一13:30发送提醒邮件
  • 周二14:30发送调研邮件

我曾尝试将父调度改为每5分钟执行一次,筛选未来5分钟内需要触发24小时提醒的群组,测试环境功能完全正常,但生产环境依旧出现重复发送的问题。

代码实现

const schedule = require('node-schedule')

const { sendMeetingReminder } = require('./meeting.email.helper')
const { findAllForEmails } = require('../controllers/groupControllers')
const { getNextRecurrenceDate } = require('./meetingRecurrenceDate')

const subjectReminder = 'Dein Gruppen-Meeting beginnt morgen!'
const subjectFeedback = 'Dein Feedback zum letzten Gruppentreffen bei Groupera'

const meetingScheduler = schedule.scheduleJob('0 0 * * *', async () => {
    let currentDate = new Date()

    try {
        const groupsAll = await findAllForEmails()

        const groups = groupsAll.filter((group) => {
            const nextMeeting = getNextRecurrenceDate(
                group.meetings[0],
                currentDate
            )

            const reminder24Hours = new Date(nextMeeting)
            reminder24Hours.setHours(reminder24Hours.getHours() - 24)

            return (
                reminder24Hours >= currentDate &&
                reminder24Hours.getTime() <
                    currentDate.getTime() + 23 * 60 * 60 * 1000
            )
        })


        groups.forEach((group) => {
            const nextMeeting = getNextRecurrenceDate(
                group.meetings[0],
                currentDate
            )

            const reminder24Hours = new Date(nextMeeting)
            reminder24Hours.setHours(reminder24Hours.getHours() - 24)

            const post1Hour = new Date(nextMeeting)
            post1Hour.setMinutes(
                post1Hour.getMinutes() + group.meetings[0].duration
            )

            const reminder = schedule.scheduleJob(reminder24Hours, async () => {
                if (process.env.NODE_ENV === 'development') {
                    console.log(`sending reminder to ${group.name}`)
                } else {
                    await sendMeetingReminder(
                        group.moderator.email,
                        group.moderator.alias,
                        group.name,
                        subjectReminder,
                        24
                    )
                    if (group.members.length >= 1)
                        group.members.forEach(async (member) => {
                            await sendMeetingReminder(
                                member.email,
                                member.alias,
                                group.name,
                                subjectReminder,
                                24
                            )
                        })
                }
                reminder.cancel()
            })

            const survey = schedule.scheduleJob(post1Hour, async () => {
                if (process.env.NODE_ENV === 'development') {
                    console.log(`sending feedback to ${group.name}`)
                } else {
                    await sendMeetingReminder(
                        group.moderator.email,
                        group.moderator.alias,
                        group.name,
                        subjectFeedback,
                        1
                    )
                    if (group.members.length >= 1)
                        group.members.forEach(async (member) => {
                            await sendMeetingReminder(
                                member.email,
                                member.alias,
                                group.name,
                                subjectFeedback,
                                1
                            )
                        })
                }
                survey.cancel()
            })
        })
    } catch (error) {
        console.error('Error fetching groups and meetings:', error)
    }
})

module.exports = { meetingScheduler }

问题排查与解决方案

1. 生产环境多进程重复执行

生产环境可能存在多个Node.js进程同时运行(比如PM2多实例、Docker容器重复启动),每个进程都会独立调度定时任务,导致同一任务被多次触发。

  • 验证:在父任务执行时打印进程ID:console.log('Parent job running, PID:', process.pid),如果生产环境同一时间出现多个不同PID的日志,说明存在多进程问题。
  • 解决:
    • 若用PM2,设置单实例运行:pm2 start app.js -i 1。
    • 必须多进程时,使用Redis分布式锁,确保同一时间只有一个进程执行父任务。

2. 筛选条件导致重复调度

原筛选条件reminder24Hours >= currentDate && reminder24Hours.getTime() < currentDate + 23h可能让同一群组在连续两天的父任务中都被选中,比如reminder24Hours是当天23:00时,第二天0点的父任务仍会满足条件,导致子任务重复创建。

  • 解决:优化筛选条件,确保每个群组的子任务只被调度一次:
// 替换原筛选逻辑
const reminderDate = new Date(reminder24Hours);
const currentDateNextDay = new Date(currentDate);
currentDateNextDay.setDate(currentDateNextDay.getDate() + 1);

// 检查reminder24Hours是否属于次日
return reminderDate.getDate() === currentDateNextDay.getDate() && 
       reminderDate.getMonth() === currentDateNextDay.getMonth() &&
       reminderDate.getFullYear() === currentDateNextDay.getFullYear();

3. 子任务重复创建

父任务重复执行时,可能为同一群组创建多个相同的子任务。可以通过唯一标识避免重复:

  • 解决:为每个子任务添加唯一key,创建前检查是否已存在:
// 提醒任务
const reminderJobKey = `reminder_${group.id}_${reminder24Hours.getTime()}`;
if (!schedule.scheduledJobs[reminderJobKey]) {
    const reminder = schedule.scheduleJob(reminderJobKey, reminder24Hours, async () => {
        // 原任务逻辑
        delete schedule.scheduledJobs[reminderJobKey];
    });
}

// 调研任务同理
const surveyJobKey = `survey_${group.id}_${post1Hour.getTime()}`;
if (!schedule.scheduledJobs[surveyJobKey]) {
    const survey = schedule.scheduleJob(surveyJobKey, post1Hour, async () => {
        // 原任务逻辑
        delete schedule.scheduledJobs[surveyJobKey];
    });
}

4. 异步循环潜在问题

forEach中使用async/await可能导致异步执行顺序混乱,改用for...of确保顺序执行,避免潜在的重复调用:

// 替换原members.forEach逻辑
if (group.members.length >= 1) {
    for (const member of group.members) {
        await sendMeetingReminder(
            member.email,
            member.alias,
            group.name,
            subjectReminder,
            24
        );
    }
}

内容的提问来源于stack exchange,提问作者John D.

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.30 22:00:17