后端 · 运维

Cron 的三个坑:星期和日期是 OR、夏令时漏跑、任务重叠

"每月 15 号、并且是周五,才执行"——有人这么写了 0 4 15 * 5,结果它在每个 15 号每个周五都跑了。不是写错,是 cron 在这种情况下用的是 OR,不是 AND。这个行为从 1970 年代留到现在,还写进了标准。Cron 看着简单,能埋人的地方比想象中多。这篇挑三个最常见的。

先快速对一下五个字段,后面都用得上:

┌── 分钟 (0-59)
│ ┌── 小时 (0-23)
│ │ ┌── 日 DOM (1-31)
│ │ │ ┌── 月 (1-12)
│ │ │ │ ┌── 星期 DOW (0-7,0 和 7 都是周日)
│ │ │ │ │
* * * * *  要执行的命令

坑一:日和星期同时写,是并集不是交集

这是最反直觉、也最容易上线后才发现的一条。规则是这样的:

30 4 1,15 * 5
# 你以为:每月 1 号和 15 号里,恰好是周五的那天 4:30 跑
# 实际上:每月 1 号、每月 15 号、外加每个周五,都跑

这不是某个版本的 bug,是 Paul Vixie 的 cron(也就是现在主流的 ISC cron)从 1980 年代就定下的语义,POSIX 也照此写进了标准。Vixie 自己都形容它"bizarre(古怪)",但"它就是标准"。

💡

真要"日期 AND 星期",cron 表达式本身做不到,把判断挪进命令里:
0 4 15 * * [ "$(date +\%u)" = "5" ] && /path/to/job
意思是"每月 15 号 4:00 触发,但只有当天是周五(%u=5)才真正执行"。注意 crontab 里 % 要转义成 \%

坑二:只看第一个字符,让 */2 这种写法翻车

上面那个"判断 DOM/DOW 是不是 *"的逻辑,实现得很潦草——Vixie cron 只检查字段的第一个字符是不是 *。1988 年那会儿没有步进语法,这么写没问题。但现在你能写 */2 这种值,它的第一个字符也是 *……

30 4 */2 * 5
# */2 第一个字符是 *,于是 cron 判定"DOM 是通配" → 走交集分支
# 结果:"每隔一天" 和 "周五" 的关系,跟你直觉的又不一样

POSIX 写的是字段"是(is)一个星号",Vixie 实现成了"以(starts with)星号开头"——一个潜伏了三十多年的标准偏差。结论很简单:DOM 和 DOW 别同时用非 * 的值,尤其别和步进混着写。想验证一个表达式到底在哪些时刻触发,别靠脑补,丢进 在线 Cron 表达式生成器 看它列出的实际执行时间。

坑三:夏令时那两个小时,漏跑或重跑

时间那篇讲过 DST 会让本地时钟跳一小时。落到 cron 上就是:春季前拨,02:00–02:59 这一小时不存在,定在这里的任务可能不触发;秋季回拨,01:00–01:59 走两遍,任务可能跑两次

各实现的处理还不一样,这是最坑的地方——你没法假设换台机器行为一致:

实现DST 处理
Vixie cron / cronie对 <3 小时的调整特殊处理:前拨跳过的固定时间任务事后补跑一次,回拨则避免重复。但仅限"固定时间、粒度大于 1 小时"的任务;高频任务按新时间正常跑
Quartz(Java)前拨的空隙里跳过;回拨的重叠区间跑两次
systemd timer原生正确处理:前拨跳过缺失小时,回拨不重复触发
旧版 AIX有过 DST 期间不触发的真实 bug(APAR IV27424)
🚫

对账、结算、备份这类一天只能跑一次、且必须跑的任务,绝不要排在 DST 切换的 01:00–03:00 之间。回拨那天跑两次的对账任务,能把账算乱;前拨那天没跑的备份,等你发现时已经少了一天的数据。

把这三个坑一次性绕开

顺带:cron 方言不止一种

别以为天下 cron 一个样。Quartz(Java 生态)是 6–7 个字段,最前面多一个,最后可选,而且星期编号是 1=周日,跟 Unix 的 0=周日错开一位。很多人把 Linux 的五字段表达式直接贴进 Spring 的 @Scheduled,要么报错,要么时间错位。标准 cron 没有"秒"这一级——要秒级调度,得换 Quartz 或专门的调度器,别指望 crontab。

跨平台/跨框架抄表达式之前,先确认对方是哪种方言、几个字段、星期怎么编号。

在线 Cron 表达式生成器

可视化生成、解析 cron 表达式,并列出接下来的实际执行时间

打开 Cron 生成器