接手过批量日志清洗、批量文件重命名的活儿,你迟早得跟 Shell 脚本里的循环语句打交道。说实话,for、while、until这些关键词看着简单,但真要在生产环境里写出不出篓子的脚本,光靠死记语法是不够的。这篇文章我打算把 Shell 循环的常用姿势、适用场景、还有那些容易让人栽跟头的细节全部摊开讲一遍,希望能帮你在下次写脚本的时候少走点弯路。
全文主要围绕循环的核心语法、遍历文件与命令结果、跳出与继续控制、以及实战中的性能优化来展开,适合刚接触 Shell 脚本不久的新手,也适合写过一些脚本但总觉得不够得劲的开发者。
1. 循环语句的适用场景与选型思路
1.1 为什么写脚本总绕不开循环
Shell 脚本里最值钱的能力不是把命令打出来,而是批量处理重复任务。比如你有几千个日志文件要扫描关键字,或者需要把几十台服务器的进程状态收集回来,这种活儿如果一条条手动敲命令,不仅效率低,而且容易漏。循环语句就是专门解决这类重复性操作的,它让你可以用一个简短的代码块去遍历一堆数据、文件或者命令输出,然后在每次迭代里做相同或类似的处理。
在我印象里,很多人最开始接触 Shell 循环都是因为某个紧急需求:某天某电商公司的朋友给我发了一堆订单导出的 CSV 文件,说要对每个文件做格式校验和字段提取。如果我手动一个个处理,估计要搞到深夜,但用for循环加上几行命令组合,几分钟就把几百个文件转了一遍。也正是从那之后,我算彻底明白了循环语句在脚本里的地位。
1.2 四种循环结构的核心区别
Shell 里常用的循环结构有四种:for、while、until和select。前两种用得最多,until在特定场景比较好用,select则主要用于交互式菜单。
| 循环类型 | 核心机制 | 典型应用场景 |
|---|---|---|
for | 遍历一个列表(文件、参数、命令输出等) | 批量文件处理、参数枚举 |
while | 只要条件为真就持续执行 | 读文件逐行处理、轮询等待某个状态 |
until | 只要条件为假就持续执行 | 等待服务启动成功、等待锁释放 |
select | 生成交互式编号菜单,按用户选择执行 | 管理脚本的选项菜单 |
选型原则其实不复杂:如果处理对象是一个固定的列表,优先用for;如果循环次数取决于一个不断变化的条件,用while;如果是“一直做某件事直到成功”,那么until更自然;需要为用户提供选项交互时就选select。这个思维框架我自己用了很久,大部分场景套进去都能很快找到合适的写法。
1.3 循环内部常见的重复工作
循环内部的业务逻辑千差万别,但抽象出来无非就这么几类:批量创建目录或文件、批量修改文件名、批量执行采集命令、批量校验数据、构造大批量的参数给下游工具处理。理解了循环只是“骨架”,真正决定脚本质量的是骨架里跑的那些“肌肉”命令。比如在循环里做字符串截取、正则匹配、条件分支,这些都是常见的“肌肉”动作,它们和循环语句搭配好了,脚本的战斗力才真正显现出来。
2. for 循环全解:从基础语法到文件遍历
2.1 基础写法和 C 风格写法
for循环最经典的形态是这样:
for i in 1 2 3 4 5; do echo "当前是第 $i 次迭代" done这里的1 2 3 4 5是一个显式列表,你把任何以空格分隔的字符串放进去都可以。例如for name in 张三 李四 王五,循环就会分三次执行,每次把$name替换成对应的名字。
如果迭代次数是由一个数字范围决定的,可以用seq命令生成列表:
for i in $(seq 1 10); do echo "数字: $i" done还有一种写法是从 C 语言借过来的,叫 C 风格 for 循环,在 Bash 里支持:
for ((i=1; i<=10; i++)); do echo "数字: $i" doneC 风格的优点是书写紧凑,适合明确的数字递增/递减场景。有人问$(seq 1 10)和((i=1; i<=10; i++))哪一种好,我的观点是:循环次数少、列表短的时候差别不大;循环次数特别大的时候,C 风格在某些实现上性能稍好,因为不需要先 fork 出一个seq子进程生成列表。
2.2 遍历文件时最容易犯的错误
很多人会用for循环遍历当前目录下的文件,最常见的两种写法是:
# 写法一 for f in $(ls *.txt); do echo "处理文件: $f" done # 写法二 for f in *.txt; do echo "处理文件: $f" done写法一其实特别不推荐,因为$(ls *.txt)会把ls的输出按空格拆分,碰上文件名里带空格的情况,循环就会把同一个文件名拆成多个词。写法二用的是路径展开,Shell 会把*.txt匹配到的每个文件原样作为一个整体交给你,文件名再奇葩也不会在循环内部被拆开。踩过几次坑之后,我现在统一习惯用for f in *.txt这种原生路径展开方式。
再有就是处理文件内容时,文件名包含换行符这种极端情况可能出现,但实际遇到概率极低,也不建议为了迎合它把脚本复杂度提得过高。大多数场景下,避开空格问题就够了。
2.3 如何遍历命令输出的每一行
需要遍历命令输出时,比如for ip in $(cat ip_list.txt),要特别注意一个问题:这个写法同样会把空格和换行都当作分隔符。如果 ip 列表是一行一个 IP,其中没有空格的话,这么写没问题;但如果命令输出的是类似“192.168.1.1 80”这种由多个字段组成的行,建议改用while read循环搭配管道来读,那样能保留一行的完整性。
cat ip_list.txt | while read ip port; do echo "IP: $ip, 端口: $port" done这段代码会逐行读取文件,并把每行按空白拆成ip和port两个变量,处理起来更灵活。这个理念同样适用于处理find、grep等命令的输出,尤其当输出内容包含空格时,while read比for更适合当遍历容器。
2.4 数组遍历与列表去重的技巧
for也可以直接遍历数组:
apps=("nginx" "mysql" "redis" "mongodb") for app in "${apps[@]}"; do echo "检查服务: $app" done这里"${apps[@]}"是关键,加引号后数组中的每个元素会被保留成一个整体,不会因为元素内部有空格而被切开。如果忘了引号,比如写成${apps[@]},那么“MySQL Server”这种含空格的字符串就会被当成两个词来处理,循环内部拿到的东西就和预期不符了。
至于列表去重,我碰到过一类需求是批量处理一批文件名,但同一批文件名可能重复出现。最简单的办法是用sort -u把文件种子的列表先清洗一遍:for f in $(find . -name "*.log" | sort -u); do ... done。如果期望保留原始文件顺序,不想排序,也可以考虑用awk去重或维护一个全局关联数组来记录已处理过的文件名。
3. while 与 until 循环:条件驱动的控制流
3.1 while 读取文件的完整姿势
while最常见的用途之一就是逐行读取文件并做处理。上面提到过一个写法,但实际中真正稳妥、可复用性很强的方式是:
while IFS= read -r line; do echo "读取到: $line" done < input.txt这里面每个要素都有含义:IFS=是为了确保行首和行尾的空白不被吃掉;-r是避免反斜杠被当成转义字符处理;< input.txt是输入重定向,这样while是在当前 Shell 环境里执行的,而不是在子 Shell 里。也就是说,如果你在循环内部修改了某个变量,循环结束后这个变量依然可用。
与之相对的,如果用管道方式cat input.txt | while read line; ...,那么while会运行在子 Shell 中,循环内设置的变量在外面就拿不到了。这是我最早写脚本时死活想不通的问题之一:为什么循环里明明给计数器加了值,循环结束后打印还是 0?原因就是子 Shell 的作用域问题。所以要特别提醒,读文件时优先用输入重定向,除非你确实不需要保留循环内部的变量。
3.2 无限循环与安全退出设计
服务监控、日志跟随这类需求经常需要一个永远跑下去的循环。Bash 里的无限循环标准写法是:
while true; do # 每 5 秒检查一次磁盘占用 df -h sleep 5 done还有另一种等价写法是while :,因为:是 Bash 内置的空命令,永远返回 0,也就是永远为真。写监控脚本时,我个人很习惯用while :; do ...; done,因为少敲几个字符,而且没有 fork 进程开销。
虽然叫无限循环,但实际设计里应该留好退出通道。一般有三种做法:一是通过break在满足某个条件时跳出;二是监测某个外部文件是否存在,比如 if [ -f /tmp/stop_monitor ]; then break; fi;三是捕获信号,通过trap来做优雅退出:
trap 'echo "收到退出信号"; break' INT TERM while :; do # 业务逻辑 sleep 1 done即使脚本不需要被手动终止,我也建议在无限循环里面至少设计一个能跳出的条件分支,否则一旦业务流程变更或者有 bug,脚本可能就变成一台“失控的拖拉机”。
3.3 until 循环在运维场景的应用
until正好和while相反:条件为假时持续执行,直到条件变为真才停止。这个特性在等待类场景里非常合适。比如等待某个服务端口起来:
until nc -z localhost 3306; do echo "MySQL 还没起来,继续等待..." sleep 2 done echo "MySQL 已就绪,继续执行"这段脚本会在nc检测到 3306 端口可用之前一直轮询,端口一开就立即跳出。相比自己去写计数器累加,until让代码意图更直白一些。我在写应用发布脚本时经常先用until来等健康检查接口返回 200,然后才执行后续的流量切换动作。需要注意一点,任何等待型循环都必须配上超时机制,否则服务异常时会死等。超时可以用一个变量配合计数来实现:
count=0 until curl -s --max-time 2 http://localhost:8080/health | grep -q "OK"; do count=$((count + 1)) if [ $count -ge 30 ]; then echo "等待超时,退出" break fi sleep 2 done4. select 循环与交互式菜单的构建
4.1 一个命令生成选项菜单
如果你的脚本需要在多个处理方案里让用户做选择,select是效率非常高的内置工具:
select action in 备份 清理 停止 重启; do echo "你选择了: $action" done执行后,脚本会打印出:
1) 备份 2) 清理 3) 停止 4) 重启 #?用户输入编号后,$action就会被赋成对应的字符串。如果输入不在编号范围内,$action会变成空字符串。很多交互脚本用这招把原本繁琐的手动选项菜单压缩成了三五行代码。
4.2 结合 case 做功能分发
select单独使用时体验还不太完整,一般要配合case来分发执行逻辑:
select action in 备份 清理 退出; do case $action in 备份) echo "开始执行备份..." ;; 清理) echo "开始执行清理..." ;; 退出) echo "退出脚本" break ;; *) echo "无效选择,请重新输入" ;; esac done这里有个细节:用户输入的如果不是有效编号,$action会为空,最后会落入*)分支,提示后继续循环。只有选了“退出”,才会执行break跳出select。我用这个组合写过不少内部运维小工具,部署、回滚、清理等操作集中在一个菜单里,使用体验比裸敲命令好很多。
4.3 给菜单加上默认值和超时能力
select有个不太方便的地方,就是用户直接按回车会无限重新打印菜单。为了提升体验,可以设置PS3提示符,并配合超时逻辑(注意不同平台支持程度有差异):
PS3="请输入选项序号: " TMOUT=10 # 10 秒无输入则退出 select action in 备份 退出; do case $action in 备份) echo "执行备份";; 退出) break;; esac doneTMOUT是 Bash 的一个特殊变量,在交互式select或read等待时会触发超时。把PS3自定义一下,提示信息就更友好。这种细节对于日常给同事用的脚本特别加分,至少比满屏“6) xxx”看起来更正规一点。
5. 循环控制:break、continue 与退出码管理
5.1 break 和 continue 的层级参数
break用来跳出当前循环,continue用来跳过本次迭代。它们都可以接一个数字参数来指定跳出的层级。比如嵌套两层循环时,内层break 2会直接把外层循环也一起跳出:
for outer in 1 2 3; do for inner in a b c; do echo "外层 $outer, 内层 $inner" if [ "$inner" = "b" ]; then break 2 fi done done这种用法看似简单,但层级参数写多了容易把脚本搞乱。我的建议是:嵌套超过三层就该想想有没有更扁平的设计。比如把内层循环改成函数封装,然后依据函数返回值决定是否跳出外层,代码会清晰很多。
5.2 continue 让代码少写一层 if
continue用于跳过本次迭代时,经常比把一大段代码包在if [ 条件 ]; then里更易读。举个例子,批量处理文件时想跳过隐藏文件和目录:
for entry in ./*; do # 只处理 .log 结尾的文件 [[ "$entry" != *.log ]] && continue echo "处理 $entry" # 剩余的都是正常文件处理逻辑 done这样主逻辑不用被条件判断层层包裹,代码缩进少了,阅读起来更轻松。这算是写循环时一个很实用的小习惯,某种意义上比整天想着怎么优化命令执行更快更重要,因为代码的可读性和可维护性对脚本的影响时间更长。
5.3 退出码与 if 判定在循环中的微妙关系
循环里经常要执行某个命令,然后根据命令的退出状态决定下一步操作。注意 Shell 中 0 代表成功,非 0 代表失败。推荐在一段循环里这么用:
for url in $(cat urls.txt); do if curl -s --max-time 3 "$url" > /dev/null; then echo "访问成功: $url" else echo "访问失败: $url" fi doneif会直接考察后面命令的退出码,不一定要写if [ $? -eq 0 ]这种啰嗦的写法。很多初学脚本的朋友会习惯性地把返回码先存进一个变量再判断,其实在if里直接执行命令更简洁,而且不会因为中间穿插其他命令导致返回码被覆盖。
循环内因为执行了很多次命令,退出码会被反复刷新,所以如果你要记录最近一次失败的退出码,一定要立刻保存:
for f in *.txt; do grep "error" "$f" > /dev/null status=$? if [ $status -eq 0 ]; then echo "$f 中存在错误关键字" fi done如果不把$?存起来,下一行执行[ $status -eq 0 ]时$?已经被这个 test 命令的结果替换了,根本对比不了。
6. 实战案例:一个批处理脚本的完整演进过程
6.1 场景描述:批量日志清洗与汇总
拿一个具体案例来说,假设你有某目录下 1000 个日志文件,每个文件里面记录着用户访问的 IP、时间、请求路径和状态码。需求是提取所有状态码为 5xx 的行,然后按 IP 统计出现次数。这个场景非常适合演示循环的进阶用法。
第一版我可能会这么写:
for logfile in /var/log/access/*.log; do grep -E "HTTP/1.1\" 5[0-9]{2}" "$logfile" >> /tmp/5xx_lines.txt done awk '{print $1}' /tmp/5xx_lines.txt | sort | uniq -c | sort -nr这个版本功能没问题,但有两个隐藏痛点:一是把所有 5xx 行先写进一个临时文件,如果数据量很大,磁盘和 IO 消耗都不小;二是循环内每次grep之后还要往外追加重定向,每跑一轮都要打开关闭一次文件。数据量小无所谓,大了就拖慢脚本。
于是第二版改成把聚合交给awk一次完成:
for logfile in /var/log/access/*.log; do awk '/HTTP\/1.1" 5[0-9]{2}/ {print $1}' "$logfile" done | sort | uniq -c | sort -nr这里不再生成临时文件,循环内只负责把提取出的 IP 打到标准输出,最后统一用管道聚合。这个流程用到了管道、进程替换的思想,循环和管道配合得好了,脚本会清爽不少。
6.2 并行优化:用后台进程和wait提速
单线程循环处理大量文件时,最耗时的往往是 IO。如果机器核心数多,完全可以把一部分循环体丢到后台执行,然后统一等待:
for logfile in /var/log/access/*.log; do awk '/HTTP\/1.1" 5[0-9]{2}/ {print $1}' "$logfile" >> /tmp/ip_chunks.txt & done wait这么做有一个需要注意的问题:多个后台进程同时往同一个文件里追加写,可能产生互相覆盖和内容交错的风险。更稳妥的做法是用flock锁文件,或者按任务编号分开写独立的分片文件,最后再合并。
实际写的时候我会按文件编号切分,比如:
for i in "${!files[@]}"; do logfile="${files[$i]}" awk '/HTTP\/1.1" 5[0-9]{2}/ {print $1}' "$logfile" > "/tmp/result_$i.txt" & done wait cat /tmp/result_*.txt | sort | uniq -c | sort -nr这样可以并行跑多个 awk 任务,每个结果落在独立文件,没有写入竞争。整个清洗流程下来,比起单线程循环,性能提升十分明显,尤其是文件的个数多但单个文件不大的场景。
6.3 动态构造命令参数列表
还有一种常见情况,需要给某个命令动态传参。例如要压缩最近 7 天内的所有 log 文件,可以由find和for组合成:
for f in $(find /var/log -name "*.log" -mtime -7); do gzip "$f" done这个写法实用性很高,但同样的空格问题依然存在。若文件名里带空格,$(find ...)会炸。更稳的做法是用find的-exec直接处理,或者用while read读取find输出:
find /var/log -name "*.log" -mtime -7 -print0 | while IFS= read -r -d '' f; do gzip "$f" done-print0配合read -d ''是比较高级的写法,它的原理是用空字符而不是换行来分隔文件名,这样可以完美规避空格和特殊字符问题。属于知道以后就回不去的技巧,值得记下来。
7. 常见陷阱与排查技巧实录
7.1 循环变量作用域和子 Shell 的坑
我见过很多新手同学写出这样的代码:
echo "统计结果: $total"结果是 0,非常迷惑。原因就是前面提到的管道加while会创建子 Shell。排查思路很简单:不要用cat file | while ...,改成while ...; done < file。如果是函数内部或脚本块内需要保留变量,也可以用进程替换while ...; done < <(command),这样while之前不会被启动为子 Shell。
7.2 IFS 引发的分词问题
IFS 是 Shell 的字段分隔符,默认包含空格、制表符和换行。遇到文件内容带空格时,分词的锅经常是 IFS 没设对。最简单的处理方法是读行时临时设置IFS=,甚至可以这样:
oldIFS=$IFS IFS=' ' for line in $(cat data.txt); do echo "$line" done IFS=$oldIFS这个做法是把 IFS 改为只按换行分,避免行内空格被拆分。但用while IFS= read -r line会更稳妥,因为-r保留了原字符串内容,不经过额外的分词处理。总之涉及文件内容的遍历,优先考虑是否需要对 IFS 做调整,这是一个非常有用的排查方向。
7.3 循环体内变量更新不生效
除了子 Shell 之外,还有一种情况会让循环内的变量“看起来没更新”:你在循环里修改了$i,但循环体下一次迭代时又把它重新赋值了。比如:
for i in 1 2 3 4 5; do i=$((i + 10)) echo "$i" done这里输出的内容是 11 12 13 14 15,但因为 for 每一次都是自动从列表取下一个值,你在循环体内修改i并不会改变列表的迭代顺序。这不算 bug,是 for 循环的既定行为。真要按顺序控制步进,建议用 C 风格写法for ((i=1; i<=5; i++)),或者改成while自己维护计数器。
7.4 循环与空文件/空列表的边界
当要处理某目录下无数个匹配文件时,for f in *.txt会把字面意义上的*.txt当作唯一条目传给循环体。于是你就会看到循环尝试去处理一个不存在的文件名的情况。最常用的写法是加一个存在的判断:
for f in *.txt; do [ -e "$f" ] || continue # 处理逻辑 done还有一种是通过nullglob选项把没有匹配时的情况变成空列表:
shopt -s nullglob for f in *.txt; do # 这里 f 为空就不会进入循环 done虽然nullglob让代码更干净,但它在某些老版本环境下不可用,脚本要兼顾兼容性时还是建议用[ -e "$f" ] || continue的方式。
7.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 循环结束后变量仍为空 | while在管道中导致子 Shell | 改用重定向或进程替换 |
| 文件名带空格被拆分 | 使用了ls或命令替换扩展 | 用路径展开或find -print0 |
| 循环内 sleep 被跳过 | 某些信号可能中断 | 检查脚本是否被trap或系统信号打断 |
| 文件内容首尾多出空格 | 默认 IFS 切分 | while IFS= read -r line |
| 循环处理空列表报错 | 通配符未匹配到任何文件 | 加[ -e "$f" ]或nullglob |
| 后台进程写入同一文件混乱 | 并发写竞争 | 分开存独立文件再合并 |
| 循环执行特别慢 | 每次迭代都启动外部命令 | 合并命令、减少 fork 或并行化 |
8. 循环性能优化与风格建议
8.1 减少不必要的子进程
Shell 里每个外部命令的启动都会 fork 一个进程,循环体要是连续执行几千次,启动进程的时间会明显拖慢脚本。一个常见的优化思路是尽量用 Bash 内置能力完成简单判断,比如[[ ]]和(( ))都是内置语法,比调用test、expr要快。
看一段对比:
# 慢版本,每次都调外部计算命令 count=0 for f in *.txt; do count=$(expr $count + 1) done # 快版本,用内置算术扩展 count=0 for f in *.txt; do ((count++)) done在这种简单计数场景里,expr和$(())的性能差距肉眼可见。微优化也许不会对一个几十行的小脚本产生质变,但要形成好习惯,遇到大数据量循环时就能厚积薄发。
8.2 用批量命令减少循环层级
有时候不一定要用循环。比如要修改一批文件的权限,其实直接chmod 644 *.txt就够了。循环的价值在于逻辑判断和动态构造,如果一个外部命令本身就支持通配符和多个参数,没必要强行用循环包一层。这也是对 Shell 设计理念的尊重:能用一行命令表达清楚的事情,尽量不写成五行的循环。
8.3 循环里打印进度的实用技巧
脚本跑大批量任务时,用户最希望看到进度。最朴素的做法是在循环体里打印当前编号:
total=$(ls -1 *.txt | wc -l) i=0 for f in *.txt; do i=$((i + 1)) echo "处理中 $i/$total: $f" # 业务逻辑 done如果不想刷屏,也可以每次覆盖当前行,用 carriage return 实现:
for f in *.txt; do printf "\r正在处理: %s" "$f" # 业务逻辑 done echo ""这种细节看似无关紧要,但脚本给同事用的时候,良好的进度反馈会让大家更愿意用命令行的工具,而不是来回问“是不是卡死了”。我自己写脚本也越来越强调这一切:给用户清晰可见的状态,是一种工程素养。
9. 我把循环用在哪:几条实在的使用心得
项目里用得最多的循环,永远是把重复的人工动作变成可复用脚本。很多人只盯着语法书上的例子,一写真的业务逻辑就发怵,根源在于对“循环里到底该放什么命令”没有感觉。我的心得是:先把要做的业务动作列出来,一个动作一条命令,再把这些命令丢进循环体,并补上退出条件和异常分支,一个合格的脚本就出来了。
写循环脚本时最忌讳藏着掖着。几行能解决的日志统计,非要写出来五十行,不仅别人看不懂,自己过两个星期再看也容易忘了设计思路。所以我强烈建议在循环体里给关键判断加上注释,特别是那些跳过的条件和需要保留变量作用域的写法。注释多了运行不会变快,但不至于让脚本变成“一次性文物”。
如果你刚上手 Shell 脚本,我建议你先别追求什么炫技写法,从最简单的for循环开始,人为造一批测试目录和文件,把遍历、判断、统计、清理这些流程跑通,再逐步引入while、select、后台并行这些进阶玩法。循环这东西,多写几遍自然就熟了,比起背语法,亲手去改一个真实需求里的批量任务,效果要快得多。