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 都是周日)
│ │ │ │ │
* * * * * 要执行的命令
坑一:日和星期同时写,是并集不是交集
这是最反直觉、也最容易上线后才发现的一条。规则是这样的:
- 日(DOM)和星期(DOW)有一个是
*→ 取交集(正常理解) - 两个都不是
*→ 取并集,哪个匹配上都跑
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 都用 UTC。UTC 不存在夏令时,坑三直接消失。需要"北京时间凌晨 3 点"就自己换算成 UTC 19:00 写进去。这是最省心的一条。
- DOM 和 DOW 不要同时填非
*值,需要"且"的逻辑放命令里判断,绕开坑一和坑二。 - 给任务加锁防重叠。cron 不管上一次有没有跑完就到点再起一个,长任务会叠在一起。用
flock兜底:* * * * * flock -n /tmp/job.lock /path/to/job - 现代 Linux 优先考虑 systemd timer。它原生处理 DST、自带日志(
journalctl)、能设依赖和资源限制,比 cron 健壮得多。
顺带:cron 方言不止一种
别以为天下 cron 一个样。Quartz(Java 生态)是 6–7 个字段,最前面多一个秒,最后可选年,而且星期编号是 1=周日,跟 Unix 的 0=周日错开一位。很多人把 Linux 的五字段表达式直接贴进 Spring 的 @Scheduled,要么报错,要么时间错位。标准 cron 没有"秒"这一级——要秒级调度,得换 Quartz 或专门的调度器,别指望 crontab。
跨平台/跨框架抄表达式之前,先确认对方是哪种方言、几个字段、星期怎么编号。