订阅更新

Shell 脚本跑完 exit 0,下游一看核心表是空的——你知道是哪个坑吗?

Linux手册 第 11 题 约 11 分钟读完

本文目录
  1. 先说结论
  2. 场景还原:那个凌晨的'假成功'
  3. 防崩三件套(1):set -euo pipefail 到底做了什么
  4. 防崩三件套(2):trap 捕捉退出 & 信号,让失败可被感知
  5. 防崩三件套(3):子 Shell 作用域 & while read 的隐蔽坑
  6. 三种常见 Shell 风格对比
  7. 面试高频追问
  8. 实战踩坑:事故复盘后我改了什么
  9. 总结

先说结论

第一条:任何生产 Shell 脚本开头必须写 set -euo pipefail,不写这行等于自愿放弃一切报错。

第二条:管道 exit code 默认只看最后一个命令,pipefail 必须手动开;循环读变量要警惕子 Shell 的作用域问题。

第三条:必须用 trap 捕捉 EXIT/ERR,保证失败时能写日志、发告警、释放锁——这是长期可维护脚本的底线。

先把这段讲顺;面试官追问时,再展开下面的原理和细节。

详细讲解

这道题几乎是所有数据/运维岗的必考题。我在某短剧出海公司踩过最离谱的一次:凌晨 2 点的报表脚本'跑完'了,退出码 0,crontab 没报错,但第二天运营打开报表——全 0。查了两个小时才发现,中间一个 hive -e 被 YARN 杀了 OOM,但后面套了个 tee 管道,tee 把错误吞成了 0,整个 pipeline 继续往下跑,把空中间结果灌进了 MySQL。

面试官喜欢这道题,是因为它一句话就能筛出——你是写过一次性脚本的'玩家',还是真的在生产里维护过定时链路的'老兵'。

场景还原:那个凌晨的'假成功'

某短剧出海公司,我负责的一条离线链路大概长这样:ODS 同步 → hive 跑 DWD → hive 跑 DWS → 结果 tee 到日志 → sink 到 MySQL 给报表。5 步串成一个 run.sh,crontab 每天 02:30 跑。

出事那天凌晨,DWS 这一步因为新加的 UDF 内存翻倍,被 YARN 的 container killer 杀了。关键问题在这一步外面套了个管道:hive -e '...' | tee -a /tmp/run.log。tee 永远返回 0,整条管道的 exit code 被硬生生吞成 0,脚本继续走下一步 sink,拿到的是空中间表,直接把 MySQL 的结果表覆盖成了空。

bash - 错误示范

#!/bin/bash
# 这是一个典型的'看起来能跑、生产坑你没商量'的脚本
 
cd /data/job                             # cd 失败不会退出
hive -e 'INSERT OVERWRITE TABLE dws...' | tee -a /tmp/run.log
# ↑ 管道最后一段是 tee,tee 永远返回 0
# ↑ hive 的错误被完整吞掉,$? 就是 0
 
mysql -h$HOST -e 'LOAD DATA ...' < /tmp/export.tsv
# ↑ 上一步哪怕空数据,这里照样跑,报表被覆盖成 0
 
echo 'done' >> /tmp/run.log
exit 0

这就是为什么面试时我反复强调 set -euo pipefail——它是 Shell 脚本的安全带,不系不会马上出事,出事就是大事。

防崩三件套(1):set -euo pipefail 到底做了什么

这是一个组合拳,一次开 4 个开关,每个开关都对应一种真实生产事故:

#!/bin/bash
set -euo pipefail
# -e          任何命令非 0 退出码立即终止脚本(除非显式 || true)
# -u          引用未定义变量报错(防 rm -rf $EMPTY/ 惨案)
# -o pipefail 管道里任一段失败,整个管道就失败
# -x (可选)    打印每一行被执行的命令,调试利器
 
