变量不加引号,清理脚本删错了整个上传目录

格 格达软件 2026-10-29 2 次阅读

周三下午运维在群里问:/data/upload 下面一个业务目录没了,能不能恢复。查下来是每天凌晨跑的清理脚本干的,核心就一行 rm -rf $f。$f 的值是 订单 2024-03.tmp 的完整路径,空格把它劈成两个参数,rm 收到的是 /data/upload/订单 和 2024-03.tmp——目录删了,该清的临时文件还在原地。

这篇文章只讲一件事:引号和空格在 shell 里怎么把参数边界搞丢,以及每个具体场景下怎么写才不丢。

不加引号的变量,被 shell 动了两刀

shell 执行一条命令前,对每个没被引号包住的词依次做两步:

  1. 分词:按 IFS 切开,默认 IFS 是空格、制表符、换行
  2. 路径展开:把 *、?、[a-z] 换成匹配到的文件名,没匹配到就保持字面量

完整顺序是:花括号 {} → 波浪号 ~ → 参数/命令/算术展开 → 分词 → 路径展开 → 去引号。这个顺序解释了后面很多“看起来莫名其妙”的现象。

跑一段就能看见:

#!/usr/bin/env bash
f='订单 2024-03.tmp'
printf '<%s>\n' $f     # 不加引号
 echo '---'
printf '<%s>\n' "$f"   # 加引号

输出:

<订单>
<2024-03.tmp>
---
<订单 2024-03.tmp>

四种写法的差别:

写法 变量为空 含空格 含 *
$f 整个词消失 拆成多个参数 展开成匹配的文件名
"$f" 一个空串参数 一个参数 字面量 *
'$f' 字面量 $f 字面量 字面量

第三行不是“变量”,列在这里是提醒:单引号里任何东西都不展开。

空变量配上通配符,就是灾难片:

