自动化指南

读懂、写对并验证 Cron 表达式

Unix 与 Quartz 的字段顺序、步长与区间语法的真实含义、「日」与「星期」之间的或逻辑、夏令时切换的行为,以及上线前如何预览下一批执行时间。

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 位置取值范围额外字符
秒无10-59* , - /
分钟120-59* , - /
小时230-23* , - /
日341-31Quartz 另有 ? L W
月451-12 或 JAN-DEC* , - /
星期56Unix 为 0-7(0 与 7 都是周日);Quartz 为 1-7(1 是周日)或 SUN-SATQuartz 另有 ? 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.sh

Quartz 独有的那几个字符

除了四个通用运算符,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 数据库会随之更新。凡是接受时区名的调度器,就应当给它时区名。

0 2 * * * 在 America/New_York 的两次切换附近
春季调快,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 这个夏令时切换窗口。
  • 部署前一定要在生产时区下读一遍具体的下次执行时间列表,因为解析通过只证明语法没错。

继续了解相关检查与工具