写Shell如果只会按顺序敲命令那顶多算个“打字员”能熟练用循环把几十台服务器的日志一次性拉下来、把几千个文件批量改名、把一个巡检脚本挂在后台跑一整夜才算摸到了自动化的门槛。循环语句就是Shell编程里最值得花时间啃透的一块原因很简单几乎你写的每一个稍微有点实际价值的脚本都离不开遍历、轮询和重复处理。这篇内容我会把for、while、until三种循环从语法到实战细节全部过一遍重点讲文档里不会写的坑和排查思路适合刚接触Shell想系统补基础的同学也适合已经写过些脚本但经常被“变量丢了”“死循环了”“文件名字被拆了”这类问题折磨的运维和开发朋友。1. 为什么循环是Shell编程的分水岭思路与选型1.1 循环要解决的四大日常场景循环的本质就一句话把同一个操作作用到一批不同的对象上。这个“对象”可以是文件、目录、主机名、端口号、日志行也可以是不断变化的状态值。我平时在服务器上干活循环最常出现在四类场景里。第一类是批量处理比如对某个目录下的所有.log文件做压缩对所有节点执行同一句远程命令给一批新入职的同事批量创建系统账号。第二类是日志和数据统计典型写法就是for line in $(cat access.log)这类遍历把每一行拆出来匹配、计数、汇总。第三类是状态轮询比如等待某个服务起来、等待某个文件被写入、等待数据库某个表的数据同步完成这种场景天然就是“每隔几秒检查一次不满足条件就继续等”的循环逻辑。第四类是重试机制调用外部接口、执行远端命令、下载资源失败了一次不代表永久失败给它自动重试三次才下结论这种逻辑没有循环根本没法优雅实现。你会发现这四类场景没有一类是单条命令能搞定的。它们共同的特点是“数量不确定”或“状态未知”而循环语句就是为这种不确定性设计的。理解这一点你就能明白循环选型不是死记语法而是先想清楚自己要面对的数据是已知集合还是动态条件。1.2 for、while、until怎么选很多初学者背着三种循环的语法却分不清什么时候用哪个。这里我给出一个经过大量实践验证的选型逻辑for循环当你知道要处理哪些东西或者至少知道要循环多少次时用for。比如遍历目录下全部文件、循环1到100、遍历命令输出的每一行。for的语义是“我有一堆东西挨个处理”。while循环当循环次数取决于某个条件是否成立时用while。比如“只要文件还没有写完就一直读”“只要服务还没起来就一直ping”“只要计数不等于5就继续”。while的语义是“只要条件为真就一直执行”。until循环当你想表达“一直做到某个事情发生为止”时用until。它和while正好相反条件是假的才进循环体条件一旦变真就退出。语义是“傻等到那一刻”。真实项目里while的使用频率明显高于until因为大部分状态检查都适合用“条件为真继续执行”来描述。但是until在“等待某个进程退出”“等待某个文件出现”这类场景里写出来的代码可读性比while更好因为它的字面意思就是“直到...才停”。2. for循环的核心实操从基础语法到C风格2.1 五种常见写法一次捋清楚for循环的语法变化比较多我按照从常用到少用的顺序列出来每种都配一个最小可运行示例。第一种直接遍历一个列表for ip in 192.0.2.10 192.0.2.11 192.0.2.12; do echo checking $ip done这种写法的特点是列表元素在命令中直接写死适合数量少、不会变化的场景。注意元素之间用空格分隔如果元素本身包含空格就要用引号包起来这一点后面专门讲。第二种借助命令替换生成列表for file in $(ls /var/log/*.log); do echo $file done或者更常见的是遍历一个文本文件里的主机名for host in $(cat hosts.txt); do ping -c 1 $host done这种方式非常灵活它是把外部命令的标准输出按照空白字符切分后逐个作为循环变量。但切分逻辑和“空格”有关一旦文件名或行内容包含空格就会出现意想不到的拆词问题这里的坑我们单独一节来说。第三种使用大括号展开for i in {1..10}; do echo num: $i done大括号展开的语法{1..10}在bash里会被展开成1到10的序列也可以反向{10..1}还可以带步长比如{1..10..2}得到1、3、5、7、9。这是需要生成连续数字序列时最快最清晰的方式但要注意两点一是它要求bash版本不低于3.0老旧系统上的sh不支持二是如果数字范围特别大比如{1..1000000}展开过程本身会占用较多内存不如用下面的seq方式。第四种使用seq命令for i in $(seq 1 2 10); do echo odd: $i doneseq 1 2 10输出1、3、5、7、9适合需要灵活控制步长的场景。seq比大括号展开更通用在任何Shell环境下都能用代价是多启动一个外部命令但对现代服务器来说这点开销完全可以忽略。第五种C风格for循环for ((i 1; i 10; i)); do echo count: $i done这种写法从C语言移植过来最接近传统编程语言的循环习惯。它需要用双括号括起来循环变量初始化、条件判断、步进修改都写在(( ))内部。它的优势在于可以方便地做复杂的条件控制比如for ((i 10; i 0; i - 2))这在处理需要倒序或跳步的逻辑时特别顺手。我自己的习惯是涉及数字索引且逻辑稍微复杂一点的场景直接用C风格读起来直观得多。2.2 最容易翻车的空格与分隔符问题for循环最大的一个暗坑就是对“列表里含有空格或者通配符展开结果中含有空格”的处理。举一个我踩过的真实例子。某次我需要批量处理服务器上的一批日志目录目录名长成“app 2024-01-01”这种带空格的格式。我一开始写了for dir in $(find /data/logs -type d); do echo $dir done结果一个目录名在循环里被拆成了两个独立的值后续的压缩命令全部出错。原因就是$(find ...)输出的整行文本会被Shell按照IFS内部字段分隔符切分成多个字段默认的IFS包含空格、制表符和换行所以带空格的目录名没法完整保持。解决办法有三种。第一种对待处理的元素统一加引号但问题是循环变量已经被拆开了引号救不回来已经被切碎的数据。第二种临时修改IFS只按换行切分IFS$\n for dir in $(find /data/logs -type d); do echo $dir done这里的$\n是bash里表示换行符的写法把IFS改成只包含换行空格就不会再触发分词了。但要注意这种改法是全局的建议在使用前存下原值、用完恢复否则会影响脚本里后续所有命令的分词逻辑。第三种更推荐的方案是凡是涉及文件遍历优先考虑用find ... -exec或者while read来处理而不是让for去接find的文本输出。比如上面的场景改成find /data/logs -type d -print0 | while IFS read -r -d dir; do echo $dir done-print0让find用空字符而不是换行来分隔结果read -d 告诉read也用空字符作为定界符这样无论文件名里有什么特殊字符都能安全传递。这个模式在处理文件名空间、换行、特殊字符时是真正稳妥的做法。for循环里另一个常见的坑是遍历某个目录下的文件时如果目录里一个文件都没有通配符*.log不会被展开成空列表而是被当作字面字符串“*.log”传给循环体。这时候循环还会执行一次循环变量等于*.log。解决办法是在循环体内加一个存在性判断for file in /var/log/*.log; do [ -e $file ] || continue echo process $file done[ -e $file ]判断文件是否存在如果通配符没有匹配到任何文件$file就是那个字面量-e判断为假continue跳过循环体自然一次都不执行。3. while循环的核心实操条件驱动与逐行处理3.1 三大典型用法计数、轮询、等待while循环的核心是“条件为真就继续”所以写while的第一步永远是想清楚“退出条件是什么”。没有退出条件的while就是死循环。第一种典型用法是计数器控制。这个和for的C风格类似只是写法更古典i0 while [ $i -lt 5 ]; do echo round $i i$((i 1)) done条件判断用了[ $i -lt 5 ]注意变量最好加双引号写成[ $i -lt 5 ]避免变量为空时语法报错。每轮循环末尾必须更新计数器这一步漏了就会无限循环。第二种典型用法是死循环配合break退出这是实现状态轮询的常见结构while true; do status$(curl -s http://localhost/health) if [ $status ok ]; then echo service is up break fi sleep 3 done这里的while true是一个永不为假的条件真正的退出是靠循环体内的break来实现的。好处是逻辑清晰检查、判断、退出都集中在循环体里适合“每隔几秒检查一次状态”这种操作。需要注意sleep 3一定要加否则脚本会疯狂空转白白占满CPU。第三种典型用法是把外部命令的执行结果作为循环条件。Shell允许while后面直接跟一条命令命令退出码为0就认为条件为真。比如数据同步的等待while ! pgrep -f sync_task /dev/null; do echo sync_task not running, wait... sleep 5 done这里用!对pgrep的退出码取反进程不存在时pgrep返回非0取反后为真循环继续进程出现后pgrep返回0取反后为假循环退出。这种写法的语义就是“等到进程出现为止”。3.2 逐行读文件的正确姿势与子Shell陷阱while在Shell编程中最有名的一个应用就是逐行读取文件。基础写法是这样的while IFS read -r line; do echo line: $line done /etc/hosts这里有几个细节值得掰开说。read命令每次从标准输入读取一行赋值给line变量。-r参数表示禁止反斜杠转义也就是说如果行里有\n这种字符不应该被解释成换行而应该保留原样处理日志和配置文件时这个参数几乎必须加。IFS放在read命令前面表示给这条read命令单独设置一个空的IFS这样行首行尾的空格就不会被read自动去掉能完整保留原始内容。注意这个IFS是read命令的前置环境变量赋值不是while的前置条件所以不能随手挪到while后面。文件重定向 /etc/hosts在done的后面表示整个while循环的标准输入都来自这个文件。这样逐行读取文件的方式比在for里用$(cat file)要稳妥得多不仅不会因为空格拆词还能处理大文件因为它是流式的不会一次性把整个文件读进内存。但这里有一个非常经典的坑管道会开启子Shell循环里的变量改动带不出来。举个例子我想统计一个文件里包含“error”的行数count0 cat app.log | while IFS read -r line; do if [[ $line *error* ]]; then count$((count 1)) fi done echo error count: $count这段代码的输出永远是0。原因是cat app.log | while ...这条管道会让while在子Shell中执行子Shell里修改的count变量在管道结束、回到父Shell后全部丢失。这是初学Shell最容易遇到、也最让人摸不着头脑的问题。解决办法有两类。第一类是改用进程替换避免使用管道count0 while IFS read -r line; do if [[ $line *error* ]]; then count$((count 1)) fi done (cat app.log) echo error count: $count (cat app.log)叫做进程替换它把命令的输出包装成一个虚拟文件重定向给while。关键区别是这里的while不在管道里没有进入子Shell所以循环体内对变量的修改在循环结束后依然有效。第二类是把后续依赖这个变量的逻辑全部放进子Shell里在循环结束前把结果输出出来。这种方法适合那些不需要把变量带出来、只想拿到最终计算结果的情况cat app.log | { count0 while IFS read -r line; do if [[ $line *error* ]]; then count$((count 1)) fi done echo error count: $count }用花括号把整个处理逻辑括起来让它在同一个子Shell里连续执行最后在子Shell内输出结果这样就不会遇到变量丢失的问题。这两种方案我实际都用过进程替换更整洁花括号分组更好移植看个人喜好。4. until循环一次把语义补全4.1 until的本质与while的对比until循环在语法上和while几乎一模一样唯一的区别是判断逻辑正好相反until 条件; do 循环体 done条件为“假”时进入循环体执行条件为“真”时退出循环。换句话说while是“条件成立就继续”until是“条件成立就停”。用逻辑表达式来看until cond等价于while ! cond。举个例子等待一个文件生成until [ -f /tmp/ready.txt ]; do echo ready.txt not found, wait... sleep 2 done如果用while写就要写成while [ ! -f /tmp/ready.txt ]; do ...虽然只有一个小小的!差别但读起来确实没有until直观。这种“等待某个外部条件变成真”的场景我个人认为until是表达最贴切的。它对应到日常语言就是“一直做某件事直到那件事发生”。4.2 典型场景等文件、等进程、等条件就绪until最适合的三类场景我分别给出可以直接抄的模板。第一等待文件出现。这在处理任务触发信号、同步标记文件时很常见until [ -f /data/flag/import.done ]; do echo $(date %H:%M:%S) waiting for import.done sleep 5 done echo flag file exists, continue注意这里的风险如果外部程序永远不会创建那个文件脚本就会永远等下去。所以在实际项目中我会加上超时限制。做法是引入一个计数器在达到阈值时主动退出wait_seconds0 until [ -f /data/flag/import.done ]; do if [ $wait_seconds -ge 60 ]; then echo timeout waiting for import.done exit 1 fi echo waiting... ${wait_seconds}s sleep 2 wait_seconds$((wait_seconds 2)) done第二等待进程退出。这常用于脚本里先启动一个任务等它结束后再执行后续步骤until ! pgrep -f start_batch /dev/null; do echo batch still running, waiting... sleep 10 done这里用! pgrep表示“当进程不在时退出循环”配合until的语义就是“直到进程消失”。同样是等进程这一段就比while取反的写法读起来顺畅不少。第三等待网络端口就绪。我写部署脚本时经常要等服务端口起来才能继续做健康检查这种场景用until非常舒服until nc -z 127.0.0.1 8080; do echo port 8080 not ready sleep 2 done echo port 8080 is opennc -z用来检测端口是否可连接可连接时退出码为0until条件变真循环退出。如果服务器上没有nc命令可以用bash自带的/dev/tcp特性until (echo /dev/tcp/127.0.0.1/8080) /dev/null 21; do echo port 8080 not ready sleep 2 done4.3 三种循环的对比与选型速查用一张表把三种循环的定位、触发逻辑和使用场景收拢起来方便日常翻看循环类型进入条件退出条件典型场景代表写法for列表中有未处理的元素列表耗尽遍历文件、遍历主机、固定次数循环for i in {1..10}while条件判断为真条件判断为假状态轮询、读文件、重试、计数while [ $i -lt 5 ]until条件判断为假条件判断为真等待文件出现、等待进程退出until [ -f /tmp/ready ]选型的时候不一定要死板同一个需求用for和while都能实现比如固定次数的循环既可以用for ((i1;i10;i))也可以用i1; while [ $i -le 10 ]。我的建议很简单哪种写法让你明天再看代码时不需要额外思考就选哪种。脚本是写给下一个维护者看的而那个维护者大概率就是三个月后的你自己。5. 循环控制与错误重试break、continue与退出码的配合5.1 break和continue的层级参数循环体里的break和continue是控制循环节奏的两个阀门。break表示立即退出当前整个循环continue表示跳过本次循环的剩余部分、直接进入下一次迭代。它们都可以接一个数字参数表示作用到第几层循环默认是1也就是当前层。嵌套循环里这个数字特别有用。举一个实际场景我要在多个目录里搜索某个配置项只要在第一个找到的目录里命中就直接停止所有搜索。for dir in /etc/app /opt/app /data/app; do for conf in $dir/*.conf; do if grep -q ENABLE_DEBUG $conf; then echo found in: $conf break 2 fi done done这里的break 2表示直接跳出两层循环也就是for和它外层for全部终止。如果只写break它只跳出内层循环外层还会继续换下一个目录搜索不是我们想要的效果。continue同样支持数字参数continue 2会跳到外层循环的下一轮迭代这在处理“跳过整个外层项的剩余处理”时很实用。我见过不少人在嵌套循环里靠标志变量来达到同样效果比如定义一个found1然后每层循环都去检查。这种做法代码冗长还容易错用break加层级数字反而清爽。前提是你要确认自己写的到底是几层嵌套数字写大了没有效果写小了照样停不下来。5.2 重试机制实战自动重试3次间隔递增循环最经典的一类应用是错误重试。日常运维脚本里调用外部接口、执行远端命令、拉取资源包都可能偶发失败一次失败就放弃太脆弱无限制重试又太危险所以通常的做法是“固定次数 固定间隔 超时退出”。我常用的一个模板是这样attempt0 max_attempts3 while true; do attempt$((attempt 1)) if curl -fsS --connect-timeout 5 http://api.example.com/health /dev/null 21; then echo request success on attempt ${attempt} break fi echo attempt ${attempt} failed if [ $attempt -ge $max_attempts ]; then echo ERROR: failed after ${max_attempts} attempts exit 1 fi sleep 2 done拆开看几个关键点。curl -fsS表示静默失败但显示错误、HTTP错误码视为失败--connect-timeout 5防止连接长时间卡住。if直接跟curl命令用命令退出码判断成功还是失败。成功时执行break退出循环失败时先判断是否已经达到最大次数达到就exit 1退出整个脚本没达到则sleep 2后继续下一轮。这里的“间隔递增”需求也很常见比如第一轮失败等2秒重试第二轮失败等4秒第三轮等8秒给下游系统更多恢复时间。实现方式是把sleep时间也做成变量attempt0 max_attempts5 wait_time2 while true; do attempt$((attempt 1)) if curl -fsS http://api.example.com/health /dev/null 21; then echo success on attempt ${attempt} break fi echo attempt ${attempt} failed, wait ${wait_time}s if [ $attempt -ge $max_attempts ]; then echo ERROR: gave up after ${max_attempts} attempts exit 1 fi sleep $wait_time wait_time$((wait_time * 2)) done这种写法把重试逻辑和业务逻辑放到了同一个循环体里结构一目了然。有一点要特别提醒sleep前面的判断不要漏掉否则脚本会在最后一次失败重试后继续sleep然后进入不存在的第6轮循环白白浪费时间。5.3 exit、break和return在循环中的分工循环里涉及退出时有三个关键字很容易弄混exit、break、return。它们的作用范围完全不同。break只退出循环本身脚本继续执行循环后面的命令。exit直接终结整个脚本进程不管你在嵌套多少层循环里一旦执行exit整个脚本终止并返回指定退出码。return是用在函数体里的它退出的是当前函数回到调用函数的地方。如果return出现在主脚本的循环里脚本本身没有函数包裹用return会直接报语法错误。我自己的使用习惯是循环体内的正常分流用break或continue确认整个脚本无法继续时才用exit函数内的循环需要提前返回结果时用return。有一个典型的误用有人想在循环里检测到致命错误后退出整个脚本却写了break结果脚本继续往下跑产生了更严重的副作用。反过来有人只是想在循环里结束一个函数却写exit结果整条脚本被掐断调用方根本拿不到返回值。这三个关键字的区别值得在写每一个循环前快速过一遍脑子。6. 常见问题与排查技巧实录6.1 高频故障速查表把我在项目里遇到过的、以及帮别人排查过的循环相关问题整理成速查表这些案例每一个都是真实踩过坑的建议收藏症状常见原因处理建议管道while里累加的变量循环结束后变成初始值管道两侧在子Shell执行变量作用域隔离改用进程替换 (command)或把逻辑整体放子Shell里for遍历带空格的目录名目录被拆成多个值默认IFS包含空格命令替换结果被分词临时调整IFS为换行文件遍历场景改用find -print0和while read文件最后一行没有被处理到文件末尾缺少换行符read读到残留内容时遇到EOF确认文件格式必要时在while结束后单独处理最后一行脚本明明写了循环却只执行了一次条件判断里变量名拼错条件恒为假用bash -x查看每条命令展开后的值循环无限跑停不下来循环体内缺少退出条件或条件永远为真加入计数器、sleep、超时判断必要时trap异常信号从Windows编辑器粘贴的脚本执行报错提示找不到命令文件是CRLF换行行尾\r被当成命令一部分执行sed -i s/\r$// script.sh或dos2unix script.sh这里面最值得反复说的是第一行。子Shell变量丢失这个问题新手遇一次基本都会懵几个小时因为它不报错、不警告就是结果不对。你真的想弄明白这个问题可以写一个最小复现脚本把cat file | while和 (cat file)放在同一个脚本里对比输出跑一次就透彻了。6.2 我常用的三个排查工具与习惯写循环脚本时我的固定动作是先过shellcheck再用bash -x跑一遍关键路径最后在循环体里临时加echo输出中间变量。shellcheck是一个Shell脚本静态检查工具很多发行版的软件源里直接就能装。它对循环类问题的识别非常敏锐比如能指出管道里while的SC2030和SC2031问题、for循环里变量没加引号的SC2086问题还会提醒你某些bash特性在sh环境下不可用。我见过太多“在bash下跑得好好的换到sh就全线崩溃”的脚本shellcheck在早期就能把这些坑标出来。bash -x script.sh是调试运行的利器。它会逐行打印每条命令展开后的实际内容比如for i in {1..10}会先展开成for i in 1 2 3 4 5 6 7 8 9 10[ $i -lt 5 ]会输出展开后的具体值。配合set -x在脚本内部启用也是一样的效果。看展开后的输出基本能定位九成以上的逻辑错误比如变量没赋值、条件写反、循环变量被意外覆盖等。还有一个习惯是“干跑”。正式执行批量写操作比如批量改名、批量删除、批量执行远端命令之前先用echo把要执行的原命令打印出来人眼确认一遍无误后再把echo去掉换成真实操作。这个习惯听着简单但在循环场景里价值极高因为循环会把一条错误命令放大几十倍甚至上千倍地执行。6.3 一个真实重构案例日志关键字巡检脚本之前接手过一段同事遗留的巡检脚本它的任务是扫描某业务集群里所有服务器的应用日志统计包含“FATAL”的行数超过阈值就告警。原始实现是这样的for server in $(cat servers.txt); do ssh $server grep -c FATAL /var/log/app/app.log done表面上看挺简洁实际用起来却有三个问题服务器列表里如果某个主机名暂时解析不了整条命令会卡很久grep -c只输出计数没法区分是0个FATAL还是文件不存在如果日志文件名随日期变化这段脚本就必须每天改。我重构后变成了这样while IFS read -r server; do [ -z $server ] continue count$(ssh -o ConnectTimeout5 -o BatchModeyes $server \ grep -c FATAL /var/log/app/app.$(date %Y-%m-%d).log 2/dev/null || true) echo $server: $count if [ ${count:-0} -gt 50 ]; then echo ALERT: $server has too many FATAL lines fi done servers.txt改动点都对应了前面讲过的原则。用while逐行读servers.txt避免for因空格拆行加了ConnectTimeout避免连不上的主机拖时间|| true防止grep找不到文件时因为非零退出码打断脚本日期用命令替换自动生成每天的文件名。这个脚本后来稳定跑了很久没有再出过之前那些幺蛾子。个人经验上循环脚本里最容易出事的往往不是语法本身而是“数据里有什么”你根本预料不到。服务器列表里有空行、文件里某一行以特殊字符开头、变量在子Shell里被静默复制——这些问题只有真跑一遍才知道。所以我的习惯是用bash -x盯紧变量展开用shellcheck做静态扫描然后在正式任务前干跑一次只打印不执行。这三级防线加下来循环脚本踩坑的概率能降一个数量级。
阅读完成 · 觉得有帮助?