年底接手一台离职同事留下的服务器,运营抱怨"月初的对账邮件一周收到三封"。我去翻 crontab,只有一行 0 9 1 * 1,脑子里的第一反应是"每月 1 号、并且正好是周一的那个 9 点"。直到把执行日志按时间排开才发现不对:它在每月 1 号跑,每个周一也照样跑——cron 里"日"和"周"两个字段同时被限定时是或的关系。一次语义误读,就变成了每周一次的重复推送。这件事逼着我把 cron 的语法重新过了一遍,下面是真正会踩到的部分。
一、五个字段就是"分 时 日 月 周",边界比想象的小
读法是从左到右,一个空格分隔一个字段,顺序固定的:分钟、小时、日、月、星期。很多人把"日"和"星期"的优先级想复杂了,其实它们只是两个独立的筛选条件。
| 字段 | 取值范围 | 写 * 的意思 | 容易看错的地方 |
|---|---|---|---|
| 分钟 | 0–59 | 每一分钟 | 没有 60,60 * * * * 是非法的 |
| 小时 | 0–23 | 每一小时 | 0 是午夜 0 点,不是"没有" |
| 日 | 1–31 | 每天 | 起始是 1,不是 0 |
| 月 | 1–12 | 每月 | 不是 0–11,和某些编程语言不一样 |
| 星期 | 0–7 | 每周每天 | 0 和 7 都表示周日,1 是周一 |
所以 0 2 * * * 是"每天 02:00",30 6 * * 1-5 是"工作日 06:30"。读表达式时我习惯先盯最后两个字段:只要"日"和"周"都不是 *,就要按"或"来理解,这是最容易和直觉冲突的一条。
二、四个符号,*/5 的含义最容易想歪
*任意值;,列举,如0,15,30,45是这个小时内固定的四个时刻。-范围,如9-18是 9 到 18 这两个端点都包含的闭区间。/n步长。关键点:cron 不是"上次跑完等 n 分钟再跑",而是在固定的时间网格上取整除的值。*/5 * * * *落在每小时的第 0、5、10……55 分,整点必触发;*/7 * * * *是 0、7、14、21、28、35、42、49、56 分,到下一小时又从 0 开始,所以相邻两次的间隔在 4 分钟和 7 分钟之间跳,而不是恒定 7 分钟。这就是"每 7 分钟"这种需求永远写不准的原因。- 步长还能带起点:
5-59/10是第 5、15、25、35、45、55 分,用来把任务错开整点,避开大家挤在 :00 跑的时段。
三、我确认一个表达式是否写对的流程
- 先在纸上把需求写成中文句子,明确到"哪一天的几点几分""一周触发几次""一个月触发几次",给出具体次数。
- 再翻译成表达式,然后把它丢进Crontab 解析里,看每个字段被解释成什么含义,重点确认"日"和"周"是不是自己想要的组合。
- 看它预测的未来 10 次执行时间点,把星期和时刻逐个念一遍:我要求"每周三 03:30",预测里就必须全是周三,间隔必须是 7 天而不是 6 或 8 天。
- 最后
crontab -l核对落盘的那一行,注意行首不要有set -e之类被当成任务行的内容,也不要有多余的 Tab。
预测执行时间这一步省不掉。日志出问题时,"到底几点跑的"是唯一有效的证据,而表达式本身不会告诉你这个。
四、另外四个把人卡住的细节
- 时区:cron 按服务器本地时区解释每个字段。一台 UTC 机器上的
0 3 * * *,对应的是北京时间 11:00,正好撞在业务高峰上。核对办法是比对date +%s和date的输出,用时间戳转换把同一个数字分别按 UTC 和本地时区展开,8 小时的偏差一眼就能看出来;较新的实现(如 cronie)还支持在文件顶部写CRON_TZ或TZ单独指定某条任务的时区,但这类变量并非所有 cron 版本都认,用之前先在测试机上确认一次。 - 百分号:
%在 crontab 里表示"换行并把后续内容当标准输入",所以date +%Y%m%d必须写成date +\%Y\%m\%d,否则任务会静默失败。 - 环境变量:cron 给的 PATH 很窄,通常只有
/usr/bin:/bin,脚本里直接调node、python3、aws经常报"command not found",而手工执行完全正常。稳妥做法是脚本第一行显式加载环境,或全部用绝对路径。 - 没人收的输出:任务有输出时 cron 会尝试发邮件,多数服务器没有 MTA,看起来就像"什么也没发生"。加一句
>> /var/log/cron_backup.log 2>&1,再把MAILTO=""关掉,至少留下时间戳线索。
五、常抄的表达式与它们的真实触发次数
| 表达式 | 含义 | 一天触发次数 |
|---|---|---|
* * * * * | 每分钟 | 1440 |
*/15 * * * * | 每 15 分钟(:00 :15 :30 :45) | 96 |
5-59/10 9-18 * * * | 9 到 18 点内每小时的第 5、15、25、35、45、55 分 | 60 |
30 3 * * 0 | 每周日凌晨 3:30 | 平均 0.14 |
0 2 1 * * | 每月 1 日 02:00 | 平均 0.033 |
0 9 * * 1-5 | 工作日 09:00 | 平均 0.71 |
0 9 1 * 1 | 每月 1 日 09:00 或 每周一 09:00 | 约 0.17(一年约 62 次) |
最后一行就是我踩的那个。真要"每月第一个周一",cron 的字段表达不出来,得让脚本自己在被触发时判断 [ $(date +%d) -le 7 ],条件不满足就直接退出。
六、常见疑问
Q:cron 能按秒执行吗?
标准 crontab 是 5 位,最小粒度就是 1 分钟,做不到秒级。需要更高频率通常用 * * * * * 里跑一个内部循环,或改用带 6/7 位(含秒、年)字段的调度框架,那类语法和 cron 并不完全通用。
Q:周日到底写 0 还是 7?
两个都可以,0 和 7 都指周日。团队里最好统一一个,混着写会让 1-7 和 0-6 这种区间变得难以判断。
Q:改完 crontab 要重启服务吗?
用 crontab -e 编辑的话不用,保存后守护进程会自动重新加载;直接编辑 /etc/crontab 或 /etc/cron.d/ 下的文件时,注意文件属主和权限,权限过宽会被忽略。
Q:为什么脚本手动跑没问题,放进 cron 就失败?
九成是环境差异:PATH 太短、HOME 没设、相对路径以 $HOME 为基准、当前工作目录不是脚本目录、或者非交互 shell 没有加载你的 .bashrc。排查时在任务里先执行一次 env > /tmp/cron_env.txt,和手动跑的 env 对比,差异会直接暴露出来。
Q:任务卡住没跑完,下一次还会触发吗?
会。cron 不关心上一次是否结束,同一任务可能重叠执行。耗时不确定的备份要自己加锁,常见做法是用 flock 包住命令,拿不到锁就直接跳过。
回头看那三封重复的对账邮件,问题不在服务器上,而在我把"和"读成了"与"。表达式这种东西,写出来只是一半,另一半是确认它到底会落在哪几个时刻——需求先写成一句中文,再用未来执行时间点去对,比翻文档管用。