HandyTools Hub

← 全部指南

Cron 表达式完全指南:五个字段、经典坑与调试技巧

2026-08-06

几乎每个后端工程师的职业生涯里都有这样一个时刻:盯着 crontab 里一行五个星号的表达式,心里默念”这到底是每分钟还是每小时”。cron 的语法只有寥寥几个符号,却浓缩了四十多年的历史包袱和反直觉设计:星期几有两个合法值、日期和星期组合起来是”或”不是”与”、夏令时切换那天凌晨两点半的任务会凭空消失。这篇文章把 cron 表达式的规则和最容易出事的场景一次讲清楚。

五个字段:cron 表达式的骨架

一条标准的 cron 表达式由五个字段组成,用空格分隔,从左到右依次是:

┌───────────── 分钟 (0-59)
│ ┌─────────── 小时 (0-23)
│ │ ┌───────── 日 (day of month, 1-31)
│ │ │ ┌─────── 月 (1-12)
│ │ │ │ ┌───── 星期 (day of week, 0-7,0 和 7 都是周日)
│ │ │ │ │
* * * * *

每个字段都可以是一个具体值、一个范围、一个列表,或者用 * 表示”任意”。当且仅当当前时间同时满足所有字段的条件时(有一个重要例外,后面讲),任务才会触发。注意 cron 的最小粒度是分钟——它没有秒字段,想”每 30 秒跑一次”是做不到的,得在脚本内部循环或者用别的调度器。

星期字段值得单独强调:0 和 7 都代表星期日,1 到 6 是周一到周六。这是历史遗留——不同版本的 Unix 用了不同约定,最后干脆两个都接受。新写的表达式建议统一用 0,避免和数字 7 的其他含义混淆。另外很多系统允许用英文缩写 SUNMON 等代替数字,可读性更好,但跨平台迁移时要先确认目标系统支持。

四个操作符:*/,-

字段内部的变化全靠四个符号:

  • *:通配符,匹配该字段的所有合法值。分钟位是 * 就是每分钟。
  • */n:步长,“每隔 n”。*/5 在分钟位表示第 0、5、10、15……分钟。
  • ,:列表,枚举多个值。1,15 在日期位表示每月 1 号和 15 号。
  • -:范围。1-5 在星期位表示周一到周五。

组合起来就是几个高频模式:

*/5 * * * *      每 5 分钟
0 */2 * * *      每 2 小时的整点
0 9-18 * * 1-5   工作日早 9 点到晚 6 点,每小时整点
0 0 1,15 * *     每月 1 号和 15 号的零点

这里藏着一个极其经典的误读:*/55 完全不同*/5 是”每 5 分钟”,一小时跑 12 次;而 5 * * * * 是”每小时的第 5 分钟”,一天只跑 24 次。新人写”我想每 5 分钟跑一次”却写成 5 * * * * 的事故屡见不鲜。步长还可以叠加范围,比如 10-30/10 表示第 10、20、30 分钟。

写表达式时如果拿不准,别在心里推演,直接用 Crontab 生成器 可视化地拼出来,它会逐字段解释含义并列出接下来的触发时间,比你数手指快得多。

常用调度速查

下面是生产环境最常见的几类调度,逐字段拆开看:

0 3 * * *
│ │ │ │ └── 星期:任意
│ │ │ └──── 月:任意
│ │ └────── 日:任意
│ └──────── 小时:3
└────────── 分钟:0
→ 每天凌晨 3:00,日志清理、备份的黄金时段
30 2 * * 0
→ 每周日凌晨 2:30,适合跑全量数据重建
*/10 8-19 * * 1-5
→ 工作日 8:00 到 19:59 之间每 10 分钟,盯业务的监控探活
0 0 1 * *
→ 每月 1 号零点,月度报表生成

除了五字段写法,很多 cron 实现还支持 @ 开头的快捷方式:@daily 等价于 0 0 * * *@hourly 等价于 0 * * * *@weekly0 0 * * 0@monthly0 0 1 * *@yearly(或 @annually)是 0 0 1 1 *。还有一个特殊的 @reboot,表示系统启动时执行一次。这些快捷方式可读性极好,能用就用。

最大的坑:日期与星期是”或”的关系

现在讲那个例外。正常逻辑下五个字段是”与”的关系,但当”日”(day of month)和”星期”(day of week)两个字段同时被限制(都不是 *)时,cron 会在任意一个匹配时就触发——是”或”,不是”与”。

这条规则几乎必然会在某个深夜坑到你。比如你想表达”每月 13 号如果恰逢周五就跑任务”(一个经典的迷信梗),写下:

