同样处理100万行访问日志的任务,我手里一份老脚本跑了1分56秒,优化完重写一遍只要7秒多。差别就两条:循环里塞了太多外部命令,每个循环迭代都在fork子进程;日志逐行写磁盘,每次迭代都在重复open/close文件。这两个坑叠加起来,哪怕逻辑再简单,性能也会被拖垮。
这篇内容适合谁看?日常维护Shell脚本、写定时任务、做日志分析、跑数据批处理的人。只要你的脚本处理的是文本文件、逐行数据,就绕不开循环和IO。标题里说的"减少循环次数、避免无效IO",听起来像两句正确的废话,但拆开看全是细节:循环里的哪一行命令最贵?为什么awk一个进程能干翻while循环里一堆命令?重定向和文件描述符怎么用才能避免百万次open/close?优化前怎么测量才不被假象骗了?我会按实际定位问题的顺序把这些讲透。
1. 先搞懂Shell慢在哪:fork/exec和文件IO这笔账
1.1 外部命令是脚本里最贵的"单品"
Shell脚本里几乎所有性能问题都能归结到一件事:进程创建。bash执行一条外部命令时,要先fork一个子进程,再exec加载对应的可执行文件。这个fork+exec的开销,通常在几百微秒到几毫秒量级。听起来不多,但乘上循环次数就吓人了。
举个例子,你写一个最简单的空循环:
time for i in {1..10000}; do :; done:是bash内建命令,不fork,耗时基本可以忽略。但你把循环体改成执行一条外部命令:
time for i in {1..10000}; do /bin/true; done/bin/true大概就是空程序,啥也不干,但这个耗时可能立刻涨到1秒以上。为什么?它每次循环都要fork一个新进程,1万次循环就是1万次fork。如果把循环体换成grep、awk、cut这种稍微干点活的命令,再叠加每次循环读取输入,10万行日志跑出几十秒就太正常了。
我见过一个典型的失败案例:循环里用echo "$line" | awk '{print $9}'取状态码,再嵌套一个if echo "$line" | grep -q ...做判断。处理一行日志,bash至少fork了4次:echo一次、awk一次、echo一次、grep一次。一行40微秒的活被放大到几百微秒。百万行下来,光是fork开销就把脚本拖死了。
所以要记住一个判断标准:如果循环体里出现了外部命令,循环次数和外部命令数量的乘积,就是你的fork次数。这个数字一旦上了百万量级,脚本几乎没有优化空间,只能重构。
1.2 无效IO往往比循环本身更致命
进程创建是显性的慢,文件IO是隐性的慢。很多人写脚本时根本意识不到自己在做"无效IO"。
最常见的一种无效IO,是循环内逐行写文件:
while read -r line; do ... echo "$line" >> /tmp/result.log done < input.log每次执行>>重定向,shell都要打开一次文件、写数据、关闭文件。100万行就是100万次open/close系统调用。就算每次打开关闭只有几微秒,100万次也是好几秒的纯开销,而且这些系统调用还会和page cache、磁盘回写纠缠在一起,产生连锁延迟。
还有一种无效IO是"重复读同一份数据"。比如说你要从日志里统计状态码分布,还要统计TOP URL,代码写成:
cat access.log | awk '{print $9}' | sort | uniq -c cat access.log | awk '{print $7}' | sort | uniq -c看起来挺正常,但access.log被你完整读了两遍。文件小还没感觉,文件一旦超过几百MB,多读一遍就意味着多等一次磁盘IO时间。awk其实完全支持单遍扫描、多个统计变量同时维护,为什么非要读两遍?
无效IO的本质是:你用M次IO做了N次本来可以1次完成的工作,而IO恰恰是脚本性能的硬瓶颈——CPU快、内存快、磁盘相对慢,管道还有调度开销。理解了这两笔账,优化方向就很清晰了。
2. 减少循环次数:从"逐行处理"到"一次扫描"
2.1 三层取舍:去循环、换内建、改结构
减少循环次数不是一句口号,而是有一套先后顺序的取舍逻辑。我自己的思路一般是三层:
第一层,先想能不能去掉循环。awk、sed、sort、uniq、paste这些文本工具本身就是"按行处理"的专用程序,它们内部用C实现,单进程扫描文件,比bash的while循环不知快到哪里去。能用awk解决的事,尽量不要用while。
第二层,如果必须保留循环,把循环体里的外部命令全部换成bash内建能力。判断字符串用[[ ]]、case,切字段用参数扩展,算数用(( )),读文件用mapfile。内建命令不fork子进程,开销少两三个数量级。
第三层,如果连内建命令都嫌慢,重新审视数据结构。是不是每次循环都在重复扫描同一个文件?能不能提前把数据加载到关联数组里?能不能一次循环把多个统计指标都算完?
这个顺序很重要。很多人一上来就在第二层做微优化,结果循环结构本身是烂的,改几个命令也就快20%。真正让性能起飞的是第一层和第三层。
2.2 实战案例:状态码统计从while+grep到awk数组
拿一个最常见的场景举例:统计Nginx访问日志里各HTTP状态码的数量。
初版代码长这样:
while read -r line; do code=$(echo "$line" | awk '{print $9}') echo "$code" done < access.log | sort | uniq -c这段代码逻辑没错,但性能灾难。每行循环做了两件最贵的事:fork一个echo、fork一个awk。我拿一个约200MB的日志实测,跑完大概2分多钟。如果日志里还有异常行,管道中断、子shell上下文切换还会进一步拖慢。
用awk重写:
awk '{codes[$9]++} END {for (c in codes) print codes[c], c}' access.log | sort -rn这版只有一个外部程序在跑。awk单进程单遍扫描,状态码作为数组下标,出现一次计数器加一,内存里聚合,文件读完直接输出结果,最后sort只对有限个状态码排序。同样200MB日志,实测几秒跑完,性能差距两个数量级。
区别在哪里?awk是C写的,它的逐行循环发生在进程内部,没有fork、没有管道、没有子shell;而bash的while循环每行都在创建新进程。循环次数从"每行一次进程创建"变成了"整个文件只创建2个进程(awk和sort)"。这才是减少循环次数的真正含义——不是少写几个for,而是别让循环体内出现外部命令。
2.3 循环内的"内建替代外部"替换表
不是所有场景都能用awk解决,比如你要对每一行做复杂的业务判断,就绕不开循环。这种情况下,把外部命令换成内建命令能做多少优化?我用一张表总结常见替换:
| 任务 | 低效写法 | 高效写法 | 说明 |
|---|---|---|---|
| 判断字符串包含子串 | echo "$x" | grep -q "abc" | [[ $x == *abc* ]] | 前者fork一次;后者纯内建 |
| 多条件字符串匹配 | echo "$x" | grep -E "A|B" | case "$x" in *A*|*B*) ...;; esac | case是模式匹配,不fork |
| 取冒号前的字段 | echo "$line" | cut -d: -f1 | user=${line%%:*} | 参数扩展零fork |
| 取路径文件名 | basename "$path" | name=${path##*/} | 参数扩展;注意尾斜杠边界情况 |
| 数值累加 | sum=$(expr $sum + 1) | ((sum++)) | expr是外部命令 |
| 逐行读入数组 | while read...; done | mapfile -t arr < file | mapfile批量读,减少循环迭代 |
| 字符串转大写 | echo "$x" | tr 'a-z' 'A-Z' | y=${x^^} | bash4+内建 |
我在实际项目里用这套替换,把一个逐行处理配置文件的脚本从每行3次fork降到了0次fork。原来处理120万行配置要6分多钟,改成纯内建版本后40秒左右跑完。性能提升主要不是来自命令本身快,而是把"每行3次fork"这个乘法因子彻底消掉了。
这里有一个很重要的坑要提醒:bash内建echo也不一定总是内建。如果shopt开启了xpg_echo,或者你用了/bin/echo的绝对路径,echo也会变成外部命令。排查性能问题时,先确认你调用的命令真的是内建版本。
3. 避免无效IO:文件描述符、缓冲与临时文件
3.1 逐行写文件:百万次open/close的开销
循环内逐行重定向写文件,是我在线上脚本里吃过亏最多的模式。以清理程序为例:
while read -r line; do if [[ $line == *ERROR* ]]; then echo "$line" >> /var/log/error.log fi done < app.log这段代码表面看没啥问题,条件过滤后才写。但一旦app.log很大,ERROR行很多,每一次echo >> error.log都是一次open+write+close。系统调用次数瞬间爆炸。我实测过一个类似场景,文件里20万条匹配行,光写文件这块就多花了十几秒——磁盘根本没满,系统CPU被open/close和文件系统锁拖住了。
这时候先别急着上缓冲库,Shell自带的两个方案就够用。
3.2 提前打开fd和批量重定向
第一个方案,也是最简单的:把重定向从循环内移到循环外。
while read -r line; do if [[ $line == *ERROR* ]];then echo "$line" fi done < app.log > /var/log/error.log整个循环的stdout一次性指向目标文件,循环体里的echo只是写标准输出,shell只在循环结束时打开一次文件、写入全部内容、关闭一次。这一处改动,可能就省掉几十万次open/close。
第二个方案是提前打开文件描述符。有些场景下你把stdout重定向给文件了,循环内部还要读到别的管道输出就会乱,这时候用fd更干净:
exec 3>> /var/log/error.log while read -r line; do if [[ $line == *ERROR* ]]; then echo "$line" >&3 fi done < app.log exec 3>&-exec 3>> file在循环开始前把fd 3绑定到文件;循环内>&3只做write,不重复open/close;循环结束后exec 3>&-关闭fd。这个模式在脚本里同时写多个输出文件时特别有用,你可以定义fd 3、fd 4分别指向不同日志,按需写入,互不干扰。
用fd要记住两个注意事项:bash里fd 0、1、2已被标准输入输出占用,自定义fd用3到9;fd必须显式关闭,否则脚本结束时还开着,可能影响后续命令对同一文件的操作。我在一个脚本里就是因为忘了关fd,后面mv目标文件一直报Text file busy,排查了半天才发现。
3.3 /dev/shm与避免重复读同一文件
临时文件位置也有讲究。默认/tmp在普通磁盘上,如果你的脚本频繁创建删除几十MB的中间文件,磁盘IO会很心疼。Linux提供了/dev/shm,这是内存文件系统,读写走的是内存页,比磁盘快一个量级。
用法很简单:
tmpfile=$(mktemp -p /dev/shm) ... rm -f "$tmpfile"注意两点:/dev/shm空间受内存大小限制,默认通常是物理内存的一半,别把几个GB的大文件往里塞;内存文件系统断电即失,仅供临时使用。我在日志分析脚本里把中间排序文件放/dev/shm,整条流水线从十几秒降到3秒,成本只是一行mktemp参数。
另一个和IO相关的高频坑是重复读文件。很多人写统计脚本时,习惯先grep过滤出一个子集,再用awk统计;或者同一个文件被多段脚本反复读。实际上awk支持在单次扫描中同时完成过滤、统计、分支判断。我重新组织了统计逻辑后,文件从被读3次变成了只读1次,IO成本直接除以3。
关于缓冲,还有一条经验:没必要迷信stdbuf。shell的文本工具默认对stdout做全缓冲,写入磁盘时是攒一批写一批,这本身就是好的行为。stdbuf -oL改成行缓冲适合实时日志场景,但会显著增加write次数,非实时处理的脚本强行加行缓冲反而是性能倒退。我见过有人给awk套stdbuf -oL处理大文件,性能反而掉了20%,纯属画蛇添足。
4. 不测量就没资格谈优化:性能定位的几条实测路径
4.1 从time到strace:快速定位瓶颈
优化Shell脚本最大的误区是凭感觉。你觉得循环慢,也许慢在IO;你觉得IO慢,也许慢在管道阻塞。正确的顺序永远是先测量,再动手。
第一件趁手工具是bash内建time加上TIMEFORMAT:
TIMEFORMAT='real %R user %U sys %S' time bash script.shuser是CPU在用户态花的时间,sys是内核态花的时间。如果一个脚本user占了大头,多半是外部命令和计算太多;sys占了大头,通常是系统调用太多,比如反复open/close、read/write。这个粗略定向能帮你决定先优化哪边。
第二件工具是strace -c。它能统计脚本运行期间所有系统调用的次数:
strace -c -f bash script.sh跑完看输出,如果fork、wait4、openat、close这几个调用的次数高得离谱,瓶颈就一目了然。我第一次用strace查一个慢脚本,发现clone(进程创建)次数接近500万次,瞬间就明白问题全在循环内部的命令调用上。
第三件是/usr/bin/time -v,注意用绝对路径,避免bash内建time干扰:
/usr/bin/time -v bash script.sh输出里会有Maximum resident set size,可以同时监控脚本峰值内存。Shell脚本通常内存不是瓶颈,但如果你用了mapfile把超大文件整进数组,内存就可能爆。
4.2 优化要建立基线:一次只改一处
做性能优化时,我有一条铁律:先保存原版本运行时间和输出,然后一次只改一处,每改一处都重新跑时间、记录结果。
为什么要这样?因为性能问题的成因经常是交互的。你同时改了循环内部命令和重定向方式,脚本快了,但到底是哪个改动生效的?不知道。下次再遇到类似问题,你还是得重新猜。我见过团队里有人优化脚本,一次改了四五个点,测出来快了很多,但上线后某天数据量大了又慢回原形,因为真正起效的那个优化点在特定数据量下失效了,其他改动其实没用,没人能说清楚。
建议维护一个简单的基线表:
| 版本 | 处理行数 | real | user | sys | 峰值内存 | 备注 |
|---|---|---|---|---|---|---|
| v0 原始版 | 100万 | 34.2s | 3.1s | 31.0s | 2.1MB | 基准 |
| v1 去cat管道 | 100万 | 28.7s | 2.9s | 25.5s | 2.2MB | 有效 |
| v2 while改awk | 100万 | 2.1s | 1.4s | 0.6s | 4.8MB | 大幅提升 |
| v3 改用mapfile | 100万 | 2.1s | 1.4s | 0.6s | 5.1MB | 无变化,回退 |
v3这个例子很典型:我用mapfile替代while read,跑完发现时间没变化,说明这个循环本身不是瓶颈,改动没必要保留。有了基线表,每个改动的价值都清清楚楚。
4.3 别被"并行"绑架:xargs -P的适用边界
聊性能优化,一定会有人提并行。"循环慢?上xargs -P跑满CPU啊。"这话一半对一半错。
xargs -P确实能把任务分发到多个进程并行执行,适合那些CPU密集、任务间完全独立的场景。比如批量压缩一堆日志文件:
find logs/ -name '*.log' -print0 | xargs -0 -P 4 -I {} gzip {}每个文件独立压缩,互不干扰,4个进程并行,压缩时间接近单进程的1/4,这种并行是实打实的收益。
但并行不是万能药。如果你的瓶颈在IO,比如awk单进程已经把磁盘读满了,开4个并行只会让多个进程同时争抢同样的磁盘带宽,还额外增加上下文切换和内存开销。我曾经试过用xargs -P 8并行统计8个日志文件,结果总耗时和串行几乎一致,内存还翻了几倍,最后老老实实改回单进程串行批量处理。
判断能不能并行的标准就一句话:任务除以数据量,到底是CPU在等数据,还是数据在等CPU。Shell脚本里绝大多数文本处理都是数据在等CPU,并行空间很小;真正适合并行的是那些每个任务内部有大量独立计算的场景。
5. 完整优化记录:一条百万行统计命令的34秒到2.1秒
5.1 初版:while循环里塞了四个外部命令
最后用一个完整的优化案例收尾。任务:统计100万行访问日志的状态码分布,以及TOP 10的请求URL。
最早的版本是这个:
#!/bin/bash logfile=$1 while read -r line; do code=$(echo "$line" | awk '{print $9}') url=$(echo "$line" | awk '{print $7}') echo "$code $url" done < "$logfile" > /tmp/stat.tmp sort -k1,1n /tmp/stat.tmp | uniq -c | sort -rn | head -10这段脚本的问题一眼就能看出来:每行运行了两次awk,每次还带一次echo,一共4次fork。处理100万行,就是400万次进程创建。我实测下来的时间惨不忍睹,real 34秒多,sys高达31秒。sys这么高,完全是被fork和文件操作拖的。
5.2 改版:awk单遍扫描加两个临时文件
重写版本把统计全部放进awk里,一次扫描同时维护状态码和URL两个计数器:
#!/bin/bash logfile=$1 awk ' { codes[$9]++ urls[substr($7,1,100)]++ } END { for (c in codes) print codes[c], c >> "/tmp/codes.txt" for (u in urls) print urls[u], u >> "/tmp/urls.txt" }' "$logfile" sort -rn /tmp/codes.txt echo "--- TOP URL ---" sort -rn /tmp/urls.txt | head -10 rm -f /tmp/codes.txt /tmp/urls.txtawk单进程处理100万行,几个条件分支和数组累加,耗时大概1秒多;两个sort排序总共几万条记录,加起来不到1秒。整个脚本实测2.1秒。从34秒到2.1秒,提升16倍。
这里用两个临时文件不是我懒,而是故意让awk的统计结果先落盘,再交给sort做全局排序。awk在END块里直接往sort管道灌数据也可以,但管道一旦被sort的缓冲塞住,awk会被阻塞,多路统计互相干扰,调试起来很麻烦。先写临时文件,再排序,逻辑清晰,性能差别微乎其微。优化不是追求理论极限,而是在可读性和性能之间找平衡。
5.3 优化前后对比与取舍复盘
把两版数据并排看:
| 指标 | 初版 | 重写版 |
|---|---|---|
| real时间 | 34.2s | 2.1s |
| sys时间 | 31.0s | 0.6s |
| 外部进程创建次数 | 约400万次 | 3次(awk、sort、sort) |
| 磁盘读次数 | 2次(awk各扫一遍) | 1次 |
| 代码行数 | 8行 | 14行 |
| 峰值内存 | 2.1MB | 4.8MB |
代码从8行变成14行,变长了,但换来的是时间和系统负载的指数级下降。sys从31秒降到0.6秒,释放出来的系统CPU对同一台机器上的其他服务都是实实在在的福报。
内存从2.1MB涨到4.8MB,在今天的服务器上完全不值一提。如果统计维度更多、字段更长,内存会进一步上升,但一般文本统计需求下,bash的关联数组不会构成压力。真遇到几十GB的超大文件,就该考虑换Go、Rust或Python streaming方案了,那不是Shell脚本的战场。
我在实际优化中还有一条心得:脚本优化从来不是把复杂写到极致,而是花最少的改动换取最大的收益。像这个案例,我甚至不需要优化掉所有fork——只要把每行4次fork变成每行0次fork,性能已经质变了。与其纠结循环内部最后一点微优化,不如先回头想想:这个循环本身能不能直接整个去掉。
另外,别忘记给临时文件设置清理和防冲突机制。两个并发跑了同一脚本,/tmp/codes.txt会被互相覆盖。生产环境里要么用mktemp,要么在脚本开头清理旧文件。我一般会把工作文件放到/dev/shm,既快又减少磁盘写,然后用trap 'rm -f "$tmpdir"/*' EXIT保证退出时清理干净。这个小习惯帮我避免过不只一次线上事故。