脚本性能优化实战

系统讲解 Shell 脚本性能优化的核心路径:启动开销与解析成本、子进程与外部命令的代价、内置命令优先、命令缓存与哈希、大文件流式处理,以及基准测试与性能剖析的方法论。

1. 启动开销与解析成本

一句话总结: Bash 每次启动要读配置、解析脚本;脚本越小、配置越精简,启动越快;高频短脚本尤其要减少初始化负担。

每次执行 bash script.sh 都是一次完整的进程启动:读取 ~/.bashrc(若 login shell)、解析整个脚本、逐个执行命令。对长脚本这部分占比不大,对高频小脚本则是主要成本。

# 计时一个空脚本的启动成本
time bash -c ':'        # 约 5~20ms,含解释器启动

# 频繁被调用的入口函数避免每次都启动新 bash
source ./lib.sh         # 当前 shell 内加载,无新进程

1.1 减少不必要的初始化

# 非交互脚本用 sh 或 dash 更轻
time dash -c ':'

# 不要在该用非交互时加载 bashrc
# 把公共函数放进独立 lib.sh,按需 source
if [[ -f ./lib.sh ]]; then
  source ./lib.sh
fi

一句话总结: 短小高频的任务选更轻的解释器(dash 比 bash 快),公共代码抽成 lib 按需加载,别让启动初始化成为主开销。

1.2 解析大脚本的成本

# 语法解析成本随脚本行数上升
# 一次性解析 vs 多次调用:把逻辑合并进单个脚本
time bash -n big_script.sh   # -n 只解析不执行,测解析耗时

2. 子进程是性能杀手

一句话总结: 每条外部命令都是一次 fork+exec,循环里密集调用外部命令最慢;能少 fork 就少 fork,能合并管道就合并。

# 慢:循环里每次 echo 都是外部命令? 不,echo 是内置;但 wc/cut 等是外部
# 对 1 万行文件逐行 wc 极慢
while read -r line; do
  echo "${#line}"        # 内置,快
done < big.txt

# 更慢的例子:循环里调用外部命令
while read -r ip; do
  ping -c 1 "$ip" >/dev/null   # 每次循环都 fork 一个 ping
done < ip_list.txt

2.1 fork 代价测量

# 对比内置与外部命令
time for i in {1..1000}; do :; done            # 内置,毫秒级
time for i in {1..1000}; do /bin/true; done     # 外部,显著更慢

# 减少 fork:能用 shell 语法就别起外部命令
# 慢:echo 接管道再 wc
lines=$(cat file.txt | wc -l)
# 快:纯内置
lines=$(wc -l < file.txt)      # 一个外部命令

一句话总结: 循环体是 fork 重灾区:每轮迭代都起外部命令,1 万次就是 1 万次 fork。把过滤、计数、变换搬进循环内用内置语法,或整体交给单个工具处理。

2.2 循环外推与批处理

# 慢:循环内逐条 grep
while read -r f; do
  grep -q error "$f" && echo "$f"
done < files.txt

# 快:一次 grep 全量处理
grep -l error $(cat files.txt)

# 慢:逐行 sed
while read -r l; do echo "$l" | sed 's/a/b/'; done < f.txt
# 快:整体管道
sed 's/a/b/' f.txt

3. 内置命令优先

