为什么值得从 cron 迁走
cron 的问题不在于「不能定时」,而在于可观测性几乎为零。任务失败时你在 `/var/log/syslog` 里翻一行被截断的文本,看不到退出码,也拿不到 stdout/stderr;任务是否正在运行、上次跑了多久、为什么被跳过,cron 一个都答不上。
环境变量也需要脑补。cron 的默认 PATH 极短,脚本里 `which java` 找不到东西只是因为 `/usr/local/bin` 不在里面。非交互式执行、没有 tty,很多程序还会因此走进不同的代码分支,这些坑排查起来非常费时间。
更致命的是漏跑不可见。机器在 03:00 宕机、04:00 重启,那个本该在 03:15 跑的任务就静默消失了,要等到第二天的数据对不上才会被发现。systemd timer 的 `Persistent=yes` 正是为这个场景而生:错过的时间点会在开机后立刻补跑一次。
最后是重复触发问题。crontab 里如果上一个任务还没跑完、下一个时间点又到了,两个实例会并发写同一个文件,轻则数据错乱,重则锁竞争雪崩。systemd 可以用 `RefuseManualStart` 之外的机制配合 service 单元的 `RuntimeMaxSec` 与 `Restart=on-failure` 约束,行为更可预期。
# 现状盘点:先看清这台机器上都有什么 cron 任务
crontab -l
ls -l /etc/cron.d/ /etc/cron.daily/ 2>/dev/null
systemctl list-timers --all最小可用形态:一个 timer 单元 + 一个 service 单元
核心心智模型只有一句:`.timer` 单元只负责「什么时候被触发」,`.service` 单元负责「跑什么」。两者同名,通过 `Unit=` 关联。timer 被触发时,systemd 会启动同名 service;如果 service 已在运行,默认行为是拒绝再次启动(这正是你想要的去重语义)。
注意 `WantedBy=` 在 timer 里指的是「timer 自己被谁拉起来」,通常是 `timers.target`;而 `WantedBy=` 在 service 里才是 `multi-user.target`。写错不报错,但 timer 永远不会被启用,这是最常见的「配了但不跑」原因。
`Type=oneshot` 是定时任务的标配:进程跑完就结束,systemd 直接认为成功。务必配合 `SuccessExitStatus` 或在命令末尾显式判断退出码,因为 oneshot 默认不重启,失败就是失败,你只能靠 `systemctl status` 发现。
文件放 `/etc/systemd/system/`(系统级)或 `~/.config/systemd/user/`(用户级)。改完必须 `daemon-reload`,否则 systemd 读的还是内存里的旧版本;这一条忘一次就排查半小时。
# /etc/systemd/system/backup-db.service
[Unit]
Description=Nightly database backup
[Service]
Type=oneshot
User=appuser
ExecStart=/usr/local/bin/backup-db.sh
# 执行超时保护,避免任务卡死永久占位
RuntimeMaxSec=2h
# /etc/systemd/system/backup-db.timer
[Unit]
Description=Run database backup at 03:15
[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true
RandomizedDelaySec=300
Unit=backup-db.service
[Install]
WantedBy=timers.targetOnCalendar:比五字段 cron 强在哪
五字段 cron 的表达力上限很低——「每月最后一个工作日」「每月 15 日的 UTC 02:00」「周末」都写不出来。`OnCalendar=` 直接支持这些:`Mon..Fri 09:00`、`*-*-01 00:00:00`、`weekly`、以及 `last`/`n..m` 区间与「跳过不存在日期」的 `~` 后缀。
时区必须显式考虑,这是迁移时最容易翻车的地方。`OnCalendar=` 里的裸时间默认按**本地时区**解释,而服务器常跑 UTC。如果业务语义是「北京时间凌晨 3 点」,一定要写 `OnCalendar=*-*-* 03:00:00 Asia/Shanghai`,不要指望系统时区恰好正确。
`OnCalendar=` 可以写多行,每行都会被调度——这等价于一条 crontab 里的多个条目,但各自独立计时。配合 `Persistent=true`,每一条都有自己的补跑判断,比一个 `0 3 * * *` 里塞十个命令健壮得多。
别忘了 `systemd-analyze calendar "*-*-* 03:15:00 Asia/Shanghai"`:它会把表达式解析成「下次触发时间 + 距今秒数」,比反复 `date` 试算快得多,也是验证表达式是否被 systemd 接受的最直接方式。
systemd-analyze calendar "*-*-* 03:15:00 Asia/Shanghai"
systemd-analyze calendar "Mon..Fri 09:00"
# 输出示例:Next elapse: Fri 2026-10-02 03:15:00 CST
# In roughly 1 day 3h 12min 4sPersistent 补跑与其它关键指令
`Persistent=yes` 的语义要精确理解:它记录上次成功触发的时间戳,机器启动时如果发现「本应触发的时刻已经过去且未触发」,就立刻触发一次;反之若上次是正常触发的,就等下一个时间点。它只保证「至少跑过一次」,不保证「按原计划时刻跑」。
配套的三个指令解决重复与抖动问题:`AccuracySec=`(默认 1 分钟)让 systemd 把多个触发点合并以降低唤醒次数,想精确执行设 `AccuracySec=1us`;`RandomizedDelaySec=` 给每次触发加随机延迟,避免全网机器在同一秒同时打向一个外部服务;`WakeSystem=` 控制是否允许唤醒停机状态的机器(需要 root 且机器支持 RTC wake)。
`AccuracySec` 与 `RandomizedDelaySec` 的顺序是 Randomize 在 Accuracy 之上:实际偏移 = 随机值(0 到 RandomizedDelaySec)再对齐到 AccuracySec 边界。所以想让大批机器错峰,把 `RandomizedDelaySec=1800` 给足,比调 AccuracySec 有效得多。
对于「每 N 分钟」这类短周期任务,不要用 `OnUnitActiveSec` 硬拼——用 `OnUnitInactiveSec=` 更准确:它在**上一个实例结束**后计时 N 秒,因此天然不会出现任务堆积。`OnUnitActiveSec` 则是从开始计时,任务耗时超过间隔时同样会堆积。
# /etc/systemd/system/heartbeat.timer
[Unit]
Description=Heartbeat every 5 minutes after previous run ends
[Timer]
OnBootSec=2min
OnUnitInactiveSec=5min
AccuracySec=10s
Persistent=true
Unit=heartbeat.service
[Install]
WantedBy=timers.target从 crontab 平滑迁移的对照关系
迁移的正确姿势不是「删掉 crontab 重写一遍」,而是并行运行一段时间验证等价:先装 timer 并启用,但把旧 crontab 注释掉并保留备份(`crontab -l > /root/crontab.bak`),观察至少一个完整周期,确认执行时刻、退出码、输出都符合预期后再彻底删除。
几个常见条目的一对一对应关系:`* * * * *` → `OnCalendar=*:0/1`;`*/15 * * * *` → `OnCalendar=*:0/15`;`0 3 * * *` → `OnCalendar=*-*-* 03:00:00`;`@reboot` → `OnBootSec=` 加 `Persistent=true`;`@daily` → `OnCalendar=daily`(注意 daily 是本地 00:00,不是 03:00)。
环境变量不再自动继承 cron 的那套,需要显式声明。最省事的是把逻辑放进脚本,然后在 service 单元里用 `Environment=` / `EnvironmentFile=` 注入变量与 PATH,避免依赖登录 shell 的 profile。注意 `EnvironmentFile=` 指向的文件必须存在,否则 unit 直接启动失败(用 `-` 前缀可让缺失被忽略)。
迁移完务必做一次端到端验证:`systemctl start <unit>.service` 手动触发一次看输出,`systemctl list-timers --all` 确认下次触发时间合理,再 `journalctl -u <unit>.service -n 50` 确认日志落在 journal 里而不是 syslog。这三条都过了,才算真的迁完。
systemctl daemon-reload
systemctl enable --now backup-db.timer
systemctl start backup-db.service # 手动跑一次看输出
systemctl list-timers --all
journalctl -u backup-db.service -n 50 --no-pager
systemctl status backup-db.timer