Linux-05 Shell 编程与脚本工程化
前置阅读:Linux-04 文本处理三剑客与正则
后端工程师写 Shell 脚本的场景很固定:部署、备份、日志清理、健康检查、CI 步骤。这些脚本有个共同特点——跑在无人值守的环境里,出错了没人看着。所以本篇的重点不是语法罗列,而是怎么让脚本在出错时正确地失败,而不是带着错误状态继续跑下去把事情搞得更糟。
三个最关键的点:set -euo pipefail、引号、以及管道会产生子 shell。
1. 脚本骨架
每个脚本都应该这样开头:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
1.1 shebang 该怎么写
#!/bin/bash # 指定绝对路径
#!/usr/bin/env bash # 从 PATH 里找 bash <- 推荐
#!/bin/sh # POSIX sh,不是 bash!
#!/bin/sh 是个陷阱。在 Debian/Ubuntu 上 /bin/sh 指向 dash,它不支持 [[ ]]、数组、local、$'...' 这些 bash 特性。脚本在你的机器上(CentOS,/bin/sh → bash)跑得好好的,换到 Ubuntu 就报 Syntax error: "(" unexpected。
规律:要用 bash 特性就明确写 bash,要极致可移植就写 sh 并且只用 POSIX 语法,不要指望 sh 是 bash。
# 确认你的 /bin/sh 是什么
ls -l /bin/sh
# lrwxrwxrwx 1 root root 4 Mar 10 2023 /bin/sh -> dash
1.2 set -euo pipefail 逐项拆解
这三个选项解决的是同一个问题:默认情况下,Shell 脚本出错会继续往下跑。
# 没有 set -e 时的灾难
cd /opt/myapp/releases # 目录不存在,cd 失败,但脚本继续
rm -rf * # 在【当前目录】执行了 rm -rf *
-e:命令失败(退出码非 0)立即退出
set -e
false # 脚本在这里就停了
echo "不会执行"
-u:使用未定义的变量时报错退出
set -u
rm -rf "$APP_DIR/data" # APP_DIR 拼错了 -> 未定义 -> 展开成 ""
# 没有 -u 的话,这条命令会变成 rm -rf /data
# 有 -u:bash: APP_DIR: unbound variable,脚本退出 ✅
这一条能防住最严重的事故。「变量拼错导致 rm -rf /」是 Shell 脚本历史上最经典的翻车方式。
-o pipefail:管道中任意一环失败,整个管道就算失败
# 默认情况:管道的退出码 = 【最后一个】命令的退出码
false | true; echo $?
# 0 <- false 失败了,但管道返回 0
set -o pipefail
false | true; echo $?
# 1 <- 正确地报告失败
没有 pipefail 时,mysqldump db | gzip > backup.gz 里 mysqldump 失败了、gzip 成功了,脚本会认为备份成功——你会得到一个内容为空的「成功」备份,直到需要恢复的那天才发现。
1.3 set -e 的四个不生效场景
set -e 不是万能的,它有明确的失效边界,不知道这些会产生虚假的安全感:
set -e
# ① 在 if / while / && / || 的条件位置 —— 设计如此,否则 if 没法用
if grep -q x f; then ... fi # grep 失败不会退出,正确
false || echo "handled" # 不退出
# ② 命令是 && 或 || 链的非最后一环
false && echo a # 不退出
# ③ 函数在条件上下文中被调用时,函数【内部】的 -e 也失效
check() { false; echo "居然执行了"; }
if check; then :; fi # 打印了 "居然执行了"
# ④ 命令替换在某些 bash 版本中的行为不一致
x=$(false) # 会退出
echo "$(false)" # 不一定
第 ③ 条最阴——把一堆检查逻辑封装成函数,然后用 if check_all; then 调用,函数内部的 set -e 全部失效,中间某步失败会被静默跳过。
所以关键操作要显式检查,不要只依赖 set -e:
# ✅ 显式判断
if ! mysqldump mydb > backup.sql; then
echo "备份失败" >&2
exit 1
fi
# ✅ 或者用 || 兜底
mysqldump mydb > backup.sql || { echo "备份失败" >&2; exit 1; }
1.4 IFS 是干什么的
IFS(Internal Field Separator)决定 Shell 如何把字符串切成词。默认是「空格、tab、换行」:
files="a.txt b.txt"
for f in $files; do echo "$f"; done # 未加引号的 $files 会被按 IFS 切分
# a.txt
# b.txt
问题是文件名里有空格时会被错误切分:
touch "my file.txt"
for f in $(ls); do echo "[$f]"; done
# [my]
# [file.txt] <- 一个文件被切成两个
# ✅ 设 IFS 只按换行和 tab 切
IFS=$'\n\t'
for f in $(ls); do echo "[$f]"; done
# [my file.txt]
更好的做法是根本不要解析 ls 的输出,用通配符或 find -print0:
# ✅ 通配符(Shell 自己展开,天然处理空格)
for f in ./*; do echo "[$f]"; done
# ✅ find + null 分隔(连换行符的文件名都能处理)
find . -type f -print0 | while IFS= read -r -d '' f; do
echo "[$f]"
done
规律:不要 for f in $(ls),用 for f in ./* 或 find -print0。
2. 引号:最容易出错的地方
一句话:变量引用永远加双引号,除非你明确知道自己需要词分割。
2.1 三种引号
name="world"
echo "hello $name" # hello world 双引号:变量展开、命令替换生效
echo 'hello $name' # hello $name 单引号:全部字面量,什么都不解析
echo hello $name # hello world 不加引号:展开 + 【词分割】+ 【通配符展开】
不加引号会多做两件危险的事:
# ① 词分割
f="my file.txt"
rm $f # 变成 rm my file.txt —— 试图删两个文件
rm "$f" # ✅ 正确
# ② 通配符展开
pattern="*.txt"
echo $pattern # a.txt b.txt c.txt <- 被展开了
echo "$pattern" # *.txt <- 保持字面量
# ③ 空值消失
empty=""
[ $empty = "x" ] # 变成 [ = "x" ] -> 语法错误
[ "$empty" = "x" ] # ✅ 变成 [ "" = "x" ]
2.2 什么时候必须加
# ✅ 这些场景必须加双引号
cmd "$var"
[ "$a" = "$b" ]
for i in "$@"; do ... done
echo "$(date)"
arr=("$@")
# ⚠️ 这些场景【不能】加(需要词分割)
OPTS="-v --debug"
cmd $OPTS # 需要拆成两个参数
# 更好的写法:用数组
OPTS=(-v --debug)
cmd "${OPTS[@]}" # ✅ 数组展开,每个元素是独立参数
"$@" 和 $* 的区别是必考点:
f() {
echo "\$@ 加引号:"; for a in "$@"; do echo " [$a]"; done
echo "\$* 加引号:"; for a in "$*"; do echo " [$a]"; done
}
f "a b" c
# $@ 加引号:
# [a b] <- 保持了原有的参数边界 ✅
# [c]
# $* 加引号:
# [a b c] <- 全部拼成了一个字符串
规律:转发参数一律用 "$@",永远不用 $*。
2.3 变量展开的实用语法
${var:-默认值} # var 未定义或为空时,返回默认值(不修改 var)
${var:=默认值} # 同上,但【会赋值】给 var
${var:?错误信息} # var 未定义或为空时,打印错误并退出 <- 参数校验神器
${var:+替代值} # var 【有值】时才返回替代值
${#var} # 长度
${var:2:5} # 从第 2 个字符开始取 5 个
${var#pattern} # 从【头部】删除最短匹配
${var##pattern} # 从【头部】删除最长匹配
${var%pattern} # 从【尾部】删除最短匹配
${var%%pattern} # 从【尾部】删除最长匹配
${var/old/new} # 替换第一个
${var//old/new} # 替换全部
实际用法:
# 参数校验:必填项没传就直接退出,比写 if 简洁
: "${APP_ENV:?必须设置 APP_ENV}"
: "${DB_HOST:?必须设置 DB_HOST}"
# 带默认值的配置
PORT="${PORT:-8080}"
LOG_LEVEL="${LOG_LEVEL:-info}"
# 路径处理(比调用 basename/dirname 快,不 fork 进程)
path="/var/log/app/access.log"
echo "${path##*/}" # access.log <- 等价 basename
echo "${path%/*}" # /var/log/app <- 等价 dirname
echo "${path##*.}" # log <- 扩展名
echo "${path%.*}" # /var/log/app/access <- 去掉扩展名
记忆法:# 在键盘上 $ 的左边 → 删头部;% 在右边 → 删尾部。单个是最短匹配,双写是最长匹配。
3. 条件判断
3.1 [ ] 与 [[ ]]:用后者
[ "$a" = "$b" ] # POSIX 的 test 命令,其实 [ 是个【命令】
[[ $a == $b ]] # bash 关键字,功能更强、更安全
[ 真的是一个命令(/usr/bin/[),所以它的参数必须严格用空格分开,且变量必须加引号。[[ ]] 是 bash 的语法结构,由 Shell 解析器直接处理:
| 能力 | [ ] |
[[ ]] |
|---|---|---|
| 变量不加引号安全吗 | 不安全 | 安全(不做词分割) |
正则匹配 =~ |
不支持 | 支持 |
通配符 == |
不支持 | 支持 |
| 逻辑运算 | -a -o |
&& ||(更清晰) |
| 可移植性 | POSIX 通用 | 仅 bash/zsh/ksh |
# [[ ]] 的两个专属能力
[[ $file == *.log ]] # 通配符匹配
[[ $ip =~ ^[0-9]+\.[0-9]+ ]] # 正则匹配,捕获组在 ${BASH_REMATCH[n]}
if [[ $version =~ ^v([0-9]+)\.([0-9]+) ]]; then
echo "主版本 ${BASH_REMATCH[1]}, 次版本 ${BASH_REMATCH[2]}"
fi
注意:
[[ ]]里正则不要加引号——[[ $x =~ "^a" ]]会把^a当字面字符串匹配。要用变量存正则:re='^a'; [[ $x =~ $re ]]。
3.2 常用测试
# 文件
[[ -e f ]] # 存在
[[ -f f ]] # 是普通文件
[[ -d d ]] # 是目录
[[ -L l ]] # 是软链接
[[ -r f ]] # 可读
[[ -w f ]] # 可写
[[ -x f ]] # 可执行
[[ -s f ]] # 存在【且非空】 <- 检查备份文件是否有效常用
[[ f1 -nt f2 ]] # f1 比 f2 新
# 字符串
[[ -z "$s" ]] # 为空
[[ -n "$s" ]] # 非空
[[ "$a" == "$b" ]]
# 数值(注意:字符串比较用 == ,数值比较用 -eq)
[[ $n -eq 5 ]] [[ $n -ne 5 ]]
[[ $n -lt 5 ]] [[ $n -le 5 ]]
[[ $n -gt 5 ]] [[ $n -ge 5 ]]
# 或者用 (( )) 做算术,更直观
(( n > 5 ))
(( n++ ))
# ❌ 用 = 比较数字,会变成字符串比较
[[ "10" > "9" ]] && echo yes # 不打印!字符串 "10" < "9"
# ✅
(( 10 > 9 )) && echo yes # yes
[[ 10 -gt 9 ]] && echo yes # yes
规律:比字符串用 [[ ]] 的 ==,比数字用 (( ))。
4. 循环与读取
# 遍历列表
for f in ./*.log; do echo "$f"; done
# C 风格
for ((i=0; i<10; i++)); do echo "$i"; done
# 序列
for i in {1..10}; do echo "$i"; done
for i in {0..100..10}; do echo "$i"; done # 步长 10
# while 读文件 —— 逐行处理的标准写法
while IFS= read -r line; do
echo "[$line]"
done < input.txt
while IFS= read -r line 这个组合的每一部分都有必要:
IFS=(置空):不让 read 去掉行首尾的空白-r:不把反斜杠当转义符(否则C:\path会被吃掉)< input.txt放在done后面:从文件重定向,而不是用cat file |
# ❌ 常见错误写法
cat f | while read line; do ...; done
# ^ 无用的 cat,而且管道会产生子 shell(见 §5.3)
for line in $(cat f); do ...; done
# ^ 会按空白切分,一行里的多个词变成多次循环
循环控制:
continue # 跳过本次
break # 跳出循环
break 2 # 跳出【两层】循环
5. 重定向与管道:文件描述符模型
这一节是 Shell 里最需要理解原理的部分。
5.1 三个标准 fd
每个进程启动时都自带三个文件描述符:
fd 0 stdin 标准输入 默认连到键盘
fd 1 stdout 标准输出 默认连到终端
fd 2 stderr 标准错误 默认连到终端
stdout 和 stderr 分开的意义:ls a b_not_exist > out.txt 时,正常结果进了文件,错误信息仍然显示在屏幕上。这样管道处理数据时不会被错误信息污染。
cmd > f # stdout 覆盖写入 f(等价 1> f)
cmd >> f # stdout 追加
cmd 2> f # stderr 写入 f
cmd 2>> f # stderr 追加
cmd > f 2>&1 # stdout 和 stderr 都进 f
cmd &> f # 同上(bash 简写)
cmd 2>&1 | less # 让 stderr 也进管道
cmd > /dev/null 2>&1 # 全部丢弃
cmd < input.txt # 从文件读 stdin
5.2 2>&1 的位置至关重要
# ✅ 正确:两者都进 f
cmd > f 2>&1
# ❌ 错误:stderr 还在屏幕上
cmd 2>&1 > f
原因:重定向是从左到右依次执行的,2>&1 的含义是「把 fd 2 指向 fd 1 当前指向的地方」,是复制当前的指向,不是建立永久绑定。
cmd > f 2>&1
① > f : fd1 -> 文件 f
② 2>&1 : fd2 -> fd1 当前指向的地方 = 文件 f ✅ 都进文件
cmd 2>&1 > f
① 2>&1 : fd2 -> fd1 当前指向的地方 = 终端
② > f : fd1 -> 文件 f(fd2 不受影响,还指着终端) ❌
cmd 2>&1 > f 这个「错误」写法其实有个正当用途——只把错误显示在屏幕、正常输出存文件。
分开保存是排查时的常用手法:
./deploy.sh > deploy.log 2> deploy.err
5.3 管道会产生子 shell(最大的坑)
count=0
cat f | while read line; do
((count++))
done
echo "$count"
# 0 <- 循环里改的是【子进程】里的 count
原因:管道的每一段都在独立的子进程里运行,子进程对变量的修改不会影响父进程。
三种解法:
# ✅ 方案一:用重定向代替管道(最简单)
count=0
while read -r line; do
((count++))
done < f
echo "$count" # 正确
# ✅ 方案二:进程替换
count=0
while read -r line; do
((count++))
done < <(grep ERROR f)
# ^^^^ 进程替换:把命令的输出当成一个文件
# ✅ 方案三:lastpipe(bash 4.2+,需关闭 job control)
shopt -s lastpipe
set +m
cat f | while read -r line; do ((count++)); done
方案二的进程替换 <(cmd) 值得单独讲——它把命令的输出变成一个「文件」(实际是 /dev/fd/63 这样的管道 fd):
# 比较两个命令的输出,不用建临时文件
diff <(ls dir1) <(ls dir2)
# 比较两台机器的配置
diff <(ssh host1 'cat /etc/app.conf') <(ssh host2 'cat /etc/app.conf')
# 同时写到多个地方
cmd > >(gzip > out.gz) 2> >(tee err.log >&2)
规律:循环里需要修改外部变量时,用 done < <(cmd) 而不是 cmd | while。
5.4 here-doc 与 here-string
# here-doc:多行输入
cat > /etc/myapp/config.yaml <<'EOF'
server:
port: 8080
host: ${HOST} # 单引号包围 EOF -> 【不做】变量展开
EOF
cat > /tmp/x <<EOF
当前用户: $USER # 不加引号 -> 【会】变量展开
EOF
# <<- 允许用 tab 缩进(只吃 tab,不吃空格)
if true; then
cat <<-EOF
这行前面的 tab 会被去掉
EOF
fi
# here-string:单行输入
grep "x" <<< "$var"
tr ':' '\n' <<< "$PATH"
<<'EOF' 和 <<EOF 的区别是高频坑:生成配置文件时想保留 ${VAR} 字面量(留给应用自己解析),必须用引号版本,否则会被 Shell 提前展开成空字符串。
6. 函数
# 定义(两种写法等价,推荐第一种)
deploy() {
local env="$1" # local 必须显式声明,否则是全局变量
local version="${2:-latest}"
echo "部署 $version 到 $env"
return 0 # return 只能返回 0-255 的【状态码】
}
deploy prod v1.2.3
Shell 函数的「返回值」有两套机制,别混淆:
# ① return 返回【退出状态码】(0-255),用于判断成功失败
is_running() {
systemctl is-active --quiet "$1"
return $?
}
if is_running nginx; then echo "运行中"; fi
# ② 用 echo + 命令替换返回【数据】
get_version() {
echo "1.2.3"
}
v=$(get_version)
注意:函数里 echo 的所有内容都会成为「返回值」,所以函数内不能用 echo 打日志:
# ❌ 日志污染了返回值
get_version() {
echo "正在获取版本..." # 这行也进了 $v
echo "1.2.3"
}
v=$(get_version)
echo "$v"
# 正在获取版本...
# 1.2.3
# ✅ 日志走 stderr
get_version() {
echo "正在获取版本..." >&2
echo "1.2.3"
}
local 必须写。Shell 变量默认是全局的,函数里给 i 赋值会覆盖外层循环的 i,这类 bug 极难排查:
process() {
for i in 1 2 3; do :; done # 没有 local,污染了外部的 i
}
for i in a b c; do
process
echo "$i" # 只打印一次 "3",外层循环被破坏
done
7. xargs:把输入变成参数
xargs 解决的问题是:很多命令只接受参数,不读 stdin。
# rm 不读 stdin
find . -name "*.tmp" | rm # ❌ 什么也不会删
# xargs 把 stdin 转成参数
find . -name "*.tmp" | xargs rm # ✅
7.1 必须用 -0 处理特殊文件名
# ❌ 文件名含空格就崩
find . -name "*.log" | xargs rm
# rm: cannot remove 'my': No such file or directory
# rm: cannot remove 'file.log': No such file or directory
# ✅ 用 null 作分隔符
find . -name "*.log" -print0 | xargs -0 rm
# ^^^^^^^ ^^
# ✅ 或者干脆不用 xargs
find . -name "*.log" -delete
规律:find | xargs 必须配对写 -print0 和 -0。
7.2 常用选项
-0 输入用 \0 分隔
-n N 每次最多传 N 个参数
-P N 并发 N 个进程 <- 很有用
-I {} 用 {} 占位,可以放在任意位置
-r 输入为空时不执行(GNU,避免空输入时误跑)
-t 执行前打印命令(调试用)
# 占位符:参数不在末尾时必须用 -I
ls *.txt | xargs -I {} mv {} {}.bak
# 并发:批量 ping 100 台机器,8 个并发
cat hosts.txt | xargs -P 8 -I {} ping -c1 -W1 {}
# 并发拉取镜像
cat images.txt | xargs -P 4 -n 1 docker pull
# 空输入保护
find . -name "*.nonexist" | xargs -r rm # 没有 -r 的话 rm 会报缺少参数
-P 是简易并行化的利器。需要对几百个目标做同样的操作(拉镜像、发请求、检查状态)时,xargs -P 8 比写循环快 8 倍,而且不用引入任何额外工具。
8. trap:确保清理逻辑一定执行
脚本创建了临时文件、上了锁、启了后台进程,如果中途 Ctrl+C 或被 kill,这些资源就泄漏了。trap 解决这个问题:
#!/usr/bin/env bash
set -euo pipefail
TMPDIR=$(mktemp -d)
cleanup() {
local code=$?
rm -rf "$TMPDIR"
echo "已清理临时目录" >&2
exit $code
}
trap cleanup EXIT INT TERM
# ^^^^ ^^^ ^^^^
# 正常退出/Ctrl+C/kill 都会触发
# 后面随便怎么写,临时目录一定会被清理
work_in "$TMPDIR"
trap ... EXIT 是最重要的一个——它在脚本以任何方式结束时都会执行,包括 set -e 触发的退出。有它就不需要在每个 exit 前重复写清理代码。
常用信号(第 8 篇会详细讲信号机制):
| 信号 | 触发时机 |
|---|---|
EXIT |
脚本结束(任何原因) |
INT |
Ctrl+C |
TERM |
kill 默认发的信号 |
HUP |
终端断开 |
ERR |
命令返回非 0(配合 set -e 做错误上报) |
# 错误时打印出错的行号,调试长脚本很有用
trap 'echo "第 $LINENO 行出错,退出码 $?" >&2' ERR
注意:
SIGKILL(9)无法被 trap 捕获,这是内核保证的。所以kill -9之后清理逻辑不会执行——这也是为什么优雅关闭必须用SIGTERM而不是SIGKILL。
防重复执行的锁也是 trap 的经典用法:
LOCKFILE=/var/run/mytask.lock
exec 9>"$LOCKFILE" # 把 fd 9 指向锁文件
if ! flock -n 9; then # 非阻塞地加锁
echo "已有实例在运行" >&2
exit 1
fi
# 锁会在进程退出时【自动】释放(fd 关闭),不需要 trap 清理
flock 比「检查 pid 文件是否存在」可靠得多——后者在进程被 kill -9 后会留下陈旧的 pid 文件,导致任务永远起不来。
9. 调试脚本
bash -n script.sh # 只做语法检查,不执行 <- 提交前必跑
bash -x script.sh # 打印每条执行的命令
bash -uex script.sh # 组合
# 只调试脚本的一段
set -x
问题代码
set +x
# 让 -x 的输出带行号和函数名
export PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
bash -x script.sh
# + deploy.sh:42:main: rm -rf /tmp/build
PS4 定制是个很实用的技巧,默认的 + 前缀在长脚本里根本看不出执行到哪了。
shellcheck 应该进 CI。它能静态发现绝大多数本篇提到的坑:
shellcheck deploy.sh
# In deploy.sh line 12:
# rm -rf $TMPDIR
# ^-- SC2086: Double quote to prevent globbing and word splitting.
10. 实战:一个生产级的备份脚本
把本篇的所有内容串起来:
#!/usr/bin/env bash
#
# 数据库备份:导出 -> 压缩 -> 上传 -> 清理过期备份
#
set -euo pipefail
IFS=$'\n\t'
# ---------- 配置(全部支持环境变量覆盖)----------
readonly DB_HOST="${DB_HOST:-127.0.0.1}"
readonly DB_NAME="${DB_NAME:?必须设置 DB_NAME}"
readonly BACKUP_DIR="${BACKUP_DIR:-/data/backup}"
readonly KEEP_DAYS="${KEEP_DAYS:-7}"
readonly LOCKFILE="/var/run/db_backup.lock"
# ---------- 日志(全部走 stderr,不污染 stdout)----------
log() { echo "[$(date '+%F %T')] $*" >&2; }
die() { log "ERROR: $*"; exit 1; }
# ---------- 防重复执行 ----------
exec 9>"$LOCKFILE"
flock -n 9 || die "已有备份任务在运行"
# ---------- 清理 ----------
TMPDIR=$(mktemp -d)
cleanup() {
local code=$?
rm -rf "$TMPDIR"
[[ $code -ne 0 ]] && log "备份失败,退出码 $code"
exit $code
}
trap cleanup EXIT INT TERM
# ---------- 主流程 ----------
main() {
local ts backup_file
ts=$(date +%Y%m%d_%H%M%S)
backup_file="$BACKUP_DIR/${DB_NAME}_${ts}.sql.zst"
mkdir -p "$BACKUP_DIR"
log "开始导出 $DB_NAME"
# pipefail 保证 mysqldump 失败时整条管道失败,
# 否则会得到一个 gzip 成功的【空备份】
mysqldump -h "$DB_HOST" --single-transaction "$DB_NAME" \
| zstd -T0 -q -o "$TMPDIR/dump.zst" \
|| die "导出失败"
# 校验:备份文件必须非空且能解压
[[ -s "$TMPDIR/dump.zst" ]] || die "备份文件为空"
zstd -t "$TMPDIR/dump.zst" 2>/dev/null || die "备份文件损坏"
# 原子移动到最终位置(同文件系统,rename 是原子的)
mv "$TMPDIR/dump.zst" "$backup_file"
log "备份完成: $backup_file ($(du -h "$backup_file" | cut -f1))"
# 清理过期备份
local deleted
deleted=$(find "$BACKUP_DIR" -name "${DB_NAME}_*.sql.zst" \
-mtime "+$KEEP_DAYS" -print -delete | wc -l)
log "清理了 $deleted 个过期备份"
}
main "$@"
这个脚本用到的本篇要点:
| 位置 | 用到的知识 |
|---|---|
set -euo pipefail |
出错立即停,管道失败能被捕获(§1.2) |
${DB_NAME:?...} |
必填参数校验(§2.3) |
flock -n 9 |
防重复执行,进程退出自动解锁(§8) |
trap cleanup EXIT |
任何路径退出都清理临时目录(§8) |
log() { ... >&2; } |
日志走 stderr,不污染函数返回值(§6) |
[[ -s file ]] |
校验备份非空(§3.2) |
mv 到最终位置 |
原子发布,避免读到半个文件(第 2 篇 §4.2) |
find -mtime +N -delete |
安全清理,不用 rm *(第 2 篇 §4.3) |
规律:备份脚本最危险的不是「失败」,而是「静默地成功但产出垃圾」。所以每一步都要校验产出物。
11. 面试题
Q:set -e、set -u、set -o pipefail 分别解决什么问题?
-e:命令失败立即退出,避免 cd 失败后继续执行 rm -rf * 这类灾难。-u:引用未定义变量时报错退出,防住「变量名拼错 → 展开成空 → rm -rf $DIR/data 变成 rm -rf /data」这个经典事故。-o pipefail:管道中任一环失败整个管道就算失败——默认管道只看最后一个命令的退出码,所以 mysqldump | gzip > b.gz 里 dump 失败但 gzip 成功时,脚本会认为备份成功,实际拿到一个空文件。
Q:set -e 在哪些情况下不生效?
四种:① 命令位于 if/while 的条件位置(设计如此,否则 if 无法工作);② 命令是 && 或 || 链的非最后一环;③ 函数在条件上下文中被调用时,函数内部的 set -e 也失效——这条最容易踩,把检查逻辑封装成函数再用 if check; then 调用,内部失败会被静默跳过;④ 命令替换在不同 bash 版本下行为不一致。所以关键操作要显式判断 if ! cmd; then exit 1; fi,不能只依赖 set -e。
Q:cmd > f 2>&1 和 cmd 2>&1 > f 有什么区别?
前者两个流都进文件,后者只有 stdout 进文件、stderr 仍在终端。原因是重定向从左到右依次生效,且 2>&1 的语义是「把 fd2 指向 fd1 此刻指向的地方」,是复制当前指向而非建立绑定。第一条:先把 fd1 指向文件,再让 fd2 指向 fd1 现在的位置(文件);第二条:先让 fd2 指向 fd1 现在的位置(终端),再把 fd1 改指文件,fd2 不受影响。后者也有正当用途——只把错误显示在屏幕。
Q:cat f | while read line; do ((n++)); done 之后 n 为什么是 0?
因为管道的每一段都运行在独立的子进程里,while 循环在子 shell 中执行,它对 n 的修改随子进程一起消失,父 shell 的 n 没变。三种解法:① 用重定向代替管道 done < f;② 用进程替换 done < <(cmd);③ 开启 shopt -s lastpipe(需关闭 job control,仅 bash 4.2+)。推荐前两种。
Q:[ ] 和 [[ ]] 有什么区别?
[ 本质是一个命令(/usr/bin/[),参数会经历正常的词分割和通配符展开,所以变量必须加引号,否则空值或含空格的值会导致语法错误。[[ ]] 是 bash 的语法结构,由解析器直接处理、不做词分割,变量不加引号也安全,还额外支持 =~ 正则匹配(捕获组在 BASH_REMATCH)和 == 通配符匹配,逻辑运算用更清晰的 &&/||。代价是不可移植(dash/POSIX sh 不支持)。写 bash 脚本一律用 [[ ]]。
Q:"$@" 和 $* 有什么区别?
"$@" 展开为多个独立的参数,保持原有的参数边界;"$*" 把所有参数用第一个 IFS 字符连接成一个字符串。所以传了 "a b" c 两个参数时,for x in "$@" 循环两次(a b、c),for x in "$*" 只循环一次(a b c)。转发参数给另一个命令时必须用 "$@",用 $* 会丢失参数边界,含空格的参数会被错误拆开。
Q:为什么 for f in $(ls) 是错误的写法?
两个问题:① ls 的输出经过命令替换后会被按 IFS(默认含空格)词分割,文件名 my file.txt 会被拆成两个循环项;② 输出还会经历通配符展开,文件名里的 * 会被再次展开。正确写法是用 Shell 自己的通配符 for f in ./*(Shell 展开天然保持完整),或者处理任意文件名用 find . -print0 | while IFS= read -r -d '' f。
Q:trap 能捕获 SIGKILL 吗?脚本怎么保证临时文件一定被清理?
不能。SIGKILL(9)和 SIGSTOP(19)无法被捕获、阻塞或忽略,这是内核保证的,否则进程就无法被强制终止了。所以 kill -9 之后清理逻辑不会执行。常规清理用 trap cleanup EXIT INT TERM——EXIT 会在脚本以任何正常方式结束时触发,包括 set -e 引起的退出,覆盖面最广。对于必须绝对可靠的资源(如锁),应该用「进程退出时内核自动释放」的机制,比如 flock 依附于文件描述符——进程无论怎么死,fd 都会被内核关闭,锁自动释放,比 pid 文件方案可靠得多。
上一篇:Linux-04 文本处理三剑客与正则 | 下一篇:Linux-06 进程管理与作业控制
xingliuhua