一句话总结: Bash 内置了 echo read [[ (( : local 等常用命令,零 fork 开销;能用内置就不调外部。

# 字符串操作用内置参数展开,而非外部命令
s="Hello World"
echo "${s//o/0}"              # 内置替换
echo "${s,,}"                 # 内置转小写
# 而不是
echo "$s" | tr 'o' '0'        # 外部命令

# 数值运算用 (( )) 或 $(( ))
echo $(( 3 * 4 ))             # 内置
# 而不是
echo 3 \* 4 | bc              # 外部

3.1 条件判断用 [[ ]] 而非外部

# [[ ]] 是关键字,比 [ (test 外部命令) 快且安全
if [[ "$a" == "$b" ]]; then :; fi
if (( a > b )); then :; fi

# 正则匹配内置
if [[ "$ip" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then :; fi

# 避免外部 grep 判断
if grep -q pattern file; then :; fi   # 外部
if [[ $(<file) == *pattern* ]]; then :; fi  # 内置读文件

一句话总结: [[ ]] 比较、${var//pat/rep} 替换、(( )) 运算都是内置;用它们替代 test/grep/tr/bc,循环里能省下可观的时间。

3.2 内置命令清单

内置命令用途替代的外部命令
echo/printf输出/bin/echo
read读行无
[[ ]]比较/正则test、grep
(( ))/$(( ))算术bc、expr
${var#/%%//}字符串处理sed、tr
:空操作/占位true
source加载脚本无(新 bash)

4. 命令缓存与哈希

一句话总结: Bash 用 hash 表缓存命令的完整路径,首次查找后不再遍历 PATH;PATH 越长、目录越多,命中缓存越关键。

# 查看命令哈希缓存
hash

# 强制重新哈希(脚本改动了命令时)
hash -r

# 统计缓存
hash | wc -l

4.1 PATH 顺序与查找开销

# PATH 越短查找越快;把最常用的目录放前面
export PATH="/usr/local/bin:/usr/bin:/bin:$HOME/bin"

# 减少 PATH 中同义目录
# 慢:PATH 里有大量冗余目录
# 快:精简 PATH

一句话总结: 首次调用外部命令 Bash 会遍历 PATH 找到路径并缓存;把常用目录放前面、精简 PATH,首次查找也会更快。

4.2 缓存失效场景

# 脚本运行中安装/替换了命令,需 hash -r 刷新
apt install -y newtool
hash -r
newtool --version

# 或直接调用绝对路径绕开哈希
/usr/local/bin/newtool --version

5. 大文件流式处理

一句话总结: 大文件处理的关键是「流式」:一行一行读、不整块载入内存;awk/sed/管道天然流式,Bash 循环读文件也应按行消费。

# 流式:awk 逐行处理,内存恒定
awk '{sum += $2} END {print sum}' huge.csv

# 流式管道,不产生中间文件
grep ERROR app.log | awk '{print $1}' | sort | uniq -c

# 避免整读
# 慢:content=$(cat huge.log) 把全文件装进内存
# 快:while read 流式消费
while IFS= read -r line; do
  ((len += ${#line}))
done < huge.log

5.1 分块与并行加速

# xargs 分块并行处理大文件
cat huge.log | xargs -P 4 -n 1000 sh -c 'grep ERROR "$@"' _ | sort

# split 切分再并行汇总
split -l 100000 huge.log part_
grep -c ERROR part_* | awk -F: '{s += $2} END {print s}'
rm -f part_*

一句话总结: 大文件先「流式单机」处理,仍嫌慢再「split 切分 + xargs -P 并行」;awk 一句流式往往就够,别急着上并行。

5.2 避免重复扫描

# 一次 awk 完成多指标,避免对同一文件扫多遍
awk '{n++; bytes += $5; if ($9 ~ /^5/) err++}
     END {printf "行数%d 字节%d 错误%d\n", n, bytes, err}' access.log

# 而不是
wc -l < access.log
awk '{s+=$5} END{print s}' access.log
awk '$9 ~ /^5/{c++} END{print c}' access.log

6. 循环与算法优化

一句话总结: 循环里少做重复工作:不变计算提出循环外、避免重复读取文件、用关联数组做查表替代重复 grep。

# 慢:每次迭代都读一次文件
while read -r k; do
  grep "^$k" map.txt
done < keys.txt

# 快:把映射读进关联数组,循环内纯内存查找
declare -A map
while IFS= read -r k v; do map[$k]=$v; done < map.txt
while read -r k; do
  echo "${map[$k]:-缺失}"
done < keys.txt

6.1 减少管道与中间进程

# 少一层管道就少一个进程
# 慢
cat file.txt | grep x | cut -d: -f1 | sort
# 快(等价)
cut -d: -f1 file.txt | grep x | sort

# 能用重定向避免的用重定向
# 慢:echo "$var" | grep pattern
# 快:[[ "$var" == *pattern* ]]

一句话总结: 管道每段都是一个子进程;把可省的 cat 去掉、用 grep ... file 代替 cat file | grep,长管道能砍掉一半进程。

6.2 提前返回与短路

# 条件短路:先做快判断,失败早退
if ! command -v jq >/dev/null; then
  echo "缺少 jq"; exit 1
fi

# 函数内尽早 return
process() {
  [[ -r "$1" ]] || return 1
  # 后续才做重活
}

7. 基准与剖析

一句话总结: 优化前先测,time 量总耗时、date +%s%N 量片段、set -x 看执行流、bash -x 数命令条数——先定位热点再优化。

# 总耗时
time ./script.sh

# 片段计时
start=$(date +%s%N)
# ... 被测片段 ...
end=$(date +%s%N)
echo "耗时 $(( (end - start) / 1000000 )) ms"

# 逐命令执行流
bash -x script.sh > trace.txt

7.1 用 trace 找热点

# 统计每条命令被执行的次数,定位循环热点
bash -x script.sh 2>&1 | sed 's/^+ //' | sort | uniq -c | sort -rn | head -20

# 只看执行的命令名(去参数)
bash -x script.sh 2>&1 | grep '^+' | awk '{print $2}' | sort | uniq -c | sort -rn

一句话总结: bash -x 输出每个 + 开头的命令行,按命令名聚合次数,循环里被调用上万次的外部命令立刻现形。

7.2 对照实验

# 同一任务两种写法计时对比
time bash -c 'while read -r l; do echo "${#l}"; done < big.txt'
time awk '{print length}' big.txt

# 多次取中位数,避免偶然抖动
for i in {1..5}; do
  /usr/bin/time -f '%e s' ./script.sh 2>&1
done

8. 总结

环节要点
启动开销高频短脚本用轻解释器,公共代码按需 source
子进程fork+exec 昂贵,循环内少调外部命令
内置优先[[ ]]、${//}、(( ))、read 零 fork
命令缓存PATH 精简、hash 命中、装新命令后 hash -r
流式处理按行消费不整读,awk/sed 天然流式
循环优化查表用关联数组、不变计算提出循环外、去多余管道
基准剖析time 总耗时、date +%s%N 片段、bash -x 定位热点
方法论先测后优、对照实验、多次取中位数

性能优化的顺序永远是「先测后改、改一测一」:启动开销看解释器,循环性能看 fork 次数,大文件看是否流式。Shell 性能的本质是「少起进程、多用内置、保持流式」。本专题到此为止,把语法、文本、进程、健壮、工程、安全与性能串成完整链路。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 网络请求与诊断实战
  2. 定时任务调度实战
  3. SSH 远程自动化实战