跳到正文

用 cron 在 Node.js 里实现周期性定时任务

每隔十秒轮询一次接口,用 setInterval 就能写。可需求变成「每天上午 10 点执行」「周一到周五 9 点半执行」「每月 1 号同步报表」后,代码就得处理日历和时间判断。这些规则更适合用 cron 表达式描述。

Node.js 里可以用 cron 这个包把 cron 表达式和 JavaScript 回调接起来。它适合放一些跟着服务进程一起跑的小任务,比如定时提醒、低频同步、清理缓存、定时拉状态。

它的调度状态在 Node 进程里。进程停了就不会触发,重启后也不会自动补回停机期间的任务。PM2、systemd 可以帮助恢复进程,不能替它补跑;系统 crontab、Kubernetes CronJob 或其他调度器也要分别确认漏跑、重试和重复执行策略,不能只换一个入口就认为不会丢任务。

本文按当前 cron 文档说明 API。旧项目升级时需要核对版本:例如 v3 调整了月份编号,并提供 CronJob.from() 的对象参数入口。

什么时候用 cron,什么时候不用

我一般先看触发条件是不是自然时间。

「每隔 5 秒刷新一次内存状态」这类固定间隔任务,用 setInterval 就够;「每天 10 点」「每周一 9 点」「每月 1 号」这类自然时间任务,用 cron 表达式更顺手。

如果任务必须跨应用重启保留,就需要把计划和执行状态放到进程之外;如果漏跑还要补偿,就要记录哪些计划尚未完成。这已经超出了应用内 cron 的职责。

安装

项目用哪个包管理器,就按哪个来装:

pnpm add cron

如果项目还在用 npm 或 yarn,就换成对应命令:

npm install cron
yarn add cron

cron 表达式怎么读

传统 Unix crontab 是 5 个域,最小粒度是分钟。

cron 这个 Node 包在它前面多加了一个「秒」域,所以常见写法是 6 个域,依次表示秒、分钟、小时、日期、月份和星期:

同样包含 10,所在字段不同,触发频率就不同

例如:

0 0 10 * * *

它的意思是每天 10:00:00 执行。

这 6 个域的范围是:

域 取值
秒 0-59
分钟 0-59
小时 0-23
日期 1-31
月份 1-12,也可以写 JAN-DEC
星期 0-7,0 和 7 都表示星期日,也可以写 MON-SUN

最常用的特殊写法只有几个:

写法 含义 示例
* 任意值 * * * * * * 表示每秒
, 枚举多个值 0 0 10,14,16 * * * 表示每天 10 点、14 点、16 点
- 范围 0 0 9-17 * * * 表示每天 9 点到 17 点整点
/ 步长 0 */15 * * * * 表示每 15 分钟

网上很多 cron 资料会混进 ?、L、W、# 这类 Quartz cron 写法。不同库支持的方言不完全一样,写 Node 代码时别把它们直接搬过来,先看当前库支持哪些语法。

常用例子

日常小任务里,最常用的其实就这些:

表达式 含义
* * * * * * 每秒执行
0 * * * * * 每分钟的第 0 秒执行
0 */5 * * * * 每 5 分钟执行
0 0 10 * * * 每天 10:00 执行
0 30 9 * * MON-FRI 每周一到周五 9:30 执行
0 0 12 1 * * 每月 1 号 12:00 执行

这里最容易写错的是每天 10 点:

* * 10 * * *

这会在每天 10 点这一整小时里每秒执行一次,不是只执行一次。因为秒和分钟都是 *。

每天 10 点只执行一次,要把秒和分钟都固定下来:

0 0 10 * * *

基本用法

最小代码可以这样写:

import { CronJob } from 'cron';

const job = new CronJob(
  '0 0 10 * * *',
  () => {
    console.log('每天 10:00 执行一次');
  },
  null,
  true,
  'Asia/Shanghai'
);

第四个参数是 start。传 true 时,任务创建后会自动启动;不传或传 false 时,再手动启动:

job.start();

停止任务:

job.stop();

查看下一次执行时间:

console.log(job.nextDate().toISO());

这能检查下一次触发日期和时区是否符合预期,不证明回调已经执行成功。真正接业务回调后,还要分别观察触发、执行结果和失败日志。

参数一多,我更愿意用 CronJob.from(),不用反复数位置:

import { CronJob } from 'cron';

const job = CronJob.from({
  cronTime: '0 0 10 * * *',
  timeZone: 'Asia/Shanghai',
  start: true,
  waitForCompletion: true,
  errorHandler(error) {
    console.error('定时任务执行失败', error);
  },
  async onTick() {
    await sendDailyMessage();
  }
});

timeZone 指定任务时区,线上最好显式设置,避免服务器默认时区变化后,任务跟着改到另一个时刻执行。

waitForCompletion: true 会阻止同一个 job 的 onTick 重叠执行。上一轮还没结束时,新到的触发会被跳过,不会排队等着补跑;它也不负责协调多个 Node 进程中的任务。

假设同一个 job 每 10 秒触发一次,每轮执行 25 秒,时间线如下:

等待当前任务完成时,重叠触发会跳过

每天上午 10 点只是触发条件,停机后的补发、重复发送和失败追查仍需单独设计。完整内容见群消息任务示例弹窗。

总结

自然时间规则适合用 cron 表达式描述,cron 包再把这些规则接到 Node.js 回调上。使用时要确认当前库的字段、方言和时区,区分「每天 10 点一次」与「10 点这一小时内反复触发」,也要分别观察计划触发和回调执行的结果。

应用内调度的可靠性取决于进程和业务处理。waitForCompletion 只阻止同一个 job 的回调重叠,忙碌期间的触发会跳过;停机补跑、多进程协调和重复执行仍需另行处理。小任务可以跟着服务跑,必须跨重启保留、补偿的任务则需要进程之外的计划与执行状态。

群消息任务示例

每天 10 点发送群消息

假设有一个机器人,每天上午 10 点往群里发一条消息,可以这么写:

import { CronJob } from 'cron';
import Bot from './bot';

const bot = new Bot();

const job = CronJob.from({
  cronTime: '0 0 10 * * *',
  timeZone: 'Asia/Shanghai',
  start: true,
  waitForCompletion: true,
  async onTick() {
    await bot.send({
      msgType: 'text',
      text: { content: '在干嘛' },
      at: { atMobiles: ['13xxxxxxxxx', '13xxxxxxxxx'] }
    });
  },
  errorHandler(error) {
    console.error('群消息定时任务失败', error);
  }
});

每天 10 点只是触发条件。消息不能漏、不能重复、失败需要追查时,还要处理执行过程:

  • 进程管理:确保服务崩了会自动重启。
  • 日志:记录每次任务开始、成功、失败的时间。
  • 幂等:重复执行时不要产生错误结果。
  • 补偿:如果服务在 10 点刚好不可用,恢复后是否需要补发。

对于这个机器人,先决定停机后要不要补发、重复发送能不能接受,再选择执行和补偿方式。cron 只负责按时触发,不能代替这两个业务判断。