1. 为什么在 Red Hat 9 上还要专门聊 systemd 定时器
很多人第一次在 Red Hat 9 上想跑个定时任务,脑子里蹦出来的第一个东西还是crontab -e。这没错,cron 用了这么多年,简单直接,一行时间表达式加一条命令就完事。但如果你稍微往深里用一点,就会发现 cron 有几个绕不过去的坎:它不知道任务上次跑成功没有,不会自动补跑错过的任务,日志散落在各处不好追,任务之间的依赖关系更是没法表达。而 Red Hat 9 这一代系统里,systemd 已经是绝对的核心,systemd.timer这套定时器机制其实是官方更推荐、能力也更完整的方案。
我先把结论摆出来:systemd 定时器不是 cron 的简单替代品,它是把"定时触发"这件事纳入了服务管理的统一框架。你写的不是一个孤零零的时间表达式,而是一个.timer单元加一个.service单元的组合。timer 负责"什么时候触发",service 负责"触发之后干什么"。这种拆分带来的好处是,任务的执行状态、输出日志、失败重试、资源限制,全部可以复用 systemd 已有的那套机制,不用自己再造轮子。
这篇文章适合谁看?如果你在 Red Hat 9(或者任何用 systemd 的现代发行版)上需要跑周期性任务,比如日志清理、数据备份、健康检查、缓存刷新,并且你希望这些任务可观测、可管理、可追溯,那 systemd 定时器值得你花时间掌握。哪怕你之前只用过 cron,跟着下面的步骤走一遍,也能顺利迁移过来。我会把每个配置项为什么这么写讲清楚,也会把我自己踩过的坑摊开说,避免你重复交学费。
需要先说明一点:Red Hat 9 默认的 systemd 版本对定时器的支持已经相当成熟,OnCalendar、OnBootSec、OnUnitActiveSec、Persistent、RandomizedDelaySec这些常用指令都能用。下面所有操作都基于这个前提展开,不涉及任何额外软件包的安装。
2. 先把 timer 和 service 的配对关系理清楚
2.1 一对单元文件,各管一摊事
systemd 定时器最容易被新手搞混的地方,就是它必须和同名的 service 配合工作。假设你要创建一个叫cleanup-job的定时任务,那么你需要两个文件:
/etc/systemd/system/cleanup-job.service:定义任务本身要执行什么/etc/systemd/system/cleanup-job.timer:定义什么时候触发这个 service
命名上的约定是:foo.timer默认会去激活foo.service。这个"同名匹配"是 systemd 的默认行为,你不需要在 timer 里显式写Unit=cleanup-job.service,除非你想让 timer 触发一个名字不同的 service。我建议新手就老老实实保持同名,省得后面排查问题时多一层心智负担。
这里有个关键认知:timer 单元本身不执行任何业务逻辑。它只做一件事——到点了,去启动对应的 service。service 跑完退出,timer 继续等下一次。所以你在 service 里写的ExecStart才是真正干活的命令,timer 里写的全是时间规则。
2.2 为什么不能只写一个 timer 文件
有人会问,能不能把命令直接塞进 timer 里?不行。timer 单元没有ExecStart这个概念,它的职责边界就是调度。这种设计看起来多了一个文件,但换来的是职责清晰:你想改执行内容,只动 service;你想改触发频率,只动 timer。两者互不干扰,这在维护多个定时任务时优势非常明显。
另外,service 单元可以被手动systemctl start触发,也可以被 timer 触发,还可以被其他单元依赖触发。也就是说,你写好的 service 是一个可复用的执行单元,timer 只是众多触发方式中的一种。这个视角转换过来之后,你会发现 systemd 的这套模型其实比 cron 灵活得多。
2.3 Type 的选择会直接影响 timer 的行为
service 单元里的Type=对定时任务来说很关键。常见的有三种:
| Type 值 | 含义 | 适用场景 |
|---|---|---|
simple | 启动后立即认为服务已就绪 | 大多数短时脚本,默认值 |
oneshot | 执行完才认为完成,适合一次性任务 | 备份、清理这类跑完就退出的任务 |
forking | 父进程 fork 后退出,子进程继续 | 传统守护进程式脚本 |
对于定时任务,我强烈建议用Type=oneshot。原因很实在:oneshot 会让 systemd 等到ExecStart里的命令真正执行完毕,才把这次激活标记为完成。这样systemctl status能看到准确的执行结果,OnUnitActiveSec这种"上次激活后间隔多久再触发"的规则也才有意义。如果你用默认的simple,systemd 在命令刚启动时就认为服务"活跃"了,后续的状态判断会失真。
还有一个配套指令RemainAfterExit=yes,它让 service 在命令退出后仍保持"active"状态。这个在纯定时任务里一般不需要,除非你有别的单元依赖这个 service 的状态。我个人的习惯是定时任务不加它,让状态如实反映"跑完就结束"。
3. 从零写一个能跑起来的定时器
3.1 先写 service:把要干的活固定下来
我们拿一个最典型的场景练手:每天凌晨清理某个应用目录下超过 7 天的临时文件。先创建 service 文件:
sudo vi /etc/systemd/system/cleanup-job.service内容如下:
[Unit] Description=Clean up temporary files older than 7 days After=local-fs.target [Service] Type=oneshot ExecStart=/usr/bin/find /var/tmp/app-cache -type f -mtime +7 -delete User=appuser Group=appuser逐行解释一下为什么这么写。After=local-fs.target保证文件系统已经挂载完成再执行,避免在挂载点还没就绪时去删文件。Type=oneshot前面说过了,让 systemd 等命令跑完。ExecStart用的是绝对路径/usr/bin/find,这一点很重要——systemd 执行时不会加载你的 shell 环境变量,PATH可能和你登录时不一样,写绝对路径能避免"命令找不到"的经典问题。User和Group指定以低权限账户运行,这是安全实践,别用 root 跑一切。
提示:
ExecStart里如果有多条命令,不要用&&或;拼接,systemd 不认 shell 语法。要么写成一个脚本文件让 service 调用,要么用多个ExecStart(仅限 oneshot 类型支持多个)。
3.2 再写 timer:把触发节奏定下来
接着创建 timer 文件:
sudo vi /etc/systemd/system/cleanup-job.timer内容:
[Unit] Description=Daily trigger for cleanup-job [Timer] OnCalendar=*-*-* 03:30:00 Persistent=true RandomizedDelaySec=300 [Install] WantedBy=timers.targetOnCalendar=*-*-* 03:30:00表示每天凌晨 3 点半触发。这个表达式后面我会专门展开讲,先记住格式。Persistent=true是个非常实用的选项:如果系统在 3 点半时处于关机状态,开机后 systemd 会检查这个 timer 上次触发的时间,发现错过了就立即补跑一次。cron 默认没有这个能力,得靠 anacron 之类的工具补,systemd 一个参数就搞定了。RandomizedDelaySec=300给触发时间加一个 0 到 300 秒的随机延迟,目的是避免多台机器在同一时刻同时触发造成资源尖峰,这个在多机部署时特别有用。
[Install]段的WantedBy=timers.target决定了systemctl enable时这个 timer 挂到哪个 target 下。timers.target是 systemd 专门管理定时器的 target,写它准没错。
3.3 加载、启动、验证,一步都不能省
文件写好后,systemd 不会自动感知,必须重新加载配置:
sudo systemctl daemon-reload然后启动 timer 并设置开机自启:
sudo systemctl enable --now cleanup-job.timerenable --now是两步合一:enable 建立开机自启的软链接,now 立即启动。启动后验证状态:
systemctl list-timers --all这个命令会列出所有 timer 的下次触发时间、上次触发时间、以及对应的 unit。你能直观看到cleanup-job.timer的NEXT列是不是你预期的 3:30。如果NEXT显示n/a,说明 timer 没启动或者配置有问题,得回头查。
再确认一下 service 本身能不能正常跑:
sudo systemctl start cleanup-job.service systemctl status cleanup-job.service手动跑一次,看Active状态和日志输出是否符合预期。这一步很多人会跳过,结果等到定时触发时才发现命令写错了,白白等一天。
4. OnCalendar 表达式:定时器的灵魂,也是最容易写错的地方
4.1 表达式的基本结构
OnCalendar的格式是星期 年-月-日 时:分:秒,其中很多部分可以用*通配。举几个最常用的例子:
| 表达式 | 含义 |
|---|---|
*-*-* 03:30:00 | 每天 3:30 |
*-*-* *:00:00 | 每小时整点 |
Mon *-*-* 09:00:00 | 每周一 9:00 |
*-*-01 00:00:00 | 每月 1 号零点 |
Mon..Fri *-*-* 08:00:00 | 周一到周五每天 8:00 |
*-*-* 02,14:00:00 | 每天 2 点和 14 点 |
注意Mon..Fri这种范围写法,用两个点表示区间,这在排工作日任务时很顺手。还有*:0/15这种步进写法,表示每 15 分钟一次,等价于*:00,15,30,45。
4.2 用 systemd-analyze 验证表达式
手写OnCalendar最容易犯的错是格式对不上,比如把星期写到日期后面,或者秒位漏了。Red Hat 9 自带一个神器:
systemd-analyze calendar "*-*-* 03:30:00"它会输出这个表达式的规范化形式、下次触发时间、以及时区信息。如果表达式非法,它会直接报错并提示哪里不对。我养成的习惯是:任何 OnCalendar 写完后,先跑一遍 systemd-analyze 确认,再写进文件。这个命令还能加--iterations=5看连续 5 次触发时间,验证周期性是否符合预期。
systemd-analyze calendar --iterations=3 "Mon..Fri *-*-* 08:00:00"输出会列出接下来三个工作日的 8 点,一眼就能看出周末有没有被正确排除。
4.3 时区这个坑,必须单独拎出来说
OnCalendar默认按系统时区解释。Red Hat 9 上可以用timedatectl查看当前时区。如果你的服务器时区是 UTC,而你以为写的是本地时间,那触发时间就会整体偏移。更稳妥的做法是在 timer 里显式声明时区:
[Timer] OnCalendar=*-*-* 03:30:00 Asia/Shanghai在表达式末尾直接跟时区名,systemd 会按这个时区计算。这样即使系统时区被改,定时任务的时间点也不会漂。我在多台机器上统一部署时,都会显式写时区,省得后面因为某台机器时区配置不一致导致任务触发时间对不上。
4.4 Persistent 和 RandomizedDelaySec 的配合逻辑
这两个参数经常一起出现,但作用完全不同。Persistent=true管的是"错过要不要补",RandomizedDelaySec管的是"准点要不要抖一下"。它们的执行顺序是:先判断是否错过(Persistent),再在触发点基础上加随机延迟。所以如果你设了RandomizedDelaySec=300,补跑的那次也会带随机延迟,不会所有机器在同一秒一起补跑。理解这个顺序,在排查"为什么任务没在整点跑"时很关键——很可能就是随机延迟在起作用,而不是配置错了。
5. 除了 OnCalendar,还有几种触发方式值得知道
5.1 OnBootSec 与 OnStartupSec 的区别
OnCalendar是绝对时间触发,而OnBootSec是相对时间——从系统启动那一刻算起,过多久触发一次。比如:
[Timer] OnBootSec=10min表示开机 10 分钟后触发。OnStartupSec类似,但它是从 systemd 管理器启动算起,两者在普通场景下差别不大,OnBootSec更常用。这类相对时间适合"开机后做一次初始化"的任务,比如预热缓存、上报启动事件。
5.2 OnUnitActiveSec:基于上次激活的间隔
这个指令的意思是"距离该 timer 对应的 service 上次被激活,过了多久再触发"。配合OnBootSec可以做出"开机后先跑一次,之后每隔 N 小时再跑"的效果:
[Timer] OnBootSec=5min OnUnitActiveSec=6h开机 5 分钟后跑第一次,之后每 6 小时跑一次。注意OnUnitActiveSec依赖 service 的激活记录,所以前面强调的Type=oneshot在这里就体现出价值了——oneshot 能准确记录完成时间,间隔计算才靠谱。
5.3 三种触发方式的选型建议
| 触发方式 | 适合什么 | 不适合什么 |
|---|---|---|
OnCalendar | 固定时间点,如每天凌晨备份 | 需要精确间隔的轮询 |
OnBootSec | 开机后一次性初始化 | 周期性任务 |
OnUnitActiveSec | 固定间隔轮询,如每 6 小时检查 | 必须在特定钟点执行的任务 |
实际项目里经常混用,比如OnBootSec加OnUnitActiveSec做"开机即跑、之后定间隔",或者OnCalendar加Persistent做"每天定点、错过补跑"。选型的关键是先想清楚:你要的是"某个钟点"还是"某个间隔"?这两个诉求对应的指令完全不同。
6. 排查定时器不触发的问题,我一般按这个顺序走
6.1 第一步永远是看 timer 状态
任务没按时跑,别急着改配置,先看状态:
systemctl status cleanup-job.timer systemctl list-timers --all | grep cleanupstatus里会显示Active: active (waiting)以及Trigger:下次触发时间。如果显示inactive或failed,问题在 timer 本身。如果Active正常但Trigger时间不对,问题在OnCalendar表达式。如果 timer 一切正常但 service 没跑,问题在 service 或触发条件。
6.2 用 journalctl 追 service 的执行记录
timer 触发了但任务没效果,去看 service 的日志:
journalctl -u cleanup-job.service --since "today"这里能看到每次激活的时间、ExecStart的输出、退出码。如果看到Failed to start或者退出码非 0,说明命令本身有问题。常见原因包括:路径写错、权限不足、环境变量缺失。特别注意环境变量——systemd 执行时环境很干净,你在终端里能跑的命令,在 service 里可能因为缺PATH或某个变量而失败。
6.3 我踩过的三个真实坑
坑一:改了 service 忘了 daemon-reload。有次我调了ExecStart的参数,直接systemctl restarttimer,结果跑的还是旧命令。原因是 systemd 缓存了单元文件,必须daemon-reload才重新读取。后来我养成习惯:只要动了/etc/systemd/system/下的任何文件,先 daemon-reload 再说。
坑二:OnCalendar 的星期和日期顺序写反。我一度写成*-*-* Mon 08:00:00,以为星期放后面也行,结果 systemd-analyze 直接报错。正确顺序是星期在前、日期在后。这种错误不跑验证根本发现不了,所以再次强调systemd-analyze calendar的重要性。
坑三:Persistent 补跑导致重复执行。有次机器关机两天,开机后 Persistent 把错过的两次都补跑了,而我的脚本不是幂等的,结果数据被处理了两遍。教训是:用 Persistent 的前提是任务必须幂等,也就是跑一次和跑多次结果一样。如果你的任务不满足这个条件,要么关掉 Persistent,要么在脚本里加锁或状态标记。
6.4 一个快速自检清单
遇到 timer 不工作,按这个清单过一遍,基本能定位到问题:
systemctl list-timers --all里 timer 是否 activeNEXT时间是否符合预期systemd-analyze calendar验证表达式是否合法journalctl -u xxx.service看 service 是否有执行记录和报错- 手动
systemctl start xxx.service能否成功 - 是否执行过
daemon-reload - 时区是否一致
7. 让定时任务更稳的几个进阶配置
7.1 资源限制,防止任务拖垮系统
定时任务最怕的就是某个脚本跑飞了,把 CPU 或内存吃满。systemd 的 service 单元可以直接加资源限制:
[Service] Type=oneshot ExecStart=/usr/local/bin/heavy-job.sh CPUQuota=50% MemoryMax=512M TimeoutStartSec=1800CPUQuota=50%限制最多用半个核,MemoryMax=512M超过就触发 OOM 终止,TimeoutStartSec=1800表示 30 分钟还没跑完就强制结束。这几个参数对无人值守的定时任务特别有价值——出问题时系统能自我保护,而不是等运维发现。cron 要做到这些得额外套systemd-run或者ulimit,systemd 原生就支持。
7.2 失败重试与依赖顺序
如果任务依赖网络或其他服务,可以在[Unit]段声明:
[Unit] After=network-online.target Wants=network-online.targetAfter保证顺序,Wants表示希望这个目标存在但不强制。对于需要联网的任务,加上这两行能避免开机早期网络还没就绪就执行导致失败。至于失败重试,systemd 的 timer 本身没有"失败后重试 N 次"的指令,通常的做法是在脚本内部做重试逻辑,或者用Restart=on-failure配合RestartSec(注意这只对 service 进程崩溃有效,对脚本返回非 0 退出码需要配合SuccessExitStatus调整判断)。
7.3 日志管理别忽视
定时任务跑久了,日志会堆积。journalctl有自动轮转机制,但你可以针对性地控制:
journalctl --vacuum-time=30d或者配置/etc/systemd/journald.conf里的SystemMaxUse限制总大小。另外,如果脚本自己有输出,建议在 service 里重定向到文件,方便长期留存:
ExecStart=/bin/sh -c '/usr/local/bin/job.sh >> /var/log/cleanup-job.log 2>&1'注意这里用了/bin/sh -c来支持重定向,因为ExecStart本身不解析 shell 重定向符号。这是很多人第一次写 systemd service 时会卡住的地方。
8. 从 cron 迁移过来,需要注意的思维转换
用惯了 cron 的人迁到 systemd timer,最大的不适应是"没有 crontab 那一行搞定"的爽快感。但换个角度想,cron 那一行背后其实藏了很多隐式行为:它用哪个 shell、环境变量从哪来、输出发到哪、失败了谁知道——这些 cron 都不管,全靠你自己兜。systemd timer 把这些都显式化了,前期多写几行配置,后期少踩很多坑。
迁移时几个具体的对应关系:cron 的*/15 * * * *对应OnCalendar=*:0/15;cron 的0 3 * * *对应OnCalendar=*-*-* 03:00:00;cron 的@reboot对应OnBootSec=1min。cron 里MAILTO发邮件那套,在 systemd 里换成OnFailure=触发一个告警 service,或者直接在脚本里调通知接口,反而更可控。
还有一个实际差异:cron 任务默认以用户身份跑,systemd 的 system 级 timer 默认以 root 跑,所以User=一定要显式写。我见过迁移后忘了写User,结果脚本以 root 身份删文件,权限范围比预期大得多,这种问题排查起来很隐蔽。
最后分享一个我自己的使用习惯:每个定时任务上线前,我都会手动systemctl start跑一次,再systemd-analyze calendar验证时间,最后systemctl list-timers确认下次触发点。这三步花不了两分钟,但能挡掉绝大多数"上线后才发现不跑"的尴尬。定时任务这种东西,出问题时往往已经过了好几天,越早验证越省心。