crontab 装了却不可靠:环境、幂等、静默失败与清理的五层检查

站里的定时任务大多数是这样开始的:从某篇教程抄一行 0 3 * * * /usr/bin/php /var/www/backup.php,然后就不再管了。三个月后回头看,会发现日志里有一堆重叠的进程、某些日子任务根本没跑、脚本报错了没人知道、机器上多出十几个 G 的中间文件。定时任务本身不复杂,但它运行在没有终端的环境里,一切靠「默认值」和「环境变量」撑着,出问题时又没人看着,所以失效方式特别隐蔽。

这篇文章不讨论语法,而是把 crontab 当成一个需要运维的服务来看待:它依赖什么环境、什么时候会重复执行、失败了怎么知道、时间本身出问题时它会怎么表现。这五层都过一遍,站里的定时任务才算真正可靠。

一、环境:cron 里没有你习惯的那一切

手工在 SSH 里跑得好好的脚本,放进 crontab 就不工作,第一个原因几乎都是环境变量。cron 执行任务时用的是最小环境:PATH 通常只有 /usr/bin:/bin,HOME 是执行用户的家目录,SHELL 是 /bin/sh,没有 LANG、没有你 .bashrc 里 export 的任何东西,也没有登录时的 profile 脚本(除非你在脚本里自己 source)。

由此会派生出一串具体故障:

  • 命令找不到:node、php 装在 /usr/local/bin 或版本管理器目录下,cron 的 PATH 里没有,于是报 command not found,而日志只记在邮件里,你从来不看邮件。
  • 中文变乱码:没有 LANG,某些工具默认 C 或 POSIX 编码,写出来的报表、日志、导出的 CSV 里中文全是问号或者乱码。
  • 找不到配置文件:脚本里写的是相对路径 ./config.php,手工执行时你在项目目录,cron 执行时工作目录是你的家目录。
  • 时区不对:PHP 与 MySQL 可能各自读系统时区,而 cron 用的又是系统时区。三者不一致时,任务在「你以为的三点」跑,实际是另一个点。

正确的做法不是去猜,而是在 crontab 顶部显式声明环境,然后让脚本自己把路径收敛成绝对路径:

SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
LANG=zh_CN.UTF-8
LC_ALL=zh_CN.UTF-8
HOME=/root
MAILTO=""

# 分 时 日 月 周   命令
0 3 * * * cd /www/scripts && /usr/local/bin/php /www/scripts/backup.php >> /var/log/backup.log 2>&1

三个细节值得强调。cd 再执行,把工作目录钉死;所有可执行文件用绝对路径;>> log 2>&1 让标准输出和标准错误都落到文件里,否则报错只会进系统邮件。MAILTO="" 是显式关掉邮件——如果邮件系统本来就没配好,留着它只是让 cron 每次失败时在 syslog 里多一行 root 用户邮件发送失败的噪音,真正的错误反而被淹没。

排查环境问题的标准姿势:在 crontab 里临时加一条 * * * * * env > /tmp/cron_env.txt 2>&1,一分钟后人手查看实际环境。这比反复猜 PATH 快得多,事后记得删掉。

二、幂等:任务还没跑完,下一次又来了

定时任务最危险的时段是「上一次没跑完,下一次已经到点」。备份脚本跑一次要四十分钟,但你设的是每三十分钟一次;某天数据量翻倍,脚本跑了七十分钟,于是两个进程同时在写同一个文件,压缩包损坏、数据库锁冲突、临时文件互相覆盖,最后表现为「备份文件存在但打不开」。

这类问题的根源是 crontab 只会按时间触发,它不知道上一次是否结束。解决办法是给任务加锁,用 flock 是最省事的一招:

*/30 * * * * /usr/bin/flock -n /var/lock/backup.lock /usr/local/bin/php /www/scripts/backup.php >> /var/log/backup.log 2>&1

-n 表示拿不到锁就立即退出(返回码 1),而不是排队等待。这样每三十分钟只有一个进程在跑,重叠时后来的直接放弃,日志上只会多一行退出码,不会造成任何破坏。如果希望「错过的那次补上」,去掉 -n 让它在后面排队也可以,但要评估排队会不会堆起来。

另一种更常见的幂等问题是「任务重复执行导致重复动作」。比如同步脚本每十分钟拉一次文章,但没有按 URL 去重,同一个文件被插入两遍;比如发邮件通知脚本没记录「已发送」,每次重跑都给全员再发一遍。这类问题不能靠加锁解决,而要在脚本内部做状态判定:先查、再写,必要时用唯一索引兜底(UNIQUE KEY 冲突就跳过而不是报错退出)。

还有一类是「同一天被触发两次」——夏令时切换、@daily 与显式时间并存、或者是加了 two 台机器跑同一份 crontab。前两种在单机上少见,第三种极为常见:换服务器或加备用机时,旧机的 crontab 没删,两个机器同时往外发数据。所以凡是「对外产生副作用」的任务(发邮件、推送、调用第三方 API、写主库),都应该有幂等键或者只在一台机器的 crontab 里存在。

三、失败与告警:没人看的结果就是没跑

cron 的默认失败反馈只有一种:给执行用户发 mail。服务器通常没配 MTA,于是失败信息进了 /var/log/syslog 或者 /var/log/cron 里一行「(CRON) MAIL (mailed N bytes of output)」,几天后被 logrotate 转走,干干净净。你以为任务一直在跑,实际已经挂了两周。

