目录

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/shbash)跑得好好的,换到 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 -eset -uset -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>&1cmd 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 bc),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 进程管理与作业控制