DevKitLab Logo DevKitLab
Cron / crontab / 定时任务 / 运维

怎么读懂 Cron 表达式(顺便写出真按你想法跑的那种)

Cron 无非是固定位置上的五个字段,加上四个符号。这篇带你一眼读懂任意调度规则,并绕开那两个会让「看着没错」的表达式在错的时间触发的坑。

一个 pull request 加了一行 */15 9-17 * * 1-5,评审就卡在一个谁都说不清的问题上:这行到底什么时候跑?有人猜「每 15 分钟一次?」,也有人补一句「白天吧,工作日,大概」。都挨着边,但谁也不敢确定。

Cron 表达式看着像密码,其实几乎没什么要背的。它就是固定位置上的五个字段,加上四个符号。只要能读懂这几个位置,*/15 9-17 * * 1-5 在同一种 cron 方言里就只有一个意思,而且每次都一样。真正把人绊倒的不是语法,是两个陷阱:表达式读着没错,却在错的时间触发。这两个后面都会讲到。

五个字段,位置决定一切

一行标准 cron 是用空格隔开的五个字段。它们的含义完全由所在的位置决定,没有任何标签,所以 30 66 30 是两个不同的时间,不是同一个时间的两种写法。

从左到右:

位置字段取值范围
1分钟0–59
2小时0–23
3日(几号)1–31
41–12
5星期0–7(0 和 7 都是周日)

所以 30 6 * * * 就是「6:30」:分钟 30、小时 6,后面三个星号表示任意日、任意月、任意星期。读一行 cron 的办法,就是顺着这五个格子,把每一格念出来:在第几分、第几时、几号、几月、星期几触发

其中有两个字段会以一种让人意外的方式互相影响,这正是第一个大坑的来源。先记着,等下面「日期字段」那一节再细讲。

大多数解析器里,月份和星期也能用三个字母的名字:JANDECSUNSAT0 9 * * MON0 9 * * 1 更好读,而且用名字能绕开各平台之间星期编号的差异,这一点下面还会讲。

先读懂四个基础符号

每个字段都用同样这四个符号。先掌握这四个,常见的五字段 cron 就能读了。

  • * 星号:任意值。小时字段里的 * 就是每小时;五个星号就是每天每分钟都跑。
  • , 逗号:列一串值。分钟字段写 0,30,就是每小时的第 0 分和第 30 分。MON,WED,FRI 就是这三天。
  • - 连字符:一段范围。小时字段写 9-17,就是 9 点到 17 点,含 17 点。MON-FRI 就是工作日。
  • / 斜杠:步进。分钟字段写 */15,就是每 15 分钟一次:0、15、30、45。也能在范围里步进:9-17/2 是 9 点到 17 点,每隔两小时一次。

最后这个最容易混淆,值得单独说清:

5   * * * *   # 每小时的第 5 分钟,一小时一次
*/5 * * * *   # 每 5 分钟一次,一小时十二次

5 是一个固定值:就第 5 分钟,也只有第 5 分钟。*/5 才是按 5 分钟步进:0、5、10……前者只匹配一个分钟数,后者会反复匹配多个。把两者搞混,「每五分钟」就悄悄变成了「一小时一次」。

还有一个更隐蔽的同类错误。步进是从字段范围的起点开始数的,不是从「现在」开始,而且这个范围每小时都会重置。所以分钟字段里的 */35 并不是「每 35 分钟一次」。它在第 0 分和第 35 分触发,接着进了下一个小时又从 0 开始,于是永远是「隔 35 分钟、再隔 25 分钟」这样交替。只要步长除不尽范围,都会这样。真要一个严格的 35 分钟间隔,单个 cron 字段表达不出来。

现在开篇那行就清楚了。*/15 9-17 * * 1-5:每 15 分钟一次,在 9 点到 17 点之间,任意日、任意月,星期 1 到 5,在标准 cron 里就是周一到周五。注意 17 点是包含在内的,所以它会一直跑到 17:45,是从 09:00 到 17:45,而不是到 17:00 为止。不想在脑子里换算,就把它贴进 Cron 表达式工具:它会给一句白话说明,把每个字段和它的取值范围拆开列出来,再按你选的时区列出接下来会触发的时间,默认 5 条,最多 50 条。

数字星期号并不通用。标准 cron 和 GitHub Actions 从 0 开始数,0 是周日。Cloudflare Workers 却从 1 开始,1 表示周日、一直到 7 表示周六,而 Kubernetes 里 0 是周日。所以同样一个 1-5,在 Linux crontab 里是周一到周五,在 Cloudflare 上却是周日到周四。只要平台认,就用名字写星期,比如 MON-FRIFRI,编号的差异就不用操心了。

日期字段的坑:「日」和「星期」同时出现

这个几乎坑过每一个人。下面这行到底什么时候跑?

0 0 13 * FRI

第 0 分、第 0 时、13 号、任意月、星期五。看着像「每月 13 号那天的午夜,而且得是周五」。并不是。在标准 Unix cron 里,也就是大多数 Linux crontab 用的 Vixie/ISC 那一支,只要「日」和「星期」两个字段都被限定了,都不是 *,cron 就会在其中任意一个匹配上时触发。所以 0 0 13 * FRI 会在每月 13 号的午夜跑,也会在每个周五的午夜跑。是「或」,不是「且」。

一句话记住:只要有一个日期字段是 *,另一个照常生效;可一旦两个都写了值,它们就按「或」组合,而不是「且」。这也是为什么没有一行简单的 cron 能表达「13 号又逢周五」,你没法同时要求两个条件成立。

这一对字段也是各家实现分歧最大的地方,所以把「或」当成常见默认就好,别当成放之四海皆准的铁律;两个日期字段都写了值时,最好查一下目标平台的文档。工具 一旦检测到两个字段都被限定,就会专门提示你,免得被它咬到。