# 现在这个脚本就是'铁皮保险柜'了:
cd /data/job                               # cd 失败立即退出
hive -e 'SELECT ...' | tee -a run.log      # hive 失败,整条管道失败
echo "${UNDEFINED_VAR}"                    # 未定义变量直接报错

特别注意:pipefail 不是默认行为。Bash 默认只看管道最后一个命令的 exit code,这是历史上数不清'脚本吃错'的根源。写生产脚本,pipefail 不是可选项,是必选项。

-e 的陷阱:set -e 遇到 if/while/until 的条件命令、|| 或 && 连接的前半段命令不退出——这是设计意图,但也意味着你不能光靠 -e,还需要对关键步骤显式检查退出码。

防崩三件套(2):trap 捕捉退出 & 信号,让失败可被感知

光 set -e 还不够。脚本失败了,谁通知你?日志谁来写?锁谁来释放?这就是 trap 要干的活。

#!/bin/bash
set -euo pipefail
 
LOCK=/tmp/run.sh.lock
exec 9> "$LOCK"
flock -n 9 || { echo 'already running'; exit 1; }
 
cleanup() {
  local code=$?
  if [ $code -ne 0 ]; then
    echo "[FAIL] exit=$code line=$LINENO" >&2
    curl -sS -X POST "$ALERT_URL" -d "text=run.sh 失败 exit=$code"
  fi
  rm -f /tmp/job_*.tmp
  flock -u 9
}
trap cleanup EXIT
trap 'echo "[ERR ] line $LINENO: $BASH_COMMAND"' ERR
 
# 业务逻辑从这里开始,不管怎么退出都会走 cleanup
hive -e 'INSERT ...' | tee -a run.log

为什么这么写:trap EXIT 是'无论如何一定会跑'的收尾回调,配合 $? 判断成败,能统一做告警/清理/解锁。trap ERR 则在错误发生时打印上下文,比 set -x 更精准定位是哪一行出的问题。

加 flock 的原因:crontab 里最容易的坑是——上一次没跑完,下一次已经启动。两个进程同时写同一张表、同一个文件,数据必定错乱。flock 是我用了 8 年最省心的锁方案,进程被 kill -9 都会自动释放。

防崩三件套(3):子 Shell 作用域 & while read 的隐蔽坑

这是最隐蔽的一个坑,面试时问出来能当场筛掉 80% 的候选人。看这段代码:

bash - 你以为的结果

# 统计文件里非空行数
count=0
cat input.txt | while read line; do
  if [ -n "$line" ]; then
    count=$((count + 1))
  fi
done
echo "总行数: $count"   # 永远输出 0

为什么永远是 0?因为 cat ... | while 管道会开一个子 Shell,循环体里对 count 的修改全在子 Shell 里,回到父 Shell 一看——根本没改过。

bash - 正确写法

# 方法一:用重定向代替管道,循环留在父 Shell 执行
count=0
while IFS= read -r line; do
  [ -n "$line" ] && count=$((count + 1))
done < input.txt
echo "总行数: $count"
 
# 方法二:Bash 4+ 可以 shopt -s lastpipe(需关闭作业控制)
shopt -s lastpipe
set +m
count=0
cat input.txt | while read line; do count=$((count+1)); done
echo "$count"

面试加分项:能说出'Bash 的管道会开子 Shell,变量改不回来'这一句话,面试官基本就会默认你是写过生产脚本的人——因为这个坑只有踩过才知道。

三种常见 Shell 风格对比

维度', '裸跑脚本', '只 set -e', '安全带全开
报错感知', '靠人眼看日志', '遇错即停', '遇错+trap 告警
管道吞错', '会吞', '会吞', 'pipefail 不吞
未定义变量', '展开为空', '展开为空', '-u 直接报错
并发冲突', '无防护', '无防护', 'flock 串行
失败告警', '无', '无', 'trap 统一出口
适用场景', '一次性跑', '本地调试', '生产定时

面试高频追问

追问一:set -e 有什么已知的坑?加了就一定安全吗?

