为什么值得从 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