Cron 表达式解析器

用大白话读懂 cron 表达式,并看到它下一次究竟何时触发。

分 · 时 · 日 · 月 · 星期。也支持 @daily 之类的宏。

试试:

运行时机

在 03:00,在周一

接下来六次运行 · UTC

  1. 2026-09-21 03:00星期一
  2. 2026-09-28 03:00星期一
  3. 2026-10-05 03:00星期一
  4. 2026-10-12 03:00星期一
  5. 2026-10-19 03:00星期一
  6. 2026-10-26 03:00星期一

以 UTC 显示。真正的 crontab 按服务器所在时区运行,而夏令时的麻烦正出在那里 —— 见下文。

逐字段说明

00
33
*任意
*任意
星期
11

关于这个计划值得注意的地方

  • 并非每个月都有 31 日,因此在那些月份里它根本不会运行。
  • 2 月 29 日只出现在闰年 —— 也就是四年才运行一次。

五个字段,越往后越粗

位置字段取值范围备注
1分钟0–59
2小时0–2324 小时制,服务器时间
31–31不存在的日期会被直接跳过
41–12或者 JANDEC
5星期0–70 和 7 都表示星期日。或者 SUNSAT

在一个字段内部:* 表示任意,, 列举取值,- 给出一个范围,/ 表示步进。分钟字段里的 */15 是每十五分钟一次;5/10 表示从 5 开始每隔十,也就是 5、15、25 等等。范围可以绕回,所以 FRI-MON 是表示「周末连着它两头」的合法写法。

那条把所有人都坑过的规则

这是本页唯一真正值得你带走的东西。

当「日」和「星期」两个字段都被限定时,cron 会在其中任意一个匹配时运行任务。不是两个都匹配。它是一个「或」,读起来却像一个「且」。

0 0 1 * MON   # NOT "the 1st, if it is a Monday"
              # Actually: every 1st, AND every Monday

那条排程一个月大约触发五次。把它粘进上面的解析器,看看接下来的运行时间——那个序列立刻就露馅了。

这条规则只在两个字段都被限定时才适用。如果其中任一个是 *,另一个说了算,这就是为什么 0 0 * * MON 的行为完全符合预期。这个行为写在 POSIX 规范里,所有主流 cron 都这么实现,所以它不是一个你能靠配置绕开的 bug。

要真正做到两个条件同时成立,就在排程里限定一个字段,另一个在任务开头自己判断:

0 0 * * MON  [ "$(date +\%d)" = "01" ] && /usr/local/bin/job

夏令时,它每年会弄坏你的任务两次

除非另有指定,cron 跑在服务器的本地时区里,而本地时间并不是连续的。时钟往前拨时,有一个小时凭空消失:一个排在 02:30 的任务可能被整个跳过,也可能在 03:30 才触发,取决于你用的是哪个 cron。时钟往回拨时,01:30 会出现两次,任务也可能跟着跑两次。

现代的 Vixie cron 会做些努力——因为向前跳而错过的任务,通常事后会补跑一次——但这个行为在各实现之间并不一致,而容器调度器又是另一套。

长久的解决办法是让调度器跑在 UTC 上,只在边界处做转换。Kubernetes 的 CronJob 默认用 UTC,正是出于这个原因。如果某个任务必须在某个特定的本地时间运行,那就接受它一年里有一部分时间会差一小时,或者换一个真正懂时区的调度器——systemd 的定时器就懂。

cron 任务跑不起来的其他原因

  • 环境几乎是空的。 cron 不会加载你的 shell 配置文件,所以 PATH 很简陋,你的虚拟环境也没有激活。永远用绝对路径。
  • 一个裸的百分号。 在 crontab 里,% 表示换行。date +%Y 会悄无声息地坏掉;它必须写成 date +\%Y
  • 结尾没有换行。 有些 cron 会忽略 crontab 里最后一行,如果那一行没有以换行结尾的话。
  • 输出无处可去。 cron 会把输出邮寄给用户,而如果邮件没配好,它就消失了。重定向到一个日志文件,好让失败看得见。
  • 运行相互重叠。 cron 不会等上一次跑完。一个耗时五分钟的任务排在每分钟一次的表上,会不断堆积,直到某个东西垮掉。用 flock

