系统与运维

systemd timer 实战:用定时任务替代 cron

crontab 只有五行环境变量、没有补跑、没有依赖关系、日志散落 syslog。systemd timer 天然解决这些痛点:统一日志、失败可查、Persistent 补跑、OnCalendar 表达力远超五字段。本文讲清 timer 与 service 的配合关系、表达式写法与从 crontab 平滑迁移的对照表。

作者:巧匠团队·10 分钟阅读·更新于 2026-09-30

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

OnCalendar:比五字段 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 4s

Persistent 补跑与其它关键指令

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

官方参考来源

下方为命令对应的官方权威文档,供你核对最新用法与深入查阅。