怎么读懂 Cron 表达式(顺便写出真按你想法跑的那种)
Cron 无非是固定位置上的五个字段,加上四个符号。这篇带你一眼读懂任意调度规则,并绕开那两个会让「看着没错」的表达式在错的时间触发的坑。
一个 pull request 加了一行 */15 9-17 * * 1-5,评审就卡在一个谁都说不清的问题上:这行到底什么时候跑?有人猜「每 15 分钟一次?」,也有人补一句「白天吧,工作日,大概」。都挨着边,但谁也不敢确定。
Cron 表达式看着像密码,其实几乎没什么要背的。它就是固定位置上的五个字段,加上四个符号。只要能读懂这几个位置,*/15 9-17 * * 1-5 在同一种 cron 方言里就只有一个意思,而且每次都一样。真正把人绊倒的不是语法,是两个陷阱:表达式读着没错,却在错的时间触发。这两个后面都会讲到。
五个字段,位置决定一切
一行标准 cron 是用空格隔开的五个字段。它们的含义完全由所在的位置决定,没有任何标签,所以 30 6 和 6 30 是两个不同的时间,不是同一个时间的两种写法。
从左到右:
| 位置 | 字段 | 取值范围 |
|---|---|---|
| 1 | 分钟 | 0–59 |
| 2 | 小时 | 0–23 |
| 3 | 日(几号) | 1–31 |
| 4 | 月 | 1–12 |
| 5 | 星期 | 0–7(0 和 7 都是周日) |
所以 30 6 * * * 就是「6:30」:分钟 30、小时 6,后面三个星号表示任意日、任意月、任意星期。读一行 cron 的办法,就是顺着这五个格子,把每一格念出来:在第几分、第几时、几号、几月、星期几触发。
其中有两个字段会以一种让人意外的方式互相影响,这正是第一个大坑的来源。先记着,等下面「日期字段」那一节再细讲。
大多数解析器里,月份和星期也能用三个字母的名字:JAN–DEC 和 SUN–SAT。0 9 * * MON 比 0 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-FRI、FRI,编号的差异就不用操心了。
日期字段的坑:「日」和「星期」同时出现
这个几乎坑过每一个人。下面这行到底什么时候跑?
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 # 和其它方言扩展
看到 ?、L、W、#,你面对的就是某种方言扩展,不是通用 cron(? 表示「不指定」,L 表示「最后」,W 表示「最近的工作日」,# 表示「当月第几个星期几」)。它们最常和 Quartz 绑在一起,但并非 Quartz 独有,比如 Cloudflare Workers 就支持其中一部分 L、W、#。而且同一个字符在不同平台含义可能不一样:Kubernetes 认 ?,但只是把它当成 *,并不是 Quartz 那种「不指定」。所以别默认它只有一种意思,以你要用的那个平台的文档为准。
这其实才是全文真正的重点:语法很简单,真正决定它是什么意思的是「方言」。部署前,先按目标平台的规则核对一次。
| 目标平台 | 字段数 | 时区 | 星期编号 | 扩展 |
|---|---|---|---|---|
| Linux / Cronie | 5 | 服务器本地,或 CRON_TZ | 0–7,0 和 7 都是周日 | @ 简写 |
| GitHub Actions | 5 | UTC | 0–6,0 是周日 | 不支持秒 |
| Cloudflare Workers | 5 | UTC | 1 是周日……7 是周六 | 部分 L W # |
| Kubernetes CronJob | 5 | .spec.timeZone | 0 是周日 | ? 当作 * |
| Quartz | 6–7 | 调度器配置 | 各方言不同 | ? L W # |
表格装不下的两条实用提醒:GitHub Actions 的调度是尽力而为,负载高时会被推迟,所以别拿它当精确定时器;还有 Windows 根本没有 cron,得用任务计划程序或 schtasks。
一份阅读清单
一行 cron 摆到你面前时:
- 先数字段。五个是标准;六个说明第一个是秒;像
@daily这种简写是另一回事。 - 按顺序读五个位置:分、时、日、月、星期,一个个念出来。
- 展开符号:
*是每一个,,是列表,-是范围,/是步进。记住5(一个点)不等于*/5(一段节奏)。 - 检查两个日期字段。「日」和「星期」都写了值时,标准 cron 按「或」处理,任意一个匹配就跑,具体以你的平台为准。
- 问清运行环境用哪个时区:服务器本地还是 UTC,因为表达式本身不说。
做到这些,每一行 cron 都能一眼读懂。想核对而不是硬算的时候,尤其是日期字段和时区这两个坑,把表达式丢进 Cron 表达式工具:白话说明、每个字段的取值范围,还有按你选的时区算出来的接下来的执行时间。
而要是一行你已经确认没写错的 cron,跑起来却什么都没发生,那是另一个问题了:不是语法,而是环境、crontab 放的位置,或者这台机器本身。那半边的现场排查,交给《为什么我的 cron 没跑起来?》。