Cron 表达式:那串看似神秘、实则讲道理的定时密码
一次让我加班到凌晨的事故
去年做的一个数据同步服务,需要在”每个工作日的早上 9 点”跑一次。我在 crontab 里写了这么一行:
0 9 * * 1-5
我从文档上看到的意思是”分 时 日 月 周”,周一至周五、9 点整。看起来没错。结果上线之后,周一早上 9 点没跑,周二也没跑,直到周三我才在日志里发现任务压根没被触发。
后来排查了一整晚,问题不在”表达式写错”,而在于我对”周”字段的理解有偏差——加上部署环境的时区是 UTC,而我在北京用本地时间理解”早上 9 点”。两个误解叠在一起,任务实际在北京时间 17 点才跑,而那几天正好赶上服务重启,第一次执行就这么错过了。
我把那次踩坑的教训整理成下面这些内容。如果你也准备在服务器或代码里写 Cron 表达式,建议先看完”日期和星期的或关系”和”时区”这两节,大部分线上事故都出在这两处。
什么是 Cron 表达式?
Cron 表达式本质上是一行用空格分开的”时间规则”,用来告诉调度器:在什么时刻做什么事。
它最常见的样子是 5 段:
分 时 日 月 周
每一段都是一个”时间单位上的匹配条件”。调度器每秒钟(或每分钟)看一眼当前时间,如果五段条件同时成立,就触发任务。
注意我说的是”同时成立”——但”日”和”周”这两段之间其实是个例外,后面会专门讲。先记住:分、时、月 是严格 AND,日 和 周 之间却是 OR。
五段分别管什么
| 段 | 含义 | 取值范围 | 例子 |
|---|---|---|---|
| 第 1 段 | 分钟 | 0–59 | 30 表示第 30 分 |
| 第 2 段 | 小时 | 0–23 | 9 表示 9 点 |
| 第 3 段 | 一个月中的第几天 | 1–31 | 1 表示 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-FRI 和 0 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 本身不管并发,要自己兜底。