Cron 表达式的五个字段,90% 的人都理解错过至少一个

2026-06-03开发工具
分享到

带新同事的时候,我发现几乎每个人都在 Cron 表达式上翻过车。最经典的一次:需求是"每月 13 号,如果是周五,凌晨执行一次",他写了 0 0 13 * 5。上线后任务每个周五都跑,一个月跑了四五次,监控告警差点把他手机打爆。

他以为自己写的是"13 号 且 周五"。但 POSIX 标准里,当日和星期两个字段都被限定(都不是 *)时,匹配规则是或——13 号执行,每个周五也执行。这是 Cron 设计里最反直觉的一处,把无数定时任务变成了意外高频运行。

这篇文章把 Cron 的字段语义、特殊字符、执行规则里容易理解错的点全部过一遍。写定时任务之前花十分钟看完,能省掉半夜被告警叫醒的次数。

五个字段各管一件事

Cron 五字段结构

标准的 crontab 表达式从左到右五个字段:

位置字段取值范围特殊值
1分钟0-59
2小时0-23
3日1-31
4月1-12 或 JAN-DEC
5星期0-7 或 SUN-SAT0 和 7 都是周日

所有字段是与关系:分钟对、小时对、月对,日或星期之一也对,任务才执行。唯一例外就是上面说的日和星期同时被限定的情况——它们俩之间是或。

0 0 13 * 5 的正确理解:"每次执行时刻为某日的 00:00,且(该日是 13 号 或 该日是周五)"。用集合的语言描述最不容易错:候选时刻 = (满足前四个字段) ∩ (日字段集合 ∪ 星期字段集合),其中日字段被限定时才参与并集。

想表达"13 号且周五",Cron 表达式无解,只能写成 0 0 13 * * 让任务在每月 13 号跑,脚本里自己判断 date +%u 是不是 5,不是就退出。也可以反过来写成每周五跑、脚本里判断日期。

*/5 不是"每 5 分钟从现在开始算"

斜杠步长是另一个高频误解点。*/5 的语义是"从字段的最小值开始,每 5 个单位一次",不是"从任意基准点开始"。

*/5 放在分钟字段:0, 5, 10, 15, … 55,整除对齐,没问题。放在小时字段 */5:0, 5, 10, 15, 20 点——不是你以为的"每 5 小时从部署时间起算",而是从 0 点开始数。凌晨 0 点、早上 5 点、上午 10 点各跑一次,分布和你想的可能完全不同。

带范围的步长同理:10-40/10 表示 10, 20, 30, 40。注意终点 40 包含在内,这和很多编程语言里 range 的开区间习惯不一样。

还有一个非常容易忽略的细节:5/15 这种写法在 Vixie cron(Linux 上最通用的实现)里等价于 5-59/15,即 5, 20, 35, 50 分;但在某些实现里 5/15 直接解析失败或者被当成 */15。跨平台部署的任务,凡是用了 起始值/步长 写法的都要实测一遍。

?、L、W、#:Quartz 和标准 Cron 的分界线

如果你在 Spring 的 @Scheduled 或者 xxl-job 里写 Cron,用的是 Quartz 语法,比标准 crontab 多一个秒字段(六个字段),并且支持扩展字符:

字符含义示例
?不指定(只用于日和星期)0 0 12 ? * MON
L最后一天0 0 2 L * ? 每月最后一天
L-3倒数第 3 天月末结算常用
W最近的工作日15W 距 15 号最近的工作日
#第几个星期几6#3 第三个周五

? 的存在就是为了化解 OR 陷阱:Quartz 规定日和星期不能同时限定,其中一个必须写 ?,把"另一个字段不参与匹配"显式写出来。所以 Quartz 里 0 0 13 ? * 5 才真的是"13 号且周五"?——不是,Quartz 里 ? 出现的那个字段被忽略,13 ? 5 意思是只看星期……不对,0 0 13 ? * 5 是日字段写 13、星期字段用 ? 忽略,语义变成"每月 13 号执行"。想在 Quartz 里表达 AND 依然做不到,还是得靠脚本判断。? 只是逼你表态,不是给你新能力。

