用 trap ERR 级联并区分命令退出的非零状态做分批失败补跑

批量任务脚本最怕的不是失败,而是失败得不明不白。一个循环里跑了 200 个对象,第 37 个因为网络抖动挂了,第 88 个因为鉴权过期挂了,第 150 个因为目标已存在挂了——三种原因,三种处理方式:第一种该重试,第二种该中断整个批次去换凭证,第三种根本不该算失败。可现实中大多数脚本只写了一句 || exit 1,于是所有失败糊成一团,运维只能翻日志人肉还原

批量任务脚本最怕的不是失败,而是失败得不明不白。一个循环里跑了 200 个对象,第 37 个因为网络抖动挂了,第 88 个因为鉴权过期挂了,第 150 个因为目标已存在挂了——三种原因,三种处理方式:第一种该重试,第二种该中断整个批次去换凭证,第三种根本不该算失败。可现实中大多数脚本只写了一句 || exit 1,于是所有失败糊成一团,运维只能翻日志人肉还原现场。

本文解决的就是这个问题:用 Shell 的 trap ... ERR 建立错误级联机制,在任意深度的函数调用中捕获失败,并把「哪条命令、哪个退出码、哪个批次对象」记录下来;再按退出码分类,决定是重试、跳过还是中止;最后用一份失败清单驱动补跑,只跑失败的,不重跑成功的。

先搞清楚:ERR 陷阱到底在什么时候触发

很多脚本写了 trap 'echo fail' ERR 却从来没被触发过,原因通常是对触发条件理解有偏差。bash 手册里的规则可以归纳成四条:

  • 只有「简单命令」返回非零状态时才触发,且该命令不能处于 if、while、until、&&、||、! 的测试位置——因为这些位置本身就「期望」非零。
  • 函数被调用时如果返回非零,ERR 陷阱也会触发(函数整体视作一条命令),但在函数体内部,只有真正失败的那条命令会触发一次,默认不会层层冒泡。
  • 命令替换 $(...) 里的失败默认不触发外层陷阱,除非开启 set -E(errtrace)。
  • 陷阱触发后如果处理函数自己不 exit,脚本会继续往下跑,这在批处理里极其危险——你以为失败了会停,其实它在裸奔。

要让错误从最内层函数一路冒泡到顶层处理器,必须同时打开两个开关:

#!/usr/bin/env bash
set -Eeuo pipefail
shopt -s inherit_errexit   # bash 4.4+:命令替换内也遵守 -e

逐条解释:-E(等价 set -o errtrace)让 ERR 陷阱被函数、命令替换、子 shell 继承,这是级联的前提;-e 让命令失败即退出;-u 让未定义变量报错,避免 rm -rf $DIR/ 这类事故;-o pipefail 让管道中任意一段失败都能被感知,否则 cmd | tee log 永远返回 tee 的 0。inherit_errexit 是 bash 4.4 才有的,老系统上要先用 bash --version 确认,CentOS 7 自带的是 4.2,这一行会报 shopt: inherit_errexit: invalid shell option name,解决办法是把脚本 shebang 换成 #!/usr/bin/env bash 并确保 PATH 里的 bash 是新版,或者干脆用 LC_ALL=C bash -c 显式指定。

让陷阱处理器拿到足够的现场信息

ERR 陷阱里能拿到的关键信息有四个:$? 是失败命令的退出码,$BASH_LINENO、$BASH_SOURCE、$FUNCNAME 三个数组共同构成调用栈。$BASH_COMMAND 则是当时正在执行的命令文本。写一个可复用的处理器:

