← 返回博客
语言: English 中文
Time 2026-06-04 7 分钟

Cron、时区与夏令时:五个字段为什么不是完整调度规则

Paul Vixie 在 1987 年写的那个 cron,给现代调度器留下了所有的约定——五个字段、星号代表“任意值”、范围与列表、`@daily` 这类宏。Cron 比 Web 还老,比 Linux 还老,几乎和我一样老。它语义里那些容易让人意外的小坑,在生产事故里占了很大比例。

CronCrontab调度QuartzDST

它到底是什么

一行 crontab 由五个空白分隔的时间字段加一条命令组成:

分    时   月内日   月   周内日       命令
0     9    *        *    1-5         /usr/local/bin/run_report

这一行的意思是:每月每天,当周内日是周一到周五时,于 9 点 0 分跑这个报表。

每个字段接受以下写法:

  • 数字 —— 9 表示 9。
  • 范围 —— 1-5 表示 1 到 5。
  • 列表 —— 1,3,5 表示 1、3、5。
  • 通配符 —— * 表示该字段允许的所有值。
  • 步长 —— */15 表示"每 15",0-30/5 表示"0、5、10、…、30"。

周内日是 0607 都表示周日。(是的两个都行。这是一处历史包袱。)有些实现也接受三字母名:MONTUE 等。

这就是原版 cron 的全部语法。任何用这些规则构造不出来的表达式都在用某种扩展。

day-of-month / day-of-week 陷阱

cron 语义里最反直觉的一处:

0 9 1 * 1   /run.sh

你也许以为这意思是"每月 1 号上午 9 点,但仅当那天是周一才跑"。它不是。它的意思是 "每月 1 号上午 9 点 每周一上午 9 点"

cron 对 day-of-month 与 day-of-week 是 OR 关系,不是 AND。如果两个字段都被限制(都不是 *),任意一个满足就会触发。上面那条表达式会在每月 1 号触发,在每周一触发。

约定做法是只限制其中一个,另一个保持 *

  • 每周一 9 点:0 9 * * 1
  • 每月 1 号 9 点:0 9 1 * *
  • "每月 1 号 9 点,但只在那天是周一才跑":原版 cron 写不出来。要么在脚本里再判一下日期,要么用 Quartz 表达式,要么每天跑然后早早 exit。

这是每个人都会被坑一次的地方。

那些宏

Vixie cron 加了一组具名快捷方式:

含义
@reboot 系统启动时跑一次
@yearly@annually 0 0 1 1 *
@monthly 0 0 1 * *
@weekly 0 0 * * 0
@daily@midnight 0 0 * * *
@hourly 0 * * * *

它们比数字版本可读,也能避免那种把 0 0 * * * 不小心写成 0 * * * * 的笔误。能用就用。唯一要避开的是 @reboot——它在不同 cron 实现里语义不同,systemd 托管的主机上还经常被直接忽略。

Quartz:六字段表亲

Java 生态围绕 Quartz 调度器形成了另一种 cron 语法。差别:

  • 六个字段而不是五个——开头多一个 seconds
  • 可选的第七个字段表示 year。
  • ? 占位符用于 day-of-month / day-of-week,当另一个被设置时用,避免前面说的 OR 歧义。
  • L 修饰符——day-of-month 中 L 表示"月末最后一天",day-of-week 中 5L 表示"该月最后一个周五"。
  • W 修饰符——15W 表示"离 15 号最近的工作日"。
  • # 修饰符——day-of-week 中 1#3 表示"该月第 3 个周一"。

Quartz 表达式严格地比 Vixie cron 表达力更强。两者互不兼容。Vixie 表达式粘到 Quartz 配置里会因为字段数不对解析失败;Quartz 表达式粘到 Linux cron 里,开头那个数字被当成"分",会乱跑。

看到 0 0 9 ? * MON-FRI 是 Quartz;看到 0 9 * * 1-5 是 Vixie。形状一眼就能分。

DST:cron 默默地做错了

cron 走墙钟时间。春天向前那一天,"消失"的那一小时里安排的任务,会有两种命运:

  • 完全不触发(大多数现代 Vixie cron 实现)。
  • 在墙钟下一个可用分钟触发(少数实现)。

秋天向后那一天,"重复"的那一小时里安排的任务,会有两种命运:

  • 触发一次(多数现代实现内部按 UTC 推进)。
  • 触发两次(老实现)。

如果你有一条 0 2 * * *,又在观测 DST 的时区,你必须知道你的调度器走哪种行为,并且通常应该把任务挪到不会切换的时刻——0 5 * * *0 12 * * *。如果它必须在切换时刻跑,那就用 UTC 来表达调度,让 tz 数据库帮你正确处理。

更好的做法:如果系统支持(SYSTEMD_OnCalendar、BSD cron 的 CRON_TZ,或者用 TZ=UTC 启动 cron),显式用 UTC 写调度。墙钟 cron + DST + 定时批处理 = 每两年都会被同一个微妙 bug 复发一次。

"都打在 0 分"问题

几乎所有人写 cron 表达式都打在分 0 上。0 * * * * 整点。0 9 * * * 上午 9 点。

意味着全世界几乎所有 cron 任务都在同一瞬间触发。被 cron 驱动的外部 API,每分钟、每小时、每天的 :00:00.000 都会迎来一次集体冲锋。你那个"5,000 RPS 的 API"其实是 0 RPS,再加一次 5,000 瞬时 RPS

务实办法:给调度加抖动*/5 替代 0,5,10,15...、报表跑 0 7 * * * 而不是 0 9 * * *、整点任务挪到 17 分。被你打的下游系统会感谢你。

(是的,我知道如果所有人都听这条建议,又变成新的协调问题。挑一个不容易撞到的数字就好。)

常见坑

  • day-of-month / day-of-week OR 陷阱。只限制一个
  • PATH 为空。cron 任务以最小环境运行——通常只有 /usr/bin:/bin/usr/local/bin 或你 shell 里的 PATH 都没有。要么在 crontab 顶部 PATH=...,要么写绝对路径。
  • 末行无换行。某些 cron 实现会安静地忽略没有以 \n 结尾的最后一行。
  • 输出缓冲。打到 stdout 的 cron 任务会发到本机用户邮箱;邮件没配,输出就丢了。重定向到日志文件或者管道到 logger
  • 长任务和下次触发重叠。cron 不串行化。用锁文件(flock)或自带串行化的任务框架。
  • Quartz / Vixie 混淆。长得几乎一样,行为差很多。

实用规则

  • crontab -e / crontab -l不要直接改 /var/spool/cron
  • crontab 顶部设 SHELLPATHMAILTO
  • 命令一律写绝对路径。/usr/bin/curl,不是 curl
  • 永远重定向 stdout 与 stderr:>> /var/log/myjob.log 2>&1
  • 不在没测试过两个方向 DST 的情况下跨切换日调度。
  • 除非时机真的重要,避开整分整点——抖动到别处。
  • 部署前先看接下来 5 次触发时刻。crontab.guru 之类的工具不是凭空出现的。

主要参考资料

用于核对本文技术细节的标准与官方文档。

在线读写 cron 表达式

本站 crontab 工具把表达式翻译成中文 / 英文,并在选定时区下生成接下来 N 次触发时刻,同时给出 DST 空缺、day-of-month 与 day-of-week 冲突等常见警告。所有运算在浏览器内。

打开 cron 工具

相关文章

继续阅读同一主题领域的实践指南。

查看全部文章

Cookie 同意

我们使用 Cookie 来增强您的体验并展示相关广告。您可以自定义您的偏好。