cron 表达式的日期和星期是或关系

工具相关 ·

有个需求是「每月 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 的兼容问题
  • 写表达式后用执行时间预览功能核对未来若干次触发时刻,比凭记忆推导可靠
阅读 12