DOM/DOW 的 OR 陷阱:为什么 '0 0 13 * 5' 不是 13 号的星期五
cron 中被误解最多的规则其实明明白白写在 POSIX crontab(5) 里:当 day-of-month 字段和 day-of-week 字段都被限制(即都不是 *)时,任意一个匹配就会触发任务。因此 0 0 13 * 5 会在每月 13 日的午夜和每个星期五的午夜都执行。你以为得到的是一年一到三次的"13 号星期五",实际上是每年约 60 次运行。由于两个触发条件各自看起来都合理,这个 bug 常常能通过 code review,直到有人问起"月度"报表为什么上周发了四次才暴露。
要获得 AND 语义,可靠的做法是只在表达式中限制其中一个字段,另一个放到命令里检查。把 day-of-month 固定为 13,让 shell 检查星期:date +%u 输出 ISO 星期(1 = 周一,7 = 周日),与 5 比较即可只选中周五。任务最多每年运行 12 次,不是周五的 13 号时守卫条件会静默退出。
这行命令里还藏着第二个陷阱:在 crontab 条目中,未转义的 % 是特殊字符。第一个 % 会截断命令——其后的内容被作为标准输入传给命令,后续的 % 则变成换行符。date +%Y-%m-%d 在交互式 shell 中运行得很好,在 cron 里却会悄悄截断你的任务。要么把每个 % 转义成 \%,要么把日期格式化挪进脚本,让 cron 只负责调用脚本。相当一部分"cron 跑了但什么都没发生"的工单,最后都归结到这一个字符上。
# OR semantics: fires every Friday AND every 13th (~60x/year)
0 0 13 * 5 /opt/report.sh
# AND semantics: pin DOM, test DOW in the command (max 12x/year)
0 0 13 * * [ "$(date +\%u)" = 5 ] && /opt/report.sh
# Unescaped % silently truncates the command line
0 1 * * * echo "backup-$(date +\%Y\%m\%d)" >> /var/log/runs.log夏令时(DST):cron 任务的坟场
在会向前拨钟的时区里把任务排在 02:30,墙上时钟会从 02:00 直接跳到 03:00——任务应当执行的那个时刻当晚根本不存在。接下来发生什么因实现而异:vixie-cron 及其后继 cronie 能检测到跳变,在切换后不久补跑被跳过的任务;而容器里常见的极简 cron 克隆则直接跳过。秋季回拨更糟:01:00-02:00 这一小时会出现两次,幼稚的调度器会把任务触发两遍。如果这个任务负责开发票或扣款,你就每年给客户重复扣一次费。
有两种防御方式:把本地时间的调度放进安全窗口(03:00 及之后、02:00 之前),让时钟切换碰不到它;或者让调度器直接跑在没有任何切换的 UTC 上。
托管调度器还有自己的语义。GitHub Actions 的 schedule: cron 只按 UTC 解释,而且文档明确说明高负载时运行可能延迟数分钟甚至被丢弃——整点这类热门分钟受影响最大,所以选 17、43 这类冷门分钟。Kubernetes CronJob 在 1.27 中让 spec.timeZone 字段进入 stable。同时配好 startingDeadlineSeconds(允许开始时间最多晚多少——超过期限该次运行计为 missed)和 concurrencyPolicy:上一个任务还在运行时,下一个是与之共存(Allow,默认)、被阻止(Forbid),还是把旧的杀掉替换(Replace)。
# Kubernetes CronJob with explicit time zone (stable in 1.27)
apiVersion: batch/v1
kind: CronJob
spec:
schedule: "30 4 * * *"
timeZone: "Asia/Seoul"
startingDeadlineSeconds: 300
concurrencyPolicy: Forbid*/N 不等于'每隔 N':步进值的真实语义
步进值 */N 展开为"该字段取值范围内、从范围起点开始每隔 N 的那些值"——而不是"每经过 N 个单位"。在分钟字段上两种理解恰好一致:*/15 得到 0,15,30,45,一小时正好 60 分钟,间隔均匀。到了 day-of-month 幻觉就破灭了:1-31/10 展开为 1、11、21、31 日——下个月又从 1 号重新开始,于是"31 号接着 1 号"变成连续两天各跑一次。
这意味着真正的"每 10 天"在 cron 里无法表达:日期计数器在每个月边界重置,而 cron 不记得上次运行是什么时候。同样的怪癖让 0 0 */2 * *("隔天执行")在 1,3,...,29,31 日运行后,下个月 1 日又运行——凡是有 31 天的月份都会出现连续两天执行,一年发生七次。
如果确实需要跨月边界的固定间隔,就让任务每天运行,在脚本里做门控:比较 epoch 天数对 N 取模,或把上次运行的时间戳存进文件、时间不够就提前退出。另一个选择是 systemd 定时器的 monotonic 调度(OnUnitActiveSec=10d),它从上次激活时刻起计数,完全不看日历。
*/15 * * * * # minutes 0,15,30,45 — uniform, as expected
0 0 1-31/10 * * # days 1,11,21,31 — NOT "every 10 days"
0 0 */2 * * # 1,3,...,29,31 then the 1st: twice in a row
# real "every 10 days": run daily, gate on the epoch day
0 3 * * * [ $(( $(date +\%s) / 86400 \% 10 )) -eq 0 ] && /opt/task.sh一种语法,多种方言:Quartz、EventBridge 与 systemd 定时器
5 字段的 POSIX 形式(分、时、日、月、星期)只是基准线。嵌入无数 Java 服务的调度器 Quartz(包括 Spring 的 @Scheduled)使用 6 或 7 个字段:秒排第一,可选的年份排最后,并且拒绝 DOM 和 DOW 同时为具体值的表达式——其中一个必须是表示"无特定值"的 ?。作为交换,你得到真正有用的运算符:L 表示当月最后一天(6L 是最后一个周五),W 表示离指定日期最近的工作日(15W),# 表示第 n 个星期几,MON#2 就是第二个周一——这些是纯 cron 根本无法表达的。
AWS EventBridge 有自己的 6 字段方言,以年份字段(1970-2199)结尾,并且像 Quartz 一样拒绝 DOM 和 DOW 同时为 *。把 crontab 行直接复制进 EventBridge 规则,常常要把其中一个字段换成 ? 才能通过校验。
经典 cron 还接受 @reboot、@daily、@hourly 这类昵称——方便,但非标准且支持参差不齐,需要可移植性的地方要避开。如果需要秒级精度或补跑(catch-up)语义,systemd 定时器是现代答案:OnCalendar 接受秒(Mon..Fri 09:00:30),Persistent=true 会记录上次触发时间,在下次开机时补跑错过的任务——这正是 cron 出了名缺失的行为,也是为睡过调度时刻的笔记本准备的 anacron 存在的原因。
0 9 * * 1-5 # POSIX: weekdays at 09:00
0 0 9 ? * MON-FRI # Quartz: seconds first, ? required in DOM
0 0 9 ? * MON#2 # Quartz: second Monday of the month
0 9 ? * MON-FRI * # EventBridge: year field appended
# systemd timer unit equivalent
OnCalendar=Mon..Fri 09:00:00
Persistent=true # run missed jobs after downtime运维卫生:重叠执行、丢失的输出与悄然停摆的任务
cron 没有补跑机制:任务该运行的时刻如果主机宕机或守护进程停着,那次运行就没了——没有队列,也没有重试。对错过一次就出问题的任务(日志轮转、证书续期、账务),要么迁到带 Persistent=true 的 systemd 定时器,要么让任务自身的逻辑能容忍空档。
下一个意外是输出。cron 会把任务写到 stdout 或 stderr 的所有内容,通过本地 MTA 邮寄给 crontab 属主;在没配 MTA 的现代机器上,这些输出直接蒸发。想要真正的告警就设置 MAILTO,否则用 >> /var/log/job.log 2>&1 显式重定向——并记得轮转那个文件,cron 会乐此不疲地往里追加好几年。
慢任务与下一次调用重叠是经典的数据损坏来源——两个 rsync 写同一个目录,两个迁移跑同一个数据库。用 flock -n /path/lock 包住命令,第二个实例会立即退出而不是越积越多。还要当心惊群效应(thundering herd):全世界数量惊人的 crontab 都写着 0 0 * * *,共享数据库和 API 在午夜整点被集中轰炸。cronie 支持 RANDOM_DELAY,systemd 有 RandomizedDelaySec,也可以用主机名哈希推导出分钟,让整个集群自动散开。
最后,最危险的故障模式是任务悄无声息地停跑了——一次错误的编辑、被禁用的账号、写满的磁盘。cron 不会告诉你。把监控反过来,用 dead-man's-switch:任务每次成功就 ping 一个 URL(healthchecks.io 模式),告警在 ping 停止到达时触发,而不是在某处报错时。
[email protected]
RANDOM_DELAY=15 # cronie: spread the start by 0-15 minutes
# no overlap, logged output, dead-man's-switch ping
17 3 * * * flock -n /run/backup.lock /opt/backup.sh \
>> /var/log/backup.log 2>&1 \
&& curl -fsS https://hc-ping.com/UUID > /dev/null