服务器与 Linux 排查 列表全部文章
服务器与 Linux 排查

手敲能跑,放进 cron 就“装死”:定时任务没执行的八种死法

备份脚本手敲一切正常,放进 crontab 却悄无声息地不执行。逐条排查 cron 服务与日志、PATH 环境变量、百分号转义、时区、权限、输出重定向、flock 防重叠、时间漂移,并对比 XXL-JOB。

更多

周一早上,DBA 同学在群里艾特我:“上周五的数据库备份文件怎么没有?”我心想不可能,那个备份脚本我亲手写的,还专门在服务器上跑过,输出一屏绿油油的“backup ok”。再看 crontab,明明白白写着 30 2 * * * /data/scripts/backup.sh。

我登上去 ls /data/backup:最近一个备份是十一天前的。这十一天里,脚本每晚“准时”地什么都没做,连个报错都没留。定时任务的可怕之处在于:它失败时通常是安静的,你不去看,它就永远不会告诉你。

这篇把“cron 里不跑”的常见死法按排查顺序整理成清单,附上每一条的验证命令。以后再遇到,照着一条条打勾就行。

一、现象

$ crontab -l
30 2 * * * /data/scripts/backup.sh

$ /data/scripts/backup.sh          # 手动执行,一切正常
[2026-09-28 10:12:03] dump start ...
[2026-09-28 10:15:41] backup ok: /data/backup/db_20260928.sql.gz

$ ls -lt /data/backup | head -3
-rw-r--r-- 1 root root 812345678 Sep 18 02:33 db_20260918.sql.gz
-rw-r--r-- 1 root root 809912004 Sep 17 02:33 db_20260917.sql.gz

一个重要的区分:“cron 没有触发”和“cron 触发了但脚本失败”是两回事。前者是 cron 服务、时间、crontab 语法的问题,后者是脚本执行环境的问题。排查的第一步,就是判断到底是哪一种。

二、排查路线图

cron 日志里有这次执行吗?grep CRON /var/log/cron 没有:cron 根本没触发服务没跑 / 语法错 / 时区不符 / 时间漂移 / 文件权限 有:触发了但脚本没成功PATH / % 转义 / 执行权限 / 相对路径 / 重叠 验证手段systemctl status crond · timedatectl · chronyc trackingcrontab -l 与 /etc/cron.d 语法 · date 对比 验证手段重定向 >> log 2>&1 · env -i 模拟 cron 环境绝对路径 · chmod +x · flock -n

三、动手:先判断“触发没有”

第 1 步:cron 服务活着吗

# CentOS / RHEL:服务名 crond;Debian / Ubuntu:服务名 cron
$ systemctl status crond
● crond.service - Command Scheduler
   Loaded: loaded (/usr/lib/systemd/system/crond.service; enabled; vendor preset: enabled)
   Active: active (running) since Mon 2026-09-01 09:00:11 CST; 4 weeks ago

$ ps -ef | grep -E '[c]ron'
root       912     1  0 Sep01 ?  00:00:12 /usr/sbin/crond -n

不是 active (running) 的话,systemctl enable --now crond。这一条听起来傻,但真有人因为重装、精简系统把 cron 服务误停了。

第 2 步:看 cron 自己的日志

# RHEL/CentOS:
$ sudo grep CRON /var/log/cron | tail
Sep 27 02:30:01 demo CROND[20871]: (root) CMD (/data/scripts/backup.sh)
Sep 27 02:30:01 demo CROND[20870]: (root) CMDEND (/data/scripts/backup.sh)

# Debian/Ubuntu(有些系统 rsyslog 默认没开 cron 日志;systemd 环境用 journalctl):
$ sudo journalctl -u cron --since "2026-09-27 02:00" --until "2026-09-27 03:00"
$ sudo grep CRON /var/log/syslog | tail

这里的关键信息:cron 在 02:30:01 确实执行了 CMD,而且几乎立刻 CMDEND(结束)——它触发了,脚本瞬间就退出了,说明不是“没触发”,而是“触发后执行失败”。(如果日志里根本没有这一行,才需要去查时区、时间和语法,见后文。)

注意:cron 日志只记录“启动了命令”,不记录命令的输出和退出码。失败原因不会在这里,需要我们自己把输出留下来。

第 3 步:让脚本开口说话——输出重定向

cron 默认把任务的 stdout/stderr 通过本地邮件发给 crontab 的所有者。crontab(5) 手册说明:MAILTO 非空则发给该地址,设为空串则不发送,未设置则发给 crontab 拥有者。多数服务器没装邮件服务,输出就悄悄消失了。所以第一件事:把输出写进日志文件。

