发布于 更新于
Cron 表达式是 5 到 7 个由空白分隔的字段,每个字段收窄时间的一个分量。语法十分钟就能学会,却能让人误读很多年:两种通行方言的字段顺序不一致,有一个运算符的含义和它的外观不符,而两个「日」字段之间是或关系而不是与关系。本文把这三件事讲透,再加上那个会悄悄跳过或重复一次执行的夏令时问题。
先确认你面对的是哪种方言
Cron 的判定逻辑是:每次时钟走动时,检查当前时间是否满足全部字段。各实现之间真正的差异在于字段有几个、每个位置代表什么。经典 Unix cron,也就是多数 Linux 发行版 crontab 背后的 Vixie cron,以及 BusyBox、cronie 和几乎所有 POSIX 系统,用的是 5 个字段:分钟、小时、日、月、星期。它能表达的最小粒度就是一分钟,没有秒字段,再怎么写语法也变不出来。
Quartz 是 Java 生态的调度器,Spring、Elastic Job、XXL-Job 以及一大批 JVM 工具都沿用了它的语法,字段数是 6 个或 7 个:最前面多一个秒字段,最后可以再加一个年字段。多出来的那个秒字段,是绝大多数复制粘贴事故的根源:同一串表达式在一种方言里是「每天零点」,在另一种方言里就变成「零点的每一分钟」,而且两边都不会报错。
6 段表达式单看是真的有歧义。它可能是 Quartz,也可能是某些云调度器和 Go 库使用的「秒在前」方言:保留 Unix 语义,只在前面补一个秒。判别的线索是 Quartz 独有的字符 —— 如果某个日期字段里出现了 ?、L、W 或 #,那就是 Quartz;如果没有,你只能去查运行时的文档。本站的 Cron 工具会把不含这些字符的 6 段表达式标记为「带秒 6 段(与 Quartz 存在歧义)」,这是诚实的说法,而不是替你猜一个答案。
动笔之前,请以调度器自己的文档为准确认方言,而不是参考某个讲另一个调度器的问答帖。这两分钟的成本,远低于一个本该每晚跑一次、结果每分钟跑一次并持续了一周的任务。
位置序号从表达式最左侧数起。请特别注意:两种方言对星期的编号并不相同,这是「差一天」类问题的真实来源。
| 字段 | Unix 位置 | Quartz 位置 | 取值范围 | 额外字符 |
|---|---|---|---|---|
| 秒 | 无 | 1 | 0-59 | * , - / |
| 分钟 | 1 | 2 | 0-59 | * , - / |
| 小时 | 2 | 3 | 0-23 | * , - / |
| 日 | 3 | 4 | 1-31 | Quartz 另有 ? L W |
| 月 | 4 | 5 | 1-12 或 JAN-DEC | * , - / |
| 星期 | 5 | 6 | Unix 为 0-7(0 与 7 都是周日);Quartz 为 1-7(1 是周日)或 SUN-SAT | Quartz 另有 ? L # |
| 年 | 无 | 7(可选) | 1970-2099 | * , - / |
四个运算符,以及被误读得最多的那个
每个字段都接受同样的四种写法。星号匹配该字段范围内的所有取值;逗号构成枚举,例如 0,15,45;连字符构成闭区间,例如 9-17;斜杠表示步长 —— 被误读的就是它。
步长不是时间间隔,而是在它所依附的那个区间上按固定跨度取值,而这个区间会在更大一级单位的边界处重新开始。所以分钟字段里的 */20 表示每小时的第 0、20、40 分钟,恰好等于每二十分钟一次,因为 20 能整除 60。但写成 */45 就只剩下第 0 和第 45 分钟:任务在 00:00、00:45、01:00、01:45 触发,中间夹着一个作者根本没打算要的 15 分钟间隔。如果你真的需要「每 45 分钟」,cron 表达不了,请改用任务自己安排下一次执行的定时器,或者用 systemd timer 的 OnUnitActiveSec。
步长也可以挂在显式区间上,而不只是挂在星号上。分钟字段里的 5-55/10 匹配 5、15、25、35、45、55。Quartz 里的习惯写法是「起点/步长」,5/10 表示「从 5 开始,每 10 一次」直到字段上界。多数 Unix cron 实现也接受这种写法,但它的可移植性不如显式区间。
枚举与区间可以在同一字段里自由组合。0 9-17 * * 1-5 表示周一到周五、九点到十七点的每个整点,读起来很清楚。但当一个表达式需要三个逗号外加嵌套步长时,它就不再自解释了,真正在承担解释责任的是 crontab 里上面那行注释。
*/20 * * * * 第 0、20、40 分钟 -> 真正的 20 分钟间隔
*/45 * * * * 第 0 和第 45 分钟 -> 45 分钟后接 15 分钟,几乎不可能是本意
5-55/10 * * * * 第 5,15,25,35,45,55 分钟
0 */2 * * * 0,2,4,...,22 点整 -> 每两小时一次
0 9-17 * * 1-5 周一至周五 09:00 到 17:00
*/15 9-17 * * * 09:00、09:15、09:30、09:45、10:00 …… 17:45
Quartz 六段,秒在最前:
0 0 12 ? * MON-FRI 工作日 12:00:00
0 */5 * ? * * 每天第 0,5,10,… 分钟的第 0 秒陷阱:日与星期之间是「或」,不是「与」
Cron 各字段之间是与关系:分钟要匹配,且小时要匹配,且月份要匹配。但有一个从最初实现继承下来、并被 POSIX 写进标准的例外,几乎每个人迟早都会踩到 —— 当「日」字段和「星期」字段同时被限定,也就是两者都不是星号时,只要其中任意一个匹配,任务就会执行。
最典型的受害者是 0 0 13 * 5,写它的人想表达「每逢 13 号且是周五的零点」。它实际排出来的是:每月 13 号零点,外加每个周五零点。以 UTC 时区、2026 年 9 月 21 日为起点,预览结果是 9 月 25 日周五、10 月 2 日周五、10 月 9 日周五,然后是 10 月 13 日周二,再接 10 月 16 日周五。那个周二就是破绽。一个本应一年跑十二次的任务,会跑大约六十四次。
只要两个日期字段里有一个是星号,这个例外就不生效,也不会有任何意外。0 0 * * 5 就是每个周五;0 0 13 * * 就是每月 13 号。或逻辑只在两个字段都被限定时出现 —— 而那恰恰是人们想表达交集时会写出的形式。
Quartz 的做法是直接不让你写出这种歧义:两个日期字段中必须恰好有一个是 ?,表示「不指定」。如果两个都写成 *,Quartz 会拒绝解析,而不是替你选一种行为。这就是为什么线上见到的 Quartz 表达式几乎都带问号,也是为什么「把问号去掉改成 Unix cron」会改变语义。
如果你确实需要交集,那 cron 就不是干这件事的那一层。正确做法是按超集排期,然后在任务内部加判断。还要提醒一句:在 crontab 文件中,未转义的百分号会在命令执行前被替换成换行符,所以日期格式符必须用反斜杠转义。
# 本意:每逢 13 号且为周五的零点执行。
0 0 13 * 5
# 以 UTC、2026-09-21 为起点的实际执行时间:
# 周五 2026-09-25 00:00
# 周五 2026-10-02 00:00
# 周五 2026-10-09 00:00
# 周二 2026-10-13 00:00 <- 仅凭「日」字段就匹配上了
# 周五 2026-10-16 00:00
# 正确写法:按 13 号排期,再在任务里判断星期。
# crontab 中的 % 必须转义,否则 cron 会把它变成换行。
0 0 13 * * [ "$(date +\%u)" = 5 ] && /usr/local/bin/report.shQuartz 独有的那几个字符
除了四个通用运算符,Quartz 还定义了四个 Unix cron 完全不认识的字符。它们之所以存在,是因为业务方真正会提的排期需求 —— 每月最后一个工作日、每月第二个周二、最接近 15 号的工作日 —— 用区间和步长根本写不出来。
这些字符也是「Quartz 表达式不能简单截断成五段」的主要原因。本站的 Cron 工具会把 ? 归一化为 *,以便完成预览;对 W 的处理方式是先算出排期日期,再把它平移到最近的工作日,这与 Quartz 的行为一致,包括「不跨月」这条规则。请把预览当作对 Quartz 的近似模型,而不是 Quartz 本身;遇到不常见的写法,务必回到真正要部署的调度器上再确认一次。
有一点值得重复:Quartz 的星期编号从 1 开始且 1 代表周日,所以周五是 6,6L 表示「当月最后一个周五」。而 Unix cron 里周日是 0(7 也被接受),周五是 5。在两种方言之间转换表达式,意味着要重新编号星期字段,而不只是加上或去掉一个秒字段。
| 字符 | 可用字段 | 含义 | 示例 |
|---|---|---|---|
| ? | 日、星期 | 不指定值。两个日期字段中必须恰好有一个使用它。 | 0 0 12 ? * MON |
| L | 日 | 当月最后一天;L-3 表示最后一天的前三天。 | 0 0 23 L * ? |
| L | 星期 | 当月最后一个指定的星期几。 | 0 0 9 ? * 6L(最后一个周五) |
| W | 日 | 最接近该日期的工作日,且绝不跨月。 | 0 0 8 15W * ? |
| # | 星期 | 当月第 n 个指定的星期几。 | 0 0 9 ? * 3#2(第二个周二) |
| LW | 日 | 当月最后一个工作日,典型的发薪日排期。 | 0 0 17 LW * ? |
时区与夏令时
Cron 表达式本身不携带时区。守护进程按系统本地时区解释它,除非 crontab 里设置了 CRON_TZ(Vixie cron)或 TZ;而容器镜像几乎总是以 UTC 运行,与集群所在地无关。「在开发机上没问题、上生产就不对」的排期,差别通常只有时区这一项。
每年有两次,本地时间的排期会撞上时间轴上的断点。春季调快时,有一整个墙钟小时并不存在:2026 年 3 月 8 日,纽约的时钟从 01:59:59 EST 直接跳到 03:00:00 EDT,于是 0 2 * * * 没有任何可匹配的时刻。各实现的处理并不一致:Vixie cron 会在跳变之后立刻把落在被跳过时段里的任务补跑一次;有些调度器干脆整天不跑;本站的预览对这一天报出的是 03:00 EDT。秋季调慢时情况相反:2026 年 11 月 1 日,纽约本地的 01:00 到 01:59 会出现两次,于是 30 1 * * * 有两个合法时刻。Vixie cron 只跑一次;而那种只是简单地重新推算「下一个本地时间」的调度器可能会跑两次。
实用的结论是:只要任务只需要「定期发生」,就让它跑在 UTC 上,这两个问题都不存在。真正与人的本地时间绑定的任务 —— 计费截止、九点的推送、某人开早会前要看的报表 —— 才需要指定命名时区,并且应当避开本地 00:00 到 03:00 这个区间,因为两次切换都发生在那里。把夜间任务从 02:00 挪到 04:00,就能消掉一整类故障。
命名时区还能保护你免受行政调整的影响。+02:00 这样的固定偏移只是一个数字,而 Europe/Berlin 是一套规则,当某国政府改规则时,tz 数据库会随之更新。凡是接受时区名的调度器,就应当给它时区名。
春季调快,2026-03-08(本地 02:00 不存在):
周六 2026-03-07 02:00:00 EST
周日 2026-03-08 03:00:00 EDT <- 该解析器的处理是平移,而非跳过
周一 2026-03-09 02:00:00 EDT
秋季调慢,2026-11-01(本地 01:00-01:59 出现两次),以 30 1 * * * 为例:
周六 2026-10-31 01:30:00 EDT
周日 2026-11-01 01:30:00 EDT <- 两个 01:30 中的第一个
周一 2026-11-02 01:30:00 EST上线之前先预览
能解析通过的表达式,不等于写对了的表达式。解析只证明语法;只有一串具体的未来时间戳才能证明意图。把表达式粘进 Cron 工具,把时区设成任务真正运行的那个时区,然后把接下来几次执行时间逐条念一遍。只要列表里出现一个你解释不了的日期,就说明你在 bug 找上你之前先找到了它。
要看的是间隔,而不是单条记录。现实中的 cron 缺陷几乎都表现为节奏异常:在整点边界处重置的步长、莫名其妙冒出来的某一天、二月被跳过的月度任务、落在周末另一侧的周任务。连续八次执行时间通常足以把这四类问题全部暴露出来。
最后,把表达式当成需要注释的代码。在 crontab 里、或在调度器的描述字段里,用一句人话写清意图,并确保两者一致。下一个读到 0 0 13 * 5 的人,会默认它就是作者想表达的意思,而唯一能挡住这一点的,就是那句说明排期用途的话。
- 先数字段个数,再去调度器自己的文档里确认方言。
- 检查两个日期字段:如果都不是 *(Quartz 里则是 ?),这条排期就是或关系,不是与关系。
- 凡是不能整除所在区间的步长都要复核,*/45、*/7、*/25 几乎都是写错了。
- 把预览时区设成生产时区而不是你本地时区,并且看一眼夏令时切换前后的那几次执行。
- 除非具体钟点真的重要,否则把夜间任务挪出本地 00:00-03:00 这个窗口。
- 需要和日志行对齐时,用时间戳工具把预览出的执行时间转换成 epoch 值。
要点回顾
- 先数字段:5 段是 Unix cron,7 段是 Quartz,6 段则要看有没有 ?、L、W、# 才能定性。
- 当「日」和「星期」两个字段同时被限定时,标准 cron 只要任意一个匹配就执行,所以 0 0 13 * 5 会在每个 13 号和每个周五都触发。
- 斜杠是区间上的跨度而不是时间间隔,凡是不能整除所在字段的步长,都会排出不均匀的节奏。
- Cron 表达式不携带时区;能用 UTC 就用 UTC,必须用本地时间的任务则要避开 00:00-03:00 这个夏令时切换窗口。
- 部署前一定要在生产时区下读一遍具体的下次执行时间列表,因为解析通过只证明语法没错。