W 有个细节:15W 如果 15 号是周六,不会跑到周日(15W 永远不跨月),而是提前到周五 14 号。跨月是明确禁止的。

Linux 服务器上的 crontab 则完全不认识这些扩展字符——把 Quartz 表达式原样拷到 Linux 里,多半直接 bad minute 报错,或者更惨:把六个字段错位解析成五字段,跑出一个谁也没预料到的调度计划。

时区:定时任务最大的隐形炸弹

Cron 表达式本身没有任何时区信息。0 9 * * 1-5 是"本地时间的每个工作日早上 9 点",而"本地时间"取决于三件事:

  1. 服务器系统时区。国内云服务器默认可能是 CST,也可能是 UTC——同一份 crontab 迁移一次服务器,任务时间整体漂移 8 小时。
  2. 调度器配置。Vixie cron 支持在 crontab 里写 CRON_TZ=Asia/Shanghai 指定时区;systemd timer 用 OnCalendar= 时同样遵循系统时区;GitHub Actions 的 schedule 只认 UTC,想在国内时间早上 9 点跑要写 0 1 * * *;Kubernetes 1.27 之后的 CronJob 有 spec.timeZone 字段,之前的版本跟随 kube-controller-manager 的时区。
  3. 夏令时。中国没有夏令时,但服务器在欧美时区就会遇到:春季拨快那天,凌晨 2:30 这个时刻不存在,任务这一周直接不跑(Vixie cron 的处理是跳过);秋季拨慢那天,1:30 出现两次,任务跑两次。对幂等任务无所谓,对发薪、对账这类任务就是事故。

我的实践规则:定时任务的执行时刻尽量避开凌晨 2 点到 3 点整段的敏感时段(正好卡在多数时区的切换点上),或者干脆全部任务统一 UTC 表达 + 业务代码里换算。时间戳和时区的更多细节可以看之前写的Unix 时间戳那些坑。

任务没跑,先查这几件事

表达式写对了,任务还是不执行,按这个顺序排查:

日和星期的 OR 规则。先确认是不是踩了开头那个坑,症状是"跑得比预期频繁"而不是"不跑"。

环境变量。cron 执行任务时环境极度干净:PATH 默认只有 /usr/bin:/bin,没有你 shell 里的 Node、pnpm、python。把 which node 的结果写死进命令,或者在 crontab 开头显式设置 PATH=。我见过一半的"手动能跑 cron 不跑"都是这个原因。

错过执行不补偿。机器重启、cron 服务停了,错过的任务默认不补跑。需要补偿语义用 anacron,或在任务开头判断上次执行时间。Kubernetes CronJob 有 startingDeadlineSeconds 和并发策略可以配置。

% 的特殊含义。crontab 里 % 会被解释成换行,命令里要用 %(比如 date +\%Y)必须转义。这个坑的症状是任务跑了但只执行了命令的前半段,日志里什么都看不出来。

输出去了哪。cron 不挂终端,stdout/stderr 默认发邮件(本地 mail usually 没人看)。养成习惯:每条任务后面跟 >> /var/log/xxx.log 2>&1,否则出错时你连第一现场都没有。

写完之后,验证一遍再上线

Cron 表达式的"下一次执行时间"没法靠目测确定,特别是带范围、步长、L、W 的复杂表达式。我的习惯是写完先在 在线 Cron 表达式解析工具 里跑一下,直接看未来几次的执行时间列表,跟需求的自然语言描述逐条对——上面说的 OR 陷阱、*/5 在小时字段的表现,用"下次执行时间列表"一眼就能看出来。

对时间敏感的调度,再补一个最后的防线:任务里打日志记录实际执行时刻,和预期的调度时刻做对比监控。表达式解析器告诉你"它认为什么时候该跑",日志告诉你"它实际什么时候跑了",两者一致才算真正闭环。

评论