定时任务的错误处理:静默失败最可怕
凌晨 03:00 的 cron 任务如果没有日志、返回码和告警,第二天很难确认它是否真的完整执行过。
定时任务最麻烦的不是直接报错,而是静默失败:进程退出了、输出被吞掉了、脚本只跑了一半、依赖服务超时了,但表面看起来一切正常。数据没更新,备份不完整,通知也没发出去;等到有人发现时,通常已经错过了处理窗口。
静默失败为什么难查
因为定时任务执行时通常没人盯着。
手动执行时会看终端输出;定时执行时,很多配置只是一行命令,能跑就算完成。结果往往是:
- 标准输出和错误输出没人接
- 返回码没人检查
- 跑成功和跑一半,外部表现接近
- 下一次任务继续覆盖现场
这里先统一一个概念:返回码就是程序结束时带出的数字,0 通常表示成功,非 0 表示失败。
如果任务没有明确记录“开始、结束、成功、失败”,出问题时基本只能猜。
最低限度,先把日志写对
很多问题不是不会处理,而是没有留下证据。
不要只写:
1 | |
至少改成这样:
1 | |
这行的意思:
>>:把标准输出追加到日志2>&1:把标准错误也写进同一个日志
但这还不够。日志不能只是程序顺手打印几句,至少要包含:
- 开始时间
- 结束时间
- 关键步骤
- 失败位置
- 最终状态
例如:
1 | |
LOG_FILE=”/var/log/job.log”
log() {
echo “[$(date ‘+%F %T’)] $*” >> “$LOG_FILE”
}
trap ‘log “FAILED at line $LINENO”; exit 1’ ERR
log “job started”
/path/to/task-a
/path/to/task-b
log “job finished”
这里有三个关键点。
set -Eeuo pipefail
这是 shell 中常用的一组严格模式,作用是尽早失败。
-e:某一步失败,脚本直接退出-u:使用未定义变量时报错-o pipefail:管道前面的命令失败时,整体也算失败
默认 shell 对错误很宽容,宽容到脚本出错后还可能继续往下跑,这正是静默失败的常见来源。
trap ... ERR
它相当于一个出错钩子。命令失败时会写一条明确日志,至少能定位到大致失败位置。
明确的开始和结束标记
只有“有日志”还不够,还要能判断任务是否完整跑完。
如果只有 started 没有 finished,通常说明中途退出了。
别只记录报错,也要记录结果
错误处理和幂等性是连在一起的:任务失败后能不能安全重跑,决定了后续是否容易补救。
“幂等”可以简单理解为:同一个脚本执行两次,结果不要越跑越乱。
例如下载、同步、生成报表这类任务,最好做到:
- 重跑不会重复写入
- 失败后能从中间恢复,或安全重来
- 成功标记只在最后一步写入
常见做法是使用临时文件,全部完成后再原子替换。也就是先写到 file.tmp,成功后再 mv 成正式文件。这样即使中途失败,也不会留下半成品。
1 | |
generate_data > “$tmp”
mv “$tmp” “$final”
mv 在同一文件系统中通常可作为原子替换使用;跨文件系统场景需单独验证。
通知要克制,但不能没有
很多人的第一反应是:失败就发消息。方向没错,但容易变成报警风暴。
更稳妥的做法是分层处理:
- 任务失败:发通知
- 连续失败多次:升级通知
- 长时间没有执行:单独告警
“长时间没有执行”很重要,因为有些问题不是脚本报错,而是任务根本没触发,例如调度器异常、权限变更、时间配置错误。这类问题只看业务日志可能完全看不到。
如果暂时没有监控系统,一个简单办法是:每次成功执行后写一个心跳文件,再检查它的更新时间。
1 | |
然后用另一个检查脚本判断它是否超过阈值。方法简单,但足以覆盖“压根没跑”的场景。
日志不是越多越好,而是要能回答问题
出事后通常只需要回答三个问题:
- 有没有跑起来?
- 跑到哪一步失败?
- 失败后系统处于什么状态?
所以日志不要堆太多噪音,重点记录关键节点,例如:
- 输入来源
- 处理数量
- 输出位置
- 耗时
- 返回码
如果脚本会调用外部服务,还要把超时写死,不要无限等待。
1 | |
这里 --max-time 30 表示 30 秒超时,--fail 表示 HTTP 返回异常状态时直接按失败处理,不把错误页面当作成功结果。
可先落地的底线配置
如果现在只改一件事,可以先补齐这几项:
- 打开严格模式
- 合并标准输出和错误输出
- 记录开始、结束和失败
- 给失败加通知
- 给任务加超时
这些改动不复杂,但足以减少大部分“悄悄坏掉”的情况。
如果后面还要继续完善,可以再考虑统一任务包装、接入 systemd timer、集中日志或监控系统。