on_error() {
  local rc=$?
  local i
  {
    printf 'ERROR rc=%d cmd=%q\n' "$rc" "$BASH_COMMAND"
    printf 'stack:\n'
    for ((i = 0; i < ${#FUNCNAME[@]} - 1; i++)); do
      printf '  #%d %s() at %s:%s\n' \
        "$i" "${FUNCNAME[$i]:-MAIN}" \
        "${BASH_SOURCE[$i+1]:-?}" "${BASH_LINENO[$i]:-?}"
    done
  } >> "$ERR_LOG"
  LAST_RC=$rc
  return "$rc"
}
trap 'on_error' ERR

注意处理器末尾的 return "$rc":陷阱函数返回非零,配合 set -e 才能让脚本终止。如果写成 return 0,错误就被吞掉了。

实际输出长这样,可以直接贴进工单:

ERROR rc=7 cmd=curl -fsS --max-time 5 http://10.0.3.11:8080/api/sync
stack:
  #0 do_sync() at batch.sh:41
  #1 run_chunk() at batch.sh:58
  #2 main() at batch.sh:73

rc=7 是 curl 的「Failed to connect to host」,一看就知道是目标不可达而不是业务拒绝。

给退出码建立语义分层,而不是只判断非零

非零有 1 到 255 共 255 种可能,全部平等对待等于没区分。工程上有效的做法是先划区间,再对区间内的具体码做映射。下面这张表覆盖了批处理脚本里最常见的几类来源:

退出码 典型来源 语义分类 处置策略
0 任意 成功 记录成功,不补跑
1 grep 未匹配、通用错误 业务性 跳过或人工确认
2 bash 语法/内建命令误用 致命 立即中止整批
5 curl 代理解析失败 环境 中止并告警
6 curl 域名解析失败 环境 中止并告警
7 curl 连接失败 瞬时 退避重试
22 curl HTTP ≥400(-f) 业务性 按 HTTP 状态细分
28 curl 超时 瞬时 退避重试
35 curl TLS 握手失败 环境 中止并告警
126 命令存在但不可执行 致命 中止整批
127 命令不存在 致命 中止整批
137 被 SIGKILL(OOM 常见) 资源 降并发后补跑

映射成策略函数:

classify() {
  local rc=$1
  case $rc in
    0)              echo ok ;;
    7|28|56)        echo transient ;;
    1|22)           echo business ;;
    2|126|127|130)  echo fatal ;;
    137)            echo resource ;;
    *)              echo unknown ;;
  esac
}

这里有个坑:set -o pipefail 下管道失败时 $? 是「最后失败的那个命令」的码,通常是 1,信息量很低。如果管道里跑的是 curl,就不要靠管道,把结果写临时文件再读:

tmp=$(mktemp)
if ! curl -fsS --max-time 10 -o "$tmp" "${url}"; then
  rc=$?
  # 此处 rc 是 curl 真正的退出码,不是管道的
  record_failure "$key" "$rc"
fi

分批执行:把失败落盘成可重放的清单

核心结构是「外层遍历批次、内层遍历对象、失败即写清单」。清单必须是机器可读且可幂等重放的,推荐 TSV:对象键 \t 退出码 \t 分类 \t Unix 时间戳。写清单要用 printf 而不是 echo,避免制表符被折叠。

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

WORKDIR=${WORKDIR:-/var/tmp/syncjob}
FAIL_FILE="$WORKDIR/failed.tsv"
DONE_FILE="$WORKDIR/done.tsv"
ERR_LOG="$WORKDIR/error.log"
MAX_RETRY=${MAX_RETRY:-3}
mkdir -p "$WORKDIR"

on_error() {
  local rc=$?
  printf '%s\t%s\t%s\n' "UNKNOWN" "$rc" "$(date +%s)" >> "$FAIL_FILE"
  printf '[%s] rc=%d cmd=%s\n' "$(date -Is)" "$rc" "$BASH_COMMAND" >> "$ERR_LOG"
  return "$rc"
}
trap on_error ERR