30 2 * * * /data/scripts/backup.sh >> /var/log/backup-cron.log 2>&1

>> 是追加,2>&1 把 stderr 合并到 stdout(顺序不能反)。为了不等到次日,先把时间改成“下一分钟”验证:

$ date
Mon Sep 28 10:20:32 CST 2026
# 临时改成 21 分(下一分钟)
21 10 * * * /data/scripts/backup.sh >> /var/log/backup-cron.log 2>&1

$ cat /var/log/backup-cron.log
/data/scripts/backup.sh: line 5: mysqldump: command not found
/data/scripts/backup.sh: line 9: aws: command not found

真相浮出水面:“command not found”。手敲时能找到 mysqldump,cron 里却找不到——环境变量不一样。

四、根因与八种死法

死法 1:PATH 与环境变量不同(本次元凶)

交互式 shell 登录时会读 /etc/profile、~/.bash_profile、~/.bashrc,PATH 里有 /usr/local/mysql/bin、JDK、node 等。而 cron 启动任务时,环境非常“干净”。crontab(5) 手册写明:cron 只会设置 SHELL(/bin/sh)、LOGNAME、HOME 这几个变量,其中 SHELL 和 HOME 可以在 crontab 里覆盖。PATH 通常是个精简值(常见为 /usr/bin:/bin),不会加载你的 profile。可以用一个小实验把 cron 的环境打出来:

# 在 crontab 里临时加一行,1 分钟后看结果
* * * * * env > /tmp/cron-env.txt 2>&1

$ cat /tmp/cron-env.txt
SHELL=/bin/sh
PATH=/usr/bin:/bin
PWD=/root
LOGNAME=root
HOME=/root

# 对比交互式环境
$ echo $PATH
/usr/local/mysql/bin:/usr/local/jdk/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/root/bin

差异一目了然。修法有三种,推荐顺序:

  1. 脚本里全部写绝对路径(/usr/local/mysql/bin/mysqldump),最稳;
  2. 脚本开头显式设置 export PATH=/usr/local/mysql/bin:/usr/local/bin:/usr/bin:/bin,需要 JDK 等还要加 JAVA_HOME;
  3. crontab 顶部声明 PATH=...、SHELL=/bin/bash(默认 /bin/sh,脚本里用了 bash 语法如 [[ ]] 会出错)。

本地复现 cron 的“干净环境”很有用:env -i SHELL=/bin/sh PATH=/usr/bin:/bin HOME=/root /bin/sh -c '/data/scripts/backup.sh'。它能在你等到凌晨之前就暴露问题。

死法 2:百分号 % 没转义

这是最阴险的一种。备份脚本里有这样一行:

30 2 * * * /usr/bin/mysqldump demo | gzip > /data/backup/db_$(date +%Y%m%d).sql.gz

手敲没问题,放进 crontab 就不生成文件。原因写在 crontab(5) 里:命令中的 % 如果没有用反斜杠转义,会被当成换行符,第一个 % 之后的所有内容会作为标准输入传给命令。于是 date +%Y%m%d 被截断,命令语法直接坏掉。修法:

# 方法 1:转义每个 %
30 2 * * * /usr/bin/mysqldump demo | gzip > /data/backup/db_$(date +\%Y\%m\%d).sql.gz

# 方法 2(更好):把命令放进脚本文件,crontab 里只写脚本路径
30 2 * * * /data/scripts/backup.sh >> /var/log/backup-cron.log 2>&1

症状是日志里有 CMD,但根本没有任何输出,也没有 command not found,因为命令在 shell 层面就被切碎了。看到日期命令就要想到这一条。

死法 3:时区不是你以为的那个

你写的是“凌晨 2:30”,服务器是 UTC,那 cron 就在北京时间上午 10:30 才跑——你半夜盯着它,当然看不到。检查:

$ timedatectl
               Local time: Mon 2026-09-28 02:31:04 UTC
           Universal time: Mon 2026-09-28 02:31:04 UTC
                 RTC time: Mon 2026-09-28 02:31:03
                Time zone: UTC (UTC, +0000)
System clock synchronized: yes
              NTP service: active

$ sudo timedatectl set-timezone Asia/Shanghai     # 改系统时区
$ sudo systemctl restart crond                    # cron 需要重启才能读到新时区

还要留意一个容易忽略的细节:crontab(5) 提到可以用 CRON_TZ 为单个 crontab 指定时区(按该时区填时间,而 cron 自己的日志仍用守护进程的本地时区,这会造成“日志时间和你写的时间对不上”的困惑)。Docker 镜像里几乎都是 UTC,容器里跑 cron 更要注意。夏令时地区还有一个坑:手册说明夏令时切换时“不存在的时刻”永远匹配不上,那个时间点的任务就不会执行;重复出现的时刻则会触发两次。

