Shell 脚本跑完 exit 0,下游一看核心表是空的——你知道是哪个坑吗?
本文目录
先说结论
第一条:任何生产 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年大数据老兵,聊技术、聊职场、聊成长