Cron 表达式揭秘:五个字段、范围、步进与星期 OR 陷阱

五个字段及其有效范围

每个标准的 cron 表达式由恰好五个以空白字符分隔的字段组成,从左到右依次为:分钟(0–59)、小时(0–23)、日期(1–31)、月份(1–12)以及星期(0–7)。分钟始终是最左边的字段——常见错误是从右向左读取 cron 行,这会使含义完全相反。星期字段同时接受 0 和 7 作为星期日,这是继承自 Unix 传统的设计,允许两种惯例都能正常工作。

在遵循 Vixie cron 惯例的实现中,月份字段也可以接受 jandec 的三字母缩写,星期字段则接受 sunsat。但为了在不同环境之间的可移植性,使用数字值更为安全。Quartz Scheduler(用于 Java 应用程序)在最左侧增加了一个字段,AWS EventBridge 等云端调度器则增加了可选的第七个年份字段——这些扩展格式与 POSIX 五字段标准不兼容。

通配符、逗号列表、连字符范围与斜线步进

四种特殊字符控制字段的匹配方式:星号*)匹配该字段的每个有效值——* * * * * 每分钟触发一次。逗号,)创建离散值列表:月份字段中的 1,3,5 代表一月、三月和五月。连字符-)定义包含端点的范围:小时字段中的 9-17 匹配 9 点到 17 点的每个整点,适用于「仅限工作时间」的调度。这三种字符可以在单个字段中组合使用:星期字段中的 1-5,0 匹配周一到周五加上周日。

斜线/)引入步进值。使用星号后时,分钟字段中的 */15 产生集合 {0, 15, 30, 45}——从字段最小值开始的每个 15 的倍数。使用范围后时,0-30/5 产生 {0, 5, 10, 15, 20, 25, 30}——该范围内每隔 5 个值。步进始终从范围的左边界开始(对于 * 则从 0 开始),而非从随机偏移量开始。这就是为什么分钟字段中的 */5 无论何时启动 cron 或添加任务,都会在 0、5、10……触发。

日期与星期的 OR 陷阱

cron 中最令人意外的行为发生在调度中同时将日期字段和星期字段设置为非通配符值时。直觉上,你可能认为 0 2 15 * 5 意味着「仅在当月 15 日且为周五时凌晨 2 点执行」——AND 条件。然而实际上,包括 Vixie cron 和 dcron 在内的大多数实现将此视为 OR 条件:任务会在每月 15 日凌晨 2 点触发,同时也会在每个周五凌晨 2 点触发,无论日期为何。cron(5) 手册页明确记载了这一点:「如果同时指定了 DOM 和 DOW,当任一字段匹配当前时间时,命令就会执行。」

当开发者试图表达复合日期条件时,这种行为往往造成困惑。如果你真的需要「每月第三个周五」,cron 无法直接表达。惯用的解决方案是调度在每个周五执行(0 2 * * 5),并在命令开头加入 shell 测试:[ $(date +\%d) -ge 15 ] && [ $(date +\%d) -le 21 ] && /path/to/script。另一种方案是使用原生支持日历循环规则(RFC 5545 RRULE)的高级调度器,例如 systemd 定时器的 OnCalendar= 指令。

预定义快捷方式:@reboot、@hourly、@daily、@weekly、@monthly

Vixie cron 引入了一组命名快捷方式,用于替代常见调度的五字段语法。@reboot 在 cron 守护进程本身启动后立即执行一次任务——适用于轻量级启动工作,但对于生产服务,建议使用适当的 init 系统(如 systemd unit)。@hourly 等同于 0 * * * *,在每小时的第 0 分触发。@daily(别名 @midnight)等同于 0 0 * * *@weekly 等同于 0 0 * * 0(周日午夜);@monthly 等同于 0 0 1 * *(每月第一天午夜);@yearly(别名 @annually)等同于 0 0 1 1 *(1 月 1 日午夜)。

这些快捷方式提高了 crontab 文件的可读性,但依赖于 cron 守护进程识别 @ 语法——POSIX 并未定义这些语法,因此在某些精简或嵌入式 cron 实现中无法使用。为云端调度器(AWS EventBridge、Google Cloud Scheduler、GitHub Actions)编写 cron 表达式时,请查阅提供者文档,因为大多数云平台不支持 @ 快捷方式,需要明确的五字段格式。

步进值与「不是从现在起每 N 分钟」的陷阱

关于步进语法的常见误解是将 */N 理解为「从此任务创建时起每 N 个单位」。事实并非如此。步进是对绝对时间值的取模过滤。分钟字段中的 */5 是集合 {x : x mod 5 = 0, 0 ≤ x ≤ 59} = {0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55} 的简写——每小时 12 个固定时钟位置。cron 守护进程将这些位置与当前时钟分钟对比;它不记忆你的任务是何时添加的。

这导致 0 */6 * * * 产生一个微妙问题。此表达式在小时 0、6、12 和 18 触发——午夜、上午 6 点、中午和下午 6 点——每天恰好四次。如果你在下午 3 点添加此任务并预期「六小时后的下午 9 点首次执行」,你会惊讶地发现:下一次执行是下午 6 点(小时 18)。若需要在下午 9 点执行,请使用明确列表 0 9,15,21,3 * * * 或使用 0 3/6 * * * 改变起始小时——但请注意,起始/步进 表示法是 Vixie cron 的扩展功能,并非所有地方都支持;为了最大可移植性,请始终使用明确的逗号分隔列表。

时区:cron 调度中的隐形错误

传统 cron 守护进程在其所在机器的系统时区中执行所有任务。在设置为 UTC 的服务器上——这是大多数 Docker 容器基础镜像和许多云端 VM 的默认值——0 8 * * 1-5 并不意味着你所在地的上午 8 点;它意味着 UTC 上午 8 点。如果你的团队在 UTC+9(日本标准时间),该任务会在本地时间下午 5 点触发。AWS CloudWatch Events(现已改名为 EventBridge)的 cron 表达式记载为仅使用 UTC。GitHub Actions 的 on: schedule: 也是 UTC。忽视这一点可能导致每周账单报告在本地凌晨 3 点而非预期的上午 8 点执行。

夏令时(DST)引入了两种额外的故障模式。当时钟向前拨(例如从 01:59 跳至 03:00)时,调度在跳过小时内的 cron 任务在那天根本不会执行。当时钟向后拨(例如从 02:59 回到 02:00)时,同一分钟出现两次;任务执行一次还是两次取决于守护进程的实现。最安全的生产策略是让 cron 服务器永远以 UTC 运行,并在应用层进行本地时间转换,或使用接受明确 TimeZone 参数的调度器,例如具有 TimeZone= 的 systemd 定时器、加入了 .spec.timeZone 的 Kubernetes 1.25+ CronJob,或接受 IANA 时区名称的 AWS EventBridge Scheduler。