有个需求是「每月 1 号,如果恰好是周一,就发一封汇总邮件」。有人写下了 0 0 1 * 1,测试时发现邮件发得比预期频繁得多——每月 1 号都发,每个周一也发。他以为日期和星期是「同时满足」,实际是「满足任意一个」。
直觉与规范的分歧
在 cron 的五个字段里,第三个是日期(1—31),第五个是星期(0—6)。直觉上,两个字段都写了具体值,应该是「与」的关系;但 Linux cron 的实现是「或」。
规则的准确表述是:当日期和星期都不为 * 时,只要其中一个匹配当前时间,任务就会执行。
所以 0 0 1 * 1 的含义是「每月 1 号的 0 点,或者每周一的 0 点」,两个条件是并列的。
为什么会被设计成「或」
这个行为源自 cron 早期的实现方式。当时的判定逻辑是分别计算日期和星期是否命中,只要有一个命中就放行,代码路径最简单。这个实现被沿用了下来,成为事实上的标准,后来的实现为了兼容也照此处理。
值得注意的是,当其中一个字段是 时,结果和「与」是一样的——因为 * 永远匹配,另一个字段单独决定是否执行。所以日常写的 0 9 * 1-5(工作日 9 点)不会有歧义,只有两个字段都限定具体值时才需要小心。
怎么实现真正的「同时满足」
标准 cron 做不到,但有几种替代方案。
第一种是在脚本里加判断。让 cron 在一个宽松的时间点触发,脚本开头自己检查日期条件,不满足就直接退出。这是最通用也最可移植的做法。
# 每天 0 点触发,脚本内判断是否为当月 1 号且为周一
0 0 * * * /usr/local/bin/check-and-run.sh
#!/bin/sh
# check-and-run.sh
[ "$(date +\%d)" = "01" ] || exit 0 # 不是 1 号就退出
[ "$(date +\%u)" = "1" ] || exit 0 # 不是周一就退出
# 到这里才真正执行业务逻辑
注意 crontab 里的 % 需要转义成 \%,否则会被当作换行符处理。
第二种是用两个任务加状态标记。一个任务每周一写一个标记,另一个任务在每月 1 号检查标记是否存在。这种做法引入了状态,排查起来更麻烦,一般不推荐。
第三种是换用支持更丰富语法的调度器。systemd timer、Quartz、Kubernetes CronJob 各有自己的表达式方言,其中部分实现提供了更明确的语义。但可移植性会下降,迁移时需要重新验证。
顺带说清星期字段的两个细节
第一,星期 0 和 7 都表示周日。有些实现只接受 0,有些两个都接受。为了可移植,建议统一写 0。
第二,星期字段的取值范围是 0—6,但不同系统对「一周从哪天开始」的处理有差异。1-5 表示周一到周五这一点是通用的,但如果要表达「周末」,写 0,6 比写 6-7 更稳妥。
检查清单
- 记住规则:日期与星期都不为
*时是「或」关系,不是「与」 - 只有一个字段有具体值时不存在歧义,因为
*永远匹配 - 需要「同时满足」时,在脚本里用
date自行判断并提前退出 - crontab 里的
%要转义成\% - 星期统一用 0 表示周日,避免 0 与 7 的兼容问题
- 写表达式后用执行时间预览功能核对未来若干次触发时刻,比凭记忆推导可靠