Cron、时区与夏令时:五个字段为什么不是完整调度规则
Paul Vixie 在 1987 年写的那个 cron,给现代调度器留下了所有的约定——五个字段、星号代表“任意值”、范围与列表、`@daily` 这类宏。Cron 比 Web 还老,比 Linux 还老,几乎和我一样老。它语义里那些容易让人意外的小坑,在生产事故里占了很大比例。
它到底是什么
一行 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"。
周内日是 0–6,0 和 7 都表示周日。(是的两个都行。这是一处历史包袱。)有些实现也接受三字母名:MON、TUE 等。
这就是原版 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 顶部设
SHELL、PATH、MAILTO。 - 命令一律写绝对路径。
/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 工具相关文章
继续阅读同一主题领域的实践指南。
Node 生产 Dockerfile 里到底该有什么,不该有什么
网上大多数 Node Dockerfile 都把 node_modules 直接拷进镜像、用 root 运行、最后产出一个 900 MB 的层。本文只讲那几个真正影响构建时间、镜像体积和运行时安全的决定:基础镜像、多阶段构建、依赖层缓存、NODE_ENV 陷阱,以及为什么你的 docker-compose 不该照搬生产。
在用户之前发现缺失的翻译键和插值参数不匹配
缺失的翻译键会把原始键路径直接渲染给用户,插值参数不匹配会渲染出空串或崩溃。这两者在评审中都容易漏,因为开发者的语言包永远有全部键。本文讲如何结构化比较 locale JSON 文件、找出缺失键,并在发布前抓住参数不匹配。
能真正压测 UI 的 mock 数据(而不是只把页面填满)
大多数 mock 数据是同一行复制十遍、只换个 id。它填满页面,却什么都测不到。本文讲如何生成能压测布局边界、长名字、缺失字段、空状态,以及会破坏格式化代码的日期和数字格式的 mock 数据,并通过字段推断让一个 JSON 样本一步变成贴近真实的数据集。