
截完屏、翻完几千行日志之后我慢慢意识到Linux命令行的真正威力不在单个命令而是把命令像流水线一样接起来。这里说的就是管道命令竖线|一个看起来毫不起眼的符号却能让一条命令的输出直接变成下一条命令的输入。很多人刚接触Linux时都记了一堆常用命令可真到排查线上问题的时候才发现真正卡住自己的不是不会用grep或者不会用sort而是不知道该怎么把这些命令组合起来。这篇内容就是围绕管道命令展开的我会从底层原理讲到高频用法再讲一讲我在实际运维过程中踩过的坑最后给出一些提升效率的进阶玩法。不管你是刚入门的小白还是写了几年脚本的老手读完应该都能对|有一个比之前清晰得多的认识。我写这篇文章的起因也挺简单——前阵子帮一个同事排查脚本问题他写了个循环去读文件逻辑看着没问题但结果死活不对。我一看管道后面的代码全在子shell里跑变量根本传不回来。这就是典型的知其然而不知其所以然。所以这次我不想只给你列一堆ps aux | grep的模板而是想认认真真把管道这件事讲透。1. 管道的本质它解决了命令行里最不优雅的一件事1.1 没有管道之前你需要怎么干活我们先回到一个最朴素的场景你想看看当前系统里有没有 nginx 进程在跑。没有管道的时候你可能会这样做ps aux /tmp/process.txt grep nginx /tmp/process.txt rm /tmp/process.txt三步操作两个临时文件还有一个删临时文件的善后工作。如果中间忘了删日积月累堆一堆临时文件在服务器上体验非常糟糕。更麻烦的是如果你要把某个命令的输出继续传给第三个命令处理就得反复建临时文件、读临时文件、删临时文件。比如统计 access 日志里访问量最大的 IP靠临时文件写出来会是一长串绕来绕去的命令而且中间任何一步出问题排查起来都头疼。1.2 一次接线胜过两次落盘管道做的事情本质上就是跳过落盘这一步直接把前一个命令的输出接到后一个命令的输入。打个比方没有管道时你要把 A 车间生产的零件运到 B 车间得先装进仓库临时文件再从仓库取出送到 B 车间。有了管道就等于在 A 车间和 B 车间之间装了一条传送带A 生产出来一个零件传送带立刻送到 B 手里。所以上面那个查询进程的例子用管道只需要一行ps aux | grep nginx你不需要知道ps aux输出多少行也不需要先把结果存到什么地方数据是一条一条从ps流向grep的。grep只关心自己拿到的标准输入里有没有nginx这个关键词有就打印没有就跳过。明白这一点很重要——管道连接的不是命令的名字而是命令背后的标准输入输出流。这也是为什么我们常说 Unix 哲学是每个命令做好一件事并通过管道把小事组合成大事。1.3 管道不是什么它和命令连接符不是一回事很多刚接触 Linux 的人会把管道和、;混在一起这里有必要先掰扯清楚几个符号的区别符号作用典型场景|把前一个命令的标准输出接到后一个命令的标准输入ps aux | grep nginx把命令输出重定向到文件echo hello /tmp/a.txt前一个命令成功执行后才执行后一个cd /data ls;无论前一个命令结果如何都执行后一个echo start; date和;控制的是执行顺序它们是 shell 层面的流程控制|控制的是数据流向它把两个进程变成一个衔接的过程。把这个问题想明白后面很多东西就顺了。2. 从 fork 到 buffer管道底层到底发生了什么我知道很多人觉得底层原理是学院派才会关心的事但管道这个事不理解底层真的容易踩坑。比如为什么管道后面跑的代码改不了外面的变量为什么yes命令接上head之后就结束了不接就永远刷屏这些问题的答案全在内核的管道实现里。2.1 pipe 系统调用一次创建两个文件描述符在 Linux 上管道的底层是pipe()这个系统调用。它的 C 语言原型非常简单int pipe(int pipefd[2]);调用一次后pipefd[0]是读端pipefd[1]是写端。这个系统调用干了什么事情呢简单说就是内核创建了一个缓冲区对象然后给了你两个文件描述符你可以往写端写数据也可以从读端读数据。你或许会奇怪这不就是内存里的一块缓存吗没错。管道本质就是一个内核空间里的环形缓冲区数据从写端进入从读端流出先进先出读走就没有了。这就意味着它不是文件不能像普通文件一样来回定位读出来的数据是一次性的。我在自己写代码模拟这个流程时通常会在 fork 之前先创建管道然后在子进程里关闭读端只留写端在另一个进程里关闭写端只留读端。这样才能让数据从一个进程单向流到另一个进程。2.2 shell 是怎么把一条管道命令组装起来的你在终端敲下ps aux | grep nginx之后shell 做的事可以分成几步shell 先调用pipe()拿到一对文件描述符fork 出第一个子进程也就是ps在ps子进程里关闭读端把写端复制到自己的标准输出上用dup2把 fd 1 指向管道的写端然后 exec 执行ps aux再 fork 出第二个子进程grep在grep子进程里关闭写端把读端复制到自己的标准输入上fd 0然后 exec 执行grep nginx。这样ps写到标准输出的数据就会通过内核缓冲区流到grep的标准输入。理解这个流程之后下面几个现象就都能解释了管道两边的命令是同时启动的不是ps跑完再跑grep中间数据不落盘完全是内存流转如果ps写太快而grep处理得慢ps会被阻塞住而不是把数据丢到内存里无限堆积。2.3 缓冲区有多大满了会怎样Linux 下管道缓冲区的默认大小在新一点的内核里通常是 64KB 左右由内核参数控制不同发行版可能有细微差别。这个值听起来不大但管道的妙处在于它是流式的——数据一边生产一边消费正常情况下不会真的把 64KB 填满。真正的问题是如果读端不读写端就会阻塞如果写端不写读端就会一直等。这就是管道同步机制的核心。你可以把它理解成两个人打电话一个说一个听说的人不会一口气把所有话说完再听而是边说边听对方没反应就不继续说了。这也就解释了为什么yes 123 | head -1能正常结束。head -1读取一行之后就不再读管道的读端了同时退出了进程。随后写端yes继续写写到缓冲区满之后因为读端已经关闭写入会触发 SIGPIPE 信号进程收到信号后默认退出所以你不会看到yes无限刷屏。2.4 管道与临时文件的实际差别有人可能会想既然管道数据用一次就没那和临时文件比到底强在哪核心差别有两个维度速度管道走的是内存缓冲区临时文件要写磁盘磁盘 IO 的延迟和带宽跟内存完全不是一个量级延迟管道是流式的grep可以在ps还没输出完时就开始处理已经到的数据而临时文件必须等前一个命令完全结束才能开始下一步。这两点在你处理日志文件、监控数据这类持续产出的内容时特别明显。比如实时 tail 一个日志再 grep 关键词如果先落盘再处理就做不出实时效果了。3. 真实场景里的管道组合我每天都在用的几个套路聊完原理咱们落回实操。管道最有说服力的地方是能把很复杂的任务压缩成一行命令。我挑几个自己日常运维和写脚本中经常用到的组合按场景拆开讲。3.1 进程与端口排查ps、grep 和 netstat 三件套服务器出问题第一件事永远是看进程在不在。最基础也最常用的就是ps aux | grep java这里有个小坑grep java会把自己的进程也匹配出来因为命令本身包含java字样。所以我习惯再加一层过滤ps aux | grep java | grep -v grep这就是典型的管道思维一次筛不干净就再接一段继续筛。grep -v grep的意思是过滤掉包含grep的行。当然也可以用 pgrep、pidof 代替但很多场景下管道组合仍然是最直观、最通用的。查端口占用也类似netstat -tlnp | grep 8080加个-p可以拿到进程 PID配合管道筛选定位谁占用了端口一分钟就能完成。3.2 文本统计三连sort、uniq 和 wc 配合 grep 和 awk日志分析是管道的重灾区。比如有一个访问日志 access.log想找出访问量最大的 10 个 IP我通常这么写cat access.log | awk {print $1} | sort | uniq -c | sort -rn | head -10一步步拆开看cat access.log把日志内容输出到 stdoutawk {print $1}按空格切分取第一列也就是 IP 地址sort把所有 IP 排序目的是让相同 IP 排到一起uniq -c统计相邻重复行的数量sort -rn按统计数量倒序排列head -10取前 10 行。这个链条每一步都在做一件简单的事组合起来就完成了一个不小的分析任务。这里要强调的是sort | uniq -c这个组合的用法——uniq 只能合并相邻的重复行所以必须先 sort。顺序反了统计结果就是错的。这个坑我见过不止一次。3.3 日志实时监控与告警前过滤上线发布后要看日志里有没有报错我常用的是tail -f app.log | grep ERRORtail -f会持续输出新写入的日志行管道把它们实时送到grep只保留 ERROR 级别的行。这是最基本的实时日志过滤。如果还想在过滤的同时保留完整上下文可以在grep上加-A、-B参数tail -f app.log | grep -A 5 Exception意思是匹配到Exception时把后面 5 行也一起打印出来这样排查堆栈时信息更完整。3.4 查找文件并对结果做批量操作find配管道也是高频组合。比如删掉 /tmp 下三天前的日志文件find /tmp -name *.log -mtime 3 | xargs rm -f这里 xargs 的作用是把标准输入里的每一行变成命令的参数——因为rm不能从标准输入直接读文件名它只接受命令行参数。管道把 find 的结果交给 xargsxargs 再拼成rm -f /tmp/a.log /tmp/b.log ...这样的命令去执行。这种组合在批量处理场景里非常实用。但要注意文件名里如果包含空格或者换行直接xargs会解析错后面我会专门讲安全的处理方式。3.5 用多个管道做多级过滤管道的强大还体现在可以叠加。比如我想看 8080 端口对应的进程是从哪个目录启动的netstat -tlnp | grep 8080 | awk {print $NF} | tr / | awk {print $2} | xargs -I {} readlink /proc/{}/cwd这条命令的每一段仍然是很简单的操作但组合起来完成了一个很复杂的任务从端口信息里提取 PID再找到进程的当前工作目录。管道的每一级都只做一件事这是设计长管道链的关键原则。如果一段管道里塞了太多逻辑可读性就会急剧下降后面你维护的时候会想骂自己。4. 管道和重定向的分野这些坑我亲手踩过管道用起来爽踩起坑来也毫不含糊。下面这几个问题我基本都在生产环境里见过或亲手踩过每一个都值得你花两分钟理解。4.1|和的数据去向完全不同别混用我见过最典型的问题是有人想把自己的命令输出写进一个需要 root 权限的文件于是写了echo some config | sudo tee /etc/somefile这其实是对的但很多人一开始会写成echo some config /etc/somefile然后爆出 Permission denied。为什么管道写法就对了因为重定向是由当前 shell 完成的当前 shell 的权限就是普通用户权限它打开/etc/somefile时自然会失败。而sudo tee的意思是用 root 身份运行tee程序让tee从标准输入读取内容并写入文件。换句话说打开文件这个动作发生在tee进程内部而tee是用 sudo 启动的自然拥有 root 权限。这个区别理解透之后你以后碰到任何需要 root 权限写的文件问题时第一反应就应该是sudo tee而不是强行sudo bash -c echo ... file——后者虽然也行但引号嵌套稍微一复杂就容易出错。4.2 管道后面是子shell变量改完带不回来这个问题我要重点讲因为中招率极高。假设你想统计某个文件的行数并把结果存到一个变量里count0 cat /etc/passwd | while read line; do count$((count 1)) done echo $count你猜$count是多少答案是 0。原因就在于while read line是管道后面的命令它运行在一个子 shell里。子 shell 是从当前 shell fork 出来的另一个进程它里面的变量修改在父 shell 里是看不见的。这个和你在函数里改全局变量改不回去的原理是一样的——不是一个进程空间。解决办法有好几种用进程替换替代管道count0 while read line; do count$((count 1)) done /etc/passwd echo $count注意这里用的是而不是|while 循环直接在当前 shell 进程里运行变量修改就能生效。或者把统计结果用命令替换保存到变量count$(grep -c /etc/passwd) echo $count这个就绕开了改变量的问题直接把结果拿回来赋给变量。这个坑在分批处理日志、做聚合计算时特别容易爆。只要你在管道右边写了循环就要意识到它跑在子 shell 里任何状态修改都带不回来。4.3 管道破裂SIGPIPE不是 bug是机制前面提到yes | head -1能正常退出靠的就是 SIGPIPE。但如果你在脚本里写了一个长管道链某个中间进程提前退出了后面的进程会不会收到错误信息比如这样cat huge_log.txt | head -5head -5只读 5 行就退出此时cat还在继续往管道里写数据。内核发现读端已经关闭就会向cat进程发送 SIGPIPE 信号cat默认行为是终止。所以这条命令能很快结束你不会说它卡住。这其实是 Unix 设计里非常优雅的一点顺着数据流主动停止不需要额外通知上一级我不需要了。但反过来你在写脚本时就要小心如果你自己是一个管道的上游突然发现没人读你的数据了记得捕获 SIGPIPE 或者接受默认退出行为不要在这里做太多善后逻辑。4.4 grep 没有匹配时管道的退出码会骗你在 shell 脚本里管道的退出状态默认是最后一个命令的退出状态。所以grep pattern /tmp/no_such_file | wc -l如果 grep 失败wc -l依然会正常执行输出 0整个管道的退出码是 0是成功的。这可能导致你的脚本判断逻辑失效。bash 里可以用set -o pipefail让管道返回第一个失败命令的退出码set -o pipefail grep pattern /tmp/no_such_file | wc -l echo $?这样 grep 的失败会传导出来脚本逻辑才能正确判断。我强烈建议只要是写了管道且关心成败的脚本顶部都加上set -euo pipefail。这已经是 bash 脚本的事实标准了。4.5 管道里的相对路径和 cd 陷阱这也是一个冷门坑管道中的每个命令默认在当前目录执行但如果你在管道里写cd想改目录那是改不到下一条命令的因为它们各自是独立进程。比如cd /tmp | ls这里cd /tmp是在子 shell 里执行的对后面的ls没有影响。ls执行的目录仍然是当前 shell 的工作目录。如果你确实需要先切目录再执行命令应该用cd /tmp ls用控制执行顺序而不是用管道。5. 进阶玩法xargs、进程替换和性能意识如果上面这些你都理解了可以再看一看这一节。这些都是我在实际工作中逐渐摸索出来的、真正能提升命令行效率的东西。5.1 xargs把标准输入变成命令行参数的关键桥梁前面提到了find | xargs rm这里展开讲一下为什么需要 xargs。很多命令不接受标准输入只接受命令行参数比如rm、chmod、mkdir。管道是把一个进程的 stdout 接到另一个进程的 stdin所以find ... | rm这种写法是错的——rm不会去读 stdin。xargs 就是把 stdin 内容按换行或空格切分成参数然后拼到命令后面执行find /tmp -type f -name *.tmp | xargs rm -fxargs默认会一次性把尽可能多的参数传给命令如果参数太多它会把命令分成多次执行避免超过操作系统命令行长度上限。但是文件名里一旦有空格默认按空格切分就会出错。这时候要配合-0选项并让 find 用-print0输出以空字符分隔的文件名find /tmp -type f -name *.tmp -print0 | xargs -0 rm -f-print0是 find 的一个选项输出的文件名之间用\0分隔而不是换行配合xargs -0就能完美处理带空格、带换行的文件名。这个组合属于生产环境必备我就是因为没加-0误删过一次带空格的文件名那次教训非常深刻。5.2 进程替换把命令输出当成文件来读有时候你会遇到这种情况一个命令本来要求传文件路径作为参数比如diff。你想比较两个命令的输出差异用管道没法同时传两个输入进来怎么办进程替换就是干这个的。diff (cat /tmp/a.txt | sort) (cat /tmp/b.txt | sort)这行命令里(命令)会启动一个子进程执行命令同时把一个类似/dev/fd/63的路径传给外层命令。外层命令把它当成普通文件路径来读实际读到的是管道另一端命令的输出。这个用法在写脚本时特别有用。比如你想对比两个不同服务返回的 IP 列表差异diff (curl -s http://service-a/ips | sort) (curl -s http://service-b/ips | sort)一行就完成了本来需要好几个临时文件的活儿。5.3 避免无谓的管道一句命令能做的别拆三截讲了很多管道用法这里我得泼盆冷水。管道虽好但也不是越多越好。有些命令本身就具备过滤能力没必要画蛇添足。比如cat file | grep pattern这完全没必要。grep可以直接读文件grep pattern file。前者多一个进程多一次进程间数据拷贝纯属浪费。还有cat log | awk {print $1}也应该写成awk {print $1} log。我见过很多人的经典习惯是什么都要先 cat 一下再管道这多半是从教程里学来的固定套路。但在 Unix 世界里绝大多数命令都接受文件名作为参数能直接读文件就别让 cat 当二传手。管道是为加工流式数据准备的不是用来弥补命令不会读文件的。这背后有一条更稳的原则一个管道链里如果某一级只是在传数据而没有做任何变换那这一级大概率是多余的直接删掉。5.4 用 tee 给管道做分叉排查长管道链管道是单向流但调试长管道链的时候我们往往希望看到中间某个环节的输出。这时候tee就能派上用场。tee的作用是从标准输入读数据同时写到标准输出和文件。所以可以在管道中间插一段cat access.log | tee /tmp/all_ip.log | awk {print $1} | tee /tmp/only_ip.log | sort | uniq -c先用tee把第一段原始数据存一份再输出给 awkawk 处理完之后再用第二个tee把只含 IP 的结果存一份继续往下走。最后整个管道正常跑完中间两个文件帮你随时查看是哪一段出了问题。我调试复杂管道时基本都会临时插一两个tee定位完再删掉非常顺手。5.5 长管道链的维护性思考写管道容易读管道难。一条七八段的长管道过两周再回来看很可能要想很久才能想起来每一段在干什么。我的习惯是两个一是把每段的作用在上方写注释二是如果管道太长拆成一个脚本函数每步用临时变量承接甚至直接用 Python 脚本处理。一句话管道适合快、短、准的场景不适合代表复杂业务逻辑。一旦管道超过五六个环节可读性和调试成本就会急剧上升。比如下面这条cat log | grep ERROR | sed s/\[.*\]// | cut -d -f2- | tr A-Z a-z | sort | uniq -c | sort -rn | head -20坦白讲超过五个人看这条命令会有五个不同的理解。倒不如拆成grep ERROR log | sed s/\[.*\]// /tmp/errors.clean cut -d -f2- /tmp/errors.clean | tr A-Z a-z | sort | uniq -c | sort -rn | head -20第一行负责提取第二行负责统计每条命令的职责边界都清清楚楚。这也是管道的另一个心法组合要适度留白要足够。如果你愿意动手写几条管道试试我建议从最日常的场景练起来查进程、查端口、统计日志、批量删除文件。当你连续两周每天都在用管道解决问题之后你会慢慢形成一种数据流思维——看到输出不再是死板的一屏文字而是一串可以继续加工的数据。等到哪一天你写ps aux | grep java | grep -v grep不需要再停顿思考的时候管道在你这里就算是真正入门了。这些年我用管道的次数多到数不清最直观的体感就是服务器排查问题的速度比原来快了一个量级。希望你也能在实操里体会到这种流畅感。