
刚入行那会儿我第一次在线上服务器处理一个快上G的Nginx日志用vim打开之后光标转了半天圈鼠标挪都挪不动最后只能强行kill掉进程。组里的老同事过来看了一眼在命令行敲了一行sed -n 10,20p app.log几秒钟就把日志里想看的部分捞了出来。那一刻我才真正理解shell三剑客这三个词不是教程里的概念而是每个靠命令行吃饭的人绕不开的日常工具。所谓三剑客就是grep、sed、awk这三个命令的组合其中sed扮演的是编辑这个角色。这篇就围绕sed展开从它的底层机制讲到高频用法再延伸到真实场景和坑点尽量把能用和为什么这么用一起讲清楚。适合刚开始接触shell脚本的入门读者也适合已经用过一些sed命令、但对执行流程和边界理解得不够透的朋友。1. 先解决定位问题三剑客凭什么分工1.1 一把武器干一件事组合起来干活很多初学者会陷入一个误区拿到文本处理需求不知道该用grep还是sed还是awk于是挨个试一遍。其实三剑客的分工相当明确用一句话概括就是grep负责找行sed负责改行awk负责算列。工具核心定位典型场景一个命令就能看懂grep查找与过滤从日志里搜出包含 error 的行grep error app.logsed编辑与替换把配置里所有 8080 端口改成 9090sed -i s/8080/9090/g app.confawk格式化与计算统计某一列的总和、按条件输出列awk {sum$1} END{print sum} data.txt实际干活的时候这三个命令也经常串成管道一起用。典型场景是先用grep把嫌疑行筛出来再用sed做去重、替换、删字段之类的处理最后用awk汇总计算。每一步都只做自己最擅长的那件事组合起来就是一套完整的文本处理流水线。1.2 流式处理到底是什么意思sed的全称是 Stream Editor流编辑器。理解流这个字就抓住了它的核心。想象一条生产线文件的内容像是传送带上的纸箱一个接一个地经过。sed每次都只处理眼前的这一个箱子读一行放进内存按你给出的命令处理处理完输出然后立刻清空继续读下一行。它不会把整个文件一次性加载到内存里等着你慢慢操作也不会像 vim 那样维护一个巨大的编辑器缓冲区。这个设计带来两个很实际的好处内存占用基本恒定。处理一个 1G 的文件和处理一个 1M 的文件sed的内存消耗不会差太多因为它只关心当前这一行长什么样。可以在管道里实时吞吐。tail -f app.log | sed s/ERROR/错误/g这种用法sed会实时处理冒出来的每一行而不需要等文件写完。1.3 学 sed 的正确节奏sed的命令格式核心就一句话sed [选项] 范围动作 文件。范围是说处理哪些行动作是说对这些行做什么。范围可以是行号、正则表达式、或者两者的组合动作则是最常用的几种打印、删除、替换、插入。你只要把范围和动作这两块拼图搞清楚大部分需求都能直接写出来。选项里最需要记牢的是三个-n安静模式取消默认输出、-i原地修改文件、-E使用扩展正则。后面的章节就按照机制—命令—正则—实战—踩坑这个路径展开这样一层层下来比零散记命令要扎实得多。2. 模式空间与执行顺序sed 背后的流水线机制2.1 模式空间每一行都有一个工位讲sed绕不开一个概念模式空间pattern space。这是sed内部一块临时的内存缓冲区。每一行文本读进来之后会被放进模式空间里然后你指定的命令全部在这个空间内执行。执行完之后如果没加-n模式空间里的内容默认会被输出到标准输出接着模式空间被清空再读入下一行。你可以把模式空间理解成工位。传送带送来一个纸箱一行文本工位上的工人sed 命令在台面上操作这个箱子操作完把箱子推走台面清空等下一个箱子送进来。它不会攒着几十个箱子一起处理也不会把箱子拆开摊到整个车间里。理解了这一点很多奇怪现象就有了解释。比如你写sed p file每一行会打印两遍——因为默认输出一遍加上p命令的显式打印又一遍。这不是 bug而是模式空间内容输出一次 命令又输出一次的叠加结果。2.2 一行命令在 sed 内部是如何流转的拿最基础的sed -n 3p file举例它的完整执行流程是这样的sed读取文件第一行放进模式空间。检查地址3是否匹配当前行。第 1 行不匹配跳过p命令。因为加了-n默认输出被抑制所以第 1 行不会打印出来。清空模式空间读入第 2 行同样不匹配不输出。读入第 3 行匹配地址3执行p命令把这一行打印到标准输出。继续往下读第 4 行、第 5 行直到文件末尾。这个流程看起来简单但它是理解sed一切行为的地基。比如你问为什么sed -n /error/p app.log等价于grep error app.log答案就在这个流程里sed一行行扫过来凡是匹配/error/的行就执行一次打印其他行什么也不输出。这不就是按条件筛选行吗2.3 地址范围从第几行到哪几行规则就这几条地址是sed的减速带决定命令在哪些行上生效。常用的表达方式就下面这几种地址写法含义3第 3 行1,5第 1 行到第 5 行$最后一行1~2从第 1 行开始每 2 行取 1 行也就是奇数行/pattern/所有匹配正则 pattern 的行/p1/,/p2/从匹配 p1 的行开始到匹配 p2 的行结束这个区间内的所有行3,2第 3 行以及往下 2 行即第 3 到 5 行其中区间匹配/p1/,/p2/是流式判断的。sed每读到一行先看当前状态如果此刻不在区间内就检查这一行是否匹配起始条件 p1一旦匹配就从这一行开始进入区间之后每一行都算命中直到某一行匹配了 p2区间结束。我经常用这个特性来处理日志。比如sed -n /2024-06-01 10:00:00/,/2024-06-01 10:30:00/p app.log就能精确截取某个时间段内的日志不需要借助其他工具。再比如sed /^kernel/,/^$/d dmesg.txt会删除从 kernel 开头到第一个空行之间的内容在清理内核启动信息时很顺手。2.4 多条命令怎么组合版本差异要注意一个sed表达式里经常需要做不止一件事。比如你想在第 20 到 30 行之间先把foo替换成bar再把空行删掉有两种规范的写法。第一种是用-e把多个表达式串起来sed -e 20,30s/foo/bar/g -e 20,30/^$/d file第二种是用花括号把地址范围内的多个命令打包sed -n 20,30{ s/foo/bar/g; /^$/d; p; } file花括号写法的可读性更好但注意地址和左花括号之间要有空格命令之间用分号分隔。这里有一个不同平台上的坑GNU sed 和 BSD sed比如 macOS 自带的那个对花括号命令块里空格和分号的要求不完全一致在一些 BSD 实现里分号后面必须跟空格否则解析就出错。为了避免折腾我建议写比较复杂的花括号块时直接用多个-e或者把命令写进一个脚本文件再用sed -f script.sed file来执行。这样可读性高也不会遇到平台差异。3. 高频动作逐个拆解p、d、s、a/i/c3.1 p 打印小命令但最容易栽在 -n 上p的作用是把模式空间的内容打印出来。单独用p一般没什么意义因为它会让每行打印两遍。它的正确用法是配合-n变成只打印你想打印的sed -n 10p file # 打印第 10 行 sed -n 20,30p file # 打印第 20 到 30 行 sed -n /error/p app.log # 相当于 grep error我在教新人sed的时候发现最容易翻车的点就是忘记-n。比如想只看匹配行写了sed /error/p app.log结果屏幕上每一行都出现了匹配行还会出现两遍。原因前面已经说过默认输出一次p命令再输出一次。所以请养成一个习惯用p之前先问自己一句要不要关掉默认输出要的话就带上-n。3.2 d 删除默认不影响原文件d命令删除的是模式空间里的内容。删除之后这一行后续不会再被输出也不会再被执行其他命令。sed 1,10d file # 输出删除前 10 行之后的内容 sed -i /^$/d file # 去掉所有空行-i 表示写回原文件 sed -i /^#/d app.conf # 删除所有以 # 开头的注释行很多新手看到d就以为原文件被删了其实不然。sed默认是把处理结果输出到屏幕原文件一动不动。除非你加了-i但那是另一个话题后面专门讲。d的用处非常广比如sed 10,$d file可以只保留前 10 行相当于用一个命令完成了head -10的效果。它和head、tail的不同在于d是基于地址规则的你可以精确控制删哪里。3.3 s 替换三剑客里最常用的改s是sed里戏份最重的命令它的格式是sed s/正则/替换文本/标志位 file比如sed s/8080/9090/ app.conf会把每一行第一次出现的8080换成9090但只换每行的第一处。要全局替换得加g标志sed s/8080/9090/g app.conf常用标志位有这些标志位作用g全局替换作用于行内所有匹配位置i忽略大小写p替换成功后打印这一行常配合-nw file把替换成功后的行写入指定文件无只替换每行第一次匹配s命令里一个非常实用的技巧是更换定界符。如果你要替换的内容本身包含/比如路径那正则里的斜杠就会和定界符撞车。比如想替换/usr/local/bin写成sed s/\/usr\/local\/bin/\/opt\/bin/g会非常痛苦。解决办法是换一个定界符跟在s后面的第一个字符就是新的定界符sed s#/usr/local/bin#/opt/bin#g file sed s|/usr/local/bin|/opt/bin|g file只要内容里没有#或|这样写就清爽很多。我实际处理路径类替换时几乎都会用#当定界符。3.4 a/i/c行插入、追加和整行替换除了改内容sed更擅长的是改结构。a是在匹配行后面追加一行i是在匹配行前面插入一行c是把匹配到的整行替换成新内容。sed /^\[server\]/a bind 0.0.0.0:8080 nginx.conf sed 3i # this line inserted by sed app.conf sed /^old config/c new config line app.conf追加多行内容时用\n换行sed /^\[server\]/a port8080\nhost0.0.0.0 app.conf一个常见的坑是 BSD 的sed对a的写法要求更严格有时候需要写成a\再加换行的形式GNU sed 则宽松一些。跨平台写脚本时这部分尤其容易踩雷建议先在小文件上测一遍。c命令的替换是整行级别的它会把匹配到的整行文本直接换掉。比如配置里有多个timeout行你只想改其中某些用c配合地址就能精准控制sed /^timeout30/c timeout60 app.conf只会把timeout30那行整行替换成timeout60。3.5 y、r、w三个低频但有价值的命令这三兄弟使用频率不如前几个但特定场景下意外地好用。y是逐字符的映射转换类似于tr命令。它的定界符格式和s一样两边的字符数量必须相等sed y/abc/xyz/ file # 把 a 换成 xb 换成 yc 换成 zr可以把另一个文件的内容读取进来插入到匹配行之后。做 HTML 模板拼接时就很方便sed /\/body/r footer.html index.htmlw命令则正好相反把匹配到的行写入另一个文件sed -n /ERROR/w error.lines app.log这条命令和grep ERROR app.log error.lines的效果一样但好处是可以直接在sed的流水线里继续做别的处理不用开一个额外的重定向。4. 正则进阶括号、花括号和定界符的那些事4.1 基础正则与扩展正则少打反斜杠的方式sed默认使用的是基础正则表达式BRE它和你在grep -E、awk、Perl 里常用的扩展正则ERE写法上有明显的差异。最大的区别在于BRE 里(、)、{、}、、?、|这些符号默认是普通字符想表达分组、重复次数、或等含义必须加反斜杠转义。元字符基础正则BRE写法扩展正则ERE写法分组\( \)( )重复次数\{m,n\}{m,n}一次或多次\零次或一次\??或|一个实际例子匹配连续 3 到 5 位数字BRE 要写成[0-9]\{3,5\}ERE 就是[0-9]{3,5}。哪种好看一目了然。所以我强烈建议写sed脚本时默认加上-E选项使用扩展正则。一个额外的坑是macOS 自带的 BSD sed 只认-E不认 GNU sed 的-r。好在这几年 GNU sed 也兼容了-E所以你的选择完全可以统一成-E在绝大多数 Linux 和 macOS 上都不会出问题。4.2 脱字符和行尾符的边界从单行到多行的观念转变在常规的逐行处理中^匹配一行的开头$匹配一行的结尾。比如sed s/^/ / file给每行开头加两个空格sed s/$/;/ file给每行末尾加个分号这些都很好理解。但sed里还有一类多行命令比如N它会把下一行也读进模式空间跟当前行拼在一起。这时候模式空间里就不止一行内容了^只能匹配整个模式空间的第一行的开头$也只能匹配整个模式空间的末尾中间夹着的那些原始行边界不再视为行首行尾。这个特性坑过不少人。比如你N之后想用s/^/ /给每一行都加前缀结果发现只有第一行被加了前缀第二行纹丝不动因为^匹配不到中间的行开头。原因就是在多行模式空间里^只认整个缓冲区的起点。遇到这种情况要么放弃N改用其他方案要么对\n做显式替换sed s/\n/ /g这种思路。知道这个边界在哪至少排查问题时能少走弯路。4.3 分组引用与 不只是替换这么简单s命令里有两个特殊的引用符号。代表整个正则匹配到的内容而\1、\2这类代表第 1、第 2 个括号分组捕获到的内容。的使用场景是在原匹配的基础上做包装。比如把所有数字加方括号sed -E s/[0-9]/[]/g data.txt它会先把每一串数字匹配出来然后用[]把匹配内容包进方括号。另一个例子是给年份加后缀sed -E s/2024/年/g file结果就是所有2024变成2024年。分组引用则更适合打乱重组的操作。比如交换一行里的前两个单词sed -E s/([^ ]) ([^ ])/\2 \1/ names.txt再比如手机号脱敏保留前 3 位和后 4 位中间打星号sed -E s/(1[3-9][0-9])[0-9]{4}([0-9]{4})/\1****\2/g phones.txt这里(1[3-9][0-9])捕获前三位([0-9]{4})捕获后四位中间四位直接替换成****。再强调一个细节如果替换文本里需要出现字面意义的必须写成\否则sed会把当成整个匹配去展开。这个细节容易在生成一些包含的代码时踩坑。5. 实战案例脱敏、批量改配置和管道解析5.1 原地修改与备份别拿线上文件开玩笑sed默认输出到屏幕原文件不变。如果想真正修改原文件需要加-i选项。它的含义是in-place原地写回。sed -i s/8080/9090/g app.properties一条命令下去文件里的端口就全变了。但-i有个安全隐患万一正则写错了文件就被改坏了而且没有撤销的机会。所以我几乎每次用-i都会顺带备份sed -i.bak s/8080/9090/g app.properties这个写法会生成一个app.properties.bak的备份文件修改后再比对确认无误再把备份删掉。成本极低收益极高。这里还要提一个跨平台差异。macOS 自带的 BSD sed 对-i的参数要求更严格如果你想不备份直接改必须写成sed -i s/8080/9090/g file空字符串表示不想要备份后缀。同样的写法放在 GNU sed 上行为不一致容易出问题。我的建议是如果你在写跨平台脚本与其纠结两种 sed 的差异不如直接避开-i用重定向的方式sed s/8080/9090/g app.properties app.tmp mv app.tmp app.properties这样在任何平台的 shell 里行为都是一致的。5.2 日志脱敏IP 和手机号这样打码线上日志里经常混着 IP、手机号、身份证号之类的敏感信息发布前需要脱敏。这种批量替换正是sed的强项。IP 地址打码sed -E s/([0-9]{1,3}\.){3}[0-9]{1,3}/x.x.x.x/g access.log这个正则会匹配192.168.1.10这类 IPv4 地址替换成x.x.x.x。还能进一步做到保留前两位、后两位打码这就看你需要什么粒度的脱敏了。手机号脱敏保留前三位和后四位sed -E s/(1[3-9][0-9])[0-9]{4}([0-9]{4})/\1****\2/g users.txt如果日志文件太大脱敏完之后你还要统计一下改动范围可以配合-n和p标志只输出发生替换的行sed -nE s/([0-9]{1,3}\.){3}[0-9]{1,3}/x.x.x.x/gp access.log | wc -l这里g是全局替换p是每替换成功一行打印一次加上-n取消默认输出最终管道统计出的行数就是被脱敏过的行数。5.3 for 循环批量处理一切还可以再自动化一步sed单文件处理只是基础实际工作中经常要批量处理一堆同类型文件。比如把/opt/config/下所有.conf文件里的注释行删掉、把127.0.0.1替换成0.0.0.0for f in /opt/config/*.conf; do sed -i.bak /^#/d; s/127.0.0.1/0.0.0.0/g $f done一个需要注意的坑文件名可能包含空格。所以for循环里引用文件变量一定要加双引号写$f而不是$f。另一个细节是如果目录里一个.conf文件都没有通配符*.conf会原样保留成一个不存在的路径循环就会进入一次空转。想避免这种情况在脚本开头加shopt -s nullglob可以解决它会让没有匹配的通配符直接变成空列表。批量修改这种事sed -i每个文件都会重写一遍效率足够但请务必在跑之前先拿一两个文件做测试确认正则的效果符合预期。5.4 从管道输出里挑行很多时候根本不用写脚本sed最常见的隐藏技能其实是处理其他命令的标准输出。比如ps aux只看表头之后的前两行ps aux | sed -n 1,3p再比如netstat -an的输出前三行是标题和统计信息直接跳过netstat -an | sed 1,3d在adb shell场景里也很常见。比如列出所有安装包只想找某个应用adb shell pm list packages | sed -n /com.tencent/p这里的思路是凡是场景是我只想要某几行我想给输出加个前缀我想把输出的某些字段打码都可以在管道末尾挂一个sed搞定完全不需要写一个完整的脚本文件。有一个小习惯我一直推荐当你想从命令输出里取某几行时sed -n 2,5p比head -5 | tail -4的组合更直观因为它只表达一个动作取 2 到 5 行不需要在两个命令之间来回换算行号。6. 我不想你再踩的坑sed 命令实战翻车现场6.1 忘记 -n 造成的重复输出前面反复强调过-n的问题因为它是我见过最多新人翻车的点。举一个具体例子你想把日志里匹配ERROR的行单独打印出来于是写了sed /ERROR/p app.log。结果终端哗哗地输出所有行而且每一行 ERROR 还出现了两次。表面上看是输出重复了本质上是默认输出 p 命令叠加了。这类问题的排查思路很简单你先问自己到底想要什么效果。如果你想要的是只输出匹配行那就该加-n如果你想要的是输出所有行但匹配行额外再看一遍那不加-n也没毛病。为了避免下意识犯错建议把sed -n /ERROR/p这种组合当成一个固定搭配来记。6.2 定界符冲突替换路径时尤其常见假设你想把文本里的http://www.example.com替换为空如果直接用/当定界符表达式会碎成一地sed s/http://www.example.com// file # 报错或者结果奇怪因为第一个/出现在s后面当定界符第二个/是http://里的斜杠第三个/是结束定界符。整个表达式的结构直接被内容里的斜杠干扰了。解决办法就是换定界符。我会优先用#或者|sed s#http://www.example.com##g file sed s|http://www.example.com||g file关键是记住s后面的第一个字符就是定界符之后所有同字符再次出现的位置都会被当成定界符解析。所以选定界符的原则是内容里大概率不会出现的字符。6.3 转义的层级问题Shell 一层sed 一层在 shell 里写sed命令转义是两层叠加的shell 解释一次sed再解释一次。如果你把正则表达式用双引号包起来shell 会先处理掉一波反斜杠最后到sed手里的正则可能已经不是你想的那个样子了。举个匹配反斜杠的例子。Windows 风格路径C:\tmp里的反斜杠在正则里需要写成\\才能匹配一个真正的反斜杠。如果你用双引号写sed s/C:\\tmp/\/data/g fileshell 会先把\\变成单个\于是sed收到的是C:\tmp在正则里\t会被解释成制表符完全不是你想要的效果。我的铁律是凡是sed表达式一律用单引号包起来除非你明确需要 shell 做变量展开。比如sed s/$old/$new/g这种需要变量插值的场景才用双引号。单引号内的一切内容都会原样传给sed至少省掉一层心智负担。6.4 不敢 -i先测试再动手的铁律-i用好了是神器用坏了是事故。最常见的翻车方式是正则写的没问题但忘了文件里还有些特殊行也被误匹配了然后-i一下几百行数据悄悄变掉。我个人的工作流是head -5 file | sed s/要替换的/替换成/g先用head取几行样本跑一遍确认输出效果。然后正式执行时带备份后缀-i.baksed -i.bak s/要替换的/替换成/g file diff file.bak file # 确认改动内容符合预期最后再决定要不要把.bak删掉。这套流程虽然多两步但在大规模批量处理文件时多这两步节省的排查时间是按小时算的。6.5 非 ASCII 字符与 locale 问题sed处理 UTF-8 中文时大多数场景下是正常的但如果当前环境的 locale 设置不正确可能会出现Invalid or incomplete multibyte or wide character的错误或者正则匹配发生诡异的偏移。我之前在处理一份中英混排的文本时就遇到过sed -E s/[a-z]//把后面的中文字符也误删的情况。排查后发现是终端会话的LANG没设对系统使用了不合适的字符集去解释输入流。解决办法是把环境变量指到 UTF-8export LANGC.UTF-8如果你的文本以中文为主而且正则逻辑很复杂我的建议是别硬用sed直接考虑 Perlperl -pi -e s/模式/替换/g file。Perl 对 Unicode 的支持比传统的sed要强得多复杂字符操作会更省心。7. 关于 sed我已经养成的几个使用习惯前面把原理、命令和坑都讲得差不多了最后分享几个我在实际使用中沉淀下来的习惯谈不上标准答案但确实帮我省了很多事。第一我给自己定了一条硬规矩涉及数据改动的sed -i前面一定先有测试步骤要么是head看样本要么是-i.bak留备份。线上环境改坏了文件再牛的补救手段都不如一开始留条退路。第二简单命令我直接写一行复杂一点的命令我会写成脚本文件再用sed -f script.sed file来跑。这样做的好处是命令里的空格、分号、转义字符都是固定的不会因为换了终端、换了 shell 环境就出幺蛾子。脚本文件里还能加注释过两个月回来看还能大致明白当时想干什么。第三我对工具的选择有一个非常明确的分工单纯找行用grep改行批处理用sed按列算数、生成报表用awk。如果某个需求的正则复杂到让我开始怀疑人生那我果断切到perl -pi不为难自己也不为难sed。工具本来就是为需求服务的没有哪个命令是必须死磕到底的。最后说一个很实用的判断标准如果你能把取行、删行、替换、插入这四类操作在 3 秒内用sed写出来你的命令行熟练度已经超过大多数人了。再往上进阶把模式空间、保留空间和多行处理的机制吃透你会发现自己不仅能更快地写出命令还能在你写之前就大概预判出sed会输出什么。这个预判能力才是真正懂 sed的标志。