D
开发工具箱

Cron 表达式:那串看似神秘、实则讲道理的定时密码

编程转换 2026年6月20日 约 1 分钟阅读

一次让我加班到凌晨的事故

去年做的一个数据同步服务,需要在”每个工作日的早上 9 点”跑一次。我在 crontab 里写了这么一行:

0 9 * * 1-5

我从文档上看到的意思是”分 时 日 月 周”,周一至周五、9 点整。看起来没错。结果上线之后,周一早上 9 点没跑,周二也没跑,直到周三我才在日志里发现任务压根没被触发。

后来排查了一整晚,问题不在”表达式写错”,而在于我对”周”字段的理解有偏差——加上部署环境的时区是 UTC,而我在北京用本地时间理解”早上 9 点”。两个误解叠在一起,任务实际在北京时间 17 点才跑,而那几天正好赶上服务重启,第一次执行就这么错过了。

我把那次踩坑的教训整理成下面这些内容。如果你也准备在服务器或代码里写 Cron 表达式,建议先看完”日期和星期的或关系”和”时区”这两节,大部分线上事故都出在这两处。

什么是 Cron 表达式?

Cron 表达式本质上是一行用空格分开的”时间规则”,用来告诉调度器:在什么时刻做什么事

它最常见的样子是 5 段:

分 时 日 月 周

每一段都是一个”时间单位上的匹配条件”。调度器每秒钟(或每分钟)看一眼当前时间,如果五段条件同时成立,就触发任务。

注意我说的是”同时成立”——但”日”和”周”这两段之间其实是个例外,后面会专门讲。先记住:分、时、月 是严格 AND,日 和 周 之间却是 OR。

五段分别管什么

含义取值范围例子
第 1 段分钟0–5930 表示第 30 分
第 2 段小时0–239 表示 9 点
第 3 段一个月中的第几天1–311 表示 1 号
第 4 段月份1–12(也可用 JAN–DEC)6 表示 6 月
第 5 段星期几0–7(0 和 7 都是周日)1 表示周一

一个最小例子:30 9 * * * 表示”每天 9 点 30 分”。其中 * 是”每一”的意思,所以日、月、周三段都是”每”,任务每天都触发。

四个符号

写 Cron 真正要记的不是数字,是这四个符号:

* 每一

* 表示该段的所有合法值都匹配。比如分钟段写 *,就是”每一分钟都触发”。

, 列举

想指定离散的几个值,用逗号分开。0,15,30,45 * * * * 表示”每个小时的 0、15、30、45 分触发”——也就是每 15 分钟一次。

- 范围

连续的一段用减号。0 9-17 * * * 表示”9 点到 17 点之间每个整点”。
范围可以和列举混用:1-5,9-12 表示 1 到 5 月,以及 9 到 12 月。

/ 步长

/ 表示”从某处起,每隔多少”。它通常跟在 * 或范围后面。*/5 * * * * 是”每 5 分钟一次”;0 */2 * * * 是”每 2 小时的第 0 分触发”,也就是 0 点、2 点、4 点……;10-30/5 * * * * 是”10 到 30 分之间每 5 分钟”(10、15、20、25、30)。

这里有个新手常踩的点:*/5 在秒段是”每 5 秒”,在分钟段是”每 5 分钟”——段不同,单位就不同,别按直觉套。

月份和星期可以用英文缩写

为了让表达式更易读,月份和星期支持三个字母的英文缩写(不区分大小写):

  • 月份:JAN(1) FEB(2) MAR(3) APR(4) MAY(5) JUN(6) JUL(7) AUG(8) SEP(9) OCT(10) NOV(11) DEC(12)
  • 星期:SUN(0) MON(1) TUE(2) WED(3) THU(4) FRI(5) SAT(6)

所以 0 9 * * MON-FRI0 9 * * 1-5 完全等价,都是”工作日早 9 点”。我个人更推荐用英文缩写,半年后再看一眼就能懂,数字 1-5 容易让人犹豫”这到底含不含周末”。

Quartz / Spring 多了”秒”段

如果你是在 Java 里用 Quartz 或 Spring 的 @Scheduled(cron = "..."),表达式通常是 6 段,在最前面多了一个”秒”:

秒 分 时 日 月 周

而我开头那个 crontab 是 Linux 的 5 段(没有秒,最小粒度是分钟)。我曾经把 Linux 的 0 9 * * 1-5 直接抄进 Quartz,结果 Quartz 把 0 当秒、9 当分,任务变成了”每小时的第 9 分 0 秒”触发——完全不是我想要的。

另外 Quartz 里”不指定”某个字段时,常用 ? 而不是 *?* 在 Quartz 里语义不同:* 是”每”,? 是”不关心/不指定”。在”日”和”周”只指定其中一个的场合,另一个必须写 ?,否则 Quartz 会报冲突。Linux 的 crontab 没有 ? 这个概念,两个都写 * 就行。

简单记:Linux 用 5 段、用 *;Quartz/Spring 用 6 段、不确定时另一个字段写 ?

日期和星期是”或”关系

这是最容易误解的地方,也是我那次事故的元凶之一。

当”日”和”周”两段都被具体指定时,绝大多数调度器把它们当成”或”:满足任意一个就触发。

例如 0 9 13 * 5:日段是 13 号,周段是周五。意思是”每月 13 号 每个周五的 9 点”——触发次数会比你想的多。如果你的本意是”某个月的 13 号且那天正好是周五”,cron 原生表达不了这种”且”逻辑,得靠代码判断。

反过来,如果只指定其中一段、另一段是 *

  • 0 9 13 * *:只有日段生效,每月 13 号 9 点(周段被 * 忽略)。
  • 0 9 * * 5:只有周段生效,每个周五 9 点(日段被 * 忽略)。

