我给每日任务配了个 cron:13 9 * * *。然后我就开始怀疑——它到底是明天 9:13 跑,还是今天 9:13 已经错过了?"每月 1 号、15 号、28 号"这种写法,遇到 2 月怎么办?0 9 15 * 1 到底是"15 号且是周一",还是"15 号或者周一"?

这些问题我每次都要现查,查完过两周又忘。这次我干脆自己写了个推算器,把答案跑出来。

五段分别是什么

分 时 日 月 周
*  *  *  *  *
|  |  |  |  └─ 0-7(0 和 7 都是周日)
|  |  |  └──── 1-12
|  |  └─────── 1-31
|  └────────── 0-23
└───────────── 0-59

支持 *、数字、范围 1-5、列表 1,15,28、步长 */15。解析完每段落成一个 Set,然后从基准时间开始一分钟一分钟往后试,试到匹配为止:

function nextRuns(expr, from, count = 5, maxDays = 366 * 5) {
  const c = parse(expr);
  const d = new Date(from.getTime());
  d.setSeconds(0, 0);
  d.setMinutes(d.getMinutes() + 1);
  const out = [];
  const limit = new Date(from.getTime() + maxDays * 86400000);
  while (out.length < count && d < limit) {
    if (match(c, d)) out.push(new Date(d.getTime()));
    d.setMinutes(d.getMinutes() + 1);
  }
  return out;
}

暴力,但绝对不会算错。五年的范围最多也就两百多万次循环,秒出。

跑出来的结果

基准时间 2026-09-30 00:00(周二):

13 9 * * *       每天 9:13(避开整点,青龙脚本常用)
   第1次: 2026-9-30 09:13:00
   第2次: 2026-10-1 09:13:00
   第3次: 2026-10-2 09:13:00
   第4次: 2026-10-3 09:13:00
   第5次: 2026-10-4 09:13:00

*/15 * * * *     每 15 分钟
   第1次: 2026-9-30 00:15:00
   第2次: 2026-9-30 00:30:00
   ...

0 3 * * 1        每周一 03:00
   第1次: 2026-10-5 03:00:00
   第2次: 2026-10-12 03:00:00
   ...

0 0 1,15,28 * *  每月 1 / 15 / 28 号零点
   第1次: 2026-10-1 00:00:00
   第2次: 2026-10-15 00:00:00
   第3次: 2026-10-28 00:00:00
   第4次: 2026-11-1 00:00:00
   ...

30 2 29 2 *      闰年 2 月 29 日 02:30(要跨好几年)
   第1次: 2028-2-29 02:30:00

0 9 15 * 1       每月 15 号 或 每个周一(OR 语义演示)
   第1次: 2026-10-5 09:00:00
   第2次: 2026-10-12 09:00:00
   第3次: 2026-10-15 09:00:00
   第4次: 2026-10-19 09:00:00
   第5次: 2026-10-26 09:00:00

最后那两行就是我写这个脚本的原因,下面细说。

坑一:第 3 段和第 5 段是「或」,不是「且」

0 9 15 * 1 我以前一直以为是"每月 15 号且那天是周一"。错。

真实规则(Vixie cron):当「日」和「周」都不是 * 时,两者取并集。所以上面那个表达式是"每月 15 号 或者 每个周一",输出的第 3 次(10-15,周四)就是明证——那天不是周一,但它跑了。

想要"15 号且必须是周一",没有原生写法,只能在命令里加判断:

[ "$(date +\%u)" = "1" ] && 你的命令

坑二:周日有两个值

第 5 段的取值范围是 0-7,0 和 7 都表示周日。写 0 3 * * 7 和 0 3 * * 0 是一回事。我的解析器里对 7 做了兼容:

let dowHit = c.dow.has(d.getDay());
if (c.dow.has(7)) dowHit = dowHit || d.getDay() === 0;

坑三:时区

cron 用的是系统本地时区。这一点害过很多人:容器默认是 UTC,你写 0 9 * * * 以为早上九点,实际是北京时间下午五点。

要么把容器时区设成 Asia/Shanghai,要么把表达式写成 UTC 的 0 1 * * *(北京时间 9 点)。我现在的做法是脚本里一律用 UTC+8 显式换算,不指望环境给我正确答案。

坑四:越界和步长不报错,只是永远不跑

60 9 * * * 或者 */0 * * * * 这种,有的 cron 实现会报错,有的会静默忽略——然后你的任务就再也不跑了,而你还以为它在跑。我在解析器里直接抛异常:

=== 边界检查 ===
越界捕获: 越界: 60
步长捕获: 步长非法: */0
段数捕获: 必须是 5 段: 13 9 * *
平年 2 月后「每月 1/15/28」:  2026-3-1 00:00:00 | 2026-3-15 00:00:00 | 2026-3-28 00:00:00

最后那行顺便回答了我的疑问:2 月没有 29 号以后的日子,但 28 号是有的,所以"每月 28 号"在 2 月会正常跑(只是平年的 2 月跑完 28 号就直接跳到 3 月 1 号了)。

我的判断

写完这个脚本我改了两个习惯。

第一,分钟位不用整点。0 9 * * * 是全世界最挤的时间点,所有人的定时任务都在那儿。改成 13 9 * * * 或者 37 3 * * *,避开高峰,尤其是要调外部 API 的任务。

第二,配完 cron 一定要验一次。以前我配完就关页面,现在我会跑一下推算器,看前五次触发时间是不是我想的那样。花三十秒,省掉"为什么这个任务从没跑过"的半小时排查。

脚本在 daily/cron-next.js,零依赖。改 cases 数组就能验自己的表达式:

node cron-next.js

(写作与实测日期:2026-09-30,Node 22.22.2,本地真实运行输出。)

Last modification:September 30, 2026
如果觉得我的文章对你有用,请随意赞赏