
凌晨三点告警响了说昨晚的数据库备份没生成。登上去看crontab -l里那行写得干干净净0 3 * * * /opt/backup.sh脚本权限也对手动执行一次也成功。可它就是没跑。查了半小时才想起来上周把这个任务的执行用户从 root 换成了一个专用账号而脚本里调用的mysqldump在/usr/local/mysql/bin下——cron 的 PATH 里根本没有这个目录。这种问题不新鲜几乎是每一个跟定时任务打过交道的运维和开发都踩过的坑。而这几年的新机器上越来越多人干脆不用 cron 了改成 systemd timer。两套东西摆在一起到底该选哪个什么时候该换换的过程中会掉进什么坑这篇就把我这些年在这件事上的判断标准、实操细节和排查经验一次性摊开讲。不管你是刚接手一批服务器的新手还是已经在维护几十上百条定时任务的老兵下面这些内容应该都能对上你的某些具体场景。1. 先搞清楚你到底该用 cron 还是 systemd timer1.1 一个反复出现的分歧场景假设你要给一台业务机加一条每日凌晨的日志清理任务。团队里立刻会分成两派一派说crontab -e两行搞定另一派说现在还用 cron 属于技术债应该写 timer。这个争论之所以长期僵持是因为两边的理由都对但讨论的往往不是同一件事。选 cron 的核心理由是普适性。从嵌入式板子到大型机从几十年前的 Unix 到今天的各种 Linux 发行版cron 几乎无处不在。学习成本极低一条表达式五个字段看一眼就懂写错了改一行就完事。它的实现极其轻量一个守护进程加一堆文本文件没有额外依赖不需要理解 unit、target、依赖图这些概念。选 systemd timer 的理由是可观测性和工程化能力。任务执行状态进了统一的 journal能查历史、能看返回码、能拿到完整输出能绑定 cgroup 做资源限制能配置依赖关系保证某些任务在挂载点就绪之后才跑能设置随机抖动避免集群同时唤醒能处理机器关机期间错过的任务。这些东西在规模上去之后每一样都是从能跑到可运维的分水岭。我自己的判断标准很朴素单机、一次性、无依赖的小脚本cron 完全够用一旦这个任务涉及资源约束、失败追溯、多任务编排或者这台机器超过十台就该考虑 timer 了。不是技术洁癖是省自己以后的时间。1.2 两条技术路线的本质差别理解差别要从执行模型说起。cron 的模型是守护进程按时扫表。crond常驻内存每分钟醒一次准确说是对齐到分钟边界把所有 crontab 里的表达式展开看当前这一分钟有没有匹配项有就 fork 一个子进程去执行。整个流程极简但也意味着它的精度天花板就是分钟而且它对执行结果完全无感——子进程跑成什么样cron 不记录、不重试、不告警最多把 stdout/stderr 攒起来发一封本地邮件。systemd timer 的模型是定时器单元触发服务单元。.timer文件本身不干活它只负责在特定时刻让 systemd 去启动配对的.service。所以任务的实际内容写在 service 里timer 只描述什么时候。这样做带来的直接好处是复用了 systemd 全套的服务管理能力启动失败会自动进入 failed 状态、可查询、可重启输出进 journal能加CPUQuota、MemoryMax、IOSchedulingClass这类资源约束能通过After、Requires串起依赖关系。这两套模型还有个隐性差异容易被忽略cron 是到点就 fork不管上一次跑完没有systemd 默认也是到点就 start但如果上一次启动的实例还在运行默认行为会怎样这个问题很多人没想过。对于Typesimple的长驻服务重复 start 会被 systemd 认为已经在运行而不做处理对于Typeoneshot的任务如果上一次还没结束新的触发会被排队或者被忽略具体取决于配置。这点跟 cron 的无脑并发是完全不同的语义迁移的时候必须搞清楚。1.3 一张表看清选择边界把关键维度拉平对比能省掉很多来回纠结维度cronsystemd timer最小时间粒度1 分钟1 秒受 AccuracySec 影响执行日志本地邮件或 syslogjournal含返回码、输出、耗时失败重试不支持需自行实现可通过Restart实现错过的任务补跑需 anacron 辅助Persistenttrue原生支持随机延迟需脚本内 sleepRandomizedDelaySec资源限制无cgroup 全套任务依赖无After、Requires等相对时间触发不支持OnBootSec、OnUnitActiveSec环境变量继承极简仅少量默认值可显式声明Environment平台可用性几乎所有类 Unix仅 systemd 系统学习成本低中看这张表会发现一个有意思的点cron 唯一明显胜出的地方就是到处都有和学起来快。其余维度上只要你的系统跑着 systemdtimer 基本都是更现代的选项。但现实中大量存量系统仍然依赖 cron所以真正有价值的不是选边站而是两边都能写、都知道坑在哪。2. cron 的脾性那些文档里不会写的行为细节2.1 crontab 的加载机制与文件分布很多人用了几年 cron也没搞清楚自己的任务到底存在哪儿。实际上 cron 的任务来源至少有这么几处用户级 crontabcrontab -e编辑实际文件在/var/spool/cron/下按用户名命名的文件里、系统级/etc/crontab、/etc/cron.d/目录下的片段文件、以及/etc/cron.hourly、/etc/cron.daily、/etc/cron.weekly、/etc/cron.monthly这四个目录。这四类来源的格式并不完全一样这是第一个容易翻车的地方。用户级 crontab 是五个时间字段加命令不带用户字段而/etc/crontab和/etc/cron.d/下的文件在时间字段和命令之间必须多一个用户字段。我就见过有人把/etc/cron.d/里的文件按用户 crontab 的格式写结果那行永远不生效日志里也不报明显错误纯靠肉眼比对了半天才发现少了一列。四个cron.*目录的机制又不一样。它们不是由 crond 直接解析的而是由系统预置的一条任务通常是run-parts按小时/天/周去扫目录执行。这意味着这些目录里的脚本必须可执行、文件名要符合run-parts的命名规则一般只允许字母数字下划线连字符文件名带个点就可能被跳过。而且这条预置任务的触发时间在各发行版里不一样/etc/cron.daily到底几点跑取决于/etc/crontab或/etc/anacrontab里的定义不是想当然的零点。还有一类容易被遗忘crontab文件的末尾必须有换行符。某些 cron 实现尤其是传统的 vixie-cron 系列在解析最后一行时如果文件末尾没有\n最后一条任务会被直接丢弃。这个坑极隐蔽因为编辑器保存时通常会补换行只有通过脚本生成 crontab 的场景才会中招。用脚本批量下发 crontab 的时候记得在写入内容末尾显式加一个换行。2.2 环境变量是最大的坑前面开头提到的那个备份脚本失败根子就在环境变量。cron 执行任务时使用的环境不是你的登录 shell 环境而是一套极简的默认值。绝大多数实现里PATH只有/usr/bin:/bin有的发行版会多一点但绝不会包含/usr/local/bin、/opt/xxx/bin这类路径HOME是执行用户的家目录SHELL通常是/bin/shLANG可能是空或者C。这带来的连锁反应很典型命令行里which python3能找到cron 里就找不到手动跑脚本没问题cron 跑就报command not found脚本里依赖LANG做字符处理的到了 cron 里中文全变乱码或者正则匹配失败依赖HOME找配置文件的应用比如各种云 CLI 工具会去错地方。处理方式有几种各有取舍。最省事的是在 crontab 顶部统一定义PATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LANGen_US.UTF-8 SHELL/bin/bash注意这几行是变量赋值不是任务行写在所有任务之前会对这个 crontab 里的所有任务生效。这种做法简单直接缺点是隐式依赖——后面接手的人看到任务行不知道环境是怎么来的容易在别处复制粘贴时漏掉这几行。另一种更稳妥的方式是把环境准备写进脚本本身脚本开头显式export PATH...或者干脆全用绝对路径调用命令。我倾向于这种因为任务的正确性不应该依赖于它被谁、以什么方式调度。脚本自己把环境兜住无论是 cron、timer 还是人手敲行为一致出问题也好复现。注意如果要在 crontab 里临时取消某个变量的继承写MAILTO可以关掉任务输出邮件。很多机器没配邮件服务任务输出的东西会被丢弃或者堆在/var/spool/mail/里慢慢涨定期清理一下能省不少磁盘。2.3 时间表达式与 % 的转义陷阱cron 的五个字段是分 时 日 月 周这个大家都知道。但有两个语义细节经常被误解。第一个是**日和周同时指定时的逻辑关系**。很多人以为是且实际上标准 cron 的行为是或——只要 day-of-month 或 day-of-week 有一个匹配任务就会执行。比如0 3 1 * 1看起来像是每月 1 号且是周一实际含义是每月 1 号或者每个周一。如果真想要每月 1 号且是周一得靠命令内部再做判断。这个坑在写复杂排期的场景里非常致命而且因为表达式看起来很合理往往要等实际跑偏了才发现。第二个是命令里%必须转义。cron 把命令部分的%当作特殊字符未转义的%表示换行并作为标准输入。所以date %Y%m%d直接写进去会导致命令被截断后面的内容变成了 stdin。正确写法是date \%Y\%m\%d或者更省心的方式——把逻辑全放进脚本crontab 里只写脚本路径彻底避开这层转义。# 容易出错的写法 0 3 * * * /usr/bin/tar czf /backup/log-$(date %Y%m%d).tar.gz /var/log # 稳妥的写法逻辑下沉到脚本 0 3 * * * /opt/scripts/pack_logs.sh除了这些还有几个宏可以用reboot、daily、weekly、monthly、yearly、hourly。reboot的语义是 crond 守护进程启动时执行一次注意是守护进程启动不是系统启动——如果 crond 因为某种原因重启了reboot的任务会再跑一遍。拿它做开机初始化在某些场景下会出意外比如重复创建资源、重复上报心跳之类的。2.4 用 flock 解决任务重叠cron 不管上一次任务跑完没有到点就发起新的。如果一个任务偶尔会因为数据量大跑上十几分钟而调度间隔只有五分钟很快就可能出现多个实例并发执行轻则互相抢文件锁重则数据错乱。cron 本身没有内置的互斥机制惯用做法是flock*/5 * * * * /usr/bin/flock -n /var/lock/myjob.lock /opt/scripts/myjob.sh-n表示非阻塞拿不到锁就立刻退出不会堆积等待。这个组合在 disk-to-disk 同步、日志轮转、缓存刷新这类任务上几乎是标配。参数上的一个细节锁文件路径要放在所有实例都能访问的地方/var/lock或者/tmp都行但不要放在会被清理脚本删掉的目录里锁文件被删会导致锁语义失效。还有一种写法是把锁加在脚本内部用flock包装整个执行体好处是手动执行脚本时也受保护坏处是脚本本身变复杂了一点。我个人偏好后者原因还是那句话行为一致比配置简洁更重要。3. systemd timer 的正确打开方式3.1 timer 与 service 的配对关系timer 的核心概念只有一条timer 不干活它只负责触发同名的 service。所以一个完整的定时任务至少需要两个文件mytask.timer和mytask.service。命名必须对应否则要么靠Unit显式指定要么就完全不触发。service 这边绝大多数定时任务应该用Typeoneshot# /etc/systemd/system/mytask.service [Unit] DescriptionDaily log packing task [Service] Typeoneshot ExecStart/opt/scripts/pack_logs.sh Userbackup Groupbackuponeshot的语义是启动后一直跑到进程退出退出即服务结束。这跟simple不同——simple是启动后立刻认为服务已就绪适合长驻进程。用错了类型systemd 会认为任务一直在运行后续触发可能被忽略systemctl status看到的状态也会误导人。timer 这边# /etc/systemd/system/mytask.timer [Unit] DescriptionTrigger daily log packing [Timer] OnCalendar*-*-* 03:00:00 Persistenttrue Unitmytask.service [Install] WantedBytimers.target几个要点值得单独说。Unit在同名的情况下可以省略但显式写出来更清楚尤其是有多个 timer 复用同一个 service 的场景。WantedBytimers.target决定systemctl enable时会挂到哪个 target 下这个不能少否则 enable 了也不会随系统启动。最容易犯的错误是启用了 service 但没启用 timer。systemctl enable mytask.service只是把这个 service 标记为可被依赖启动并不会让它按时触发。正确的命令是systemctl enable --now mytask.timer注意是 timer。很多人第一次写的时候两个都 enable 了结果--now对 service 生效任务立刻跑了一次看起来成功了实际上定时根本没生效。3.2 OnCalendar 表达式怎么写才不出错OnCalendar的语法比 cron 更啰嗦但也更直观格式大致是星期 年-月-日 时:分:秒支持*、..区间、,列举、/步进。# 每天凌晨 3 点 OnCalendar*-*-* 03:00:00 # 工作日周一到周五早上 8 点半 OnCalendarMon..Fri 08:30:00 # 每月 1 号和 15 号的中午 OnCalendar*-*-01,15 12:00:00 # 每小时的第 5 分钟 OnCalendar*:05:00写完之后一定要用systemd-analyze calendar验证这是省时间的利器systemd-analyze calendar Mon..Fri 08:30:00 systemd-analyze calendar --iterations5 *-*-01,15 12:00:00它会告诉你这个表达式下次、下下次触发的确切时间点一眼就能看出写没写对。--iterations参数能列出多次触发时间验证每月 1 号和 15 号这种排期特别有用。我现在的习惯是任何非平凡的表达式都先过一遍这条命令比部署上去等一天看结果高效得多。还有两个语法点容易搞混。一个是日期和星期同时指定时的逻辑是与这跟 cron 的或刚好相反。OnCalendarMon *-*-01表示既是 1 号又是周一一个月最多出现一次跟 cron 里同样的写法含义完全不同。迁移的时候这是必须逐条核对的点。另一个是时区后缀。OnCalendar支持在末尾加时区比如OnCalendar*-*-* 03:00:00 Asia/Shanghai。不写的话使用系统时区。多机部署、跨时区同步任务的时候显式写上时区能避免一堆因为机器时区配置不一致导致的错乱。3.3 AccuracySec 与 RandomizedDelaySec省电和抖动的权衡这两个参数是 timer 相对 cron 的硬核优势但默认值和行为容易被误解。AccuracySec默认是1min。它的含义不是误差最多一分钟而是允许 systemd 在这个时间窗口内合并唤醒。系统为了省电会把时间窗口内多个到期的 timer 合并到一次唤醒里一起处理代价是实际触发时间可能偏离设定时间最多一个 AccuracySec。对于凌晨的备份任务晚个几十秒完全无所谓默认值正好但如果任务对时间敏感比如要在某个整点做数据切分就得把它调小比如AccuracySec1s代价是牺牲一点电源效率。RandomizedDelaySec默认是 0。它的作用是在触发时间上叠加一个随机延迟范围是0到设定值。这个参数在集群场景下价值巨大假设你有 100 台机器都配置了OnCalendar*-*-* 03:00:00去做日志上报如果不加抖动凌晨三点整这 100 台机器会同时打向中心服务形成一道尖锐的流量峰。加上RandomizedDelaySec600触发时间就会均匀散布在三分钟到十三分钟之间峰值被抹平。这里的参数选择有个简单的估算方式把任务数量乘以单次请求的负载跟中心服务的承载能力对比。100 台机器、每台请求耗时 200ms、服务端并发处理能力 50那么理论最短完成时间是 100×0.2/50 0.4 秒看起来没问题但如果每次请求涉及数据库写入实际并发能力可能只有个位数那就必须靠抖动把压力摊开。抖动窗口一般取任务总耗时的一个量级比如整体完成需要十分钟那RandomizedDelaySec300到600都合理。需要提醒的是RandomizedDelaySec会和Persistenttrue产生交互。补跑的场景下抖动同样会被应用所以补跑的时间点也不是精确的。这在绝大多数场景下无害但如果你指望关机错过的任务开机后立刻补跑得同时考虑Persistenttrue和抖动的叠加效果。3.4 Persistenttrue 到底补跑什么Persistenttrue解决的是机器关机期间错过的任务这个经典问题。它的工作机制是systemd 会在/var/lib/systemd/timers/下为每个启用了 Persistent 的 timer 记录一个时间戳文件记录上次触发的时间。系统启动后systemd 会比对当前时间和这个时间戳如果中间有本该触发但错过的时刻就立即补触发一次。这里有三个必须理解的边界。第一只补一次。关机三天错过的三次每日任务不会补三次只会补一次。对于幂等的任务比如确保某个文件存在、同步最新状态这没问题对于累加型的任务比如每天追加一条统计记录补跑一次意味着数据缺失需要任务本身能识别并补齐。第二只对 OnCalendar 有意义。OnBootSec、OnUnitActiveSec这类相对时间触发器本身就跟上次执行时间绑定Persistent 对它们不起作用。有些资料没讲清楚这一点导致有人配了OnUnitActiveSec1d加Persistenttrue期待关机补跑结果自然是不生效。第三时间戳文件会持久化。如果你改了OnCalendar的排期旧的时间戳可能还在导致行为不符合预期。调试期间如果怀疑补跑逻辑有问题可以去看/var/lib/systemd/timers/stamp-xxx.timer文件的内容和修改时间或者直接删掉让它重新开始记录。这个排查手段在文档里基本找不到但很实用。4. 实操把同一个备份任务写两遍4.1 cron 版本需求每天凌晨 3 点把/var/log/app打包压缩到/backup/保留最近 7 天任务不能重叠执行执行结果要留痕。# crontab -e 以 backup 用户身份 PATH/usr/local/bin:/usr/bin:/bin MAILTO 0 3 * * * /usr/bin/flock -n /var/lock/pack_app_logs.lock /opt/scripts/pack_app_logs.sh /var/log/pack_app_logs.log 21脚本内容#!/bin/bash set -euo pipefail SRC/var/log/app DST/backup STAMP$(date %Y%m%d) TARGET${DST}/app-log-${STAMP}.tar.gz mkdir -p $DST tar czf $TARGET -C $SRC . # 只保留最近 7 个归档 ls -1t ${DST}/app-log-*.tar.gz | tail -n 8 | xargs -r rm -f echo [$(date %F %T)] packed to ${TARGET}几个设计取舍值得说明。set -euo pipefail让脚本在任何一步失败时立刻退出避免半成品归档被当成正常文件flock -n保证并发时后来的直接退出而不是排队把输出重定向到固定日志文件是因为 cron 的默认行为是发邮件而这台机器上没配邮件服务输出会被丢弃重定向到文件至少事后能查。日志文件本身也需要轮转简单点可以用logrotate配一条规则复杂点就在脚本里顺手控制行数看具体需求。这个方案的短板也很清楚任务失败了你不会知道。日志文件里会留下痕迹但没有任何主动告警。要做到失败通知得在脚本里加判断比如检查$?后调一个通知接口或者在日志侧做监控。这是 cron 方案必须自己补的部分。4.2 systemd 版本同样的需求写成两个 unit 文件# /etc/systemd/system/pack-app-logs.service [Unit] DescriptionPack application logs into backup archive Afterlocal-fs.target [Service] Typeoneshot Userbackup Groupbackup ExecStart/opt/scripts/pack_app_logs.sh StandardOutputjournal StandardErrorjournal# /etc/systemd/system/pack-app-logs.timer [Unit] DescriptionDaily trigger for app log packing [Timer] OnCalendar*-*-* 03:00:00 Persistenttrue RandomizedDelaySec180 AccuracySec1min [Install] WantedBytimers.target部署命令systemctl daemon-reload systemctl enable --now pack-app-logs.timer systemctl list-timers --all | grep pack-app-logssystemctl list-timers会输出下一次触发时间、上次触发时间、以及配对的 unit一眼看清状态这是 cron 完全没有的能力。查看历史执行journalctl -u pack-app-logs.service --since 3 days ago返回码、耗时、脚本输出全在里面不需要再单独维护日志文件的轮转。这里有个细节RandomizedDelaySec180在这个单机场景下其实是多余的加上它主要是演示写法。真正的价值在多机场景单机任务加抖动反而让触发时间变得不可预测排查问题时容易困惑。所以不是所有 timer 都该加抖动得看有没有同时唤醒的实际风险。Afterlocal-fs.target这一行也不是必须的因为默认的启动顺序在大多数情况下已经保证了文件系统就绪。但显式写出来是有意义的——它表达了这个任务对文件系统的依赖意图后续如果有更复杂的依赖需求比如依赖某个网络挂载点可以在此基础上继续加。这类显式依赖是 timer 相对 cron 的真实优势cron 完全无法表达我要等某个东西就绪再跑。4.3 验证与观察两套方案部署完验证方式也值得对比。cron 侧基本靠手动把时间改到下一分钟然后等。改 crontab 里的时间到*/1 * * * *等一分钟看日志文件有没有新增内容。这个方法土但有效。systemd 侧可以更主动# 直接触发一次 service验证脚本本身 systemctl start pack-app-logs.service # 看结果 systemctl status pack-app-logs.service journalctl -u pack-app-logs.service -n 50 # 模拟下次触发时间 systemd-analyze calendar *-*-* 03:00:00 --iterations3先把 service 单独跑通再验证 timer这个顺序很重要。因为 timer 触发的失败和 service 本身的失败排查路径完全不同——前者要看 timer 是否启用、表达式是否被解析后者要看服务配置和脚本逻辑。混在一起排查会浪费大量时间。提示systemctl list-timers --all里的NEXT列如果显示-或者时间很奇怪通常意味着 timer 没启用或者OnCalendar解析失败。用systemctl status xxx.timer看详细状态解析错误会明确写在里面。5. 迁移路线从 crontab 平滑过渡到 timer5.1 迁移前的盘点动手改之前先把现有的 crontab 完整盘一遍。命令很简单for u in $(cut -d: -f1 /etc/passwd); do crontab -l -u $u 2/dev/null | grep -v ^# | grep -v ^$ | sed s/^/[$u] / done cat /etc/crontab /etc/cron.d/* 2/dev/null | grep -v ^# | grep -v ^$输出会告诉你这台机器上到底有多少条任务、分别在哪个用户下、跑什么。盘点的时候要顺手记录四件事任务的实际功能、执行用户、依赖的环境变量、失败时的业务影响。最后一项特别重要——把影响大的任务和无关紧要的任务分开处理先迁无关紧要的积累经验再动核心任务。盘点过程中经常会有意外发现某些任务对应的脚本已经不存在了、某个任务三年没成功执行过但没人发现、两个任务之间存在隐含的先后依赖但都写在同一时刻。这些问题在迁移前暴露出来是好事迁移过程中暴露就是事故。5.2 分批迁移与并行观察我的做法是先把新 timer 加上但把触发时间错开让新旧两套同时跑一段时间。比如原来是0 3 * * *新的 timer 先配成03:30这样两边都会执行产出的结果可以对比。跑上一周确认新方案的结果一致、日志正常、没有副作用再把旧的 crontab 注释掉最后调整 timer 的时间回到 3 点。这个并行期看着笨但能拦住大量问题。两个方案在环境变量、执行目录、PATH 上的细微差异只有在真实运行中才会暴露。尤其是那些依赖当前目录或用户 shell 配置的脚本在 cron 下能跑换了 systemd 就挂——因为 systemd 默认的工作目录是/不读用户的.bashrc环境变量继承完全走另一套逻辑。并行期结束后旧任务不要马上删注释掉保留两到四周。真出问题的时候取消注释比重新写一遍快得多。这个习惯看起来保守但我见过太多次迁移完第二周才发现某个边缘场景没覆盖的情况。5.3 回滚预案迁移必须有回滚路径而且回滚动作要简单到能在压力下执行。具体来说# 停用新方案 systemctl disable --now pack-app-logs.timer # 恢复旧方案取消 crontab 里的注释即可 crontab -e两条命令不需要思考。但如果新方案已经产生了副作用比如写了新格式的文件、改了某些状态单纯回滚调度器是不够的还得处理数据。所以迁移前要明确一件事这个任务的输出是否幂等、是否可撤销。不可撤销的任务比如发出通知、扣减额度迁移时应该先在测试环境验证完整流程而不是直接上生产。还有个小技巧迁移期间给新任务加一个明显的标识比如在日志里多打一行[viasystemd-timer]排查时一眼就能分清是哪个调度器触发的。这个细节在并行期能省不少事。6. 排查实录定时任务不跑的那些原因6.1 cron 侧速查任务不执行按下面的顺序过一遍基本能覆盖九成情况。第一看守护进程是否在跑。systemctl status cron或systemctl status crond不同发行版服务名不一样。进程挂了自然什么都不跑但这种情况少见。第二看任务是否被正确加载。crontab -l确认内容在注意是不是编辑到了错误的用户下根用户的任务和普通用户的任务是分开的。如果是/etc/cron.d/里的文件确认格式里带了用户字段文件名不含点号文件权限不是所有人可写cron 出于安全考虑会拒绝加载 group 或 other 可写的文件。第三看时间表达式。0 3 * * *是每天 3 点0 3 * * 0是每周日 3 点两个很容易看混。日和周同时指定时的或逻辑也要注意。第四看环境。手动用env -i /bin/sh -c 你的命令模拟 cron 的极简环境执行一次能复现问题就说明是环境问题。这一招比反复改 crontab 试错高效得多。第五看输出。任务到底跑没跑在日志里找痕迹journalctl -u cron或者grep CRON /var/log/syslog不同系统日志路径不同。如果日志显示任务被执行了那问题在脚本内部如果没有执行记录问题在调度层。第六看权限和路径。脚本有执行权限吗用的解释器路径存在吗脚本里引用的文件在 cron 环境下能访问吗尤其是挂载点如果任务在挂载完成前触发文件系统还没就绪读写都会失败。6.2 systemd 侧速查systemd timer 不触发排查路径不太一样。第一步永远是systemctl list-timers --all。这个命令会直接告诉你有几个 timer、下次什么时候触发、上次什么时候触发过。如果列表里根本没有你的 timer说明没启用如果NEXT是空的说明OnCalendar解析失败或者 timer 处于 inactive。第二步看 unit 状态。systemctl status xxx.timer显示 timer 本身的状态和最后一次触发时间systemctl status xxx.service显示服务执行结果。这两个要分开看timer 正常不代表 service 正常。常见情况是 timer 准时触发了但 service 因为脚本报错进入 failed 状态之后每次触发都会快速失败日志里能看到重复的失败记录。第三步看表达式。systemd-analyze calendar 你的表达式直接验证。语法错了、日期组合不可能成立比如*-02-30、时区写错都能在这一步暴露。第四步看依赖和执行顺序。service 里如果有After指向的 unit 启动失败你的服务可能被卡住。systemctl list-dependencies xxx.service能看清依赖链。第五步看日志。journalctl -u xxx.service -n 100 --no-pager拿最近输出journalctl -u xxx.service --since today拿当天全部。需要看更细的调度过程可以journalctl -u xxx.timertimer 的触发记录在这里。6.3 一份通用排查表把两边的常见现象和处置方式整理成表出问题的时候对着查现象可能原因处置cron 任务完全不执行服务未运行 / 文件格式错 / 时间表达式错查服务状态核对用户字段验证表达式cron 报 command not foundPATH 缺少目录crontab 顶部定义 PATH 或用绝对路径cron 输出乱码或匹配失败LANG 未设置显式设置 LANGcron 命令被截断%未转义加反斜杠或逻辑下沉到脚本cron 任务重复并发无互斥机制用flock -n包装timer 不触发忘了 enable timersystemctl enable --now xxx.timertimer 触发但服务失败脚本环境差异 / 权限 / 路径查 journal用systemd-run模拟timer 触发时间不符预期AccuracySec 或抖动调整AccuracySec1s检查RandomizedDelaySectimer 关机后未补跑未开 Persistent 或用了相对触发器设Persistenttrue确认用OnCalendartimer 补跑多次或时间异常时间戳文件残留检查/var/lib/systemd/timers/stamp-*.timer手动执行成功调度失败环境变量差异env -i模拟或在 unit 里显式声明 Environment表里最后一行值得多说一句。这个现象在两种调度器上都会出现根因是同一个交互式登录的 shell 会加载一堆配置文件把 PATH、别名、函数都准备好而调度器执行时这些统统没有。养成脚本自己管好环境的习惯这一类问题就基本绝迹了。我现在的做法是每个定时任务的脚本开头都写一段环境准备显式 export 需要的变量不依赖任何外部上下文。排查这件事还有个体会先把问题范围切成调度层和执行层两半比一上来就翻脚本代码有效得多。判断调度层是否正常就看有没有执行记录有记录但结果不对问题一定在执行层。这个二分法能省掉大量盲目尝试。最后分享一个我常用的验证手法不管用哪种调度器新任务上线时先让它在一两分钟内跑一次用sleep或者临时改表达式的方式触发确认完整链路通了再调回正式时间。宁可多花十分钟做这一步也别等到第二天早上发现昨晚什么都没发生。