系统与运维

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

官方参考来源

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