0 0 13 * 5

你以为它只会在”黑色星期五”触发,实际上它会在每月 13 号触发一次,并且每个周五再触发一次。cron 手册里这条规则白纸黑字写着,但绝大多数人第一次知道它都是在事故复盘会上。想实现”13 号且周五”,正确姿势是把日期或星期之一交给脚本判断:cron 写 0 0 13 * *,脚本开头检查今天是不是周五,不是就直接退出。

时区与夏令时:会消失和会翻倍的任务

cron 默认使用系统本地时区。这听起来无害,直到你的服务器迁了机房、容器镜像默认是 UTC、或者夏令时来了。

夏令时切换那天是最魔幻的。春季拨快一小时时,凌晨 2:00 到 2:59 这段时间在本地时间中不存在——定于 30 2 * * * 的任务那天会直接跳过不跑。秋季拨回一小时时,1:00 到 1:59 会经历两遍,这个时段的任务会执行两次。如果你的任务是”发账单”或”发推送”,重复执行就是资损和投诉。应对策略有三:一是把关键任务挪到安全时段(比如凌晨 3 点半以后,任何时区的夏令时切换都不会落在那里);二是让服务器直接跑 UTC,cron 表达式也按 UTC 写,需要换算本地时间时用 时间戳转换工具 核对;三是任务本身做好幂等,跑两遍结果等价。

顺便提醒:跨国团队协作写调度时,先说清楚”这个时间是哪个时区的时间”。想确认”UTC 零点对应北京时间几点”这类换算,可以打开 时间戳转换 对照,或者用 日期计算器 推算某个调度在若干天后的具体日期。

crontab、systemd timer 与 CI 调度器

五字段语法是个通用语,但不同调度器说的”方言”不一样,迁移时最容易翻车。

传统 crontabcrontab -e 编辑,最小粒度分钟,字段顺序就是上面那五个。Vixie cron 及其后代(绝大多数 Linux 发行版默认)遵守本文讲的全部规则,包括日期与星期的”或”语义。

systemd timer:现代发行版的推荐方案。它不用五字段语法,而是自己的 OnCalendar 格式,比如 OnCalendar=Mon..Fri 09:00。优势很明显:依赖 journald 有完整日志、支持秒级精度、机器关机错过的任务可以用 Persistent=true 补跑、依赖关系清晰。劣势是语法又多学一套,且跨字段语义与传统 cron 有微妙差别。

GitHub Actions:workflow 里的 schedule 用的是五字段 cron 语法,看起来和 crontab 一模一样,但有一个致命差异——它永远按 UTC 解释。你写 0 9 * * * 以为早上九点跑,实际在北京时间下午五点才触发。另外 GitHub 官方文档明确说明:高负载时段定时任务可能延迟几十分钟甚至更久,不要把 CI 定时任务当成精确闹钟用。GitLab CI 的 pipeline schedule 则可以自选时区,语义又不同。从 crontab 迁移到 CI 调度前,先用 Crontab 生成器 把表达式按 UTC 重新校对一遍触发时间。

调试技巧:任务没跑怎么办

crontab 任务静默失败是家常便饭,按这个清单排查:

  1. 先看日志grep CRON /var/log/syslog(Debian/Ubuntu)或 /var/log/cron(RHEL/CentOS),确认 cron 守护进程到底触发没有。
  2. 手动跑一遍脚本:cron 的环境变量极其精简——PATH 通常只有 /usr/bin:/bin,你在交互 shell 里能用的命令在 cron 里可能找不到。脚本里用绝对路径,或者在 crontab 顶部显式设置 PATH
  3. 百分号陷阱:crontab 里的 % 是特殊字符(表示换行),date +\%Y-\%m-\%d 要转义,写在脚本文件里调用则不用。
  4. 确认表达式本身:把表达式粘贴到 Crontab 生成器,看它列出的未来触发时间是不是你想要的——尤其警惕 */5 写成 5、星期写错数字这类问题。
  5. 检查时区:服务器是 UTC 还是本地时区?timedatectl 一条命令看清楚,别猜。
  6. 输出别扔:任务默认把 stdout/stderr 以邮件发给本地用户,没人看就等于丢了。显式重定向到日志文件:*/5 * * * * /opt/job.sh >> /var/log/job.log 2>&1

cron 是 Unix 哲学里”简单工具做好一件事”的典范:语法极简、无处不在、几十年不折旧。代价是那几个反直觉的设计——字段语义、或逻辑、时区行为——都等着不懂的人踩。理解了本文这几条,你的定时任务会安静很多,事故复盘会也会少很多。