不是。set -e 有几个不触发退出的场景必须背下来:一是在 if/while/until 条件里的命令失败不退出;二是用 || 或 && 连接的前半段失败不退出;三是函数内部的失败在不同 Bash 版本行为不完全一致。所以 set -e 只是基础防线,关键步骤我还是会显式 cmd || { echo fail; exit 1; } 或配合 trap ERR。生产上通常 set -euo pipefail + trap ERR 一起用,做两层防护。

追问二:你怎么让一个 Shell 脚本'只能同时跑一个实例'?

三种方法我都用过:第一种是 flock,最推荐,内核级锁,进程被 kill -9 也会自动释放;第二种是 PID 文件 + kill -0 检查,老派但兼容老系统;第三种是 lockfile 命令(procmail 提供)。生产我只用 flock,写法是 exec 9> /tmp/xxx.lock; flock -n 9 || exit 1,简单直接。千万别用'判断文件存在就退出'这种土办法——一次 Ctrl+C 锁就永远留在那儿了。

追问三:脚本里有一个 for 循环读 5000 个文件,跑到一半失败了怎么做断点续跑?

我的习惯做法是:把每一次成功处理的文件名追加写到一个 done.list,下次启动用 comm -23 <(ls | sort) <(sort done.list) 算出未处理的。这比在数据库里打标记简单,比在内存里记状态可靠——因为文件落盘,脚本挂了也不会丢。某短剧出海公司一个批量 OSS 下载脚本就是这么写的,跑了 3 年没出过事。

实战踩坑:事故复盘后我改了什么

那次'空数据覆盖报表'事故复盘后,我们团队给所有凌晨批跑脚本定了一个统一的 run.sh 模板,规定所有新脚本必须从这个模板出发。模板长这样:

bash - 生产模板

#!/bin/bash
set -euo pipefail
IFS=$'\n\t'
 
JOB_NAME=$(basename "$0" .sh)
LOG=/data/logs/${JOB_NAME}_$(date +%F).log
LOCK=/tmp/${JOB_NAME}.lock
exec 9> "$LOCK"
flock -n 9 || { echo "[$(date)] already running"; exit 1; }
 
exec > >(tee -a "$LOG") 2>&1
 
on_exit() {
  code=$?
  if [ $code -ne 0 ]; then
    curl -sS -X POST "$ALERT_WEBHOOK" \
      -d "{\"text\":\"[FAIL] $JOB_NAME exit=$code\"}"
  fi
  exit $code
}
trap on_exit EXIT
 
echo "[START] $JOB_NAME $(date)"
 
# ====== 业务逻辑从这里开始 ======
hive -e 'INSERT OVERWRITE TABLE dws_xxx ...'
hive -e 'INSERT OVERWRITE TABLE ads_xxx ...'
mysqlimport ... < /tmp/result.tsv
 
echo "[DONE ] $JOB_NAME $(date)"

上线效果:模板推广后,凌晨 0-6 点的脚本相关告警从原来每周 3-4 次,掉到每月 1 次以内。最大的收益其实不是少出错——而是'出错必告警',再也不会出现'静默失败,第二天被业务投诉'的尴尬。

总结

Shell 脚本不是写给自己看的一次性玩具,生产脚本是要在凌晨 3 点你睡觉的时候替你干活的。set -euo pipefail + trap + flock 这三板斧是底线,不写就等着出事。面试时你就这么说:'我写生产 Shell 脚本有三条铁律,第一开头必写 set -euo pipefail,第二用 trap 接管 EXIT 做告警和清理,第三用 flock 防并发重跑'——面试官基本就知道你是干过活的人了。

下一篇预告:grep 全量 30G 日志把服务器 OOM——生产日志切片的正确姿势。

你写过最离谱的 Shell 脚本 bug 是什么?欢迎在评论区吐槽,我挑几个有意思的在下一篇里讲讲怎么避。

关注「Wood聊大数据」| 9年大数据老兵,聊技术、聊职场、聊成长

刷题进度保存在当前浏览器