
接手过几台 Ubuntu 服务器之后我养成了一个习惯新机器上不再第一时间往 crontab 里塞定时任务而是先问一句——这个任务放在 systemd timers 里是不是更合适。说实话很多人第一次听到 systemd timers 都会觉得crontab 用得好好的简单直接为什么要换但等你真正跑过几年的运维工作经历过任务漏执行、脚本静默失败、排查半天不知道上次任务到底跑没跑之后你会发现 crontab 的简单恰恰也是它最大的天花板。systemd timers 是 systemd 原生提供的定时任务机制不需要额外安装任何软件Ubuntu 16.04 之后的版本都自带。它和 crontab 解决的是同一类问题——按计划执行定时任务——但它的设计思路完全不同不是像 cron 那样把“时间命令”写死在表格里而是用两个单元unit来协作一个负责定义“什么时间触发”另一个负责定义“触发后执行什么”。这套机制天然支持失败重试、依赖控制、随机延迟、统一日志甚至能补跑系统关机期间错过的任务。这篇文章不打算给你堆一堆概念我会从为什么迁移、核心机制、迁移实操、踩坑经验、故障排查五个维度完整拆一遍。不管是刚接触 Ubuntu 的小白还是已经在生产环境用 crontab 的老手这篇文章都能让你把 systemd timers 真正用到自己的机器上。1. 为什么我在服务器上逐步放弃 crontab五个不能回避的痛点先说结论crontab 不是不能用而是它的“省事”在复杂场景下会变成运维成本。我在带团队和管服务器的过程中反复被以下五个问题折磨过最终才下决心整体迁移到 systemd timers。1.1 错过的任务不会补跑crontab 最让我头疼的一点是它没有“补跑”机制。假设你有一台服务器配置了每天凌晨 2 点跑数据库备份结果那天晚上机器因为内核升级重启了或者机房断电开机时间是早上 8 点。那么在 crontab 的逻辑里凌晨 2 点这一班任务就是直接跳过没有任何记录也不会在开机后自动补执行。有人会说那我备份脚本里加个判断发现今天还没备份就补一次。确实可以但这就是典型的“用业务代码去弥补调度器的缺陷”。如果系统里十几条定时任务都这样搞每一条都要自己写幂等、写补跑逻辑维护成本瞬间就上去了。systemd timers 里有一个Persistenttrue参数就是为了解决这个场景设计的。任务触发时如果系统处于关机状态开机后 systemd 会立即补触发一次真正做到“该执行的备份不会因为机器睡着了就消失”。1.2 触发顺序和依赖没人管cron 在执行定时任务时不考虑任何依赖关系。比如某个任务必须在 NFS 挂载盘就绪之后才能运行或者必须在网络连通之后才能调外部接口cron 不管这些。它只负责到点执行至于执行的时候挂载盘在不在、网络通不通那是脚本自己要考虑的事。很多运维脚本里因此出现了一大堆“睡眠等待”的 hack比如sleep 30然后重试、循环判断网络、自己写挂载检测。systemd timers 依托的是 systemd 的单元依赖体系你可以在 service 单元里直接声明Afternetwork-online.target、RequiresMountsFor/backup这样的依赖关系。systemd 会确保前置条件满足之后才启动你的任务这比脚本里写 sleep 靠谱得多。1.3 执行状态不透明crontab 的任务输出默认会以邮件形式发给本地用户但服务器上基本没有人配置本地邮件服务所以这些输出往往就默默丢掉了。大多数人会选择把输出重定向到日志文件比如 /var/log/backup.log 21然后再定期去翻这个文件。问题在于一个任务到底跑没跑跑成功了没有失败了是在哪一步失败的这些信息在 crontab 里完全不可见全靠日志文件自己去推。systemd timers 把任务交给了 systemd 的进程管理。每个任务都会有一个执行状态active、inactive、failed通过systemctl status一眼就能看到。日志通过journalctl -u 任务名统一管理不需要自己写重定向也不怕日志文件丢失。这种“可观测性”在排查问题的时候是决定性的。1.4 没有失败通知和重试机制crontab 里一个脚本执行失败了然后呢没有然后。除非你专门在脚本里写一个失败时的邮件发送或 webhook 调用否则没有任何人知道这个任务失败了。而且 crontab 不会因为脚本返回非零退出码就自动重试。systemd timers 则可以在 service 单元中定义OnFailure来指定一个失败后触发的通知单元也能结合Restart等参数对某些类型的失败进行自动重试。更重要的是你可以通过 systemd 的状态信息明确知道哪些任务最近失败过而不是靠读者自己去翻日志猜。1.5 无法错峰监控负载容易扎堆最后一个是比较容易被忽略的问题如果几十台服务器都配置了“每天零点执行日志清理”那么在零点整所有机器会同时开始消耗 CPU、磁盘 IO形成明显的负载尖峰。crontab 没有原生的随机延迟能力虽然你可以在命令里写sleep $((RANDOM % 60))这种土办法但每次都手动加既丑又容易漏。systemd timers 提供了RandomizedDelaySec参数直接告诉 systemd“在这个时间点的基础上随机推迟 0 到指定秒数再执行”一行解决扎堆问题。这里我用一张表把两边的差异做一个直观对照方便你判断自己的场景是否需要迁移对比维度crontabsystemd timers时间表达5 个字段秒级无法表达日历表达式 相对时间支持秒级触发错过的任务直接跳过无补跑Persistenttrue可自动补跑触发依赖无需脚本自行判断Unit 依赖、RequiresMountsFor随机延迟需在命令中自己 sleepRandomizedDelaySec原生支持失败通知无内置需脚本封装OnFailure失败触发指定单元日志管理需手动重定向到文件journalctl统一收集与查询运行状态不可见systemctl status直接查看分布式锁无无但这不属于 systemd timer 的职责2. timer 单元与 service 单元的协作机制先懂原理再动配置这是 systemd timers 和 crontab 理念差异最大的一部分。cron 是“一个表格一个任务一个命令”systemd timers 是“timer 管时间service 管执行”。要玩好它这个模型必须熟记。2.1 一个最小可用的 timer 配置长什么样我直接用最典型的一个例子来讲。假设你有一个脚本/opt/scripts/backup.sh原来写在 crontab 里的形式是0 2 * * * /opt/scripts/backup.sh /var/log/backup.log 21迁移到 systemd timers需要两个文件。第一个是 service 单元它定义任务本身要执行的命令。文件路径/etc/systemd/system/backup.service[Unit] DescriptionDatabase backup task [Service] Typeoneshot ExecStart/opt/scripts/backup.sh第二个是 timer 单元它定义触发时间。文件路径/etc/systemd/system/backup.timer[Unit] DescriptionTimer for database backup task [Timer] OnCalendar*-*-* 02:00:00 Persistenttrue [Install] WantedBytimers.target最后执行sudo systemctl daemon-reload sudo systemctl enable --now backup.timer这样每天的触发逻辑就由 timer 负责实际的备份逻辑由 service 负责。你可能会觉得这不就是把一个 crontab 拆成了两个文件吗没错但正是这个拆分把“时间”和“动作”解耦了后面所有高级能力都是从这上面长出来的。比如你可以让多个 timer 指向同一个 service 单元也可以复用一套 service 配置只是触发时间不同。2.2 单调时间与日历时间两种触发基准的适用场景systemd timer 的触发时间分两大类Realtime 和 Monotonic。很多人第一次接触容易被这两个词吓住我换个说法你立刻就懂了。Realtime日历时间指真实的墙上时间比如“每天凌晨 2 点”“每周一早上 8 点”。这种触发方式用OnCalendar来表达对应的是 crontab 里的经典写法。大多数业务场景比如备份、日志轮转、报表生成都是用这种。Monotonic单调时间它不是看墙上的钟而是看系统的某个时间基准比如“开机后 30 分钟执行”“上次执行结束后 1 小时执行”。这些都用OnBootSec、OnUnitActiveSec、OnUnitInactiveSec来表达。这类时间不依赖墙上时钟适合“相对事件间隔”的任务。举个例子你想让某个清理脚本在服务每次重启后 5 分钟清理一次临时文件cron 很难优雅表达但 systemd timer 一行就搞定[Timer] OnUnitActiveSec5min我做一个对照表方便你按需选择触发方式单位参数典型场景使用频率日历时间OnCalendar每天、每周的固定业务任务高开机后相对时间OnBootSec开机延时执行的服务预热任务中上次激活后相对时间OnActiveSec周期短、与上次启动时间强相关的任务低上次激活结束后相对时间OnUnitActiveSec/OnUnitInactiveSec任务结束后的冷却式定时中2.3 OnCalendar 日历表达式从五个字段到秒级触发的进阶如果你习惯了 crontab 的“分 时 日 月 周”五段式第一次看到OnCalendar的语法可能会觉得有点怪。但多试几次你会发现它比 cron 更接近自然语言而且表达力更强。常用写法如下# 每天凌晨 2 点 OnCalendar*-*-* 02:00:00 # 简写形式等价于每天 00:00:00 OnCalendardaily # 每周一到周五的 02:30 OnCalendarMon..Fri 02:30:00 # 每 15 分钟 OnCalendar*:0/15 # 每月的第一天和第十五天的凌晨 3 点 OnCalendar*-*-01,15 03:00:00注意最后那个例子它支持秒级别的时间表达而 crontab 的最小粒度只有分钟。对绝大多数运维场景来说秒级精度用不到但当你确实需要“每小时的第 30 分 15 秒执行”的时候只有 systemd timers 能表达。还有一个实用的验证工具。写好了日历表达式拿不准下一次执行时间别急着自己算用systemd-analyze calendar$ systemd-analyze calendar Mon..Fri 02:30:00 Original form: Mon..Fri 02:30:00 Normalized form: Mon..Fri 02:30:00 Next elapse: Mon 2025-06-09 02:30:00 CST (in UTC): Sun 2025-06-08 18:30:00 UTC From now: 20h 52min left这条命令会直接告诉你标准化后的表达式、下次触发时间以及距现在还有多久非常实用。我在配置任何新的 OnCalendar 表达式之前都会先跑一遍从根上避免“写错表达式导致任务按错误时间触发”的尴尬。2.4 timer 到 service 的激活链路与状态模型知道文件怎么写还不够你得理解整个激活链路。启动backup.timer后systemd 会把它注册到timers.target里这也是 timer 单元[Install]段WantedBytimers.target的原因。当时间条件满足时systemd 会激活对应的backup.serviceservice 里的ExecStart命令开始运行。任务结束后service 进入 inactive 状态timer 继续等待下一次触发。这里有一个容易混淆的地方systemctl start backup.timer和systemctl start backup.service是两个完全不同的动作。前者是启动“定时器”意味着开始倒计时系统会记录下一个触发点后者是立即执行一次任务主要用于手动测试。很多人操作的时候把两者搞混配置改好了重启了 timer 但没重新加载配置就会遇到“任务不按新时间跑”的情况。状态模型大概是这样timer 被 enable开机自启 - 系统启动时 timer 被激活 - 到点后 systemd 激活对应的 service - service 执行 ExecStart进入 active 状态 - 结束后进入 inactive如果你想知道当前系统上有哪些 timer 在跑一条命令就能看到systemctl list-timers --all输出里会显示 NEXT 下一次触发时间、LEFT 距离下次触发还剩多久、LAST 上次实际触发时间、PASSED 上次触发距离现在多久以及 UNIT 对应的 timer 和 service 名称。这个命令我几乎每天都会看一次它比 crontab -l 的信息量高太多了。这里要提一嘴AccuracySec的概念。systemd timer 在触发时并不保证毫秒不差它默认允许 1 分钟左右的误差范围目的是把“到点触发的多个任务”合并起来减少系统唤醒频次。如果你有个任务必须精确到秒执行可以在 timer 单元里加一行AccuracySec1us来提高精度。不过对绝大多数备份、日志清理类任务默认精度完全够用。3. 迁移实操把 cron 备份任务改成 systemd timers 的完整过程理论讲完该动手了。这一节我会拿一个线上很常见的备份任务作为例子把迁移的完整过程走一遍包括迁移前的脚本改造、单元文件编写、启用验证以及临时任务怎么处理。3.1 迁移前先做的三件事脚本路径、日志输出和锁在动系统配置之前先把脚本本身收拾利索。很多人迁移后任务不执行问题根本不在于 systemd 配置而是脚本在 cron 环境下能跑到了 systemd 环境下就挂了原因基本都是环境变量和路径问题。这里我建议先做三件事第一脚本里的命令和文件路径全部改成绝对路径。不要写backup.sh里的mongodump而是写/usr/bin/mongodump。因为在 systemd 的 service 环境里PATH 变量和你在交互式终端里看到的并不完全一样。第二明确日志输出位置。虽然 systemd 会把标准输出和标准错误都收到 journald 里但有些脚本内部用了 /var/log/backup.log这没关系可以继续保留。不过我建议至少让 systemd 也拿到日志这样journalctl可以统一查询。第三加一把互斥锁。定时任务最怕的是上一轮还没跑完下一轮的触发又来了两个实例同时跑互相争抢资源。crontab 里大家很少加锁systemd 环境里依然要自己控制。最简单的方式是用 flock/usr/bin/flock -n /var/lock/backup.lock /opt/scripts/backup.sh-n是非阻塞模式如果锁已经被占用任务直接退出不参与竞争。这一行我会直接写进 ExecStart 里比在脚本内部实现锁要简单得多。3.2 备份任务的 service 单元与 timer 单元假设我原来的 crontab 是0 2 * * * /opt/scripts/backup.sh /var/log/backup.log 21我迁移后的/etc/systemd/system/backup.service是这样[Unit] DescriptionDaily database backup Afternetwork-online.target Wantsnetwork-online.target [Service] Typeoneshot EnvironmentPATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin EnvironmentHOME/root WorkingDirectory/opt/scripts ExecStart/usr/bin/flock -n /var/lock/backup.lock /opt/scripts/backup.sh TimeoutStartSec30min这个文件就体现了“为什么用 systemd 更好”的核心Afternetwork-online.target和Wantsnetwork-online.target确保脚本执行时网络已经就绪TimeoutStartSec30min防止脚本卡死导致服务一直挂在 active 状态。对应的/etc/systemd/system/backup.timer[Unit] DescriptionTimer for daily database backup [Timer] OnCalendar*-*-* 02:00:00 Persistenttrue RandomizedDelaySec5min [Install] WantedBytimers.target相对原来的 crontab多了两个关键参数。Persistenttrue保证系统关机错过触发时间的情况下开机后补跑一次RandomizedDelaySec5min让任务在 02:00 到 02:05 之间的某个随机点执行避免和多台机器的整点任务打架。写完之后执行sudo systemctl daemon-reload sudo systemctl enable --now backup.timer sudo systemctl start backup.service注意第三条命令是手动触发一次 service用于验证脚本能不能在 systemd 环境下正常执行。这是迁移后第一件要做的事不要等第二天凌晨再验证。3.3 日志轮转任务的随机延迟设计备份之外另一类高频定时任务是日志轮转。Ubuntu 自带的logrotate本来就有 cron 配置但如果你自己有一批业务日志要处理用 systemd timers 管理也很合适。我的日志清理任务会这样配置[Timer] OnCalendar*-*-* 00:00:00 Persistenttrue RandomizedDelaySec15min这里的随机延迟比备份任务设置的 5 分钟更长因为日志轮转、压缩、清理都是 IO 密集操作如果所有机器都在零点整同步跑磁盘压力会突然飙高。设置 15 分钟的随机窗口负载就被自然削平了。实测下来对多个集群节点同时管理的场景这个参数带来的平滑效果非常明显。3.4 启用、验证与确认下次触发时间配置写好后验证环节别偷懒。我常走的验证流程是第一步重新加载配置并启用 timersudo systemctl daemon-reload sudo systemctl enable backup.timer sudo systemctl start backup.timer第二步查看 timer 状态和下一次触发时间systemctl list-timers backup.timer如果输出显示 NEXT 列和 LEFT 列都正常说明 timer 已经被正确调度。第三步手动触发一次 service 验证脚本本身sudo systemctl start backup.service然后看任务是否成功结束systemctl status backup.service状态显示inactive (dead)且没有 failed说明任务执行成功。同时也看一下这次的日志输出journalctl -u backup.service --since today这几步走完迁移才算真正落地。别嫌麻烦这一步多花 5 分钟能帮你省掉第二天凌晨爬起来看任务到底跑没跑的痛苦。3.5 systemd-run不想写单元文件时的临时任务方案有时候我们只是临时想跑一次任务并不想为它专门写两个单元文件那用systemd-run就对了。它可以在命令行直接创建瞬时的 timer 和 service不用落盘配置文件。比如我想在 30 秒后执行一次/opt/scripts/clean.shsudo systemd-run --on-active30 --unittmp-clean /opt/scripts/clean.sh再比如我明天的 03:15 要执行一次数据库全量导出sudo systemd-run --on-calendar*-*-* 03:15:00 --unittmp-db-export /usr/bin/mysqldump -A /tmp/db.sql--unit参数只是给它起个临时名字方便后面查看和清理。跑完没有第二次触发任务很适合做“一次性操作”。这种用法尤其适合线上临时变更时留个可追踪的执行记录——你在journalctl -u tmp-clean里能看到完整日志比在 nohup 里后台跑一个进程要可控得多。4. 迁移后最容易踩的坑环境变量、时区与挂载依赖迁移成功只是第一步真正让你头疼的往往是迁移之后遇到的一系列“小问题”。这些坑我基本都踩过整理出来给你排雷。4.1 PATH 和 HOME 不是你以为的那样这是迁移后最经典的翻车场景。很多脚本在交互式终端里跑得好好的一放到 systemd service 里就报command not found。原因很简单systemd service 里的环境变量和你的 shell 环境不是一回事。crontab 执行任务时的 PATH 通常是/usr/bin:/bin而 systemd service 里的 PATH 更接近/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin关键区别在于/usr/local/bin这类自定义安装路径不在其中。如果你用过 pip install --user 安装过命令行工具或者把脚本放在/opt下面极有可能在执行时找不到命令。解决办法是在 service 单元里显式声明环境变量[Service] EnvironmentPATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin EnvironmentHOME/root EnvironmentLANGC.UTF-8尤其是脚本里包含 Python 脚本、pip安装的命令、或者依赖HOME目录下的配置文件时HOME变量必须手动设置。crontab 默认会继承用户的基本环境但 systemd 单元环境更接近“空状态”不要有任何“应该会有”的侥幸心理。4.2 等网络、等磁盘依赖管理怎么配第二个高频坑是“思想还停留在 cron 时代”。很多人把脚本从 crontab 搬到 systemd 之后脚本里的 sleep 还是保留着因为他们不知道 systemd 其实提供了干净利落的依赖声明。比如你的备份脚本依赖网络那就不要写sleep 30等网络直接在 service 单元里声明[Unit] Afternetwork-online.target Wantsnetwork-online.target如果任务依赖某个挂载目录比如/data/backups是一个独立的 NFS 挂载盘加上RequiresMountsFor/data/backupssystemd 会确保这个挂载点已经成功挂载之后才启动你的任务。这比你脚本里写mountpoint -q /data/backups || mount -a可靠得多因为 systemd 能看到挂载的真实状态和错误信息。4.3 时区陷阱OnCalendar 默认走本地时间OnCalendar的时区默认跟随系统时区这一点本身没什么问题。但如果你管理的机器不止一个机房或者你经常从 UTC 的容器镜像里复制配置就会出现“在 A 机器上明明设置的是 2 点到了 B 机器却变成 10 点”的怪事。验证方式很简单还是用systemd-analyze calendar它会同时输出本地时区和 UTC 时区的时间。如果发现时区不对可以在 OnCalendar 表达式里显式指定时区比如OnCalendar*-*-* 02:00:00 Asia/Shanghai这样无论系统时区怎么变任务的触发时间都会固定在指定时区的凌晨 2 点。多机房部署的时候我强烈建议所有定时任务都显式带上时区避免因为服务器配置差异造成任务执行时间的飘忽不定。4.4 Persistenttrue 补跑的隐藏风险Persistenttrue是很多人从 cron 迁移过来的最大动力但它也有一个隐藏陷阱补跑会发生在开机后不久此时网络、挂载盘等服务可能还没有完全就绪你依赖的 NFS 目录可能还没挂上数据库服务可能还没起来。结果就是任务确实“补跑了”但一跑就失败。我处理这个问题的方法是给补跑留出缓冲时间。在 timer 单元里同时加上OnBootSec5min这样即使系统启动后触发了补跑逻辑也会等开机 5 分钟之后再执行给网络和磁盘挂载留出足够时间。另一种更稳妥的方式是配合 4.2 的依赖声明把network-online.target和RequiresMountsFor写进 service确保前置条件真的满足了再跑。还有一个细节Persistenttrue只会记住“最近一次错过的触发点”但如果你关机了好几天开机后它会立即补跑一次而不是把错过的每一天都补跑一遍。这对大多数“只要最近执行过就行”的任务没问题但如果你真的需要“每天一次关机期间的都补齐”那就要在业务逻辑里自己处理了systemd 不会给你补一个清单。5. systemd timer 故障排查的完整链路从状态查看到日志定位配置写得再细心总有出问题的时候。我见过太多人一出问题就跑去翻脚本、改代码结果最后发现是 timer 根本没有启用或者 service 文件写错了。这里我把自己常用的排查链路完整写出来照着走一圈基本都能定位。5.1 先确认 timer 是不是真的在跑遇到“任务没执行”的情况第一反应不要是改脚本先看 timer 状态systemctl status backup.timer这个命令会告诉你 timer 当前是否 active、下一次触发时间是什么、上一次触发是什么时候。如果显示inactive (dead)说明 timer 没有启动或者启动后又被停止了。这时执行systemctl list-timers --all检查你的 timer 是否在列表中。如果不在大概率是[Install]段没写WantedBytimers.target或者写错了如果列表里有但 NEXT 列显示n/a可能是 timer 配置里的日历表达式有问题用了systemd-analyze calendar重新验证一下。如果 timer 状态正常、时间也对但任务就是没触发再检查一下是不是手动 start 过 service 但忘了 start timer。记住enable 只是设置开机自启systemctl start backup.timer才是激活本次调度的关键。5.2 手动触发 service 判断脚本自身问题在确认 timer 正常的情况下第二大步是手动触发一次 servicesudo systemctl start backup.service然后立刻查看状态和结果systemctl status backup.service如果状态是failed说明问题出在 service 配置或脚本本身。此刻先用journalctl -u backup.service -n 50 --no-pager拉出最近的日志看具体的报错信息。常见的报错有几种ExecStart 指定的脚本不存在、脚本没有执行权限、脚本内部命令找不到。最后一种情况对应的就是 4.1 讲的环境变量问题去 service 里补Environment即可。值得一提的是系统默认的 systemctl start 是同步等待的——对于 Typeoneshot 的 service命令会一直等到脚本执行完才返回。所以如果脚本本身很耗时执行这条命令时要有点耐心不要看到终端没反应就以为卡死了。5.3 journalctl 与 Invocation ID还原任务的执行现场journald 是 systemd 家的日志系统所有单元的标准输出和标准错误都会自动被记录。查询某个任务的日志不需要配置直接journalctl -u backup.service --since today如果你用了 3.1 里提到的 flock 锁日志里还能看到每次执行时的锁竞争情况。为了更精确地定位某一次执行journald 为每次调用都生成了一个 Invocation ID在journalctl里的显示类似-- Logs begin at Mon 2025-06-02 08:11:00 CST, end at Tue 2025-06-03 09:00:00 CST. -- Jun 03 02:01:14 hostname systemd[1]: Starting Daily database backup... Jun 03 02:01:14 hostname backup.sh[12345]: Backup completed successfully Jun 03 02:01:14 hostname systemd[1]: Finished Daily database backup.如果我想单独看某次调用的输出可以指定_SYSTEMD_INVOCATION_ID。日常排查用不到那么精细但当你怀疑“同一个任务是不是被执行了两次”时Invocation ID 就是最直接的证据。5.4 任务重入、超时与清理让定时任务自己恢复最后一类问题是“任务卡住”。比如脚本里某个外部接口一直没有响应导致任务一直处于 active 状态。这时候如果你手动再执行一次systemctl start backup.service两次任务会同时跑后续的 timer 触发也可能叠加进来直接把系统资源耗尽。我的经验是两手准备。第一service 单元里设置合理的超时时间[Service] TimeoutStartSec15min脚本超过 15 分钟没跑完systemd 会直接将其杀掉并标记为 failed。第二ExecStart 里用 flock 锁杜绝并发重入把“同时只能有一个实例在跑”落到最底层ExecStart/usr/bin/flock -n /var/lock/backup.lock /opt/scripts/backup.sh当任务已经卡死连手动 start 都被锁挡住时用journalctl -u backup.service --since today查看是否出现了flock: ... Resource temporarily unavailable如果有就说明上一个实例还占着锁。此时用systemctl kill backup.service强制结束任务等锁释放后就能正常跑下一次了。这套流程我从最初的“任务没跑”开始逐步排除 timer、service、脚本、环境、锁基本能覆盖 95% 的故障类型。关键在于每一步都要看证据不要在第一步就跳到改脚本的老路上去。我在生产环境维护的几十台 Ubuntu 服务器现在已经把所有新增定时任务都默认用 systemd timers 管理老的 crontab 条目也迁移得差不多了。说实话日常使用中并不需要记住全部参数真正高频用到的就是OnCalendar、Persistent、RandomizedDelaySec和RequiresMountsFor这几个配合systemd-analyze calendar验证时间、journalctl查日志整套体系足够顺手。关于到底该继续用 crontab 还是切 systemd timers我的看法是如果你的定时任务只是“一天跑一个简单命令、不需要管失败、不需要补跑、机器就一台”crontab 也不是不能用。但只要你碰过任务漏跑、状态不透明、凌晨零点多机扎堆这种问题系统里已经原生带了更好的工具没必要守着旧方案继续加班排查。