一、systemd timer 和 cron 到底差在哪
几乎每台 Linux 服务器上都有 crontab。写一行 */5 * * * * /usr/local/bin/backup.sh,重启都不用,任务就生效了。这种便利是 cron 几十年来不可替代的原因。
但只要你用的机器是 systemd 系统(Debian 8 以后、CentOS 7 以后基本都是),就有一个更完整的选择:systemd timer。它不是 cron 的替代品,而是另一套思路。
两套机制的核心差别可以概括成一张对照:
- 调度精度:两者都是分钟级,但 timer 额外支持秒级(
OnCalendar=*:0/30)和单调时钟(OnBootSec、OnUnitActiveSec)。 - 依赖顺序:cron 不管你的数据库起没起,时间到了就执行。timer 可以用
After=、Requires=表达「网络就绪之后再跑」「MySQL 起来之后再跑」。 - 日志:cron 的输出如果不重定向,会被塞进系统邮件(很多机器上根本没装 MTA,于是输出直接消失)。timer 的输出全部进 journald,
journalctl -u 名字一次看全。 - 失败可见性:cron 的任务失败,除非你自己写告警,否则永远不知道。timer 跑的是 service,systemd 记录每次运行的成功/失败状态,
systemctl status直接能看到上次退出码。 - 并发控制:cron 不管上一次跑完没有,到点就再起一个,长任务会叠加。timer 默认不会在同一个 service 已运行时重复启动(因为 service 是单例)。
- 资源与安全:timer 可以给 service 指定
User=、MemoryMax=、CPUQuota=、PrivateTmp=、ProtectSystem=strict,等于免费获得了沙箱能力。cron 给不了这些。
结论是:cron 适合「简单、短、无依赖、失败了不影响业务」的任务;timer 适合「有依赖顺序、需要日志、需要资源限制、失败要能发现」的任务。大部分运维脚本属于后者,所以新写的计划任务优先考虑 timer 是有道理的。
二、最小例子:把一条 crontab 翻译成 timer
原 crontab:
*/10 * * * * /usr/local/bin/sync-cache.sh >> /var/log/sync-cache.log 2>&1
改写成两个文件。第一个是 /etc/systemd/system/sync-cache.service,描述「做什么」:
[Unit]
Description=Sync application cache
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=www-data
Group=www-data
WorkingDirectory=/var/www
ExecStart=/usr/local/bin/sync-cache.sh
# 资源与沙箱
MemoryMax=512M
CPUQuota=50%
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/www/cache /var/log
# 失败处理
TimeoutStartSec=300
第二个是 /etc/systemd/system/sync-cache.timer,描述「什么时候做」:
[Unit]
Description=Run sync-cache every 10 minutes
[Timer]
OnCalendar=*:0/10
Persistent=true
RandomizedDelaySec=30
AccuracySec=1s
Unit=sync-cache.service
[Install]
WantedBy=timers.target
启用:
systemctl daemon-reload
systemctl enable --now sync-cache.timer
systemctl list-timers --all | grep sync-cache
关键点在 Type=oneshot:这是所有「跑完就退出的脚本」必须用的类型,告诉 systemd「这个服务会执行完然后结束,结束时才更新状态」。如果写成默认的 Type=simple,systemd 会认为脚本启动后立刻成功,退出码和耗时都拿不到,日志顺序也会乱。
Persistent=true 是另一个必须理解的参数:如果定时点到来时机器是关着的,开机后 systemd 会补跑一次。cron 有个类似但不同的行为(cron 会补跑错过的那一次),而 timer 不加 Persistent 就是真的跳过。备份、统计这类「必须每天有一次」的任务,一定要加 Persistent=true。
RandomizedDelaySec 在多台机器上是刚需。如果你有十台机器跑同一个 timer,不加延迟它们会在同一秒一起访问数据库、一起写入对象存储,形成一个小型 DDoS。加个 30 秒随机延迟,负载就被摊平了。
三、OnCalendar 的写法:最容易出错的一块
OnCalendar 的语法看着像 cron 但不一样,出错的方式也不一样。几条实用规则:
- 格式是
星期 年-月-日 时:分:秒,前面的部分可以省略。省略不代表通配,而是代表「匹配所有」。 - 范围用
..,步长用/。例如Mon..Fri *-*-* 03:00:00是工作日凌晨三点。 *:0/15是「每小时的第 0、15、30、45 分」,等价于 cron 的*/15。- 常见简写:
daily、weekly、monthly、yearly、hourly、minutely。注意daily默认是 00:00:00,也就是午夜整点。
判断自己写的表达式对不对,不要靠猜,用这条命令让它算给你看:
systemd-analyze calendar "Mon..Fri *-*-* 03:00:00"
它会输出「下次触发时间」「规范化后的表达式」和「是否包含 UTC 后缀」。这个工具能省掉大量试错。如果要看未来几次的触发时间,加上 --iterations=5。
时区是一个隐蔽的坑。timer 默认按系统时区解释。如果服务器时区是 UTC,而你本意是「北京时间凌晨三点跑」,那实际会在北京时间上午十一点跑。检查方式:timedatectl | grep "Time zone"。如果服务器时区是 UTC 又不想改系统时区,可以在 timer 里加 OnCalendar=*-*-* 03:00:00 Asia/Shanghai,让单条规则自带时区。
四、两类定时语义:日历时间与单调时钟
timer 有两种完全不同的计时方式,用错了会产生很难查的问题。
第一类:OnCalendar,按挂钟时间。「每天凌晨三点」「每周一早上八点」都属于这一类。它的特点是跟现实时间绑定,不管机器什么时候开的机。适合:备份、报表、日志清理、对外服务的维护窗口。
第二类:OnBootSec / OnUnitActiveSec,按单调时钟。「开机 5 分钟后跑一次」「每 30 分钟跑一次(从上次跑完算起)」属于这一类。它不关心现在几点,只关心「距离上次过了多久」。适合:监控采集、缓存刷新、队列消费这类与业务节奏相关、与钟点无关的任务。
两者的差别在机器重启时最明显:OnCalendar=daily 的任务,一天内重启五次也不会跑五次;OnUnitActiveSec=30min 的任务,重启后计时器归零,如果配合 OnBootSec=5min,就会在开机五分钟后立刻跑一次。
一个常见需求是「每 30 分钟一次,但开机后先等 5 分钟」:
[Timer]
OnBootSec=5min
OnUnitActiveSec=30min
注意:OnUnitActiveSec 是从上次启动该 unit 算起,不是从上次运行结束算起。如果脚本运行 20 分钟,间隔 30 分钟意味着「跑完后 10 分钟又来一次」。想要「跑完后再等 30 分钟」,用 OnUnitInactiveSec=30min。
五、手动跑一次和调试
timer 最大的好处之一是调试方式统一。想看它到底会不会跑、跑起来是什么样:
# 手动触发 service(不影响下一次定时)
systemctl start sync-cache.service
# 看本次运行的完整输出
journalctl -u sync-cache.service -n 100 --no-pager
# 看下次什么时候跑
systemctl list-timers sync-cache.timer
# 看最近一次运行的结果(退出码、耗时)
systemctl status sync-cache.service
调试阶段还有一个非常有用的技巧:临时把 OnCalendar 改成一分钟后,验证整条链路通了再改回去。或者干脆不改配置,直接 systemctl start,因为 service 和 timer 是解耦的——手动跑 service 和定时跑 service 走的是同一条路径,环境变量、用户、工作目录、沙箱限制全部一致。
这一点和 cron 形成鲜明对比:cron 里手动跑脚本是在你当前的 shell 环境下跑,环境变量齐全,一切正常;到点由 cron 跑时环境变量只剩 PATH 的一小部分,HOME 可能没设,于是找 ~/.config 就失败。这个「手动能跑、定时不跑」的经典故障,在 timer 里基本不会出现,因为两者环境完全一致。
六、失败可见:让定时任务出问题时能被发现
这是 timer 相对 cron 最大的实际收益,值得单独一节。
systemd 记录每个 service 的退出状态。你可以直接查:
systemctl --failed
这条命令列出所有失败的 unit,包括 timer 触发的 service。把它接进监控(或者每天早上手动看一次),就相当于获得了一个免费的失败告警,不需要在脚本里写任何逻辑。
想要更主动的告警,有两条路:
- 用 OnFailure 钩子。在 service 里加
OnFailure=notify-failure@%n.service,一旦失败就启动一个专门的通知服务,由它发邮件或推送。这种方式适合把失败信息带上去。 - 用 journald 的转发能力。rsyslog 或 systemd-journal-remote 可以把
priority=err的日志转发到集中日志平台,再配告警规则。
另外要注意 TimeoutStartSec:如果脚本卡死(比如某个网络请求永远不返回),不加超时它就会一直挂着。设一个合理的上限(备份 300 秒、缓存刷新 60 秒),超时后 systemd 会杀掉进程并把这次运行标记为失败,于是 systemctl --failed 里能看到,告警链路也就通了。
七、资源限制与沙箱:cron 给不了的东西
这一点是被严重低估的。一个 rsync 备份脚本如果某个环节失控,可能把磁盘 IO 打满,导致前台服务响应变慢;一个 PHP 清理脚本如果内存泄漏,可能触发 OOM 把 MySQL 一起杀掉。cron 对这类问题无能为力,timer 一行配置就能限制住。
常用的几个指令:
MemoryMax=512M:超过就杀进程。比 OOM killer 全局裁决安全得多,因为它只影响这一个任务。CPUQuota=50%:限制这一组进程最多用半个核。压缩、转码类任务尤其需要。IOWeight=:相对的 IO 权重,可以让备份任务在业务高峰时让路。Nice=10、IOSchedulingClass=idle:让任务在系统空闲时才大量占用资源。ProtectSystem=strict+ReadWritePaths=:整个文件系统只读,只有指定路径可写。备份脚本只需要写目标目录,一旦脚本被替换或注入,破坏范围也就被限制在那一小块。PrivateTmp=true:给这个服务一个独立的 /tmp,互相看不到对方的临时文件。NoNewPrivileges=true:禁止通过 setuid 提权。
这些配置加起来不超过十行,但对一个以 root 身份运行的定时备份脚本来说,等于从「全权限裸奔」变成「只能做这一件事」。以后即使脚本本身有漏洞,损失也有限。
八、从 cron 迁移的六步,以及不该迁的情况
迁移的稳妥做法:
- 盘点:
crontab -l、/etc/cron.d/、/etc/cron.daily/全看一遍,列成表:任务做什么、多久跑一次、失败有没有影响。 - 先迁无风险的:日志清理、缓存刷新、统计采集这类,出问题也不影响业务。
- 一次只迁一个,迁完在 timer 里跑通一次、看日志、确认结果,再迁下一个。
- 灰色期并行:迁完先别删 cron 那条,把 cron 的输出重定向到 /dev/null 让它继续跑但观察结果是否冲突。确认 timer 稳定后删 cron。
- 验证持久化:
Persistent=true的任务,模拟关机错过时点,确认开机后补跑。 - 记录在案:把每个 timer 的用途、所有者、失败处理方式写进运维文档。半年后你会感谢自己。
不该迁的情况也要说清楚:
- 一次性、极简的任务:比如每天重启一个服务,cron 一行搞定,写两个 unit 文件反而增加心智负担。
- 需要按秒精确对齐的:timer 最小粒度是秒,但它的调度不是硬实时,
1s的 timer 不建议用来做高频任务,runaway 的风险很高。 - 团队全是新人、只会 cron:迁移的前提是有人能维护。如果没人看得懂
systemd-analyze calendar,出问题时排查成本比 cron 高。 - 容器环境里:容器内通常没有 systemd,还是得用 cron 或者靠编排层(Kubernetes CronJob)来调度。
还有一个常被忽略的点:本机的 /etc/cron.daily 这类目录,在 Debian 和 RHEL 系上已经被 systemd-cron 或 anacron 接管了一部分,行为与想象不同(比如 anacron 会补跑错过的日常任务)。迁移前先看清 ps aux | grep cron 到底跑的是哪个实现。
九、小结
systemd timer 不是新东西,它只是把「调度」「执行环境」「日志」「资源限制」「失败可见」这五件事放进同一套模型里。对一个仍在用 cron 的站长来说,值得迁移的不是全部任务,而是那些「出问题你会很久之后才发现」的任务——备份、清理、证书续期、数据同步。把它们迁过去,配上 TimeoutStartSec 和 systemctl --failed 的日常巡检,你会第一次真正知道自己的定时任务有没有在跑。