Shell 技巧与一行命令

同一件事在 Shell 里通常有五六种写法,差别往往在「引号怎么用」「错误去哪了」「失败要不要停」。这一节按使用场景整理引号与变量展开、管道与重定向、文本处理、作业控制,以及可以直接抄走的一行命令片段。默认以 bash 为准,zsh 有差异的地方会写明。

共 32 条命令 · 最近更新

引号、变量与参数展开

Shell 里绝大多数「命令不生效」其实都是引号问题。记住一条主线:单引号是完全字面量,双引号只让 $、反引号和反斜杠特殊,不加引号才会做分词和通配符展开。

单引号 vs 双引号

单引号里一切原样保留,连 $ 和反斜杠都不解释;双引号里变量、命令替换、算术展开仍然生效。写正则、awk 程序、含 $ 的密码时优先用单引号。

echo '$HOME 不会被展开'
echo "$HOME 会被展开"
grep -n '^root' /etc/passwd
awk '{print $1, $3}' /etc/passwd

字符串里既要单引号又要变量时,用 '"'"' 拼接,或者改用双引号加转义。

${VAR} 参数展开

花括号把变量名和后面的字符隔开,并支持一大批默认值语法:${VAR:-默认值} 为空时取默认值,${VAR:=默认值} 为空时赋值,${VAR:?提示} 为空时报错退出,${#VAR} 取长度。

PORT="${PORT:-8080}"
echo "监听端口: $PORT"
echo "日志长度: ${#LOG_LINE}"
: "${DB_HOST:?请先设置 DB_HOST}"

脚本里读环境变量一律用 ${VAR:-默认值},能避免大量「变量为空导致命令变成另一个命令」的事故。

命令替换 $() 与进程替换 <()

$(命令) 把命令输出当字符串用,嵌套时比反引号清晰得多。<(命令) 把命令输出伪装成一个文件路径,常用来给只接受文件名的命令喂数据。

echo "今天是 $(date +%F),内核 $(uname -r)"
diff <(sort a.txt) <(sort b.txt)
while read -r line; do echo "$line"; done < <(ls -1)

<() 依赖 /dev/fd,在部分精简容器里不可用;此时可退回临时文件。

花括号与通配符展开

{a,b,c} 是纯文本展开,不依赖文件是否存在;* ? [] 是通配符,必须匹配到真实文件,匹配不到时默认按字面量传给命令。

cp /etc/nginx/nginx.conf{,.bak}
mkdir -p data/{raw,clean,archive}
ls -1 /var/log/*.log
shopt -s nullglob   # 匹配不到时展开为空,脚本更安全

* 默认不匹配以点开头的隐藏文件,需要 .* 或 dotglob。

IFS 与按行读取

read 默认会把行首尾空白吃掉,并解释反斜杠转义。读文件时统一用 IFS= read -r,否则带空格或反斜杠的行会被改坏。

while IFS= read -r line; do
  printf '%s\n' "$line"
done < input.txt
IFS=: read -r user _ uid _ <<< "$(grep '^root:' /etc/passwd)"
echo "$user $uid"

read 的 -r 表示不处理反斜杠。忘了它,Windows 风格的路径几乎必然出错。

管道、重定向与退出码

重定向决定数据去哪,退出码决定脚本停不停。这两件事搞混,是「命令明明成功了脚本却退出」和「错误信息看不到」的主要来源。

重定向 > >> 2> &>

> 覆盖写,>> 追加写,2> 只重定向标准错误,&> 或 >file 2>&1 把输出和错误合到一起。n>file 可以按编号重定向任意文件描述符。

command > out.log 2> err.log
command >> /var/log/app.log 2>&1
command &> /dev/null          # 全部丢弃
command > /dev/null 2>&1       # POSIX 写法

2>&1 的位置很关键:> file 2>&1 正确,2>&1 > file 只把错误指向终端。

退出码 $? 与 && ||

$? 是上一条命令的退出码,0 表示成功。&& 前一条成功才执行后一条,|| 前一条失败才执行,; 无条件顺序执行。

systemctl is-active --quiet nginx && echo 运行中 || echo 未运行
cd /data && rm -f cache/*.tmp
command; echo "退出码: $?"

管道默认只看最后一条命令的退出码。要抓中间失败,用 set -o pipefail 或 ${PIPESTATUS[@]}。

管道与 tee

| 把左边标准输出接到右边标准输入。tee 一边显示一边写文件,配合 sudo 可以往只有 root 能写的文件里追加内容。

journalctl -u nginx --since today | grep -i error | tee errors.log
echo '127.0.0.1 api.local' | sudo tee -a /etc/hosts
ps aux | awk '{print $3, $11}' | sort -rn | head

sudo tee -a 是 >> 的提权替代品:sudo echo x >> file 里的重定向由当前用户执行,会失败。

set -euo pipefail

脚本开头的三条保险:-e 任一命令失败就退出,-u 用未定义变量报错,-o pipefail 让管道中任一环失败即算失败。生产脚本建议一律加上。

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
set -x    # 调试:打印每条实际执行的命令
set +e    # 临时允许失败

-e 对 if 条件、&& 左侧、! 取反的命令不生效,这正是它可用却不烦人的原因。

xargs 批量处理

把标准输入拆成参数列表反复调用命令。-0 配合 find -print0 处理含空格和换行的文件名,-n1 一次一个,-P 并行,-I{} 把参数插到任意位置。

find /data -name '*.log' -print0 | xargs -0 -r gzip
cat urls.txt | xargs -n1 -P8 curl -sO
ls *.png | xargs -I{} convert {} -resize 50% sm-{}

-r(GNU)或 --no-run-if-empty 可避免输入为空时仍然执行一次命令。

cut / sort / uniq / wc 常用组合

不写脚本也能做完大部分日志统计,靠的就是这四个工具加管道。下面的组合可以直接替换字段名和文件名后使用。

cut 取列

-d 指定分隔符(默认制表符),-f 选字段,-c 按字符位置切。处理空格分隔的文本用 awk 更稳。

cut -d: -f1,3 /etc/passwd
cut -c1-19 /var/log/app.log | uniq -c
awk '{print $1, $NF}' access.log

sort 排序

-n 按数值,-h 按人类可读大小(1K 2M),-r 倒序,-u 去重,-k 指定列,-t 指定分隔符。

du -sh /var/lib/* | sort -rh | head -10
sort -t: -k3 -n /etc/passwd
sort -u hosts.txt > hosts.uniq

排序结果受 locale 影响。要按字节序稳定排序,加 LC_ALL=C。

uniq 去重与计数

uniq 只能去掉相邻重复行,所以几乎总是先 sort。-c 统计次数,-d 只看重复行,-u 只看唯一行。

awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -20
sort ip.txt | uniq -d

统计 top N 的标准姿势:sort | uniq -c | sort -rn | head。

wc 计数

-l 行数,-w 词数,-c 字节数,-m 字符数。统计文件数时应配合 find 而不是 wc。

wc -l /var/log/nginx/access.log
find /data -type f -name '*.csv' -exec wc -l {} +  | tail -1
grep -c 'ERROR' app.log

grep -c 比 grep | wc -l 快,也不会有管道退出码的坑。

tr / paste / column 排版

tr 做字符级替换和删除,paste 按列合并多个文件,column -t 把对齐混乱的输出排成表格。

tr '[:lower:]' '[:upper:]' < names.txt
tr -d '\r' < dos.txt > unix.txt
awk -F: '{print $1, $3}' /etc/passwd | column -t

tr 不认识多字符替换,把 abc 换成 xyz 要用 sed。

前后台作业与长任务

SSH 一断命令就没了,是新手最常踩的坑。短任务用 nohup / &,正式服务交给 systemd,交互式长任务用 tmux。

& jobs fg bg

命令末尾加 & 直接丢到后台;jobs 列出当前 shell 的作业,fg 调回前台,bg 让暂停的作业继续在后台跑。

./backup.sh &
jobs -l
fg %1
Ctrl+Z   # 暂停当前任务,然后输入 bg 转后台

& 起的进程仍是当前 shell 的子进程,关掉终端会收到 SIGHUP。

nohup 与 disown

nohup 忽略 SIGHUP,让命令在终端关闭后继续运行;disown 把已经跑起来的后台任务从作业表里摘掉。

nohup ./sync.sh > sync.log 2>&1 &
./sync.sh & disown
setsid ./daemon.sh > /dev/null 2>&1 < /dev/null &

nohup 不会把进程变成守护进程,退出后仍然是普通进程,机器重启就没了。

tmux 会话

需安装

把长任务放在 tmux 会话里,断线重连后继续看。适合编译、数据迁移、需要交互的排查。

tmux new -s build
tmux ls
tmux attach -t build
Ctrl+B 然后 D   # 脱离会话

多数发行版默认没装:apt install tmux / dnf install tmux。

timeout 限制运行时间

给命令设一个上限,超时后先发 TERM,再加 -k 会在宽限期后发 KILL。适合脚本里调用可能卡死的命令。

timeout 30s curl -sSf https://example.com/healthz
timeout -k 5s 60s ./migrate.sh || echo '迁移超时'

默认信号是 TERM。对忽略 TERM 的程序要显式加 -s KILL。

脚本骨架与易错点

一个能直接改的模板,以及几个反复出现的坑。

脚本模板

带严格模式、参数校验、临时目录清理和日志函数的骨架。

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

readonly SCRIPT_NAME="$(basename "$0")"
log()  { printf '[%s] %s\n' "$(date +'%F %T')" "$*"; }
die()  { log "错误: $*" >&2; exit 1; }

[[ $# -ge 1 ]] || die "用法: $SCRIPT_NAME <目标目录>"
TARGET=$1
[[ -d $TARGET ]] || die "目录不存在: $TARGET"

tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

log "开始处理 $TARGET"

trap ... EXIT 保证脚本无论正常结束还是报错退出都会清理临时文件,是很容易漏掉的一步。

位置参数与 shift

$1..$9 是位置参数,$# 是个数,$@ 是全部参数(加引号时每个参数保持独立)。

for arg in "$@"; do echo "参数: $arg"; done
while [[ $# -gt 0 ]]; do
  case $1 in
    -v|--verbose) VERBOSE=1; shift ;;
    -o|--output) OUT=$2; shift 2 ;;
    *) die "未知参数: $1" ;;
  esac
done

"$@" 和 $@ 差别很大:前者保留带空格的参数,后者会重新分词。

[[ ]] 与 (( ))

bash 里判断字符串用 [[ ]],判断数字用 (( ))。[[ ]] 不会做分词和通配符展开,比 [ ] 安全。

[[ -f $file && $file == *.conf ]] && echo 是配置文件
(( count > 10 )) && echo 超过阈值
[[ ${name,,} == *.tar.gz ]] && echo 压缩包   # ,, 转小写

test / [ 是外部命令语义,字符串比较要用 = 而非 ==,且变量必须加引号。

for 循环遍历的三种写法

遍历列表、遍历命令输出、遍历文件。遍历 find 结果时用 while read 而不是 for。

for i in $(seq 1 5); do echo "第 $i 次"; done
while IFS= read -r f; do gzip "$f"; done < <(find /data -name '*.log')
for f in /etc/nginx/conf.d/*.conf; do nginx -t -c "$f"; done

for f in $(find ...) 会在文件名含空格时出错,一律改成 while read。

快捷键与历史

键盘上省下的时间比换工具更多。下面按 bash 默认绑定(emacs 模式)列出。

行编辑快捷键

光标移动、删除、清屏、补全等最常用的一组键。

Ctrl+A / Ctrl+E     # 行首 / 行尾
Ctrl+U / Ctrl+K     # 删到行首 / 行尾
Ctrl+W              # 删除前一个单词
Ctrl+L              # 清屏(等价 clear)
Ctrl+C / Ctrl+D     # 中断当前命令 / 结束输入

Ctrl+D 在空行上等于 logout,SSH 里误按会直接掉线。

历史与反向搜索

Ctrl+R 增量搜索历史命令,是复用复杂命令最快的方式。

Ctrl+R              # 输入关键词反向搜索
Ctrl+R 再按 Ctrl+R  # 继续往前找
右方向键或 Ctrl+E   # 接受当前匹配
history | grep ssh
!!            # 重复上一条命令
sudo !!       # 用 sudo 重跑上一条

history -c 只清当前会话,别指望它抹掉磁盘上的 ~/.bash_history。

补全与纠错

需安装

Tab 补全命令、路径、变量;连续两次 Tab 列出候选。shellcheck 能在运行前查出脚本里的常见错误。

Tab  Tab          # 第一次补全,第二次列候选
shellcheck deploy.sh
bash -n script.sh   # 只做语法检查,不执行

可以直接抄走的一行片段

按「我想干什么」组织的现成写法,改掉路径和字段就能用。

统计访问量前 20 的 IP

nginx access.log 默认格式里第一列就是客户端 IP。

awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20

找出占用空间最大的目录

逐层下钻,直到定位到具体的大文件。

du -h --max-depth=1 / 2>/dev/null | sort -rh | head -15
du -ah /var | sort -rh | head -20
find / -xdev -type f -size +1G -printf '%s\t%p\n' 2>/dev/null | sort -rn | head

-xdev 不跨文件系统,避免扫到挂载进来的网络盘或 /proc。

按时间批量归档日志

/etc/logrotate.d/ 是正解,但临时处理用一行也够。

find /var/log -name '*.log' -mtime +7 -print0 | xargs -0 -r gzip -9
find /var/log -name '*.gz' -mtime +90 -delete

先跑不带 -delete 的版本确认清单,再加删除参数。

把命令结果写成 CSV

用 OFS 指定输出分隔符,适合直接喂给表格软件。

ps -eo pid,user,pcpu,pmem,comm --sort=-pcpu | awk 'BEGIN{OFS=","} NR>1 {print $1,$2,$3,$4,$5}' > procs.csv
df -hP | awk 'NR>1 {print $1","$5","$6}' > disk.csv

字段里本身含逗号时要加引号,这种场景用 python3 -c 或 csvkit 更可靠。

等待服务就绪

启动依赖服务后轮询端口,避免脚本抢跑。

for i in $(seq 1 30); do
  curl -sf http://127.0.0.1:8080/healthz >/dev/null && break
  sleep 1
done || { echo '服务未就绪'; exit 1; }
until ss -ltn | grep -q ':5432'; do sleep 1; done

一定要有次数上限,否则依赖挂掉时脚本会永远等下去。

批量重命名

没有 rename 命令时的纯 Shell 写法,先把 mv 打印出来确认。

for f in *.jpeg; do echo mv -- "$f" "${f%.jpeg}.jpg"; done   # 先干跑
for f in *.jpeg; do mv -- "$f" "${f%.jpeg}.jpg"; done

debian 系的 rename 是 perl 版,用法与 util-linux 的完全不同,跨机器别直接抄。