所以”每个工作日早 9 点”必须写成 0 9 * * 1-5(日段是 *),而不是把日段也写上具体数字。

时区:cron 只看运行环境的时钟

cron 本身没有时区概念,它读的是运行它的机器/进程的本地时间

Linux crontab 用的是系统的时区设置。Quartz 默认用 JVM 的默认时区,也可以显式指定。Spring 的 @Scheduled 默认同样用服务器时区,但可以通过 zone = "Asia/Shanghai" 指定。

我开头事故的教训就是:本地以为”北京 9 点”,但容器镜像默认是 UTC,于是实际是 UTC 9 点 = 北京 17 点。写法没问题,理解错了时区。现在凡是写定时任务,我第一件事是确认部署环境的时区,并在配置里显式写死,不依赖默认。

顺带一提,夏令时(DST)也会搞事:某些时区在切换那天会”凭空消失一小时”或”重复一小时”。如果你的任务恰好落在那个时间点,行为会依赖具体调度器实现——这种边界场景,最好实测一次。

闰年与”不存在的日期”

0 0 29 2 * 表示”2 月 29 日 0 点”,但它只会在闰年触发——平年那次直接跳过,不会报错。这其实是符合预期的,但如果你期望”每年都跑”,就会失望。

更极端的是 0 0 30 2 *(2 月 30 日),这是永远不成立的日期,任务永远不会触发。解析器通常不会报错(30 在 1–31 范围内合法),但逻辑上无解。所以日期段要小心月份的实际天数。

常用示例速查

表达式含义
* * * * *每分钟
*/5 * * * *每 5 分钟
0 * * * *每小时整点
0 9 * * *每天 9:00
0 9 * * 1-5每个工作日 9:00
0 0 * * 0每周日 0:00
0 0 1 * *每月 1 号 0:00
0 0 1 1 *每年 1 月 1 日 0:00
*/30 9-17 * * 1-5工作日 9–17 点之间每 30 分钟
0 0 15 * *每月 15 号 0:00(若 15 号是周六日也会跑)
0 8 1,15 * 1每月 1 号和 15 号,外加每个周一的 8:00(或逻辑)

怎么验证表达式真的对?

光看字符串容易自欺欺人。我的习惯是:写完表达式,用解析工具算出”未来 10 次执行时间”,肉眼核对前几次。如果第一次就对不上预期,说明字段理解有误,立刻改。

本站这个 Cron 工具就有”未来 10 次执行时间”和”未来 20 次(可指定起点)“两个视图,能直观看到下一次、下下次触发时刻。对于”每 5 分钟""每个工作日 9 点”这类,扫一眼就知对错。

调试时还有个经验:先临时把表达式改到”最近的未来几秒/几分钟”跑一次,确认任务真的会被触发、且触发逻辑正确,再改回正式频率。比直接上生产等一天看结果要快得多。

Cron 和其他调度方式的取舍

现在不止 cron 一种选择:

  • systemd timer:Linux 上比 cron 更现代,能感知服务依赖、失败时重试、指定时区,日志集成更好。适合长期驻留的服务。
  • 云厂商的事件调度(如 AWS EventBridge、Cloud Scheduler):托管环境里更省心,配时区、重试、死信队列都现成,但要绑定平台。
  • 代码内调度库(如 Node 的 node-cron、Python 的 APScheduler):适合调度逻辑要随代码走、或多环境一致的场景。

cron 的优势是简单、到处都有、运维一眼能懂。对于”每天跑一次数据同步""每小时清理缓存”这种粗粒度任务,它依然是最省事的选择。只是别忘了时区和”或”逻辑这两个坑。

常见问题

Q: */5 到底是每 5 分钟还是每 5 秒?

看它在第几段。标准 Linux cron 没有秒段,*/5 出现在”分钟”段就是每 5 分钟;Quartz 的 6 段里,第一段是秒,*/5 在秒段就是每 5 秒,第二段(分)才是每 5 分钟。段决定单位,别混。

Q: 我想”每月最后一天”怎么写?

标准 cron 没有”最后一天”关键字。常见做法:Linux 可以用 @monthly 或借助 0 0 28-31 * * 配合脚本判断当天是否是月末(28-31 范围覆盖所有月份,再多写一行”如果是当月最后一天才执行”的逻辑)。部分扩展(如某些 quartz 变体)支持 L。最简单可靠的是在脚本里判断,而不是硬塞进表达式。

Q: 为什么任务没按预期触发?

按这个顺序排查:① 表达式字段理解是否对(尤其日/周的或逻辑);② 运行环境的时区是否和你以为的一致;③ 服务/进程是否真的在运行(cron 任务挂了没有日志);④ 分钟段是否写成了 * 导致每分钟都触发却你以为是每天。多数问题落在这四项里。

Q: 日和周同时写具体值会怎样?

按”或”触发,满足其一即可。想表达”且”几乎都得靠代码判断。所以只写你真正要约束的那一段,另一段留 *

Q: Linux cron 和 Quartz 能直接互相抄吗?

不能直接抄。Linux 是 5 段、最小分钟级、无 ?;Quartz 是 6 段、含秒、用 ? 表示不指定。混用时一定要重排字段位置,并确认秒段是否多出来导致频率错位。

Q: 担心任务在同一秒堆积怎么办?

如果上一次还没跑完,下一次又到点了,多数调度器默认会”错过就跳过”或”排队”。对长任务,建议脚本里加锁(如文件锁、分布式锁),或者把频率调低、把执行粒度做幂等。cron 本身不管并发,要自己兜底。