"手动跑得好好的,放进 cron 就不行"——这句话背后九成是环境差异:PATH 少了、HOME 不对、locale 变成 POSIX、脚本没有执行位。剩下那一成是并发与重叠:上一轮还没跑完,下一轮已经起来,两份任务同时写同一张表。
下面按"怎么写 → 放哪里 → 环境差在哪 → 怎么防重叠 → 什么时候换 timer"走:字段语义 → 文件位置 → 环境变量 → 输出与日志 → 并发 → systemd → 判据 → 时区。
1. 五字段语义与写法陷阱
┌───────── 分钟 (0-59)
│ ┌─────── 小时 (0-23)
│ │ ┌───── 日 (1-31)
│ │ │ ┌─── 月 (1-12)
│ │ │ │ ┌─ 星期 (0-7,0 与 7 都是周日)
* * * * * command
| 写法 | 实际行为 | 陷阱 |
|---|---|---|
*/5 * * * * |
每 5 分钟 | 分片边界由 0 起算,*/7 会留下不整齐的空隙 |
0/5 * * * * |
从 0 起每 5 分钟 | 与 */5 在分钟字段等价,在日字段不等价 |
*/5 写在"日"字段 |
1、6、11、16、21、26、31 号 | 跨月不连续:31 号到 6 号之间空 6 天 |
0 0 13 * 5 |
13 号或周五 | 日与周同时受限是"或",不是"且" |
@daily |
等价 0 0 * * * |
只在 0 点整,不是"每天启动后第一个整点" |
@reboot |
cron 守护启动时跑一次 | 容器里"重启容器"就算一次 |
常用简写:@hourly / @daily(@midnight) / @weekly / @monthly / @yearly(@annually) / @reboot。简写只解决可读性,语义与展开后的五字段完全相同。
2. 三个位置的差别
| 位置 | 是否需要用户字段 | 生效方式 | 适用 |
|---|---|---|---|
crontab -e(用户 crontab) |
不需要 | 保存即生效 | 个人或单账号任务 |
/etc/crontab |
需要(第 6 列) | 由 cron 读取 | 系统级,但更容易被包管理覆盖 |
/etc/cron.d/<name> |
需要(第 6 列) | 文件放入即生效 | 应用自带的定时任务,随包分发 |
/etc/cron.{hourly,daily,weekly,monthly}/ |
不需要 | run-parts 调度 |
只放可执行脚本,不写时间 |
# /etc/crontab 与 /etc/cron.d/* 多一个用户字段
30 3 * * * root /usr/local/bin/backup.sh
# 用户 crontab(crontab -e)没有用户字段
30 3 * * * /usr/local/bin/backup.sh
两个容易忽略的细节:/etc/cron.d 只处理由字母、数字、连字符、下划线组成的文件名,带 . 的文件被直接跳过("backup.sh" 这种命名永远不生效);/etc/cron.{daily,...} 目录里的脚本必须可执行,参数由 run-parts 决定,不接受自定义命令行。
3. 环境变量:手工能跑、cron 不能跑
cron 启动的是非交互、非登录 shell,不读 .bashrc、.profile,环境极简:
| 变量 | cron 里的值 | 后果 |
|---|---|---|
PATH |
通常是 /usr/bin:/bin |
python3 在 /usr/local/bin 时直接 command not found |
HOME |
取自 /etc/passwd |
相对路径 ~ 指向的目录与交互时可能不同 |
SHELL |
/bin/sh |
脚本里用了 [[ ]]、<<< 等 bash 语法会报错 |
LANG/LC_* |
常为 POSIX/C | 中文乱码、日期格式与排序结果变化 |
排查最快的一招:把环境原样 dump 出来对比。
# crontab -e 里临时加一条
* * * * * env > /tmp/cron-env.txt 2>&1
# 交互 shell 里
env | sort > /tmp/login-env.txt
diff /tmp/cron-env.txt <(sort /tmp/login-env.txt)
修法优先级:脚本里写绝对路径(/usr/bin/python3)> crontab 顶部显式声明 PATH=/usr/local/bin:/usr/bin:/bin 与 LANG=zh_CN.UTF-8 > 用 bash -lc '...' 加载登录环境(最慢、最容易引入副作用,放最后)。
4. 输出与日志
cron 会把任务的 stdout/stderr 通过邮件发给任务所有者;机器没装 MTA 时这些输出进本地邮件队列,日积月累就是磁盘问题。
| 做法 | 写法 | 说明 |
|---|---|---|
| 丢弃全部输出 | cmd >/dev/null 2>&1 |
最省事,出问题也没线索 |
| 落文件 | cmd >> /var/log/job.log 2>&1 |
记得配 logrotate |
| 只要错误 | cmd >/dev/null 2>> /var/log/job.err |
折中 |
| 关掉邮件 | MAILTO=""(crontab 顶部) |
关的是投递,不是输出产生 |
| 指定收件人 | [email protected] |
需要本机有可用 MTA |
# 推荐:自己带时间戳,落文件并按天切
0 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
MAILTO="" 只阻止投递,任务仍在产生输出、仍写管道;真正省资源的是把输出重定向掉。
5. 并发保护:别让两轮任务撞在一起
| 手段 | 写法 | 特点 |
|---|---|---|
flock 非阻塞 |
flock -n /var/lock/job.lock -c 'cmd' |
拿不到锁立即退出,最常用 |
flock 等待 |
flock -w 600 /var/lock/job.lock -c 'cmd' |
最多等 10 分钟 |
| 文件描述符式 | flock -n 9; [ $? -ne 0 ] && exit 0 |
可在脚本内部加锁 |
systemd 单实例 |
service 默认不并发 | timer 触发时上一个还在跑会被跳过或排队 |
# 每 5 分钟一轮,但同一时刻只允许一个实例
*/5 * * * * flock -n /var/lock/sync.lock -c '/usr/local/bin/sync.sh >> /var/log/sync.log 2>&1'
flock 的锁随进程退出自动释放,比"检查 pid 文件是否存在"可靠得多——pid 文件在 kill -9 后会残留,flock 不会。
6. systemd timer:结构对照
cron 的等价物是"一个 .service 加一个 .timer",两者默认同名配对:
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
StandardOutput=append:/var/log/backup.log
# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup nightly
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
AccuracySec=1min
RandomizedDelaySec=2min
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers --all | grep backup
journalctl -u backup.service -n 50 --no-pager
systemd-analyze calendar "*-*-* 03:30:00" # 验证表达式与下次触发时间
| 能力 | cron | systemd timer |
|---|---|---|
| 补跑错过的任务 | 不支持 | Persistent=true |
| 依赖其他服务 | 只能自己 sleep/重试 | After=、Requires=、Wants= |
| 日志 | 邮件或自行重定向 | journalctl -u,带结构化字段 |
| 并发控制 | 需 flock |
service 天然单实例 |
| 随机抖动 | 自己做 | RandomizedDelaySec |
| 表达式校验 | 只能等触发 | systemd-analyze calendar 离线验证 |
| 秒级精度 | 不支持 | OnCalendar 支持秒 |
OnCalendar 的常用形态:*-*-* 03:30:00(每天)、Mon..Fri *-*-* 09:00:00(工作日)、*-*-01 00:00:00(每月 1 号)、Sun *-*-* 04:00:00(每周日)。AccuracySec 默认 1 分钟,它允许 systemd 在这个窗口内合并唤醒以省电,要求准点就调小;RandomizedDelaySec 用来打散一批同时触发的任务。
Persistent=true 只在有 OnCalendar 时有意义:它记录上次触发时间(/var/lib/systemd/timers/),开机后补跑错过的轮次。这正是 cron 最缺的能力——机器在 3:30 关机,cron 就永久丢了这一轮。
7. 选择判据
| 需求 | 选 cron | 选 timer |
|---|---|---|
| 一次性、简单的命令 | ✅ | — |
| 需要补跑错过的轮次 | — | ✅ |
| 依赖网络/数据库就绪 | — | ✅(After=network-online.target) |
| 需要精细日志与退出码 | — | ✅(journalctl) |
| 秒级或复杂日历 | — | ✅ |
| 分发给非 systemd 环境 | ✅ | — |
| 想一条命令改完就走 | ✅ | — |
原则:任务本身有状态(要补跑、要依赖、要查日志)就用 timer;纯"到点跑一下"就用 cron。 同一台机器上两者可以共存,别为了一致性把简单的 flock 任务改写成三个文件。
8. 时区
| 位置 | 时区来源 | 注意 |
|---|---|---|
| cron | 系统时区(/etc/timezone、timedatectl) |
CRON_TZ=Asia/Shanghai 可改单条,支持情况按发行版核对 |
| systemd timer | 系统时区 | 跨时区机器先 timedatectl set-timezone |
| 脚本内部 | 进程继承 | TZ=UTC date 只影响该次调用 |
夏令时地区还有额外的坑:切换当天可能出现"这一小时不存在"或"这一小时出现两次",0 2 * * * 这类表达式在春季跳变的日期会跳过或重复执行。对时间敏感的任务,把触发点避开 1:00–3:00。
9. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 手工能跑,cron 报 command not found | PATH 不含自定义目录 |
脚本内写绝对路径,或 crontab 顶部设 PATH |
| 脚本无输出、也不执行 | 没有执行位,或 shebang 缺失 | chmod +x,首行写 #!/usr/bin/env bash |
报 bad interpreter |
shebang 指向不存在的解释器 | 核对 which bash 的实际路径 |
/etc/cron.d 里的任务不生效 |
文件名带 .,或缺少用户字段 |
改名,补第 6 列 |
| 输出里出现意外换行/截断 | 命令里的 % 未转义 |
写成 \%(cron 把裸 % 当换行符) |
| 同一任务重复执行 | 多份 crontab 或 cron 与 timer 同时配 | grep -r 全盘找一遍,用 flock 兜底 |
| 任务重叠导致数据重复 | 上一轮未结束,下一轮已启动 | flock -n 或改 timer 单实例 |
Persistent 没补跑 |
timer 没有 OnCalendar,或从未 enable |
systemctl enable --now x.timer |
| 中文日志乱码 | cron 的 locale 是 C/POSIX | 脚本里显式 export LANG=zh_CN.UTF-8 |
10. 边界
| 场景 | 更合适的方案 | 理由 |
|---|---|---|
| 分钟级、跨多台机器 | 调度平台(XXL-JOB 等) | 需要统一视图、失败重试、分片 |
| 需要任务依赖 DAG | Airflow / DolphinScheduler | cron 与 timer 都表达不了依赖图 |
| 高频(秒级、毫秒级) | 常驻进程 + 内部调度 | 每次 fork 进程的成本吃不消 |
| 一次性延迟执行 | systemd-run --on-active=30m |
不必写单元文件 |
| 容器内定时 | 容器外调度,或应用内调度器 | 容器重启即丢 cron 状态 |
一句话:cron 解决"到点 fork 一个进程",timer 解决"到点 fork 一个进程并留下可查的证据";再往上就该换真正的调度系统。
11. 自测
*/5写在"日"字段,为什么会出现跨月不连续的间隔?- 日与星期同时写了具体值,是"且"还是"或"?
/etc/cron.d里的文件为什么不能叫backup.sh?- cron 任务的
PATH与交互 shell 不同,最快的定位手段是什么? flock -n比 pid 文件可靠在哪?Persistent=true为什么必须配合OnCalendar才有意义?