定时任务:cron 还是 systemd timer

「脚本手动跑得好好的,放进 cron 就不执行」——这是定时任务最经典的坑。理解两者的差别,就少踩很多。

一、怎么选

场景推荐
简单的周期性任务(备份、清理)cron,够用
需要依赖环境变量、服务上下文systemd timer
需要「错过就补跑」systemd timer(Persistent=true
需要精确到秒都不行,用循环 + sleep
已有 systemd 服务,想定时跑systemd timer
容器里一般都没有,用宿主机或 k8s CronJob

我的默认:简单任务用 cron,重要任务用 systemd timer。

二、cron 语法

bash
crontab -e            # 编辑当前用户的任务
crontab -l            # 列出
crontab -r            # 删除全部(危险)
sudo crontab -u www-data -e   # 指定用户
bash
# 分 时 日 月 周  命令
# │  │  │  │  │
# │  │  │  │  └─ 0-7(0 和 7 都是周日)
# │  │  │  └──── 1-12
# │  │  └─────── 1-31
# │  └────────── 0-23
# └───────────── 0-59

30 3 * * *   /opt/scripts/backup.sh        # 每天 3:30
0 */4 * * *  /opt/scripts/sync.sh          # 每 4 小时
15 2 * * 1   /opt/scripts/weekly.sh        # 每周一 2:15
0 0 1 * *    /opt/scripts/monthly.sh       # 每月 1 号
@daily       /opt/scripts/daily.sh
@reboot      /opt/scripts/onboot.sh

系统级任务放 /etc/cron.d/

bash
# /etc/cron.d/baize
# 比用户 crontab 多一个「用户」字段
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin

30 3 * * * root /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

三、cron 的经典坑

1. 环境变量几乎为空

cron 的执行环境只有极简的 PATH(通常 /usr/bin:/bin)。你的脚本里用了 /usr/local/bin/nodepython3.11,就找不到。

bash
# 错误示范:手动能跑,cron 里失败
node /opt/app/run.js

# 解法一:写绝对路径
/usr/local/bin/node /opt/app/run.js

# 解法二:crontab 顶部声明 PATH
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# 解法三:脚本里自己 source
source /etc/profile

2. 没有输出 = 看不到错误

cron 的输出默认会尝试发邮件(多数机器没配,直接丢弃)。一定要重定向到日志

bash
30 3 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1
#                                  ^^ 追加   ^^ 把错误也收进来

3. 百分号要转义

cron 里 % 有特殊含义(换行)。date +%F 会出错:

bash
0 3 * * * cp f f-$(date +\%F)    # 必须转义

或者把 date 写进脚本里(更推荐)。

4. 末尾要有换行

crontab 文件最后一行没有换行符,该任务会被忽略。

5. 机器关机时任务不会补跑

这是 cron 与 systemd timer 最大的差别之一。

四、systemd timer

需要两个文件:.service + .timer

nginx
# /etc/systemd/system/backup.service
[Unit]
Description=每日备份

[Service]
Type=oneshot
ExecStart=/opt/scripts/backup.sh
User=root
Environment="PATH=/usr/local/bin:/usr/bin:/bin"
nginx
# /etc/systemd/system/backup.timer
[Unit]
Description=每天 3:30 跑备份

[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true            # 关机错过的话,开机后补跑
RandomizedDelaySec=300     # 随机延迟,避免多台机器同时跑
AccuracySec=1min

[Install]
WantedBy=timers.target
bash
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer

# 查看
systemctl list-timers --all
systemctl status backup.timer

# 手动触发一次
sudo systemctl start backup.service

# 看日志(这是最大优势)
journalctl -u backup.service
journalctl -u backup.timer

OnCalendar 表达式

bash
OnCalendar=*-*-* 03:30:00       # 每天 3:30
OnCalendar=Mon *-*-* 02:00:00   # 每周一 2:00
OnCalendar=*-*-01 00:00:00      # 每月 1 号
OnCalendar=hourly               # 每小时
OnCalendar=daily
OnCalendar=weekly
OnBootSec=10min                 # 开机后 10 分钟(相对时间)
OnUnitActiveSec=1h              # 上次执行后 1 小时(适合做轮询)

# 验证表达式写得对不对
systemd-analyze calendar "*-*-* 03:30:00"
# 会显示下一次触发时间

五、对比表

维度cronsystemd timer
配置一行两个文件
日志自己重定向journalctl 直接看
环境极简,易踩坑可指定 Environment
补跑Persistent=true
随机延迟要自己写 sleepRandomizedDelaySec
依赖管理After=/Requires=
资源限制MemoryMax=
精确到秒

六、任务重叠问题

上次任务没跑完,下次又启动了。解法:

bash
# 方案一:脚本里加锁(flock,简单有效)
30 3 * * * flock -n /tmp/backup.lock /opt/scripts/backup.sh
# -n 表示拿不到锁就立即退出,不会堆积

# 方案二:脚本开头自己判断
if pgrep -f "backup.sh" | grep -v $$ > /dev/null; then
  echo "上一个还没跑完,退出"
  exit 0
fi

systemd 天然不会重叠(同一个 service 不会并发启动)。

七、监控:任务到底跑了没

这是最容易被忽略的。备份脚本悄悄失败了半年,真出事才发现——我见过太多次。

bash
# 1. 脚本成功时写一个心跳文件
date > /var/run/last-backup

# 2. 另一个任务检查心跳是否新鲜
#!/usr/bin/env bash
STAMP=/var/run/last-backup
if [ ! -f "$STAMP" ]; then echo "备份从未成功过!"; exit 1; fi
AGE=$(( ($(date +%s) - $(stat -c %Y "$STAMP")) / 3600 ))
if [ "$AGE" -gt 26 ]; then echo "备份已 $AGE 小时没成功!"; exit 1; fi

更简单的方式:用外部监控服务(如 healthchecks.io 的 ping URL),脚本结束前 curl 一下,没按时 ping 就告警。

八、检查清单

  • 命令用绝对路径,或声明了 PATH
  • 输出重定向到日志文件
  • 加锁,防止重叠
  • 脚本本身 set -euo pipefail
  • 有失败告警或心跳检查
  • date +%F 里的 % 已转义(cron)
  • systemd-analyze calendar 验证过时间表达式

定时任务的关键不是「跑起来」,而是跑失败了你能知道