死法 4:脚本没有执行权限,或解释器不对

$ ls -l /data/scripts/backup.sh
-rw-r--r-- 1 root root 812 Sep 10 09:00 /data/scripts/backup.sh     # 没有 x 权限

$ chmod +x /data/scripts/backup.sh
$ head -1 /data/scripts/backup.sh
#!/bin/bash        # 第一行要有 shebang;文件若是 Windows 换行(CRLF)会报 "bad interpreter"
$ file /data/scripts/backup.sh          # 看是否有 "with CRLF line terminators"
$ sed -i 's/\r$//' /data/scripts/backup.sh   # 去掉 CRLF

从 Windows 编辑器上传的脚本经常带 CRLF,症状是 /bin/bash^M: bad interpreter,同样只会出现在你留下的输出日志里。另外,crontab 文件本身(/etc/cron.d/ 下的文件)要求属主为 root、权限不可被组/其他用户写,否则 cron 会忽略并在日志里报错;/etc/cron.d/ 里的行还需要多写一个用户名字段(第六列),少写了这一列整行会失败。

死法 5:工作目录与相对路径

cron 任务的当前目录通常是执行用户的 HOME(上面输出里 PWD=/root)。脚本里如果写了 ./conf/db.properties、cd 之后的相对路径,手敲时在脚本目录里一切正常,cron 里就找不到文件。脚本开头固定加一句:cd "$(dirname "$0")" || exit 1,或者干脆全部用绝对路径。

死法 6:任务重叠,前一次没跑完,后一次又来了

每 5 分钟跑一次的同步任务,某次耗时 12 分钟,于是同一时刻有 3 个实例同时在跑,互相抢数据库连接、重复处理数据,越堵越慢。用 flock 给脚本加互斥锁,flock(1) 手册的 -n 表示拿不到锁就立刻失败而不是等待,-w 是限时等待:

# 拿不到锁就直接退出,不排队
*/5 * * * * /usr/bin/flock -n /var/lock/sync-order.lock /data/scripts/sync-order.sh >> /var/log/sync-order.log 2>&1

# 检查是否有多个实例在堆积
$ ps -ef | grep '[s]ync-order.sh' | wc -l
3

锁是内核层面的文件锁,进程崩溃时锁自动释放,不会像“自己写个 pid 文件”那样留下死锁。别忘了给任务加超时(timeout 240 script.sh),避免一次卡死拖住所有后续调度。

死法 7:服务器时间漂移或被大幅回拨

如果系统时间不准,cron 就会在“错误的时间”触发,或者在时间被大幅向前调整时错过任务。查看同步状态:

$ chronyc tracking
Reference ID    : 0A000101 (ntp1.demo.example.com)
Stratum         : 3
System time     : 0.000012345 seconds fast of NTP time
Last offset     : +0.000008765 seconds
RMS offset      : 0.000021000 seconds
Leap status     : Normal

$ chronyc sources -v
MS Name/IP address         Stratum Poll Reach LastRx Last sample
^* ntp1.demo.example.com         2   6   377    12   +12us[ +25us] +/- 1.2ms

chrony 文档对字段的解释是:System time 是 NTP 时钟与系统时钟之间当前的偏差;Leap status 为 Not synchronised 代表根本没有同步上;sources 输出里的 ^* 表示当前选中的最佳时间源,? 表示不可用。若偏差很大,可以用 chronyc makestep 强制立刻校准(会让时间发生跳变,业务敏感时段慎用)。老系统用 ntpd 的话,对应命令是 ntpq -p。长时间没校准的虚拟机、休眠后恢复的机器都容易“时间漂移”,任务表现为“偶尔不跑或跑偏”。

死法 8:crontab 自身写错

  • 五个时间字段少写或多写:分 时 日 月 周,缺一个整行失效;
  • 最后一行没有换行符:某些版本 cron 会忽略最后一行,编辑后确保末尾有回车;
  • 在 crontab -e 之外直接改了 /var/spool/cron/ 里的文件:不会被 cron 立即感知,请始终用 crontab -e;
  • 用户被 /etc/cron.deny 拒绝,或不在 /etc/cron.allow 里;
  • 密码过期或账号被锁:以该用户身份运行的任务被 PAM 拒绝,日志里有 pam_... 相关记录。

五、止血与修复

回到本次事故:根因是脚本里的 mysqldump、aws 不在 cron 的 PATH 里(死法 1);同时还发现备份文件名用了未转义的 date +%Y%m%d(死法 2)。处理步骤:

  1. 止血:立刻手动执行一次备份,先把数据留住;
  2. 修脚本:统一使用绝对路径,并加上失败即退出与日志:
