systemd timer 定时任务实战:OnCalendar 语法、Persistent 补跑与「任务没执行」排查全流程

为什么要在 2026 年重新学会 systemd timer

绝大多数站长第一次接触定时任务,都是从 crontab 开始的。五分钟学会 crontab -e,写一行 0 3 * * * /path/backup.sh,备份就跑起来了。cron 足够简单,也足够古老——它的设计可以追溯到 1975 年的 Unix V7,五十年来这套「分 时 日 月 周」的五段式语法几乎没变过。

但 cron 的问题也很明显:它不知道任务跑成功没有,出错了只会在本地发一封可能根本没人看的邮件;机器关机的时段它直接跳过,不会补跑;它没有日志(除非你自己重定向),没有资源限制,没有依赖顺序,也没有超时控制。当你的服务器越来越多、任务越来越重要,cron 的短板就会一个个暴露出来。

systemd timer 是 systemd 生态里对定时任务的重新设计。它把「定时」和「执行」拆成两个单元:timer 负责什么时候触发,service 负责触发之后跑什么。这样带来了一堆 cron 没有的能力:OnCalendar 支持比五段式强大得多的日历语法、失败自动重试、错过的任务开机补跑、随机延迟防雪崩、日志自动进 journald、可以用 cgroup 限制 CPU 和内存。这篇文章就把 systemd timer 在真实运维场景里的用法讲透,并给出从 cron 迁移的完整思路。

最小心智模型:timer 与 service 是一对

cron 是「一行的艺术」,systemd timer 则至少要写两个文件。以「每天凌晨三点跑备份」为例,你需要一个 service 描述「跑什么」:

[Unit]
Description=每日备份任务

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
User=backup
WorkingDirectory=/var/lib/backup

再写一个同名的 timer 描述「什么时候跑」:

[Unit]
Description=每天凌晨三点触发备份

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true

[Install]
WantedBy=timers.target

两个文件放在 /etc/systemd/system/ 下,名字相同只是后缀不同,比如 backup.service 和 backup.timer。这样 timer 触发时会自动去找同名的 service。启用时,注意启用的是 timer 而不是 service:

systemctl daemon-reload
systemctl enable --now backup.timer

这里第一个新手坑就出现了:很多人习惯性写 systemctl enable backup.service,结果 service 被设成开机就立刻执行一次,而 timer 根本没人管。记住原则——timer 是调度器,service 是被调度者,你永远启用 timer。查询状态也要查 timer:

systemctl list-timers --all
systemctl status backup.timer
journalctl -u backup.service -n 50 --no-pager

list-timers 会告诉你每个 timer 上次触发时间(LAST)、下次触发时间(NEXT)、以及激活状态。这是排查「任务到底跑没跑」的第一入口。

OnCalendar 语法:比五段式强太多的地方

OnCalendar 的格式是人类可读的时间描述,基本形式是 星期 年-月-日 时:分:秒。它支持范围、列表、步长和通配,表达力远超 cron。下面这张表覆盖了日常 90% 的场景:

  • *-*-* 03:00:00 每天凌晨三点
  • *-*-* 00/6:00:00 每 6 小时一次(0 点、6 点、12 点、18 点)
  • Mon..Fri *-*-* 09:00:00 周一到周五早上九点
  • *-*-01 04:30:00 每月 1 号四点半
  • Sun *-*-* 02:00:00 每周日凌晨两点
  • *-*-* 03:00:00 Asia/Shanghai 指定时区(默认跟随系统)

相比 cron 的五段式,最直观的优势是「星期和日期可以同时生效」。cron 里如果同时写了「日期」和「星期」,系统是「或」的关系——0 0 1 * 1 会在每月 1 号或者每周一都执行,很多人以为这是「且」,结果任务跑得比预期多好几倍。而在 systemd 里,星期和日期是「与」关系,语义清晰,不会踩这个坑。

另一个杀器是 OnCalendar 支持口语化简写和前缀:hourly、daily、weekly、monthly、yearly,以及带 - 前缀表示「相对上一次」,比如 OnUnitActiveSec=1h 表示「上次激活后一小时」。这类「相对定时」在 cron 里完全做不到。