另一个坑:它到底按哪个时区跑

cron 表达式里根本没有时区。0 9 * * * 是「9 点」,可是哪儿的 9 点?答案在表达式之外决定,弄错了,一个「早上 9 点」的任务就会在凌晨 2 点把人吵醒。

传统 crontab 按服务器的本地时区跑。在兼容 Cronie 的 crontab 里,你可以在文件顶部用 CRON_TZ 指定调度时区;但别以为设个普通的 TZ 环境变量就能改调度用的时区,也别把这两个前缀塞进 Kubernetes 的 spec.schedule,Kubernetes 会直接拒绝,它用的是单独的 spec.timeZone 字段。服务器一搬家,或者机房默认走 UTC,同一行就会在不同的钟点触发。大多数托管调度器干脆把一切都固定成 UTC 来绕开这种歧义,比如 GitHub Actions 的 schedule: 触发和 Cloudflare Workers 的 cron 触发,都按 UTC 解释你的表达式,没有例外。

夏令时又加了一层,但只在「按本地墙上时间匹配」的实现里才有,也就是 Vixie/Cronie 那类 crontab。在那种环境里,一个定在凌晨 2:30 的任务,在「春季拨快」那天不会跑,因为那个钟点根本不存在;在「秋季拨慢」那天则可能跑两次。走 UTC 的平台压根没有本地的夏令时跳变,变的只是同一次运行换算到你所在时区显示出来的钟点。要是这些真会影响到你,别在脑子里硬算,工具 让你选一个时区,把接下来每一次运行同时按那个时区和 UTC 列出来,夏令时的偏移就一眼可见。至于 UTC、偏移量、时区这三者本身有什么区别,看这篇 UTC、GMT、ISO 8601 与 Unix 时间戳有什么区别?

五个字段、六个字段,还有 @daily 这类简写

传统 Unix cron 是五个字段。有些调度器会在最前面加一个「秒」字段:Spring 和很多编程语言的 cron 库用六字段形式,Quartz 则是六个必填字段,外加一个可选的第七个「年」。所以 */30 * * * * *(六个字段)是「每 30 秒一次」,而 */30 * * * *(五个字段)是「每 30 分钟一次」。读之前先数字段数,前面多出一个字段,后面所有字段的含义都跟着变。

Cronie 和 Vixie 风格的 crontab 还认一组直接替代五个字段的命名简写:

@yearly    # 0 0 1 1 *   每年一次,1 月 1 日午夜
@monthly   # 0 0 1 * *   每月 1 号午夜
@weekly    # 0 0 * * 0   每周日午夜
@daily     # 0 0 * * *   每天午夜(@midnight 一样)
@hourly    # 0 * * * *   每小时整点
@reboot    #             开机时跑一次

方便,但不通用,它们只是部分 crontab 实现支持的扩展。尤其是 @reboot,得有「开机」这个生命周期才行,所以没有开机过程的托管调度器一般不认它,可能被拒绝,或者根本不支持。想用工具查看某个简写时,先把它展开成五字段形式:工具解析的是五字段和六字段的数字语法,所以 0 0 * * * 能给出字段拆解和未来执行列表,而单写 @daily 不行。

? L W # 和其它方言扩展

看到 ?LW#,你面对的就是某种方言扩展,不是通用 cron(? 表示「不指定」,L 表示「最后」,W 表示「最近的工作日」,# 表示「当月第几个星期几」)。它们最常和 Quartz 绑在一起,但并非 Quartz 独有,比如 Cloudflare Workers 就支持其中一部分 LW#。而且同一个字符在不同平台含义可能不一样:Kubernetes 认 ?,但只是把它当成 *,并不是 Quartz 那种「不指定」。所以别默认它只有一种意思,以你要用的那个平台的文档为准。

这其实才是全文真正的重点:语法很简单,真正决定它是什么意思的是「方言」。部署前,先按目标平台的规则核对一次。

目标平台字段数时区星期编号扩展
Linux / Cronie5服务器本地,或 CRON_TZ0–707 都是周日@ 简写
GitHub Actions5UTC0–60 是周日不支持秒
Cloudflare Workers5UTC1 是周日……7 是周六部分 L W #
Kubernetes CronJob5.spec.timeZone0 是周日? 当作 *
Quartz6–7调度器配置各方言不同? L W #

表格装不下的两条实用提醒:GitHub Actions 的调度是尽力而为,负载高时会被推迟,所以别拿它当精确定时器;还有 Windows 根本没有 cron,得用任务计划程序或 schtasks

一份阅读清单

一行 cron 摆到你面前时:

  1. 先数字段。五个是标准;六个说明第一个是秒;像 @daily 这种简写是另一回事。
  2. 按顺序读五个位置:分、时、日、月、星期,一个个念出来。
  3. 展开符号* 是每一个,, 是列表,- 是范围,/ 是步进。记住 5(一个点)不等于 */5(一段节奏)。
  4. 检查两个日期字段。「日」和「星期」都写了值时,标准 cron 按「或」处理,任意一个匹配就跑,具体以你的平台为准。
  5. 问清运行环境用哪个时区:服务器本地还是 UTC,因为表达式本身不说。

做到这些,每一行 cron 都能一眼读懂。想核对而不是硬算的时候,尤其是日期字段和时区这两个坑,把表达式丢进 Cron 表达式工具:白话说明、每个字段的取值范围,还有按你选的时区算出来的接下来的执行时间。

而要是一行你已经确认没写错的 cron,跑起来却什么都没发生,那是另一个问题了:不是语法,而是环境、crontab 放的位置,或者这台机器本身。那半边的现场排查,交给《为什么我的 cron 没跑起来?》