Shell重定向完全指南:从文件描述符到管道与进程替换的工程实践

Shell重定向完全指南:从文件描述符到管道与进程替换的工程实践 重定向在Shell里也算是一个人人都见过、但多数人没吃透的东西。刚入门的同学可能知道 log.txt是输出到文件但一旦看到21、exec 3这类写法就完全懵了。我在实际项目里见过不少线上脚本出事故起因往往就是重定向上那一点细节没弄对——比如把日志文件搞丢、覆盖了不该覆盖的配置、或者因为缓冲问题导致日志迟迟刷不出来。这篇就把我这些年用重定向的经验做一个系统整理覆盖原理、操作符用法、进阶技巧进程替换、命名管道、实际脚本组合场景以及一些坑和排查方法希望你读完能直接拿去用。1. 先搞懂重定向到底重定向到哪里文件描述符才是核心重定向这名字挺容易误导人它的本质不是定向而是把一个文件描述符FD与某个文件或设备绑定在一起。Linux/Unix体系里每个进程一开始都会打开三个默认的描述符0标准输入、1标准输出、2标准错误。你在Shell里做的各种重定向本质上都是在改变这三个或者额外添加新的描述符指向哪里。大多数资料讲重定向都是先给一张符号表然后让人背。坦白讲我见过不少人确实背下了21和file 21但换个场景就不知道顺序为什么要这样排。原因在于Shell解析重定向时是从左往右执行的每碰到一个符号就立即修改对应的描述符指向。如果你先写21、再写file那1号描述符被改到file之后2号还指着旧的1号最后标准错误依然会打到终端上。反过来先file 212才会跟着指向文件。除了理解顺序还需要知道Shell对描述符的操作底层都是dup2系统调用完成的。这个系统调用的意思就是把旧描述符的内容复制到新描述符上使两者指向同一个打开的文件描述其中包含同一个文件偏移量。这带来一个很有意思的副作用如果你用exec 31把3号FD绑定到当前屏幕上再切到别的地方之后用exec 13切回来会特别方便。很多脚本开头会做类似操作本质上就是在用dup2制造备份。文件描述符看上去只是一个整数但背后关联着一个内核维护的打开文件描述结构里面保存了读/写偏移、文件状态标志等。这个细微差别导致了我们后面会讲的一个经典场景为什么用tail -f看脚本输出时日志一直在更新而用某些方式重定向到文件后输出却不实时可见—— 这和缓冲有关但也和描述符对应的文件状态有关。再补充一个底层知识点每个进程能打开的描述符数量是有限制的用ulimit -n可以查看。虽然默认通常是1024但在写脚本时如果反复打开文件而不关闭文件描述符泄漏可能让你在某个时刻突然遇到Too many open files错误。这不是重定向本身的问题但很多脚本工程师排查了半天最后才发现是循环里重定向没关导致的。理解描述符之后所有重定向符号就不需要死记了。它们的逻辑其实很统一把1号描述符指向文件创建或截断把1号描述符指向文件创建或追加把0号描述符指向文件2把2号描述符指向文件MN把M号描述符指向N号描述符当前所指的位置file是1file 21的简写但和分开写在解析细节上有微小区别后面踩坑部分再讲把这些符号都翻译成把几号描述符指向哪里再看任何复杂重定向表达式思路都会清晰很多。2. 操作符速查与正确姿势从单文件到复合重定向这一节把实际工作中最常用的重定向写法拉出来逐个过一遍并标出哪些是我个人强烈建议的、哪些是看着可以但容易出问题的。2.1 基础三件套、、这是最初级的三个符号但还是有两个细节值得提醒。第一不是新建或清空这么简单。如果你输入 fileShell会在打开文件的时候就把它的长度截为0。这个特性其实常被用在脚本里做一个快速清空文件的操作代替cat /dev/null file而且它的表现比truncate -s 0 file还要直接。不过这同时也意味着当你写command config.txt时如果命令执行失败配置文件也已经被清空了。我见过有人在初始化脚本里用重定向生成配置运行失败后发现原配置全没了就是这个原因。所以涉及重要文件时建议先输出到临时文件再mv覆盖或者至少提前备份。第二是追加模式。这里容易忽视的是追加模式下每次写入都会定位到文件末尾所以即使两个进程同时向同一个日志文件追加内容不会出现互相覆盖的情况但行交错还是可能的。对于单条脚本来说天然适合记录流水型日志。的应用场景主要是让程序从文件读取输入比如mysql init.sql。它本身没什么坑但要注意如果输入文件不存在Shell会在运行命令之前就报错而且这个报错的输出是给终端的不属于你脚本内的标准错误。很多脚本新手在set -e模式下没搞懂为什么文件不存在时脚本就提前退出了其实就是这个原因。2.2 标准错误的处理2、21、日常写脚本最常用的其实是把标准输出和标准错误一起记录到日志里。这里有几种等价写法command log 21这是最稳的写法兼容所有POSIX Shell。command logbash/csh的简写更短但可读性略差。command 21 | tee log既记录到文件又保持终端可见。顺序问题前面已经提过。这里补一个很冷门但真实的坑在早期bash版本里file虽然看起来是file 21的简写但解析方式其实略有差异某些极端情况下比如文件打开失败表现会不一样。但现代bash4.x之后基本已对齐不必过于担心。不过为了让脚本有更好的可移植性我还是建议统一写成file 21。另一种常见需求是只丢弃标准输出、保留标准错误常见写法是command /dev/null如果你想同时丢弃两者可以写command /dev/null 21这里/dev/null是一个黑洞设备往里面写多少数据都不会占磁盘。写脚本时我还经常把它作为用来判断命令是否成功但不在乎输出内容的兜底方案。配合if command /dev/null 21; then ...可以干净地判断一条命令是否返回成功。2.3 Here Document与Here String把多行文本喂给命令写脚本最实用的技巧之一就是用here documentEOF向命令或变量喂入多行内容。它本质上也是输入重定向只不过输入源不是文件而是脚本里的一段文本块。基础的用法是cat EOF /etc/xxx.conf key1value1 key2value2 EOF这个写法在部署类脚本里几乎是标配动态生成配置文件不需要外置模板文件。里面有几个变量特性要注意不转义变量默认情况下here document中的$VAR会展开成当前Shell环境的值。这也是它能动态生成的底气。加引号则不展开如果你写EOF里面的所有内容就变成纯字面量$VAR、反引号都不会执行。这在生成脚本模板、或者包含大量特殊符号的报文时非常有用。-EOF忽略前导制表符方便在缩进风格严格的代码里嵌入文本。举个例子我要在脚本里生成一段包含当前时间戳的配置cat EOF /tmp/app_${DATE}.conf server_name${HOSTNAME} listen_port${PORT} EOF这里$DATE、$HOSTNAME、$PORT会被展开。如果想生成的配置里需要带上$字面量比如Java系统属性就一定要给这个EOF加引号cat EOF /tmp/java_opts.conf JAVA_OPTS-Xms${HEAP_SIZE} -Xmx${HEAP_SIZE} EOF如果不加引号${HEAP_SIZE}会被当成变量展开成空配置就错了。这种问题在生成Spring Boot启动脚本、Nginx配置等场景非常容易踩到。here string是bash提供的一种更简单的输入方式grep error $LOG_CONTENT我自己的习惯是如果只是想把一个字符串变量作为标准输入传给一条命令首选here string因为它不需要为定界符另起一行代码更紧凑。不过要记住这是bash特性shPOSIX模式里不一定支持。2.4 临时描述符的复制与移动31和exec配合有些脚本需要在执行过程中临时改变输出方向结束之后还原。最优雅的做法是借助一个额外的描述符做存档。经典场景是脚本整体输出进日志但中间有一段提示必须显示在终端上。exec 31 # 把3号描述符保存为当前终端的输出 exec 1 run.log 21 echo 这条会进日志 exec 13 # 还原标准输出 echo 这条会出现在终端运行的时候终端上只会看到第二行echo而日志文件里则记录了第一条消息和所有中间命令的输出。执行exec 13之后1重新指向终端但注意这时最好exec 3-把3号描述符关掉否则这个描述符在整个脚本周期里都会占着也会造成FD泄漏。exec本身在这里除了重定向外还承担当前Shell进程中生效的功能。你直接在交互式Shell里执行exec 1file当前终端的所有输出都会进文件——这也是常见的一个小把戏用exec让一段脚本内所有命令的输出都走同一个重定向而不是每条命令都写一遍。这个模式我几乎是每个工具脚本都会用。特别适合那些整体日志落盘关键进度上终端的运维脚本。3. 进阶技巧一进程替换与命名管道解决给命令喂另一个命令输出的痛点3.1 进程替换(command)和(command)的妙用进程替换Process Substitution这个概念经常被忽视但它能解决很多管道做不到的事。管道最大的限制是它只能把标准输出接到标准输入但某些命令要求文件路径参数。比如diff需要两个文件参数你怎么直接比较两个命令的输出传统做法是输出到临时文件再比较其实有更干净的方案diff (cat a.txt | sort) (cat b.txt | sort)这里(...)会启动括号里的命令并让Shell在/dev/fd下创建一个虚拟文件路径把命令的输出当作这个文件的内容传给外层命令。外层命令把路径当成普通文件来读得到的就是内层命令的标准输出。除了diff我还常用它来处理只接受文件路径、不接受标准输入的场景比如while read line; do ... done (find /var/log -name *.log | head -20)这里如果把(...)换成普通管道子Shell的变量在循环外就读不到但进程替换可以规避这个坑。它允许while循环在当前Shell中运行所以循环里修改的变量在结束后还能保留。(command)则是反向用法让你把一段输出喂给命令同时还可以继续追加。举个例子tee (grep ERROR error.log) all.log这样可以同时把数据写到all.log又让错误行通过进程替换分流到error.log。不过这种写法的可读性稍差我用的时候都会加注释。进程替换在bash和zsh里都支持但注意POSIXsh不支持。3.2 命名管道FIFO跨命令协作的另一种思路命名管道是文件系统里一个特殊类型的文件p类型它像一个队列一个进程往里写另一个进程从里读。和普通管道最大的区别是普通管道没有名字、只能在创建者和消费者之间单向传输命名管道有一个路径任何进程都可以打开它。创建方式mkfifo /tmp/my_fifo然后你可以在一个终端往里面写cat data.txt /tmp/my_fifo在另一个终端读cat /tmp/my_fifo注意FIFO在没有读者的时候写入者会被阻塞。这不是bug而是一种同步机制。我做过的一个数据处理脚本里为了让两个进程之间可以来回交换数据就用FIFO做双向管道比临时文件更可靠而且避免写磁盘。不过我要提醒一句对大多数日常脚本来说进程替换已经够用不需要动不动上FIFO。FIFO更适合数据流水线有多个处理阶段且希望这些阶段可以独立启停的复杂场景。如果你只是实现简单的两命令协作用管道解决就行。3.3 我为什么说进程替换是更不容易出bug的写法很多人写脚本遇到需要把命令输出当作文件传给另一个程序时习惯用临时文件cmd1 /tmp/tmp1 cmd2 /tmp/tmp2 diff /tmp/tmp1 /tmp/tmp2 rm /tmp/tmp1 /tmp/tmp2问题在于如果脚本中途异常退出临时文件就留在那了如果两个脚本并行可能互相覆盖。进程替换直接规避了这些问题因为虚拟文件随进程结束自动消失。更重要的是进程替换天然节省磁盘IO。如果你处理的是一次几百MB的日志临时文件方案要先把全部内容落到磁盘而进程替换是边产生边消费实时性高得多。说回命名管道它和进程替换是两种不同定位的东西。进程替换适合单次喂入命令FIFO适合长周期、多进程、持续协作。写脚本时不要混为一谈。4. 进阶技巧二在脚本中综合运用重定向——日志、循环、数据流转的实战组合理论说了这么多还是得看实际场景。我把自己在脚本开发中经常用到的几个组合场景写下来每个场景都能直接抄走改改用。4.1 场景一给整个脚本做日志终端双输出我曾经接手过一个部署脚本原版是每条命令都分别写 log.txt结果不仅代码冗余而且漏记很多内部函数的输出。我的改造方案是用重定向tee结合#!/bin/bash log_file/var/log/deploy_$(date %Y%m%d_%H%M%S).log # 打开一个文件描述符6后续用它来还原标准输出 exec 61 # 让所有输出同时进文件和终端 exec 1 (tee -a $log_file) 21 echo 开始部署... ...这里用到进程替换(tee -a $log_file)让所有标准输出变成同时写文件和终端。普通重定向只会走文件终端看不到进度用tee可以两个都保留。配合exec 1整个脚本里的所有命令输出都会自动做这种分发无需逐条命令处理。有一个细节用exec 1 (tee -a ...)这种写法时由于进程替换里的命令在子Shell中运行脚本退出时可能来不及刷新所有缓冲导致日志尾部缺失。我的做法是脚本末尾加一个wait或者sync指令。另外如果不需要终端输出直接用exec 1$log_file 21更稳、性能也更好。4.2 场景二循环内收集日志并按时间切分数据处理脚本里经常要循环读取一批文件逐个处理后把结果记录下来。容易踩的一个坑是每次循环都重新打开文件句柄性能很差还可能导致文件描述符耗尽。推荐的写法是循环外统一打开描述符循环内直接往描述符写入。exec 3 /var/log/process.log for file in /data/input/*.txt; do result$(process $file) echo [$file] $result 3 done exec 3-这段代码在循环开始前只做一次exec 3log循环内几十次甚至几百次的echo都指向同一个已打开的文件描述符。相比在循环里用echo $data /var/log/process.log需要在每次迭代重新open文件这种方式更快也能避免因文件被外部删除而出现句柄失效的问题。如果你想按日期切分日志可以在循环里判断日期变化后重新打开描述符CURRENT_DATE while read line; do DAY$(date %Y%m%d) if [[ $DAY ! $CURRENT_DATE ]]; then exec 3/var/log/process_${DAY}.log CURRENT_DATE$DAY fi echo $(date %H:%M:%S) $line 3 done input.txt注意这里exec 3在重新打开之前一定要先exec 3-关闭旧描述符否则两个描述符指向不同文件内容会交错。关闭后再重新打开才安全。4.3 场景三从命令输出读取数据并保留变量——进程替换的正确打开方式在日常脚本中从命令输出逐行读取数据并期望循环结束之后仍然保留某些变量是很常见的需求。最初我常常写成cat data.txt | while read line; do total$((total 1)) done echo $total但结果total永远是0。原因是管道会创建一个子Shell循环里的变量修改只发生在子Shell中父Shell完全无感。这是一个特别经典、且咨询量极大的坑。解决方式之一是用进程替换注意对比管道和进程替换的差异while read line; do total$((total 1)) done (cat data.txt)这里(...)不是管道它让while循环运行在当前Shell进程里所以total的修改会保留下来。另一个更简单的方案是用和命令替换但要注意大数据的限制while read line; do total$((total 1)) done $(cat data.txt)这两种写法我现在已经彻底替代了管道式while循环。凡是需要在循环里累计结果、修改变量的场景我都避免使用| while组合。包括对文件逐行处理之后要生成汇总报表的场景也一样。4.4 场景四数据行转列——利用tr和重定向做简单清洗重定向不只是发往文件它也可以与命令组合做数据流加工。一个经典需求是把一个多行的数据变成用逗号分隔的一行。传统做法cat list.txt | tr \n , oneline.txt这里tr从标准输入读取通过管道接到标准输出再由重定向写进文件。整个过程就是标准数据流的流转文件 - tr命令 - 文件。在实际脚本中更常见的写法是先清洗再重定向进变量users$(grep allowed auth.log | awk {print $3} | sort -u | tr \n ) echo allowed users: $users report.txt用管道串联多个命令最后用$(...)收集输出这是日常脚本的基础操作。很多人把这种做法看作命令替换而不是重定向其实底层都是同一个标准输入输出模型每个管道符号都在重定向上一个命令的标准输出到下一个命令的标准输入。4.5 场景五给子进程传文件描述符避免多次打开配置文件一个比较复杂的场景你需要在脚本里启动一个后台守护进程并且希望它继承指定的输出描述符。Shell默认情况下后台进程会继承当前Shell已经设置好的描述符exec 3 /var/log/daemon.log (sleep 100 echo task done 3) 这个后台子进程里的3号描述符指向同一个文件因而无需在子进程里重新打开。这在写一些单例任务调度脚本时非常有用。不过有一个注意点后台进程如果不停产生输出而Shell脚本已经退出这个进程还持有文件描述符会导致日志文件一直被占用即使你在外部用rm删除该文件空间也不会释放。所以我通常在启动后台任务前会先考虑是否需要给它单独的重定向而不是让后台进程继承日志FD。区分好脚本内的输出与后台任务的输出能避免不少线上事故。5. 重定向相关的踩坑清单从排查POST到解决方案重定向看似简单实际坑特别多。这一节我把多年遇到的高频坑整理成一个现象—根因—解法的排查清单如果能帮你在出问题时少花一两个小时那这篇文章就值了。5.1 输出顺序错乱标准输出有缓冲标准错误没有现象同一脚本里echo普通日志和echo错误信息你重定向到文件后发现错误信息全部出现在前面普通日志反而在后面顺序和代码里写的完全不一致。根因标准输出默认是全缓冲block-buffered而标准错误默认是无缓冲unbuffered。当输出被重定向到文件时标准输出会攒够一定大小通常是4KB才写一次而标准错误是立即写入。因此时间上错误信息先落盘普通日志后被刷出来导致顺序错乱。解法要么统一用stdbuf -oL或者stdbuf -eL设置行缓冲要么在关键点手动sync或fflush。最常见的是这样stdbuf -oL -eL your_command log 21如果你在自己的脚本里用echo也可以用一个更简单的技巧在需要强制刷新的地方先看看文件确认更稳的是直接exec 1 (tee ...)但同样有缓冲问题。因此凡是需要实时tail -f查看日志的场景我都建议把外部命令用stdbuf -oL包一层保证日志按行输出即时落盘。5.2nohup和重定向的关系为什么nohup命令没输出现象用nohup script.sh log 21 启动后发现log文件一直是空的或者没生成。根因nohup只负责让进程忽略SIGHUP信号并不负责重定向。如果你的脚本执行太快已经跑完或者你的命令本身就往no nohup.out方向走了就可能出现文件为空。更常见的坑是在子Shell里使用nohup ... 父Shell退出后子进程可能立刻收到SIGHUP如果没有正确重定向到文件。而某些系统的nohup会默认把输出追加到nohup.out而不是你指定的日志文件——当标准输出是终端时nohup会自动把输出写到./nohup.out如果你在命令行先写log 21nohup才会把输出交给你的log文件。解决方式其实很简单重定向优先级最高先写重定向再考虑nohupnohup bash script.sh /tmp/myscript.log 21 不要写成nohup bash script.sh /tmp/myscript.log 21这样重定向只作用于后面的命令等于没生效。5.3 清空文件却提示Text file busy或者设备或资源忙现象在脚本里把正在运行的进程所持有的日志文件用 log清空有时候会报错甚至清空后进程仍在往旧位置上写导致磁盘空间不释放。根因一个进程打开文件后如果外部把文件清空但进程并没有重新打开文件它仍然持有旧的inode后续写入会继续写到那个已经被截断或删除的inode上。对 log来说它把文件长度截为0但进程的文件偏移量仍然在原来的位置之后会继续写内容形成一个稀疏文件。如果你用rm log删掉该文件进程依然写着这个inode直到进程退出磁盘空间都不释放。解法需要轮转日志时正确做法是使用logrotate或手动复制并模拟copytruncatecp log log.old : log先复制、再截断。这样进程继续写原文件内容从当前偏移量继续而你保留了一个旧内容的备份。对于持续写日志的服务也可以在脚本侧实现检测到文件被替换就重新打开的逻辑但最简单可靠的做法还是logrotate里的copytruncate模式。写自动化脚本时建议优先采用先mv再重启进程或发送信号让进程重新打开文件的标准轮替方案。5.4 在循环里重定向造成文件描述符耗尽现象脚本跑了一会儿突然报Too many open files但文件明明没那么多。根因在一个for/while循环里做command file每次执行都会打开文件然后再关闭。如果命令非常多且Shell没有及时关闭句柄或者进程的FD限制太小就会出问题。这种情况常常出现在循环次数上万甚至上百万的数据处理任务中。解法一循环外统一重定向前面场景二已给出。解法二如果确实需要在循环内每次写不同文件要确保命令本身执行完之后Shell会正确关闭如果不能保证可以考虑把内部命令放进子Shellfor file in ...; do ( ... echo $x $file.log ) done子Shell退出时会自动关闭所有描述符不会累积。不过注意子Shell会带来额外的进程开销对于少量文件不必这样仅在遇到FD耗尽问题时考虑。5.5 Shell脚本开头常见的#!/bin/bash和重定向的适配差异现象脚本在同一台机器的bash下能跑但你在sh script.sh环境下执行重定向相关的高级写法如、(...)、全部报错。根因很多高级重定向语法只在bash中有效而shdash是更精简的POSIX Shell不支持这些扩展特性。解法如果脚本需要跨Shell执行尽量用POSIX兼容的写法不要用改写成 file 21不要用改用echo $var | command或写成here document但注意here document是POSIX标准的一部分可以放心用不要用进程替换改走临时文件或管道另一点是就算脚本开头写了#!/bin/bash如果调用时用了sh script.sh依然按sh来解释。所以要让脚本用bash解释应该直接./script.sh或bash script.sh。我遇到过不少同事因为习惯性用sh xxx.sh而在部署脚本上莫名其妙出错最后发现就是Shell不一样。5.6 写入内容被截断和选择错误引发的数据丢失现象脚本里想往配置文件追加一行但写完之后文件内容只剩新写入的一行原配置全没了。根因用了而不是把文件截断了。这种问题在脚本反复执行、不小心重定向了同一个配置文件的场景中特别常见。解法对配置文件、数据文件这类存在即价值的文件永远优先考虑追加语义如果是覆盖生成必须确保生成成功后再替换。我个人习惯是new_config/tmp/config_$RANDOM.$$ cat EOF $new_config ... EOF # 验证语法或内容无误后才替换 mv $new_config /etc/app/config.ini这种先写临时文件再原子替换的方式比直接覆盖安全得多。即便脚本执行到一半挂掉至少原配置文件完好无损。5.7 Windows环境下无法运行脚本的排查链路写Shell/Bat脚本时偶尔会遇到Windows上执行不了脚本的报错虽然报错很奇怪但排查思路其实是有固定链路的。最常见的问题是PowerShell执行策略和文件编码。先看执行策略。当你看到类似无法加载文件因为在此系统上禁止运行脚本的提示时说明当前PowerShell执行策略是Restricted或者AllSigned。先查看当前策略Get-ExecutionPolicy如果返回Restricted可以临时放开以管理员身份运行PowerShellSet-ExecutionPolicy -Scope CurrentUser RemoteSignedRemoteSigned允许本机脚本运行但要求从网络下载的脚本必须有签名兼顾了安全性与易用性。这是我自己在Windows开发机上的推荐配置。然后是编码问题。用记事本默认保存中文脚本为UTF-8带BOM时某些解释器尤其是旧版PowerShell或Windows自带的cscript会把BOM当成内容的一部分导致第一行报错。解决方案保存为UTF-8无BOM或者保存为GBK/ANSI取决于系统默认代码页。比如用VS Code保存文件时右下角把编码改成UTF-8和无BOM即可。最后是脚本闪退问题。双击.bat或.ps1文件一闪而过通常是脚本遇到错误直接退出了但终端窗口随之关闭你根本来不及看错误信息。我的建议是不要在资源管理器里双击运行脚本而是先在当前目录打开终端手动输入脚本路径执行这样错误信息会留在窗口里。如果是bat脚本还可以在每个关键命令后加pause临时查看输出。另外Windows下经常看到无法将xxx识别为cmdlet、函数、脚本文件或可运行程序的名称这类报错本质是命令不在PATH环境变量里和重定向没有直接关系但排查顺序一样先where.exe 命令名确认命令是否存在再查看它的路径是否加入PATH。6. 日常维护重定向相关脚本时的几个习惯重定向写起来简单但写得好不好直接决定脚本出问题时你能多快定位。下面这几点是我长期维护重定向密集型脚本时总结出来的个人习惯。6.1 每个脚本开头固定日志策略头我写的每一个工具脚本开头都会固定做三件事备份原始描述符、设置日志文件、把关键输出同时落到终端和日志。具体模板大致如下#!/bin/bash LOG_DIR/var/log/myapp mkdir -p $LOG_DIR LOG_FILE$LOG_DIR/run_$(date %Y%m%d_%H%M%S).log # 保存原本的标准输出描述符 exec 61 # 所有输出进入日志同时通过tee保持终端可见 exec 1 (tee -a $LOG_FILE) 21 echo [$(date %Y-%m-%d %H:%M:%S)] 脚本开始执行这样带来的好处是每条echo、每条命令的输出都自动进了日志排查问题时只需看一个文件不用去猜脚本内部到底往哪里写了。6.2 日志文件命名带时间戳但注意清理策略如果每次运行都生成一个新日志文件积累一段时间会非常占磁盘。我会在脚本结束前做一个简单的清理find $LOG_DIR -name run_*.log -mtime 7 -delete这样只保留最近7天的日志。如果你用统一追加到同一个日志文件则需要配合logrotate做按天或按大小切割否则日志会越来越大。6.3 重定向联合trap释放文件句柄脚本意外退出时重定向打开的FD可能不释放。为避免脚本中断后残留句柄如果你打开过多重定向描述符比如exec 3file这种建议同时注册一个清理函数cleanup() { exec 3- exec 6- echo [$(date %Y-%m-%d %H:%M:%S)] 脚本退出清理完成 $LOG_FILE } trap cleanup EXIT这样无论是正常结束还是CTRLC中断描述符都能被关闭临时文件也不会一直占着。6.4 永远不要忘记重定向也会截断文件最后强调一次和虽一字之差效果天差地别。凡是脚本里可能重复执行的场景我写重定向时都会下意识问自己一句这个文件是每次生成还是追加记录如果答案不确定我会优先用临时文件加mv的方式宁可多一点代码也绝不把原数据覆盖掉。另外用cat file生成文本时在最后的EOF之前一定要确认内容完整如果生成过程中出现语法错误而脚本没有set -e可能已经写入了残缺内容。所以我常用set -euo pipefail配合重定向脚本尽早暴露问题。6.5 Windows脚本下的对应检查如果你在Windows环境写bat或PowerShell脚本同样要养成检查脚本编码、执行策略、命令路径的习惯。尤其是从Linux移植过来的重定向语法比如 log 21在PowerShell里的语义可能不同——PowerShell的实际上是Out-File的别名默认编码是UTF-16生成的日志文件在很多工具里看起来会带大量空字节。如果想在PowerShell里把输出重定向成UTF-8文本建议显式使用Out-File -Encoding utf8而不是裸用。具体写法举例Get-Process | Out-File -FilePath C:\logs\process.txt -Encoding utf8或者用*把错误和输出一起重定向但同样需要指定编码Get-Process * C:\logs\process.txt如果直接在PowerShell里写cmd /c dir dir.txt 21这走的是cmd解析器规则和Linux Bash又不同容易混淆。我的原则是不要在PowerShell里混合使用cmd的旧式重定向避免行为不一致。7. 从重定向脚本出发的扩展思路日志轮替、可视化与自动化学会基本重定向之后能做的事情就远不止把输出写到文件了。简单列几个扩展方向长期做运维和自动化脚本的人大概率都会用上。7.1 把日志升级成JSON结构如果只是用echo xxx log日志内容是纯文本后面想解析会非常痛苦。我自己在写数据同步脚本时会把每次执行的关键状态输出成JSON行一行一个对象echo {\time\:\$(date -Iseconds)\,\level\:\INFO\,\action\:\sync\,\status\:\ok\,\rows\:$COUNT} sync_log.jsonl这样后续用jq或者其他数据处理工具解析时特别方便。重定向本身不关心内容格式但它一定要为后续的数据处理留好接口。7.2 日志按级别分流一个更实用的重定向技巧是把不同级别的日志通过不同描述符输出到不同目标比如把错误单独送进error文件把普通日志送进all文件# 3号FD流向错误文件4号FD流向全部文件 exec 3 error.log exec 4 all.log log_info() { echo [INFO] $* 4 } log_error() { echo [ERROR] $* 4 echo [ERROR] $* 3 }平时查看按小时滚动的info日志出错时直接看error.log不用在一大堆正常输出里翻错误。多描述符重定向在这种日志分级场景下非常自然。7.3 重定向与计划任务的结合定时任务cron里如果不做重定向系统会通过邮件把输出发给你或者直接丢到系统邮件池大多数云服务器上根本没有邮件服务输出就丢了。所以cron任务我基本固定写成*/5 * * * * /opt/scripts/check_health.sh /var/log/health.log 21这样既保留记录又避免每天一堆系统邮件。如果你希望cron任务失败时才发警报可以在脚本里针对异常输出单独处理输出到错误日志并触发告警命令。7.4 进程替换配合文本处理工具做无文件化的ETL前面提过diff (...)的用法其实在任何需要多输入文件的命令中进程替换都能帮你省掉临时文件。举一个实际例子我要对比两个不同环境下的包列表diff (ssh env1 rpm -qa | sort) (ssh env2 rpm -qa | sort)不用在本地生成临时文件也不用往服务器传文件直接在本地拿到差异。这种用法在多服务器运维场景中几乎每周都会用到。再比如你要统计多个文件中共出现多少次关键词可以用grep -c error (cat /var/log/app1/*.log) (cat /var/log/app2/*.log)组合起来非常灵活。7.5 把重定向脚本化、模板化让谁都能用最后我建议把常用的重定向模式沉淀成模板脚本。比如我自己的log_utils.sh包含了几组通用函数初始化日志、写info、写error、结束清理等。新项目里直接source ./log_utils.sh就能用不必重复造轮子。这也是从会用重定向迈向会设计脚本的一个明显分水岭。#!/bin/bash source ./log_utils.sh init_log /var/log/myapp log_info start deployment ... log_error deployment failed finish_log每个人可以根据自己的项目风格定义一套这类模板函数最终让重定向逻辑从业务代码里剥离开来代码可读性和可维护性都会有明显提升。