可行的做法是三层递进:

第一层,所有任务都写日志,用统一的目录与命名,比如 /var/log/tasks/backup.log,并在脚本里打印开始时间、结束时间、处理数量和退出码。没有日志的定时任务,出问题时基本无法回溯。

第二层,让脚本用退出码表达成败,exit 0 表示成功,非零表示失败。然后写一个统一的包装器,只在失败时报警:

#!/bin/bash
# /www/scripts/run_task.sh  <任务名> <命令...>
name=$1; shift
start=$(date '+%F %T')
"$@" >> "/var/log/tasks/$name.log" 2>&1
rc=$?
end=$(date '+%F %T')
if [ $rc -ne 0 ]; then
  echo "[$start - $end] $name FAILED rc=$rc" >> /var/log/tasks/alert.log
  # 此处接推送:curl 一条 webhook,或者写进数据库由监控读取
fi
exit $rc

第三层,做心跳而不是等失败。失败告警会漏掉一种情况:任务根本没被触发(cron 服务挂了、crontab 被误删、机器重启后服务没起来)。这时不会有任何失败事件,也就不会有告警。解决办法是让每次成功执行都写一条心跳记录(文件时间戳、数据库一行),再由另一个地方检查「最后一次成功时间距今是否超过预期」。心跳超时的检查本身不能依赖同一台机器的 cron,最简单可靠的是站外的免费监控服务打一个健康检查 URL,指向你的「最近一次成功」时间。

四、时间:它比你以为的更容易出问题

定时任务的语义完全建立在「时间是准的」这个前提上。服务器时间漂移几分钟,任务就会在错误的时间跑;时区配置不一致,任务可能提前或推迟八小时;而改了系统时间后,部分老版本 cron 的行为是按「墙上时间」还是「单调时间」判断,结果可能是同一时段内重复触发或者整天不触发。

实务上需要确认三件事。第一,timedatectl 看系统时区与 NTP 同步状态,确认 System clock synchronized: yes。第二,确认业务涉及的每一层时区一致:系统时区、PHP 的 date.timezone、MySQL 的 time_zone,这三者不一致是最常见的「日志时间差八小时」来源。第三,跨时区场景下(比如你在 UTC 机器上写「凌晨三点」),要么把系统时区改成当地时区,要么在 crontab 顶部加 CRON_TZ=Asia/Shanghai(cronie 支持,部分发行版的 vixie-cron 不支持,需要实测)。

另一个容易被忽略的时间问题是「月与星期的组合语义」。crontab 里日期字段和星期字段同时被指定时,两者是「或」的关系,不是「且」。写 0 3 1 * 1 期望的是「每月一号并且是周一」,实际结果是「每月一号,或者每周一」——一个月最多跑五次。要在某一天并且是某个星期几跑,只能在脚本里判断并立即返回。

五、清理:定时任务自己会制造垃圾

定时任务跑得越久,它产生的文件越多:日志、临时文件、导出的 CSV、重复的备份、失败留下的半截压缩包。它们不会被自动清理,通常要等到磁盘报警(那时候排查思路是另一篇的内容)才被发现。所以每个产生文件的定时任务,都应该自带清理逻辑,或者搭配一个专门负责清理的任务。

清理任务的写法要注意三点。用 find 目录 -type f -mtime +7 -delete 时必须先在同样的条件上跑一次 -print,确认命中的文件就是你想要删的,尤其是带 -maxdepth 与目录层级时;不要用 rm -rf 目录/* 配合空变量,历史上无数事故来自变量为空时 rm -rf /* 被执行;保留策略按「天数 + 最小份数」双重约束更稳,即「超过七天删除,但至少保留最近三份」,避免某段时期全部失败导致把仅存的旧备份也清掉。

# 日志按天保留 14 天
find /var/log/tasks -type f -name '*.log' -mtime +14 -print -delete

# 备份:超过 7 天且不是最近 3 份才删
ls -1t /www/backup/*.tar.gz | tail -n +4 | xargs -r ls -1 --time-style=+%s | \
  awk -v cut="$(date -d '-7 days' +%s)" '{ if ($6 < cut) print $7 }'

第二段脚本特意保留了最近三份不动,就是在防范「连续失败一周后把历史全删空」这种灾难。写到生产环境前,把 print 换成 echo 跑一遍看输出,确认无误再改成真删除。

六、上线检查清单

新加一条定时任务时,按下面顺序过一遍,基本可以把上面五类问题堵住:

  1. crontab 顶部有 SHELL、PATH、LANG,可执行文件用绝对路径,输出重定向到日志。
  2. 任务重跑不会产生重复数据或重复副作用;必要时用 flock -n 加锁。
  3. 脚本对失败有明确退出码,统一包装器只在失败时告警。
  4. 有成功心跳,且心跳由站外监控检查,防止「任务没被触发」这种静默失败。
  5. 确认系统、PHP、MySQL 时区一致,日期与星期字段没有误用「或」语义。
  6. 任务产生的文件有清理策略,清理脚本先 dry-run 验证命中范围。

手工验收时,最有效的一招是把时间设成 * * * * * 让它每分钟跑一次,观察两到三次完整执行的日志与副作用,确认无误后改回真实时间。这比在脑子里推演半小时靠谱得多,也是我把这类问题从「偶发故障」变成「一次配好」的转折点。

Last modification:November 11th, 2026 at 08:24 pm

Leave a Comment