sync_one() {
  local key=$1 url=$2
  local out rc=0
  out=$(curl -fsS --connect-timeout 3 --max-time 15 "$url") || rc=$?
  if ((rc == 0)); then
    printf '%s\n' "$key" >> "$DONE_FILE"
    return 0
  fi
  printf '%s\t%d\t%s\t%d\n' "$key" "$rc" "$(classify "$rc")" "$(date +%s)" >> "$FAIL_FILE"
  return 0   # 单条失败不算整批失败,交给人/补跑阶段处理
}

run_batch() {
  local batch=$1
  while IFS=$'\t' read -r key url; do
    [[ -z $key ]] && continue
    sync_one "$key" "$url"
  done < "$batch"
}

关键设计:sync_one 内部对可预期失败「消化」掉并返回 0,这样 set -e 不会终止整个循环;只有 sync_one 自身出了意外(比如临时文件创建失败、赋值语法错误)才会冒泡到 ERR 陷阱。这正好实现了「业务失败按条记录、脚本级故障整体中止」的分层。

注意 ((rc == 0)) 这种写法在 set -e 下是安全的,因为它在 if 条件位置;但如果写成独立语句 ((rc++)),当 rc 为 0 时算术结果非真会返回 1,直接触发 ERR 陷阱退出脚本——这是无数人踩过的坑,改成 rc=$((rc+1)) 即可。

补跑阶段:只读失败清单,按分类决定重试与否

补跑脚本要做三件事:读 failed.tsv、按分类过滤、对 transient 做指数退避重试,成功后从清单里摘掉。清单的去重与摘除用临时文件 + mv 原子替换,避免并发或中断导致清单损坏。

retry_transient() {
  local key=$1 url=$2 attempt=0
  local delay=2
  while ((attempt < MAX_RETRY)); do
    if curl -fsS --connect-timeout 3 --max-time 15 "$url" >/dev/null; then
      return 0
    fi
    local rc=$?
    attempt=$((attempt+1))
    case $(classify "$rc") in
      transient) ;;
      *) return "$rc" ;;   # 非瞬时的立刻放弃,省时间
    esac
    ((attempt < MAX_RETRY)) && sleep "$delay"
    delay=$((delay*2))
  done
  return 1
}

rerun() {
  local tmp; tmp=$(mktemp)
  local still=0
  while IFS=$'\t' read -r key rc cls ts; do
    [[ -z $key ]] && continue
    if [[ $cls != transient ]]; then
      printf '%s\t%s\t%s\t%s\n' "$key" "$rc" "$cls" "$ts" >> "$tmp"
      still=$((still+1)); continue
    fi
    if retry_transient "$key" "${URL_MAP[$key]}"; then
      printf '%s\t%s\n' "$key" "recovered@$(date +%s)" >> "$DONE_FILE"
    else
      printf '%s\t%s\t%s\t%s\n' "$key" "$rc" "$cls" "$ts" >> "$tmp"
      still=$((still+1))
    fi
  done < "$FAIL_FILE"
  mv "$tmp" "$FAIL_FILE"
  echo "remaining=$still"
  ((still == 0))
}

这里的 URL_MAP 需要从原始清单重建,实际工程里更稳妥的做法是把 URL 直接写进 failed.tsv 的第一列之后,或者存一个 key -> url 的映射文件,避免关联数组在老 bash 上不可用(bash 4.0 才支持关联数组)。

最大重试次数为什么要限制成 3

指数退避 2s、4s、8s,单条最坏耗时约 14 秒,与 --max-time 15 同量级。200 条全失败的最坏耗时超过 45 分钟,这在运维窗口里不可接受。真实经验是:瞬时故障(连接拒绝、超时)在 3 次重试后的恢复率已经趋于平坦,第 4 次之后的收益极小,而故障往往需要人工介入(比如目标端重启)。把 MAX_RETRY 做成环境变量,让窗口紧的批次调到 1 或 2。

并发时的陷阱:trap 不会自动保护子进程