写完日历表达式,强烈建议先验证一遍再启用。systemd 自带一个校验命令,会打印未来若干次触发时间:

systemd-analyze calendar "*-*-* 00/6:00:00"

输出里会显示 Next elapse: 和 Iteration of unit list being completed,把你期望的下几次时间对照一遍,能提前发现「我以为每 6 小时,结果写成了每小时」这类低级错误。这个命令几乎是写 timer 的必备动作。

Persistent=true:cron 永远做不到的补跑

假设服务器每天凌晨三点备份,但某天凌晨两点到四点你在做系统维护,机器是关着的。cron 会怎么办?什么也不做,这一次备份就永远丢了,你甚至不会知道。这在生产环境里是危险的——你可能连续几个月没有成功备份,直到真正需要恢复的时候才发现。

Persistent=true 就是为解决这个问题设计的。开启之后,systemd 会记录这个 timer 上次触发的时间戳(存在 /var/lib/systemd/timers/ 下)。如果机器在应该触发的时间点是关机或休眠的,那么等它下次开机、timer 被激活时,systemd 会立刻补跑一次错过的任务。

[Timer]
OnCalendar=daily
Persistent=true

对于备份、证书检查、数据同步这类「必须保证执行」的任务,Persistent=true 基本是标配。而对于「每小时刷新一次缓存」这种错过也无所谓的任务,则可以不开启,避免开机瞬间一堆任务同时挤进来。

这里有个容易忽略的细节:Persistent=true 的补跑是「合并」的。如果你错过了三次触发(比如关机三天),systemd 不会补跑三次,而是只补跑一次。因为它的语义是「保证这个时间点之后至少执行过一次」,而不是「精确重放每一次」。理解了这一点,就不会对补跑次数感到困惑。

RandomizedDelaySec:防止任务雪崩

这是 systemd timer 另一个很实用但常被忽视的参数。想象你有 20 台服务器,都配置了「每天凌晨三点跑一次清理脚本」。到了三点整,20 台机器同时开工,同时打满磁盘 IO、同时往日志服务器灌数据、同时调用外部 API——这就是定时任务的「惊群效应」。

RandomizedDelaySec 让每次触发在一个随机延迟窗口内错开:

[Timer]
OnCalendar=daily
RandomizedDelaySec=30min

这样任务会在三点到三点半之间的随机时刻执行,20 台机器的负载自然被摊平。配合 AccuracySec(精度,默认 1 分钟)一起理解会更透:AccuracySec 是 systemd 为了省电而允许的触发时间容差,它会把同一时间点的任务合并到一次唤醒里;RandomizedDelaySec 则是在此基础上再加一层主动的随机抖动。对单机任务来说,把 AccuracySec 调大(比如 AccuracySec=1h)能减少唤醒次数、省电;对集群任务来说,RandomizedDelaySec 是更直接的错峰手段。

依赖顺序、超时与资源限制

cron 里如果任务 A 依赖任务 B 先完成,你只能靠「把 B 定在 2 点、A 定在 2 点 30 分」这种脆弱的时间差来保证顺序,一旦 B 跑慢了就崩。systemd 用依赖关系原生解决:如果 backup.service 必须先跑完再跑上传,可以用 ExecStartPost 串起来,或者用 unit 之间的 After=、Requires=。

超时控制也是 systemd 的强项。Type=oneshot 的任务如果卡死(比如备份脚本卡在 NFS 挂载上),会一直占着不放。加一个超时就能自动杀掉:

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
TimeoutStartSec=2h

超时之后进程被终止,任务的失败状态会如实记录在 journald 里,systemctl status 会显示 failed,配合 OnFailure= 还能自动触发一个告警单元。这就补上了 cron「出错了没人知道」这块最大的短板。再搭配 cgroup 资源限制,防止一个失控的备份把整台机器的内存吃光:

[Service]
MemoryMax=1G
CPUQuota=50%
IOWeight=50

这几个参数是 systemd 服务通用的,写在定时任务的 service 里同样生效,等于免费得到了资源隔离。

