我给每日任务配了个 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,本地真实运行输出。)