cron 表达不了什么

各字段是彼此独立的,所以任何不能整除一小时或一天的东西都够不着。「每 90 分钟」写不出来。标准 cron 里也写不出「每月最后一个星期五」——那正是 Quartz 的 L# 存在的理由,也是它们不可移植的原因。

如果你发现自己在和这套语法搏斗,那通常就是该换一个按时间间隔而不是按日历字段思考的调度器的信号。一个写着 OnUnitActiveSec=90min 的 systemd 定时器,一行就说清了你的意思。

相关

上面的运行时间是 UTC 的——时间戳转换器会把其中任意一个换算到你自己的时区,或者换成你的调度器记日志所用的任何格式。如果你要的是匹配文本而不是排程,正则测试器就在隔壁。

cron 相关问题

为什么我的任务在我没安排的日子里跑了?

几乎肯定是「日」和「星期」这条规则。当这两个字段都被限定时,cron 会在其中任意一个匹配时运行任务——不是两个都匹配。所以 0 0 1 * MON 会在每月一号触发,也会在每个星期一触发,一个月大约五次,而不是你本以为的那一次。如果其中任一字段是 *,这条规则就不适用,另一个字段说了算。要求两个条件同时成立的办法是:在 cron 里限定其中一个,另一个在任务内部自己判断。

这五个字段分别是什么?

分钟(0–59)、小时(0–23)、日(1–31)、月(1–12)、星期(0–7,其中 0 和 7 都表示星期日)。从左往右读,粒度逐渐变粗,这是个好用的记忆法。有些调度器——Quartz、Spring 以及几种任务运行器——会在最前面多加一个「秒」字段;而标准的 crontab 根本没有秒这一栏。

夏令时切换时 cron 任务会怎样?

这取决于具体实现,而定时任务每年正是在这两次悄悄出问题。时钟往前拨时,有一个小时根本不存在——一个 02:30 的任务可能被整个跳过,也可能在 03:30 才跑,取决于是哪个 cron。时钟往回拨时,01:30 会出现两次,于是任务可能跑两遍。现代的 Vixie cron 和 systemd 定时器会试着处理得合理些;老的则不会。稳妥的答案是:定时工作一律按 UTC 运行,只在边界处做转换。

L、W 和 # 是什么?这里为什么不接受它们?

它们是 Quartz 的扩展——L 表示「最后」(每月最后一天、最后一个星期五),W 表示最近的工作日,# 表示「本月第 n 个某星期几」。它们确实很有用,但它们不是标准 cron。crontab、Kubernetes 的 CronJob 和 GitHub Actions 要么拒绝它们,要么理解错。这个解析器选择拒绝而不是去猜,因为一个悄悄忽略了 L 的排程,恰恰会以谁也不会去核对的方式出错。

@reboot 是做什么的?

它在 cron 启动时运行任务一次,通常是在开机时。它没有周期性排程,所以也就没有「下次运行时间」可以算——这正是本工具直说这一点、而不是给你看一张空列表的原因。值得知道的是,@reboot 是在 cron 守护进程启动时触发的,而不是在机器完成启动时,所以任何依赖网络就绪的东西都得自己等一等。

我想每 90 分钟跑一次,该怎么写?

没法直接表达,这是一个真实的限制,而不是你还没学会的什么技巧。cron 的各字段是彼此独立的,所以分钟字段里写 */90 是非法的,也没有办法排出一个不能整除一小时或一天的时间表。变通办法是:用两条记录把一整天里的这个规律拼出来;或者更坦率一点,换一个按时间间隔而非日历字段思考的调度器,比如带 OnUnitActiveSec 的 systemd 定时器。

最后审校于 。发现有内容过时了? 告诉我们.