服务类型:oneshot 与 forking 的选择

定时任务的 service 有一半以上都用 Type=oneshot——意思是「执行完就退出,退出即完成」。这是脚本类任务最合适的类型:systemd 会等待进程结束,把退出码当作成功/失败依据(退出码 0 为成功)。

但如果你的脚本内部又 fork 了一个子进程去后台跑,然后主进程立刻返回,就会出现「systemd 以为任务结束了,其实子进程还在跑」的情况。这时要么用 Type=forking 并配合 PIDFile=,要么(更推荐)改造脚本让它自己 wait 子进程。绝大多数情况下,把任务写成「前台执行、跑完才退出」的 oneshot 是最稳妥的,不要在里面再套一层 nohup 后台。

从 cron 平滑迁移的实操清单

如果你已经有一堆 crontab,不必一次性推倒重来,可以按下面的路线逐步迁移:

  • 第一步:列出所有 crontab 任务,标记哪些是「必须保证执行」的(备份、证书、同步),优先迁移这些。
  • 第二步:每个任务写一个 .service,把原来一行命令里的重定向、环境变量、工作目录显式写清楚(cron 的环境极度贫瘠,PATH 只有 /usr/bin:/bin,很多脚本在 cron 里跑不起来就是因为 PATH 不同)。
  • 第三步:写对应的 .timer,把五段式翻译成 OnCalendar,用 systemd-analyze calendar 校验。
  • 第四步:enable --now 启用 timer,先保留原 cron 任务观察几天,确认 systemd 版本运行正常后再删掉 cron。
  • 第五步:给关键任务加 OnFailure= 告警单元,接入 ntfy 或邮件,让失败可见。

迁移过程中最常见的报错是「service 能手动 systemctl start 成功,但 timer 触发时失败」。九成原因是环境变量差异——手动执行时你带着 shell 的 PATH 和 env,而 systemd 用的是干净环境。解决办法是在 service 里显式声明 Environment= 或在脚本开头设置好绝对路径。养成「脚本里所有命令都写绝对路径」的习惯,能从根上避免这类问题。

踩坑复盘:三个真实故障

第一个坑是修改 timer 后忘了 daemon-reload。systemd 不会自动感知 unit 文件的改动,你改了 OnCalendar 却不 reload,跑的还是旧配置,排查半天以为语法写错了。记住:改任何 unit 文件之后,第一件事永远是 systemctl daemon-reload,然后 systemctl restart xxx.timer。

第二个坑是把 timer 的时间理解成本地时区。如果系统时区是 UTC(很多云服务器默认如此),你写的 03:00 实际是北京时间上午 11 点。要么把系统时区设对(timedatectl set-timezone Asia/Shanghai),要么在 OnCalendar 里显式带时区后缀。备份任务跑到业务高峰时段,往往就是这个原因。

第三个坑是oneshot 任务的退出码。脚本最后一条命令失败,整个 service 就标记为 failed——哪怕前面所有步骤都成功了。如果脚本里有些「允许失败」的清理命令(比如删除临时文件,文件不存在就返回非 0),记得在末尾补一句 exit 0 或者用 || true,否则会在监控里制造大量误报。反过来说,如果你希望任何一步失败都让任务算失败,就在脚本开头加 set -e,让 systemd 能准确感知。

小结

systemd timer 不是「cron 的替代品」这么简单,它是把定时任务从「能不能跑」升级到「跑得可观测、可恢复、可隔离」。对个人站长来说,最值得立刻用上的三个能力是:Persistent=true 保证错过的任务能补跑、日志进 journald 让失败可见、RandomizedDelaySec 避免多机同时开工。这三条正好补上了 cron 最要命的三个盲区。

迁移不必一步到位,先把最关键的备份和证书任务换成 timer,用几天确认稳定,再逐步扩展。等到你的所有定时任务都跑在 systemd 下、失败会自动告警、错过的会自动补跑时,你会发现运维里那种「不知道昨晚任务跑没跑」的不安感,彻底消失了。

Last modification:October 16th, 2026 at 10:25 pm

Leave a Comment