Cron 作为 Unix 系统的默认定时任务调度器已经存在了四十年,到现在依然无处不在:备份脚本、报表生成、缓存预热、日志切割,背后都是它。但这个表达式语法是那种大多数工程师从网上抄一段、却从不真正吃透的东西。这篇指南,就是我想一直摆在终端旁边的那份参考。
五个字段,从左到右
标准 Cron 表达式是五个用空白分隔的字段:
┌── 分钟 (0–59)
│ ┌── 小时 (0–23)
│ │ ┌── 日 (1–31)
│ │ │ ┌── 月 (1–12,也可写 JAN–DEC)
│ │ │ │ ┌── 星期 (0–6,0 和 7 都表示周日)
│ │ │ │ │
* * * * *
从左到右读作"在哪些分钟、哪些小时、哪些日期、哪些月份、哪些星期"。默认情况下这五个字段是"与"的关系,一个时间点只有五个字段全部命中才会触发。
每个字段的取值范围
| 字段 | 允许值 | 注意 |
|---|---|---|
| 分钟 | 0–59 | — |
| 小时 | 0–23 | 0 是午夜,23 是 23:00 |
| 日 | 1–31 | 会按当月实际天数截断 |
| 月 | 1–12 | 也接受 JAN–DEC 名称 |
| 星期 | 0–6 | 0 和 7 都表示周日,也接受 SUN–SAT |
每个特殊符号的含义
| 符号 | 含义 | 例子 |
|---|---|---|
* | 任意值 | 分钟写 * = 每一分钟 |
, | 枚举 | 1,15 = 1 号和 15 号 |
- | 闭区间范围 | 9-17 = 9 到 17 |
/ | 步长 / 间隔 | */15 = 每 15 个单位 |
? | "不指定"(仅 Quartz) | 用于日或星期其一 |
L | 最后(仅 Quartz) | 5L = 当月最后一个周五 |
W | 最近的工作日(仅 Quartz) | 15W = 离 15 号最近的工作日 |
# | 第几个星期几(仅 Quartz) | 2#1 = 第一个周一 |
最后四个 —— ?、L、W、# —— 在标准 Vixie Cron 里根本不存在,它们是 Quartz / Spring 调度体系特有的。如果你把它们直接贴进普通的 /etc/cron.d,要么报错,要么行为诡异。在从 Java 教程里抄表达式之前,先画清这条界线。
最容易踩的坑:日与星期的"或"
这是 Cron 里最反直觉的一条规则。当日和星期两个字段都被限制(都不为 *)时,大多数实现(包括 Vixie Cron 和几乎所有主流 Linux 发行版)会把它们当成"或",而不是"与"。
0 0 1 * 1
直觉会读成"每月 1 号、且是周一的零点",实际含义却是"每月 1 号,或者任意一个周一"。要表达"1 号且是周一",只能把其中一个字段留成 *,剩下的判断写进脚本里:
# 只在每月 1 号且是周一时运行
0 0 1 * * [ "$(date +%u)" = "1" ] && /opt/report.sh
这条规则每年都会坑到一批人,尤其是账单、报表这类对日期极其敏感的任务。
常见表达式对照表
| 表达式 | 触发时机 |
|---|---|
*/5 * * * * | 每 5 分钟 |
0 * * * * | 每小时整点 |
0 3 * * * | 每天凌晨 3:00 |
30 9 * * 1-5 | 工作日 9:30 |
0 0 1 * * | 每月 1 号零点 |
0 0 * * 1 | 每周一零点 |
0 0 1 1,4,7,10 * | 每季度首日 |
0 2 * * 6 | 每周六 2:00 |
预设宏
很多 Cron 守护进程接受这些简写,用来替代五个字段:
@yearly (或 @annually) 0 0 1 1 *
@monthly 0 0 1 * *
@weekly 0 0 * * 0
@daily (或 @midnight) 0 0 * * *
@hourly 0 * * * *
@reboot 开机时执行一次
宏用起来方便,但并非所有平台都支持。生产环境依赖它们之前,先查一下你那台机器上 Cron 的手册。
时区:服务器时间 vs 业务时间
Cron 是按服务器本地时区来算的。同一份配置部署到两个不同时区,触发时间可能差好几个小时。在夏令时切换那天,落在被"跳过"的那个小时里的任务可能整天都不跑,而落在"重复"小时里的任务可能跑两遍。
如果时间精度是业务关键的,要么把主机时区统一成 UTC 并自己做好换算,要么改用能显式指定时区的调度器(systemd timer 支持 Persistent=true 和 Timezone=;Kubernetes 的 CronJob 默认按 UTC,除非你在容器里自己处理)。
标准 Cron 与 Quartz / Spring 六字段
Quartz 和 Spring 的 @Scheduled 调度在最前面多了一个秒字段,变成六个字段:
┌── 秒 (0–59)
│ ┌── 分钟 (0–59)
│ │ ┌── 小时 (0–23)
│ │ │ ┌── 日 (1–31)
│ │ │ │ ┌── 月 (1–12)
│ │ │ │ │ ┌── 星期 (0–6)
│ │ │ │ │ │
* * * * * *
所以 Spring 里的 0 0 3 * * * 表示 03:00:00,而系统 crontab 里的 0 3 * * * 表示 03:00。把一个五字段表达式原样塞进六字段位置,每个值都会整体右移一列,悄悄把排期改掉。秒字段同时也解锁了 ?、L、W、#,这些是普通 Cron 拒绝的。
漏跑与重入
如果任务该触发时机器是关机的,标准 Cron 不会事后补跑 —— 那个时间点就这么错过了(而带上 Persistent=true 的 systemd timer 行为不同,会在下次开机时补跑)。又因为默认没有锁,一个运行时间超过自身间隔的任务会自己和自己重叠。对耗时或高频的任务,要用锁文件或 flock 保护:
* * * * * /usr/bin/flock -n /tmp/job.lock /opt/job.sh
地雷:永远不触发的表达式
因为日和星期是"或"的关系,有些表达式永远不可能命中:
0 0 31 2 *
二月从来没有 31 天,所以这个任务从出生起就是死的 —— 但它能干净地通过解析,安安静静地躺在那儿装无辜。另一种静默杀手是跨月范围导致没有任何日期能同时满足。新表达式上线前,务必核对一下它接下来几次的触发时间。
部署前先验证
最快的检查办法,是让工具把表达式读回成一句人话,并列出接下来几次触发时间。Cron 表达式工具 就能把你的字符串翻成一句说明、并展示未来的触发时刻,像 0 0 31 2 * 这种地雷、或者那个无意间产生的"或",在上线前就被揪出来了。