定时任务的错误处理:静默失败最可怕

凌晨 03:00 的 cron 任务如果没有日志、返回码和告警,第二天很难确认它是否真的完整执行过。

定时任务最麻烦的不是直接报错,而是静默失败:进程退出了、输出被吞掉了、脚本只跑了一半、依赖服务超时了,但表面看起来一切正常。数据没更新,备份不完整,通知也没发出去;等到有人发现时,通常已经错过了处理窗口。

静默失败为什么难查

因为定时任务执行时通常没人盯着。

手动执行时会看终端输出;定时执行时,很多配置只是一行命令,能跑就算完成。结果往往是:

  • 标准输出和错误输出没人接
  • 返回码没人检查
  • 跑成功和跑一半,外部表现接近
  • 下一次任务继续覆盖现场

这里先统一一个概念:返回码就是程序结束时带出的数字,0 通常表示成功,非 0 表示失败。

如果任务没有明确记录“开始、结束、成功、失败”,出问题时基本只能猜。

最低限度,先把日志写对

很多问题不是不会处理,而是没有留下证据。

不要只写:

1
0 3 * * * /path/to/job.sh

至少改成这样:

1
0 3 * * * /path/to/job.sh >> /var/log/job.log 2>&1

这行的意思:

  • >>:把标准输出追加到日志
  • 2>&1:把标准错误也写进同一个日志

但这还不够。日志不能只是程序顺手打印几句,至少要包含:

  • 开始时间
  • 结束时间
  • 关键步骤
  • 失败位置
  • 最终状态

例如:

1
2
#!/usr/bin/env bash
set -Eeuo pipefail

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
2
tmp="/data/result.tmp"
final="/data/result.json"

generate_data > “$tmp”
mv “$tmp” “$final”

mv 在同一文件系统中通常可作为原子替换使用;跨文件系统场景需单独验证。

通知要克制,但不能没有

很多人的第一反应是:失败就发消息。方向没错,但容易变成报警风暴。

更稳妥的做法是分层处理:

  • 任务失败:发通知
  • 连续失败多次:升级通知
  • 长时间没有执行:单独告警

“长时间没有执行”很重要,因为有些问题不是脚本报错,而是任务根本没触发,例如调度器异常、权限变更、时间配置错误。这类问题只看业务日志可能完全看不到。

如果暂时没有监控系统,一个简单办法是:每次成功执行后写一个心跳文件,再检查它的更新时间。

1
touch /path/to/job.heartbeat

然后用另一个检查脚本判断它是否超过阈值。方法简单,但足以覆盖“压根没跑”的场景。

日志不是越多越好,而是要能回答问题

出事后通常只需要回答三个问题:

  1. 有没有跑起来?
  2. 跑到哪一步失败?
  3. 失败后系统处于什么状态?

所以日志不要堆太多噪音,重点记录关键节点,例如:

  • 输入来源
  • 处理数量
  • 输出位置
  • 耗时
  • 返回码

如果脚本会调用外部服务,还要把超时写死,不要无限等待。

1
curl --fail --max-time 30 https://example.invalid/api

这里 --max-time 30 表示 30 秒超时,--fail 表示 HTTP 返回异常状态时直接按失败处理,不把错误页面当作成功结果。

可先落地的底线配置

如果现在只改一件事,可以先补齐这几项:

  • 打开严格模式
  • 合并标准输出和错误输出
  • 记录开始、结束和失败
  • 给失败加通知
  • 给任务加超时

这些改动不复杂,但足以减少大部分“悄悄坏掉”的情况。

如果后面还要继续完善,可以再考虑统一任务包装、接入 systemd timer、集中日志或监控系统。


定时任务的错误处理:静默失败最可怕
https://ghost.kasumi.live/2026/04/21/定时任务的错误处理:静默失败最可怕/
作者
Amadeus
发布于
2026年4月21日
许可协议