为了提速,很多人会在循环里塞 & 并发跑。此时 ERR 陷阱只在父 shell 里生效,子 shell 的失败既不会触发父陷阱,也不会自动写清单——除非你在子 shell 内单独装陷阱。

sync_one() {
  local key=$1 url=$2
  trap 'on_error' ERR
  # ... 实际逻辑
}
export -f sync_one
export -f on_error
export FAIL_FILE ERR_LOG

printf '%s\n' "${keys[@]}" | xargs -r -P "${CONC:-8}" -I{} bash -c 'sync_one "$@"' _ {}

几个必须注意的点:xargs -I{} 隐含了 -L1,如果键里有空格会分裂,安全写法是用 -d '\n'(GNU xargs 支持);export -f 依赖 bash,/bin/sh 是 dash 的 Debian 系上 bash -c 必须显式写;多个进程同时 >> "$FAIL_FILE" 在单行不超过 PIPE_BUF 时可以认为原子,但为保险起见建议每个子进程写独立文件 $FAIL_FILE.$PID,主进程最后 cat 合并。另外 -P 太高会把目标端打挂,从 4 到 8 起步,观察 5xx 比例再调。

并发跑失败的另一个隐形来源是 xargs 自身:当子命令被信号杀死时,xargs 退出码是 123/124/125,而不是子命令的码。所以不要把 xargs 放在需要精确退出码的位置,用 -P 时以清单文件为准。

小结

整套机制的骨架就是三层:set -Eeuo pipefail 打开级联;trap on_error ERR 抓现场并写入结构化日志;分类函数把退出码映射成 transient/business/fatal/resource 四种策略。业务内失败被函数内部消化成清单行,脚本级故障由陷阱中止,补跑阶段只吃清单,按分类决定重试与否。这样做的价值在于:失败不再是「退出码 1」,而是一条带退出码、分类、时间戳、调用栈的可重放记录。

FAQ

开了 set -e 脚本还是没停,为什么

最常见三种原因:一是 set -e 写在某个函数内部,作用域没覆盖到后面;二是失败命令处于 if/while/&&/||/! 的测试位置,按规范本就不触发;三是用了 cmd | tee log 却没开 pipefail,退出码被 tee 覆盖成 0。还有一种隐蔽情况:函数作为条件调用(if f; then),函数体内的 set -e 会被整体禁用。

trap ERR 里报 command not found,但脚本里明明有这个命令

检查 PATH 是否在陷阱触发前被修改,以及命令是否依赖某个尚未 source 的函数。另一个高频原因是 trap 'on_error' ERR 写在了 on_error 定义之前,此时陷阱捕获的是空字符串,执行时报 on_error: command not found,退出码 127,进而再次触发陷阱形成递归——务必先定义函数再 trap。

ERR 陷阱被触发了两次甚至多次

典型是陷阱函数内部又执行了失败命令(比如往只读目录写日志),导致处理器自己失败、再次触发。解决方式是陷阱函数第一行就 trap - ERR 临时解除,最后再 trap 'on_error' ERR 恢复;或者保证处理器内部所有命令都加 || true。

bash 4.2 上 inherit_errexit 报 invalid shell option name

该选项 4.4 引入。替代方案是把命令替换改成临时文件:out=$(cmd) 改写为 cmd > "$tmp",再用 $? 判断;或者在使用命令替换的地方显式检查:out=$(cmd) || exit $?。升级 bash 后记得 shopt -s inherit_errexit 要放在脚本顶部而不是函数里。

退出码 137 频繁出现,怎么定位

137 = 128 + 9,表示被 SIGKILL。在容器里基本等同于 OOM。用 dmesg -T | grep -i 'killed process' 或 journalctl -k | grep -i oom 确认;容器环境查 /sys/fs/cgroup/memory/memory.failcnt 或 cgroup v2 的 memory.events。处置策略不是重试而是降并发或缩小单批处理量,直接无脑补跑只会再被杀一次。