如何在Node.js中实现可跳过周末、按规则重发邮件的cron-job任务
Node.js定时邮件提醒任务实现方案
技术选型与前置准备
- 定时调度工具:使用
node-schedule库,支持动态创建、暂停、销毁定时任务,比原生crontab更适配业务灵活调度需求 - 持久化存储:生产环境建议使用MySQL、MongoDB或Redis存储任务状态,避免服务重启导致任务丢失,需要存储的核心字段包含:任务唯一ID、剩余重试次数、是否已收到回复、任务启动时间、已累计计时时长、任务暂停状态、对应业务请求ID
核心逻辑实现思路
1. 工作日时长计算工具函数(核心)
提前封装工具函数实现仅统计工作日时长的逻辑,自动跳过周六、周日,所有任务的触发时间都通过该函数计算:
- 从指定起始时间开始逐小时累加计数,遇到周六(
getDay()返回6)、周日(getDay()返回0)的时间直接跳过不纳入计时 - 如果计时过程中到周五23:59,自动跳转到下周一00:00后继续累计剩余时长
- 累计满指定的24小时工作日时长后,返回最终的任务触发时间
2. 新请求初始化逻辑
新业务请求创建时执行以下操作:
- 立即调用邮件发送函数完成首次发送
- 初始化任务记录,剩余重试次数设置为3,标记为未回复状态
- 调用工具函数计算首次24小时倒计时的触发时间,创建对应定时任务,存入持久化存储
3. 定时任务触发逻辑
定时任务到触发时间后执行以下判断:
- 如果该任务已经标记为已收到回复,直接销毁任务并删除存储记录
- 如果剩余重试次数大于0:再次调用邮件发送函数,剩余重试次数减1,重新调用工具函数计算下一个24小时的触发时间,更新任务记录与定时任务
- 如果剩余重试次数为0:标记任务为逾期结束,执行对应业务的逾期处理逻辑,销毁任务
4. 全局周末暂停兜底逻辑
额外配置两个全局定时任务做兜底,避免动态计算出现偏差:
- 每周五23:59触发:暂停所有运行中的任务,记录每个任务已经累计的计时时长,标记任务为暂停状态
- 每周日23:59触发:恢复所有暂停的任务,用剩余需要计时的时长重新计算触发时间,更新定时任务
核心代码示例
const schedule = require('node-schedule'); // 示例用内存存储,生产环境替换为持久化数据库 const taskStore = new Map(); // 自行实现业务邮件发送逻辑 const sendBusinessEmail = async (taskId, requestInfo) => { console.log(`向任务${taskId}对应的用户发送提醒邮件`); }; /** * 计算仅统计工作日的结束时间 * @param {Date} start 起始时间 * @param {Number} needHours 需要累计的工作日时长(小时) * @returns {Date} 最终触发时间 */ const calculateWorkEndTime = (start = new Date(), needHours = 24) => { let currentTime = new Date(start); let remainingHours = needHours; while (remainingHours > 0) { currentTime.setHours(currentTime.getHours() + 1); const weekDay = currentTime.getDay(); // 周六周日跳过不统计 if (weekDay === 0 || weekDay === 6) continue; remainingHours--; } return currentTime; }; // 新请求创建时初始化任务 const initEmailTask = async (taskId, requestInfo) => { // 首次发送邮件 await sendBusinessEmail(taskId, requestInfo); // 计算首次触发时间 const firstTriggerTime = calculateWorkEndTime(new Date(), 24); // 定时任务回调 const taskCallback = async () => { const task = taskStore.get(taskId); if (!task || task.replied) { task?.job?.cancel(); taskStore.delete(taskId); return; } if (task.remainRetry > 0) { // 重试发送邮件 await sendBusinessEmail(taskId, task.requestInfo); task.remainRetry--; // 计算下一次触发时间 const nextTriggerTime = calculateWorkEndTime(new Date(), 24); task.job.cancel(); task.job = schedule.scheduleJob(nextTriggerTime, taskCallback); task.triggerTime = nextTriggerTime; taskStore.set(taskId, task); } else { // 重试次数耗尽,执行逾期逻辑 task.job.cancel(); taskStore.delete(taskId); // 这里加你的业务逾期处理逻辑 } }; // 创建定时任务并存入存储 const job = schedule.scheduleJob(firstTriggerTime, taskCallback); taskStore.set(taskId, { taskId, requestInfo, remainRetry: 3, replied: false, paused: false, triggerTime: firstTriggerTime, job, callback: taskCallback }); }; // 收到邮件回复时调用,销毁对应任务 const handleEmailReply = (taskId) => { const task = taskStore.get(taskId); if (task) { task.job.cancel(); taskStore.delete(taskId); } }; // 兜底定时:每周五23:59暂停所有运行中任务 schedule.scheduleJob('59 23 * * 5', () => { taskStore.forEach(task => { if (!task.paused && !task.replied) { const usedHours = (new Date() - task.triggerTime) / 1000 / 3600; task.usedHours = Math.max(0, 24 - usedHours); task.job.cancel(); task.paused = true; } }); }); // 兜底定时:每周日23:59恢复所有暂停任务 schedule.scheduleJob('59 23 * * 0', () => { taskStore.forEach(task => { if (task.paused && !task.replied) { const newTriggerTime = calculateWorkEndTime(new Date(), task.usedHours); task.job = schedule.scheduleJob(newTriggerTime, task.callback); task.triggerTime = newTriggerTime; task.paused = false; } }); });
生产环境注意事项
- 服务重启时需要扫描持久化存储中未完成的任务,重新生成对应的定时调度,避免任务丢失
- 业务量大的场景建议将定时调度模块与业务处理模块拆分,通过消息队列异步触发邮件发送逻辑,避免调度阻塞
- 所有时间计算统一时区,推荐使用UTC时间避免服务器时区差异导致的触发时间错误
- 可以添加任务执行日志,方便排查发送失败、触发时间偏差等问题
内容的提问来源于stack exchange,提问作者Jithin Jimmy
相关产品推荐
相关产品推荐