#!/bin/bash
set -euo pipefail
export PATH=/usr/local/mysql/bin:/usr/local/bin:/usr/bin:/bin
cd "$(dirname "$0")"

TS=$(date +%Y%m%d)
LOG=/var/log/backup.log
echo "[$(date '+%F %T')] backup start" >> "$LOG"

/usr/local/mysql/bin/mysqldump --single-transaction demo | gzip > "/data/backup/db_${TS}.sql.gz"
test -s "/data/backup/db_${TS}.sql.gz"       # 文件为空则视为失败

echo "[$(date '+%F %T')] backup ok" >> "$LOG"
# crontab:flock 防重叠 + 输出落盘 + 失败告警(用 || 触发通知)
MAILTO=""
30 2 * * * /usr/bin/flock -n /var/lock/backup.lock /data/scripts/backup.sh >> /var/log/backup-cron.log 2>&1 || /data/scripts/notify.sh "backup failed"

根治:给任务加“心跳/失败告警”。脚本成功后,向监控推一条心跳(例如 Pushgateway 或者 Healthchecks 这类服务的“死人开关”式检测:超过约定时间没收到心跳就告警)。这样“无声无息地不跑”会变成“十分钟内有人知道”。

六、顺便:什么时候该用 XXL-JOB

我们的 Java 业务里,与业务强相关的定时任务(对账、过期订单关闭、统计汇总)早就迁到了 XXL-JOB。它把“调度”和“执行”分开:调度中心统一配置和触发,执行器(业务服务)接收调用。对照 cron,痛点对得上:

维度Linux cronXXL-JOB
执行结果可见性只能自己写日志;默认邮件通常没人看调度日志、执行日志在控制台可查,失败有告警
多实例部署每台机器各跑一份,容易重复执行,要自己加锁路由策略(如分片广播、一致性 HASH、故障转移)和阻塞策略(单机串行、丢弃后续、覆盖之前)
失败重试没有,自己写支持失败重试与失败告警
修改与发布登录服务器改 crontab,易出错,无审计控制台修改 cron 表达式,即时生效
适用场景系统运维类:日志清理、备份、证书续期、监控采集业务调度类:与应用代码和数据强相关的任务

两者不是替代关系:运维脚本用 cron 更简单,业务任务用调度平台更可控。但要注意,XXL-JOB 也有它自己的“不跑”:执行器没注册上、调度中心与执行器时间不同步、线程池被占满……排查思路类似——先看有没有触发,再看执行结果。

七、复盘:cron 任务上线检查单

做法为什么
先看 cron 日志判断“触发没有”(/var/log/cron 或 journalctl -u cron)把问题一分为二:没触发查服务/时间/语法,触发了查环境/脚本
输出永远重定向:>> log 2>&1cron 的输出默认走邮件,没配邮件就是黑洞
脚本用绝对路径并显式设置 PATHcron 环境只有 SHELL、LOGNAME、HOME 等少数变量,不会加载 profile
命令里的 % 一律转义,或放进脚本未转义的 % 会被当成换行,命令被截断
确认时区(timedatectl)与时间同步(chronyc tracking)时区错位和时间漂移是“该跑不跑”的常见原因
chmod +x,检查 shebang 与 CRLF“权限不足”“bad interpreter”只会出现在你的输出里
长任务加 flock -n 与 timeout防止任务重叠与雪崩
关键任务加心跳与失败告警;业务任务上调度平台把“安静地失败”变成“会喊的失败”

一句话备忘:cron 不跑 → 先看 cron 日志分“没触发/触发了”;触发了就 >> log 2>&1 看输出;十有八九是 PATH、%、时区、权限。手敲能跑不代表 cron 能跑,因为 cron 的世界里没有你的 profile。

从那以后,我给每个 crontab 里的任务都写了一行注释:“负责人、用途、失败了找谁”。因为定时任务最像家里的闹钟:它响了你未必听见,它没响你一定会迟到。

参考资料

  • crontab(5) 手册:cron 默认设置的环境变量(SHELL、LOGNAME、HOME)、% 未转义会被当作换行、MAILTO 行为、CRON_TZ 与夏令时行为。
  • flock(1) 手册:-n 拿不到锁即失败、-w 限时等待,以及在 cron 中用 flock 防止任务重叠的写法。
  • chronyc 文档(chrony 项目):chronyc tracking 中 System time、Last offset、RMS offset、Leap status 字段以及 sources 输出标记的含义。
  • XXL-JOB 官方站点:介绍分布式任务调度平台的核心组成,包括调度中心、执行器、路由策略、阻塞处理、失败重试、日志与告警等。
— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人