UPLOAD_DIR=
rm -rf $UPLOAD_DIR/*      # 实际执行的是 rm -rf /*

$UPLOAD_DIR 为空 → 分词后这个词直接消失 → 剩下 /* 被路径展开成根目录下所有条目。注意 set -u 只挡“未定义”,挡不住“定义了但是空”,所以还要显式检查:

#!/usr/bin/env bash
set -euo pipefail
: "${UPLOAD_DIR:?UPLOAD_DIR 必须设置}"   # 为空或未定义直接退出,报错带上变量名

同样的坑在 [ ] 里更常见:

MODE=
if [ $MODE = 'prod' ]; then echo prod; fi
# bash: [: =: unary operator expected

x=
if [ -n $x ]; then echo '非空'; fi   # 会打印「非空」

第二个更阴:$x 消失后变成 [ -n ],这是单参数测试,被测字符串是 -n 本身,非空 → 真,逻辑直接反了。加引号就对了:[ -n "$x" ]。

规则很简单:bash 里一律用 [[ ]],它不做分词也不做路径展开;必须写 POSIX sh(#!/bin/sh、Dockerfile 的 RUN)时才用 [ ],且每个变量都加引号。

遍历文件名:for 的三个错法和一种对法

三个错法,全在带空格/换行的文件名上挂掉:

# 错法 1:ls 的输出不可解析
for f in $(ls /data/upload); do rm -f "$f"; done

# 错法 2:find 的输出同样被分词
for f in $(find /data/upload -name '*.tmp' -mtime +7); do rm -f "$f"; done

# 错法 3:管道右边在子 shell 里跑
count=0
find /data/upload -name '*.tmp' | while read -r f; do
  count=$((count + 1))
done
echo "$count"   # 永远是 0

错法 3 有两个问题:| 右边在子 shell 执行,count 的修改出不来;read 不加 -r 会把 \ 当转义符吃掉,不加 IFS= 会削掉首尾空白。

正解:

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

cleanup() {
  local root=$1
  local n=0
  # -print0 用 \0 分隔,read -d '' 按 \0 读,两者必须配对
  while IFS= read -r -d '' f; do
    rm -f -- "$f"
    n=$((n + 1))
  done < <(find "$root" -maxdepth 1 -type f -name '*.tmp' -mtime +7 -print0)
  echo "deleted $n"
}

cleanup /data/upload

逐项解释:

  • IFS= 清空,read 不再削首尾空白
  • -r 关闭反斜杠转义
  • -d '' 用 NUL 做分隔符(bash 里空串等价于 \0)
  • < <(...) 是进程替换,while 跑在当前 shell,计数能带出来
  • rm -f -- 挡住以 - 开头的文件名

不需要计数、不需要变量带出来时,find -exec {} + 更省事也更快:

find /data/upload -maxdepth 1 -type f -name '*.tmp' -mtime +7 -exec rm -f -- {} +

{} + 把多个文件拼成一条命令(批量模式),{} \; 是每个文件起一个进程。一万个文件时差距是一个量级:

写法 一万个文件起的进程数 备注
while read 里调 rm 10000+ 每次 fork,最慢
-exec {} \; 10000+ 同上
-exec {} + 几十(受 ARG_MAX 限制) 快
xargs -0 -n 100 约 100 快,还能 -P 并行

自己测比看数字准:

seq 1 10000 | xargs -I{} touch "/tmp/t {}"
time find /tmp -maxdepth 1 -name 't *' -exec rm -f -- {} \;

xargs 也有脾气:默认引号不配对就直接报错退出;-I{} 隐含 -L1,等于每条命令只处理一个文件,性能掉回 -exec {} \; 的水平。批量场景用 -0 -n 100,别用 -I。

引号被吃了两层:ssh、sudo、find -exec sh -c

# 本地 shell 先把 $CACHE_DIR 展开,远程收到的是展开后的字符串
ssh web01 "rm -rf $CACHE_DIR/*"

# 想让它远程展开,用单引号包住整条命令
ssh web01 'rm -rf "$CACHE_DIR"/*'

第一行的危险在于 $CACHE_DIR 在本地可能是空的,于是远程执行 rm -rf /*。第二行要求 CACHE_DIR 在远程 shell 里有定义——注意非交互 shell 未必加载 ~/.bashrc,写进 ~/.profile 或直接在命令里赋值更稳。

文件名带空格时更麻烦,得让本地拼出来的字符串在远程被当成一个整体:

FILE='My Report 2024.pdf'

ssh web01 "cat $FILE"                      # 远程执行:cat My Report 2024.pdf
ssh web01 "cat '$FILE'"                    # 文件名里有单引号就炸
ssh web01 "cat $(printf '%q' "$FILE")"     # 让 shell 自己生成转义,稳

printf '%q' 是 bash 内建,输出一种“再喂给 shell 还能还原”的形式。任何要拼进另一个 shell 的变量,都该过一遍它。

find -exec sh -c 有一模一样的问题,还多一个坑:

# 错:文件名成了 $0,$1 是空的
find . -name '*.log' -exec sh -c 'gzip -9 "$1"' {} \;

# 对:第一个参数是 $0 的占位符,习惯上写成 _
find . -name '*.log' -exec sh -c 'gzip -9 "$1"' _ {} \;

sh -c 'script' 后面的第一个参数会变成 $0,第二个才是 $1。省掉 _ 时 {} 占了 $0,$1 为空,gzip -9 "" 直接报错。

sudo 同理:sudo 后面的命令由本地 shell 解析,sudo rm -rf $DIR/* 里的 $DIR 和 * 都在本地就展开了,提权上去的 root 根本没参与这次展开。

加了引号反而不对的地方

[[ ]] 的模式匹配,右侧不能加引号:

f=a.log
[[ $f == *.log ]] && echo match       # match
[[ $f == "*.log" ]] || echo nomatch  # nomatch,加了引号变成字面比较

[ ] 里正好相反,但错得更隐蔽:

cd /tmp && touch a.log
f=a.log
[ "$f" = *.log ] && echo same        # 打印 same —— 但这是巧合

*.log 没被引号保护,会先做路径展开。当前目录恰好有 a.log,于是变成 [ "a.log" = a.log ],成立。换个没有 .log 文件的目录,*.log 保持字面量,又不成立。同一行代码的行为取决于当前目录里有什么文件,这种 bug 最难查,因为它在你机器上永远是好的。

数组的 @ 和 *:

files=("a.txt" "b c.txt" "d*.log")

for f in ${files[@]}; do echo "<$f>"; done    # b c.txt 被拆开,d*.log 被展开
for f in "${files[@]}"; do echo "<$f>"; done  # 正确,三个参数原样
printf '%s\n' "${files[*]}"                    # 用 IFS 首字符拼成一个字符串

规则:"${arr[@]}" 是“每个元素一个参数”,"${arr[*]}" 是“拼成一个字符串”,$@ / $* 同理。参数转发永远写 "$@":

log()  { printf '[%s] %s\n' "$(date +%T)" "$*"; }  # 只做日志拼接,$* 可以
wrap() { some_cmd --flag "$@"; }                     # 转发参数,必须 "$@"

边界条件:- 开头、换行、以及 set -e 的脾气

以 - 开头的文件名会被当成选项:

touch -- '-rf'
f='-rf'
rm "$f"           # rm: invalid option -- 'r'
rm -- "$f"        # 对
rm "./$f"         # 也对,但后续改名/移动逻辑要跟着改

-- 是 POSIX 约定的“选项结束”标记,rm、cp、mv、grep、xargs 都支持。写清理脚本时不用想,无脑加。

文件名里带换行,NUL 分隔能处理,行式工具不能:

touch $'a\nb.txt'
find . -maxdepth 1 -name '*.txt' | wc -l                          # 2,实际只有 1 个文件
find . -maxdepth 1 -name '*.txt' -print0 | tr -cd '\000' | wc -c  # 1

set -e 也不是万能的:

set -e
if grep -q foo file; then :; fi   # if 条件里 set -e 不生效,正常
grep -q foo file || true          # || 右边同理
n=$(grep -c foo file)             # 这里会退出:grep 没匹配到返回 1

pipefail 配上 head 会直接要命:

set -o pipefail
find / -name '*.log' 2>/dev/null | head -5   # 退出码 141,脚本当场结束

head 读够 5 行就关管道,find 收到 SIGPIPE,退出码 141,pipefail 把最右边的非零码抛上来。规避办法:

find / -name '*.log' 2>/dev/null | head -5 || [ $? -eq 141 ]

或者干脆别在 set -e 的脚本里用裸的 | head。

还有一个常被忽略的:命令替换会吞掉尾部所有换行。

out=$(printf 'a\n\n\n')
printf '%s' "$out" | wc -c    # 1,不是 4

要保留就 out=$(cmd; printf x); out=${out%x}。

收尾:脚本头 + 一份照着删的清单

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

# 必需变量:为空或未定义直接死
: "${UPLOAD_DIR:?}"

main() {
  local root=${1:?用法: $0 <目录>}
  [ -d "$root" ] || { echo "目录不存在: $root" >&2; exit 1; }
  # ...
}

main "$@"

有人喜欢在脚本头写 IFS=$'\n\t',这样 for f in $(cmd) 不会被空格劈开。代价是 read 的默认分隔符变了、"$*" 的连接符也变了,旧代码里到处用逗号分隔的地方会悄悄改行为。我的建议是别动全局 IFS,老老实实每个变量都加引号。

提交前跑这三条:

shellcheck -S warning *.sh    # SC2086 (Double quote to prevent globbing and word splitting) 必须清零
bash -n deploy.sh             # 只做语法检查,不执行
bash -x deploy.sh             # 执行时打印每一步展开后的真实参数

清单:

  • [ ] 每个变量展开写成 "$var",例外只有 [[ ]] 的 == 右侧和 =~ 右侧
  • [ ] 遍历文件名用 while IFS= read -r -d '' + -print0,或者 find -exec {} +
  • [ ] rm/cp/mv 后面跟了 --
  • [ ] 拼进 ssh/sudo/sh -c 的变量走了 printf '%q'
  • [ ] 参数转发写的是 "$@",不是 $@ 也不是 "$*"
  • [ ] set -e 的脚本里没有裸的 | head
  • [ ] shellcheck 的 SC2086 是零
格
格达软件

专注软件定制开发,分享技术实践与行业观察。有开发需求?欢迎随时咨询。

咨询我们

有软件开发需求?我们 24 小时内响应

提交您的需求,格达软件将为您提供免费的需求评估与技术方案建议,咨询全程不收取任何费用。