很多站长给服务器加定时任务,第一反应还是 crontab -e。cron 足够老、足够稳,但它有几个天生的短板:任务被关机/重启错过就永远丢了、日志只能靠 MAILTO 发邮件看、启动时间全部挤在整点、依赖别的服务是否就绪完全不管。systemd timer 正是为了补这些短板而生的——它不是 cron 的替代品,而是「更懂服务依赖和错过补偿」的调度器。这篇文章把 timer 的核心机制、最容易翻车的参数、以及排错套路一次讲透。
一、cron 的三个真实痛点
先别急着换,先看看你是否真的被这几个问题咬过:
错过就丢。服务器凌晨 3 点在做快照或者重启维护,2 点的备份任务这次就彻底没了,cron 不会补偿,你也不会收到任何提示。
雪崩。你写了「每天凌晨 3:00 备份」「3:00 清理缓存」「3:00 汇总日志」,三件事同时抢磁盘 IO,服务器直接卡死几分钟。
不知道跑没跑。cron 的输出去向取决于 MAILTO,很多机器根本没装 MTA,于是任务静默失败几个月你都不知道。
timer 针对这三点分别给出了 Persistent=true(补跑)、RandomizedDelaySec(错峰)、以及天生接入 journald(可查日志)的答案。
二、最小可用示例:一个带补跑的每日备份
timer 由两个文件组成:一个 .service 描述「跑什么」,一个 .timer 描述「什么时候跑」。先写 service:
[Unit]
Description=每日站点数据备份
关键:如果上次没跑完,这次不要重复启动
After=network-online.target mysql.service
Wants=network-online.target
[Service]
Type=oneshot
User=root
WorkingDirectory=/root
ExecStart=/usr/local/bin/backup.sh
给足时间,长任务别被 systemd 默认 90 秒掐断
TimeoutStartSec=3600
Nice=10
IOSchedulingClass=idle
再写 timer:
[Unit]
Description=触发每日备份
[Timer]
语义:每天 03:30 运行(下面是日历表达式)
OnCalendar=--* 03:30:00
关机/停机期间错过的任务,开机后立刻补跑一次
Persistent=true
在 0~5 分钟内随机延迟,避免和其他任务撞车
RandomizedDelaySec=300
精确到秒的随机抖动(systemd 247+ 支持,可选)
AccuracySec=1s
定时器随系统启动而生效,但不会开机立刻跑一遍
Unit=backup.service
[Install]
WantedBy=timers.target
启用:
systemctl daemon-reload
systemctl enable --now backup.timer
systemctl list-timers --all
systemctl list-timers 是 timer 最实用的命令,它会直接告诉你 NEXT(下次触发时间)、LEFT(还有多久)、LAST(上次触发时间)、PASSED(距上次多久)。一眼就能看出「下次到底会不会跑」,这是 cron 永远给不了的。
三、OnCalendar 语法:别用 cron 的思路去套
OnCalendar 不是五个星号的翻译,它是一套可读性更强的日历表达:
daily = --* 00:00:00
--* 03:30:00 = 每天 03:30
Mon..Fri --* 09:00:00 = 工作日 09:00(注意星期在最前面)
--1 04:00:00 = 每月 1 号 04:00
hourly / weekly / monthly / yearly 都是快捷写法
-- :00/15:00 = 每 15 分钟(/15 是步进)
验证表达式是否合法、下次触发时间是多少,用这个命令,不用真的等到点:
systemd-analyze calendar "Mon..Fri --* 09:00:00"
它会打印 Next elapse 和 From now,非常直观。写错语法也会在这里直接报错,比「等一天看有没有跑」强太多。
一个最容易踩的坑:时区。systemd 默认用系统时区,如果你的服务器是 UTC 而你在脑子里算的是北京时间,任务就会「莫名其妙早/晚 8 小时」。用 timedatectl 先确认,必要时在 timer 里显式写 OnCalendar=... 配合系统时区,而不是指望记忆。
四、Persistent=true 的真实行为
很多人以为 Persistent 是「一定补跑」,其实它的准确语义是:记录上次触发时间戳(存在 /var/lib/systemd/timers/ 下),如果系统在应该触发的时刻是关闭的,那么启动后立刻触发一次。
几个推论:
对于 OnCalendar 型 timer,Persistent 才有意义;对于 OnBootSec 这类相对 timer,它没有概念。
它只补一次,不会因为你关机三天就补三次。
如果补跑的任务本身依赖 MySQL 还没起来,需要靠 service 里的 After=mysql.service 兜底,而不是靠 timer。
五、错峰:RandomizedDelaySec 与 AccuracySec
cron 的经典问题是一堆任务挤在整点。timer 的解法是让触发时间「模糊化」:
RandomizedDelaySec=300:每次触发前随机等 0~300 秒,适合「几点几分跑不重要,错开就好」的批处理。
AccuracySec=1s:默认是 1 分钟,systemd 可能为了省电把触发时间上下浮动。想要准点跑就显式设小。
注意:RandomizedDelaySec 只在单机上错峰。如果你是十台服务器同时跑备份,随机范围要足够大(比如 15 分钟)才不会一起砸向同一个存储后端。
六、日志与排错:journald 是免费送的
timer 触发的 service 输出全部进 journald,查历史非常方便:
看某个 service 最近 50 行
journalctl -u backup.service -n 50 --no-pager
只看昨天以来的
journalctl -u backup.service --since yesterday
实时跟踪
journalctl -u backup.service -f
排查清单,按顺序问自己四个问题:
timer 到底启用了吗? systemctl is-enabled backup.timer,以及 systemctl list-timers --all 里有没有它。常见错误是只 start 了没 enable,重启就没了。
下次触发时间对吗? 看 list-timers 的 NEXT 列,或 systemd-analyze calendar 验语法。
触发了但任务失败? 看 systemctl status backup.service 和 journalctl,注意 ExecStart 里的脚本路径是否是绝对路径、脚本有没有执行权限、脚本依赖的环境变量是否在 service 里缺失(systemd 的环境比登录 shell 干净得多,PATH 通常只有 /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin)。
触发了但没执行? 检查 service 是否被 ConditionPathExists 之类的条件挡掉了,或者上一次实例还在跑(oneshot + 未设 TimeoutStartSec 时可能堆积)。
七、什么时候该继续用 cron
timer 不是银弹。以下场景 cron 反而更顺手:
极简的、单行的、不需要补跑和依赖管理的任务。cron 一个文件搞定,timer 要维护两个文件 + daemon-reload。
非 systemd 环境(老旧的 CentOS 6、Alpine 默认用 OpenRC、各种精简容器)。
需要按「用户级」跑且系统不支持 user manager 的场景(不过现代 systemd 的 systemctl --user 也能跑 timer,配合 loginctl enable-linger)。
我的实践是:关键任务(备份、证书续期触发、数据同步、清理)用 timer,临时的一次性小任务用 cron。关键任务最怕的就是「静默地没跑」,而 timer 的 Persistent + journald + list-timers 三件套,恰好把「静默失败」这个最大的坑填上了。
八、一个完整的可复制模板
把下面这段存成 /etc/systemd/system/mysql-backup.service 与 .timer,改成你的脚本即可:
/etc/systemd/system/mysql-backup.service
[Unit]
Description=MySQL 逻辑备份
After=network-online.target mysql.service
Wants=network-online.target mysql.service
ConditionPathIsMountPoint=/data
[Service]
Type=oneshot
User=root
ExecStart=/usr/local/bin/mysql-backup.sh
TimeoutStartSec=7200
Nice=10
IOSchedulingClass=idle
StandardOutput=journal
StandardError=journal
/etc/systemd/system/mysql-backup.timer
[Unit]
Description=每天 03:30 触发 MySQL 备份
[Timer]
OnCalendar=--* 03:30:00
Persistent=true
RandomizedDelaySec=600
AccuracySec=1min
Unit=mysql-backup.service
[Install]
WantedBy=timers.target
上线后第一件事不是等它跑,而是用 systemctl start mysql-backup.service 手动验证一次逻辑,再用 systemctl list-timers 确认调度排上了。定时任务最大的信任成本从来不是「能不能跑」,而是「你以为它在跑」。把 timer 用对,就把这份不确定性收窄了大半。
九、cron 与 timer 能力对照表
为了让你快速判断某个任务该用哪个,这里做一张对照:
指定时刻执行:cron 五字段;timer OnCalendar,可读性更好。
关机期间错过:cron 永久丢失;timer Persistent=true 开机补跑一次。
错峰随机:cron 需要自己在脚本里 sleep $RANDOM 凑;timer 有原生 RandomizedDelaySec。
依赖别的服务:cron 无法表达;timer 用 After=/Wants= 声明依赖,等 MySQL 起来再跑。
运行日志:cron 依赖 MAILTO 或手动重定向;timer 天生进 journald。
失败重试:cron 无;timer 可配 Restart=on-failure(不过对 oneshot 型任务要谨慎,避免无限重试)。
资源限制:cron 无;timer 可复用 service 的 Nice、IOSchedulingClass、MemoryMax、CPUQuota。
超时兜底:cron 无;timer 有 TimeoutStartSec,任务卡死会被自动终止。
可以看出,timer 在「可观测性」和「依赖编排」上全面胜出,代价只是多一个文件和一次 daemon-reload。对于个人站长手里的关键批处理,这个代价完全值得。
十、把脚本写得更抗造:几个配套习惯
再好的调度器也救不了一个脆弱的脚本。配合 timer 使用,建议在脚本里坚持这几条:
用 set -euo pipefail 开头,任一命令失败即退出,避免「前半段失败后半段继续」把数据搞成半成品。
加锁防重叠。虽然 timer 能靠 Type=oneshot 一定程度上防止并发,但如果脚本会被手动触发,仍建议用 flock:exec 9>/var/lock/mysql-backup.lock; flock -n 9 || exit 1。
写好退出码。任务成功 exit 0、失败非 0,systemd 才能通过 systemctl --failed 和告警发现问题。
关键步骤打时间戳日志。echo "[$(date '+%F %T')] start backup",排错时能一眼看出卡在哪一步。
备份类任务必须自校验。生成后 gzip -t 或算 md5,别等真恢复时才发现文件是坏的。
把这几条和前面的 timer 模板组合起来,你就得到了一套「会补跑、会错峰、有日志、有锁、有超时、有校验」的定时任务体系。这才是真正意义上的「设置好就可以忘掉」——而不是「设置好后每天偷偷担心它有没有跑」。
(adsbygoogle = window.adsbygoogle || []).push({});