有一次我在生产服务器上写了一条 crontab,用来清理临时目录,结果第二天一看,目录比前一天还大。命令单独跑一遍完全正常,一放进调度任务里就失效。排查了大半天,真正的问题出在我对 Linux 调度未来任务这件事的理解不够——不是命令不会写,而是调度器运行的环境、时区、服务状态这些细节在背后捣乱。
这篇文章围绕 Linux 下的任务调度展开,会覆盖 at、cron、anacron、systemd timer 这四种主流方案,讲清楚各自适合什么场景、怎么用、以及任务没跑时怎么一步步排查。适合刚接触 Linux 的初学者,也适合被"定时任务没执行"折磨过的同学。
1. 别急着敲命令:先分清"一次性"和"周期性"
1.1 同样是定时,为什么有人用 at 有人用 cron
很多初学者拿到需求后第一反应是"我要写个定时任务",然后直接打开 crontab。这个习惯其实有点危险,因为"定时"这个词在 Linux 里至少要被拆成两种语义:
- 未来的某一个时间点,只执行一次。例如今晚 23:00 重启服务,明天 02:00 做一次数据归档。
- 按照固定的周期反复执行。例如每 5 分钟做一次健康检查,每天凌晨 02:30 做全量备份。
前者应该用 at,后者才轮到 cron 或者 systemd timer。我把这几个工具放在一起对比过,它们的关系大致是这样的:
| 工具 | 适用场景 | 时间精度 | 错过后补执行 | 依赖的后台服务 |
|---|---|---|---|---|
| at | 一次性未来任务 | 分钟级 | 看发行版,一般不承诺 | atd |
| cron | 周期性任务 | 分钟级 | 不补,错过的就错过了 | cronie / crond |
| anacron | 周期任务但设备可能关机 | 天级 | 开机后会补跑 | anacron |
| systemd timer | 周期/一次性都可以 | 秒级可配 | Persistent=true 可补 | systemd |
所以选型的时候不要问"哪个工具最强",要问"我的任务触发条件到底是什么"。如果只是因为下次重启前想执行一次某个脚本,却非要写进 crontab,你还得在任务后面加一条"执行完自己删掉的逻辑",完全是自找麻烦。反过来,如果任务需要每天凌晨跑,你却用 at 去手工排,那也只是暂时能用,第二天就断档。
1.2 所有调度器都在做同一件事:等待、匹配、执行
理解这几个工具,最关键的是建立同一个心智模型:不管 at 还是 cron,背后都有一个常驻后台的守护进程。at 对应 atd,cron 对应 cronie 或者 crond,systemd timer 则由 systemd 自己管理。
你把任务交给它们的时候,实际发生的事是:
- 任务被解析成一个文件,存放在 spool 目录下。at 的任务在 /var/spool/at 或 /var/spool/atd,cron 的任务分散在 /var/spool/cron/crontabs、/etc/crontab、/etc/cron.d 等位置。
- 守护进程一直循环扫描这些文件,把当前时间和任务里记录的执行时间做比对。
- 时间匹配上之后,守护进程负责拉起一个子进程来运行你的命令。
这个模型能解释很多奇怪现象。比如你明明提交了任务,它却不跑——第一件事就应该去查守护进程是不是还活着。再比如你改完 crontab 之后不需要重启服务,因为 crond 每次循环都会重新读取配置。又比如任务文件权限不对,守护进程会跳过它而不是报错提醒你。搞清楚"任务只是被记住了,不等于任务一定会执行",后面排查时思路会清晰很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用 at 提交一次性任务:常见用法和容易翻车的细节
2.1 at 的基本用法与时间表达式
at 的用法短小精悍。直接在终端输入:
bash复制at 14:30
系统会进入一个交互式提示符,你可以输入要执行的命令,输完后按 Ctrl+D 结束。这种方式适合临时输入几条命令。不过我更推荐用管道,把命令一次性提交进去,这样更清晰,也方便留在 shell history 里:
bash复制echo '/usr/local/bin/cleanup.sh > /var/log/cleanup.log 2>&1' | at 21:30
at 对时间表达式的解析非常灵活,可以直接用自然语言,实际工作中我常用的无非这几种:
bash复制at now + 5 minutes # 5 分钟后
at now + 2 hours # 2 小时后
at 09:00 tomorrow # 明天早上 9 点
at 14:00 2025-06-30 # 指定日期时间
at 4pm + 3 days # 3 天后下午 4 点
at teatime # 等价于 16:00
提交之后怎么管理?三个命令足够:
bash复制atq # 查看排队中的任务列表
atrm 3 # 删除编号为 3 的任务
at -c 3 # 查看编号为 3 的任务内容
atq 输出的第一列是任务编号,这个编号由 atd 分配。我经常用 at -c 3 去确认自己提交的环境变量和命令是否完整,这个习惯帮我避免过好几次"我以为我提交了 A,实际提交的是 B"的尴尬。
2.2 atd 服务与权限控制
at 任务要运行,前提是 atd 服务在跑。Debian 系一般默认安装并启动,但很多精简过的服务器镜像里根本没有 atd:
bash复制sudo systemctl enable --now atd
systemctl status atd
如果服务没起来,你提交 at 任务时不会有任何报错,任务会安静地排队,到了时间也静悄悄什么都不执行。这是我见过最多次的 at 翻车现场。
权限控制方面,at 依赖 /etc/at.allow 和 /etc/at.deny 两个文件。如果 /etc/at.allow 存在,只有里面列出的用户能提交任务;如果只有 /etc/at.deny,不在里面的人都能提交。两块文件都不存在时,不同发行版的行为不一样,有的发行版只允许 root 提交任务。生产环境里我建议直接创建 /etc/at.allow,把需要授权的用户名逐个写进去,避免多人共用服务器时有人误提交任务。
另外,at 任务在系统重启后并不会消失,它们以文件形式存在磁盘上。但重启后 atd 是否会把错过的任务补跑,不同发行版处理方式不一致。不要把它当成一个可靠的能力,重要的一次性任务如果要保证错过了也能补,应该用 systemd timer 的 Persistent 机制,后面会单独讲。
2.3 环境变量和输出重定向:两个经常被忽略的细节
at 在提交任务时会把当前 shell 里的环境变量捕获一部分进来,作为任务执行时的环境基础。这意味着如果你先 export 了一个变量,再提交 at 任务,任务里能用上这个变量。但你的 shell 别名、未导出的函数、以及 ~/.bashrc 里定义的动态逻辑,at 是一概不认的。
所以最稳妥的写法是,把任务写成一个完整脚本,脚本内自行设置需要的 PATH:
bash复制#!/bin/bash
export PATH=/usr/local/bin:/usr/bin:/bin
/usr/local/bin/cleanup.sh
另一个必须养成习惯的点是输出重定向。at 任务如果没有任何输出重定向,执行过程中的 stdout 和 stderr 会尝试通过邮件发送给当前用户。很多服务器根本没装 MTA(邮件传输代理),邮件发不出去,输出就凭空消失了。到了排查的时候,你既看不到报错,也没有日志,完全无从下手。
我在生产环境里的统一写法是:
bash复制echo '/usr/local/bin/backup.sh >> /var/log/backup_at.log 2>&1' | at 02:00
确保所有输出都被重定向到文件。即使任务失败了,至少日志里会留下 traceback,而不是什么痕迹都没有。
3. crontab:周期性任务的默认答案,但默认环境有点"穷"
3.1 五段式语法与三种写法
crontab 的语法是经典的"五段式":
text复制* * * * * command
┬ ┬ ┬ ┬ ┬
│ │ │ │ └─ 星期 (0-7,0 和 7 都代表周日)
│ │ │ └─── 月份 (1-12)
│ │ └───── 日期 (1-31)
│ └─────── 小时 (0-23)
└───────── 分钟 (0-59)
常见写法我直接给模板:
cron复制*/5 * * * * /usr/local/bin/check_health.sh
30 2 * * * /usr/local/bin/backup.sh
0 9 * * 1-5 /usr/local/bin/send_report.sh
0 0 * * 1 /usr/local/bin/weekly_maintenance.sh
还要记住几个特殊字符串,它们能省不少事:@reboot 表示开机时执行,@daily 等价于 0 0 * * *,@hourly 等价于 0 * * * *。
crontab 有三处可以写,优先级和用途不同:
crontab -e:编辑当前用户自己的任务,存到 /var/spool/cron/crontabs/ 下,不需要指定用户名。- /etc/crontab:系统级任务表,比用户 crontab 多一个"用户"字段,例如
30 2 * * * root /usr/local/bin/backup.sh。 - /etc/cron.d/ 目录:适合随软件包分发任务文件,同样需要指定用户。
很多软件安装包习惯把自己的定时任务丢进 /etc/cron.d/,这没问题。但有一个天坑:/etc/cron.hourly、/etc/cron.daily、/etc/cron.weekly 这些目录由 run-parts 处理,里面的脚本文件名如果含有点号,比如 my.backup.sh,会被直接忽略。我第一次往里面丢脚本时被这个问题坑过,后来一律用纯字母加下划线的命名:daily_backup.sh。
3.2 cron 的环境变量问题:PATH 和百分号
cron 设计出来的时候,就故意不加载用户的 shell 配置文件。它默认的 PATH 通常只有 /usr/bin:/bin,SHELL 是 /bin/sh。这意味着你平时在终端里跑得好好的命令,一旦放到 crontab 里,可能连 python3、node 都找不到。
解决办法有两种,我一般同时用:
cron复制PATH=/usr/local/bin:/usr/bin:/bin
SHELL=/bin/bash
在 crontab 顶部写上这两行,然后任务里尽量用绝对路径。如果脚本本身要读取特定用户的环境变量,不妨在脚本里手动 source 对应的配置文件:
bash复制#!/bin/bash
source /etc/profile
source ~/.bashrc
注意不要依赖 bash -lc 去加载所有 profile,因为 cron 调用它的时机和你手工登录时不同,有些环境变量会互相覆盖,实测下来反而更容易出问题。
另一个让无数人头疼的是百分号。crontab 解析时,命令字段里第一个未转义的 % 会被替换成换行符,% 后面的内容会被写进任务命令的标准输入。这句话翻译成人话就是:如果命令里直接写 date +%Y-%m-%d,cron 会把命令拦腰截断,把 % 后面的字符串塞给命令的 stdin。
正确写法是转义:
cron复制30 2 * * * /usr/local/bin/backup.sh --date $(date +\%Y-\%m-\%d) >> /var/log/backup.log 2>&1
后面第 6 章我会详细讲一个因为忘记转义导致脚本挂起半小时的真实事故。
3.3 超时、防重叠、错过后补跑
周期性任务最容易出现的两个问题是任务超时和任务重叠。
任务超时:如果脚本因为某个外部接口卡住而迟迟不退出,cron 会傻傻地等它。更糟的是,等它超时退出后,下一次触发又来了,于是出现僵尸进程堆积。我习惯在 crontab 里给每个有外部依赖的脚本套一个 timeout:
cron复制*/10 * * * * /usr/bin/timeout 120 /usr/local/bin/process.sh >> /var/log/process.log 2>&1
这样即使脚本内部没有做超时控制,120 秒后也会被强制终止,至少不会无限堆积。
任务重叠:备份脚本执行时间超过 5 分钟,而你的 crontab 是每 5 分钟触发一次,后一个任务就会和前一个同时跑。轻则重复告警,重则写坏数据。防重叠的标准做法是用 flock 给任务加锁:
cron复制*/5 * * * * /usr/bin/flock -n /tmp/process.lock /usr/local/bin/process.sh >> /var/log/process.log 2>&1
-n 参数表示拿不到锁就直接退出,放弃本次执行。这样能保证同一时间只有一个实例在跑,没跑上的下一次再补。
如果服务器不是 7x24 小时开机的,cron 还有一个天生的短板:关机期间错过的任务不会补跑。这时候需要 anacron。Debian 系的 cron 调度在 /etc/cron.daily、weekly、monthly 目录下默认就走的是 anacron,不是直接 cron。anacron 的配置在 /etc/anacrontab,格式是:
text复制# period delay job-identifier command
1 15 daily.backup /usr/local/bin/backup.sh
意思是每天检查一次,如果发现上次没跑,就等开机 15 分钟后补执行。period 的单位是天,delay 的单位是分钟。注意 anacron 的粒度是天级,没法精确到你想要"每天下午 3 点",这点要心里有数。
4. systemd timer:比 cron 更可控,但必须多写两个文件
4.1 为什么我越来越倾向 systemd timer
说实话,cron 在简单场景下仍然够用,我也没打算劝所有人立刻放弃它。但如果你对任务有下面任何一条要求,systemd timer 会是更顺手的方案:
- 希望任务的输出直接进 journald,用 journalctl 统一查日志,而不是在每个任务里手工重定向到文件。
- 希望给任务设置资源限制,比如 CPU、内存限额,或者限定任务在某个 cgroup 里运行。
- 希望开机后多少分钟执行一次(单调时间),而不是靠墙钟时间。
- 希望系统关机期间错过的定时任务能在下次开机后补跑,并且只补最新一次。
这些能力 cron 不是完全没有,但实现起来非常别扭。systemd timer 天生就长在 systemd 这套体系里,和服务管理、日志、依赖关系都是融通的。
4.2 最小可运行的 timer + service 对
systemd timer 和 cron 最大的心智差异在于:cron 只需要一个配置,而 systemd timer 需要两个 unit 文件,一个描述"做什么",一个描述"什么时候做"。
先建服务文件 /etc/systemd/system/mybackup.service:
ini复制[Unit]
Description=My backup service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
再建定时器文件 /etc/systemd/system/mybackup.timer:
ini复制[Unit]
Description=Run mybackup every day at 03:30
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
RandomizedDelaySec=5m
[Install]
WantedBy=timers.target
然后加载并启用:
bash复制sudo systemctl daemon-reload
sudo systemctl enable --now mybackup.timer
sudo systemctl list-timers
list-timers 会显示所有 timer 的最近触发时间。service 里的 Type=oneshot 表示这个服务执行完就退出,不会被当作常驻服务去管理。exec 路径一定要用绝对路径,这点和 cron 一样,systemd 同样不会加载你的 login shell 环境。
4.3 OnCalendar 语法和 Persistent 的真实行为
OnCalendar 语法和 cron 不一样,第一眼可能不习惯,但掌握几个模式就够用了:
ini复制OnCalendar=*-*-* 03:30:00 # 每天 03:30
OnCalendar=Mon..Fri 02:00:00 # 周一到周五 02:00
OnCalendar=*:0/15 # 每隔 15 分钟
OnCalendar=Sat,Sun 09:00:00 # 周六周日 09:00
OnCalendar=*-*-01 04:00:00 # 每月 1 号 04:00
如果任务和开机时间相关,可以用单调时间:
ini复制OnBootSec=10min # 开机 10 分钟后
OnUnitActiveSec=1h # 距上次触发 1 小时后
Persistent=true 是我认为 systemd timer 比 cron 强很多的地方。它会在磁盘上记录这个 timer 上次被触发的时间。如果系统在应当触发的时间点处于关机状态,下次开机激活 timer 后,它会立刻补跑一次,但只补最近一次,不会把关机期间错过的每次触发都重复执行。
举个例子,你设置了每天 03:30 备份,但电脑在 03:30 没开机,到了第二天 09:00 才开机,那么 timer 会立刻触发一次备份。如果关机了三天,开机后也只补最新一次,不会补三份。这个行为对备份类任务非常友好。
4.4 日志和失败处理:journalctl 比邮件好用太多
cron 任务不重定向就发邮件,重定向了又要自己维护日志文件。systemd timer 则直接由 journald 接管服务输出:
bash复制journalctl -u mybackup.service
任务的 stdout、stderr、退出码都在里面,排查问题时能少走很多弯路。如果任务执行失败,你还可以在 service 文件里指定 OnFailure,把它指向一个专门发告警的单元:
ini复制[Unit]
Description=My backup service
OnFailure=mybackup-failure@%i.service
这样失败不再依赖"有没有人看邮件",而是触发系统自带的失败处理机制。对需要值班告警的生产环境来说,这比 cron 的盲盒式邮件靠谱得多。
另外,systemd 的 timer 默认有一个 AccuracySec=1min,意思是它允许误差 1 分钟,以减少系统频繁唤醒。如果你对时间精度有硬性要求,可以在 timer 里加上 AccuracySec=1s,代价是系统可能在那个时间点被唤醒更多次。日常任务没必要动它,但知道这个默认值存在,能解释为什么 03:30:00 的任务偶尔在 03:30:30 才跑。
5. 任务没跑?用一套固定链路来排查
5.1 先确认调度器活着
不管用的是哪种调度器,任务没跑的第一件事不是改脚本,而是确认调度器本身还活着。
bash复制systemctl status cron # 或 crond,取决于发行版
systemctl status atd
systemctl list-timers --all # 查看所有 systemd timer
看到 inactive 或者 failed,直接说明问题不在你的命令上。最常见的原因是服务器从最小镜像安装,cron 服务默认没被启用;或者有人做安全加固时把 atd 给禁用掉了。
如果服务正常,再看调度器自己记没记录。cron 的日志通常在 /var/log/syslog(Debian 系)或 /var/log/cron(RHEL 系),可以用 journalctl 统一收:
bash复制journalctl -u cron | tail -100
grep CRON /var/log/syslog | tail -50
cron 的日志里会记录每条命令真正执行的时间点(CMD 行),你可以据此判断 crond 到底有没有收到你的任务。如果日志里连你任务的名字都没出现,说明任务文件根本不在 crond 的扫描范围内。
5.2 看日志,也要看"静默的邮件"
很多新手不知道,cron 任务如果没做重定向,执行时的输出默认通过 sendmail 发给当前用户。有邮件服务的话,它会躺在本地的 /var/spool/mail/ 目录下。查一下:
bash复制mail
如果没有邮件也没有日志,而任务确实被 cron 日志记录为已执行,那多半是输出被丢弃了。另一种常见情况是 crontab 里设置了 MAILTO=,把邮件功能直接关掉,输出也一起静默了。所以排查时出现"日志显示执行过,但没有任何输出"的现象,不要怀疑系统,先确认自己有没有在哪一行写过 MAILTO 或者重定向。
这里有一个实用习惯:新加 cron 任务时,前几次运行保留输出重定向到独立日志文件,例如 /var/log/my_job.log 2>&1,确认稳定后,再决定是继续留文件还是切到 journald。
5.3 权限、路径、时区:90% 的问题出在这三处
按照我的经验,把日志差异排除之后,剩下的问题基本可以归到下面三类:
权限问题。脚本没有执行权限是最常见的。ls -l 看一眼,如果权限是 -rw-r--r--,那 crond 执行时会直接拒绝。要么 chmod +x,要么在任务里显式调用解释器:
cron复制30 2 * * * /bin/bash /path/to/script.sh
如果是系统级 crontab,还要确认用户字段写对了。root 的任务写到普通用户名下,大概率会因为权限不足跑不起来。
路径问题。cron 的 PATH 极简,没有 /usr/local/bin,也没有你自己加的其他目录。复现 cron 的"穷人环境"最直接的方式是:
bash复制env -i /bin/sh -c '/path/to/script.sh'
env -i 会清空所有环境变量,模拟出一个接近 cron 的运行环境。如果这个命令下脚本报 command not found,说明问题就是 PATH。不要在脚本里写裸命令名,全部改绝对路径,或者在脚本开头 export 一个完整的 PATH。
时区问题。cron 判断执行时间的依据是系统当前时区。如果服务器时区是 UTC,而你的业务时间概念是北京时间,那么 0 2 * * * 其实会在北京时间上午 10 点执行。用 timedatectl 确认一下。
部分发行版的 cron 支持在 crontab 里设置 CRON_TZ 来指定时区,但这个功能不是所有实现都支持,不建议依赖。更稳的做法是:要么把系统时区统一成业务时区,要么改用 systemd timer,在 [Timer] 段显式写 Timezone=Asia/Shanghai。
6. 三个真实踩坑记录:从现象到根因
6.1 百分号让脚本挂起
有一次我给一个 Python 备份脚本配 cron,命令里有日期参数:
cron复制0 2 * * * /usr/bin/python3 /opt/backup.py --date $(date +%Y-%m-%d) >> /var/log/backup.log 2>&1
第二天发现备份任务没成功,进程还挂在那里。ps 一看,Python 脚本处于等待标准输入的状态。我想了半天,备份脚本没有任何读 stdin 的逻辑,为什么会卡住?
后来才明白,cron 解析命令时会把 % 变成换行符,并把换行后的内容送到命令的 stdin。原本的 --date $(date +%Y-%m-%d) 在 cron 眼里被拆成了两段:真正的命令变成 --date $(date +,后面的 %Y-%m-%d) 则被塞给了进程的 stdin。Python 脚本启动后一直在读 stdin,于是就这么眠着。
修正非常简单,转义所有百分号:
cron复制0 2 * * * /usr/bin/python3 /opt/backup.py --date $(date +\%Y-\%m-\%d) >> /var/log/backup.log 2>&1
后来我养成了习惯:凡是 cron 命令行里出现 date 格式串或者 curl 的 URL 编码参数,先扫一遍有没有裸 %。
6.2 时区错位:凌晨 02:00 变成了上午 10:00
有一阵子我管理的服务器全部采用 UTC 时区,因为日志统一用 UTC 更方便对比。但业务上要求每天凌晨 02:00 做数据归档,于是 crontab 里理所当然地写了 0 2 * * *。
结果早上过来一看,归档任务每天都是在上午 10 点跑的。原因很简单:凌晨 02:00 是 UTC 时间,换算成北京时间就是上午 10:00。我在配置的时候脑子里想的是"凌晨两点",但系统测量的是 UTC 时间,两个概念被混在一起了。
当时我为了治这个问题,在脚本开头第一行加过 TZ='Asia/Shanghai' date,但这只能影响脚本内部获取的时间,cron 判断"什么时候触发"仍然用系统时区。真正靠谱的解决方案是:要么统一时区,要么给定时器显式指定时区。用 systemd timer 的话,可以直接这样写:
ini复制[Timer]
OnCalendar=*-*-* 02:00:00
Timezone=Asia/Shanghai
这个字段在 cron 里没有对应物,也是我后来把越来越多任务迁到 systemd timer 的原因之一。
6.3 Persistent=true 的"惊喜":开机后立刻补跑
有一台测试机上跑着一个每天上午 09:00 发送日更摘要的 systemd timer,配置里写了 Persistent=true。结果某天下午 14:00,摘要突然被发了出来。
排查过程绕了一点。先是怀疑有人手动触发了 service,但 journalctl 显示确实是 timer 触发的。后来才反应过来,那台测试机那天上午一直处于关机状态,直到下午 14:00 才被重新启动。开机后 systemd 发现"上次触发时间已经过了",Persistent 机制立刻补跑了一次。
如果你希望备份类任务在机器恢复后补跑,Persistent=true 非常有用。但如果任务是对时间敏感、带有业务含义的(比如"每日早报"),这次补跑就会变成一条迟到 5 小时的错误消息。这个坑给我的教训是:Persistent 不是万金油,它只解决"错过了要不要补"的问题,不解决"补跑是否合理"的问题。判断这两个问题,应该是在写 timer 之前就完成的。
