ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

sed流编辑器实战:模式空间、正则表达式与批量替换避坑

sed流编辑器实战:模式空间、正则表达式与批量替换避坑

凌晨两点多,一个批量改配置的活儿落到我头上:两百多份服务的配置文件,全都要把下游地址从一个旧的内部域名换成新的,还得分环境、分模块,旧的先注释掉留着回滚。手改不现实,用编辑器全局替换又容易误伤——有的文件里旧地址出现在注释里,有的藏在 fallback 列表中间,还有的写在一个多行块里面。那次我最后敲下去的是这么一条:

sed -i.bak '/^[[:space:]]*#/!s/old-svc\.internal/new-svc\.internal/g' *.conf

一条命令,一杯咖啡的功夫收工,而且每个文件都留了.bak,真出问题还能马上退回去。sed 这个工具在运维圈子里的地位其实挺微妙:人人都知道它叫"流编辑器",人人都背过那句s/old/new/g,但真正敢在批量改配置、清洗日志、提取配置块这些活儿上放心交给它的,没想象中那么多。大多人卡在两个地方——一是被它那套"模式空间"的执行模型绕晕了,写出来的命令能跑但不敢确认对不对;二是被正则和-i的坑坑过一两次,从此见了 sed 就绕着走。

这篇内容就是把 sed 从"能背的那几条命令"往"敢用的那一套方法"上推一推。我不会按手册顺序把命令字母挨个念一遍,而是按运维真实的工作路径来:先搞清楚它一行一行读进来之后到底发生了什么,再往上堆替换、删除、提取、批量落盘这些高频动作,然后把正则、分隔符、-i这三块最容易翻车的地方单独拆开讲,最后说说什么时候该干脆换工具。适合已经把基础命令忘得差不多、但天天要跟文本打交道的运维、后端和测试同学,零基础也能顺着往下看,有经验的可以直接跳到自己栽过跟头的那一节。

1. 那条凌晨的批量替换命令,拆开看都是什么零件

1.1 为什么这种活儿最适合 sed,而不是编辑器或脚本

先说清楚 sed 的定位,不然后面所有技巧都是散的。sed 是Stream EDitor,流式编辑器:它读一段、处理一段、输出一段,从头到尾不把整个文件装进内存。这个特性决定了它在运维场景里的三件事特别顺手——文件有多大都不影响内存占用、能直接接在管道后面处理实时输出、语法短到可以塞进一行&&或者 Ansible 的 shell 模块里。

对比一下就明白了。用 vim 批量改两百个文件,得靠录制宏或者:bufdo,一旦中途匹配错位,回滚成本极高;用 Python 写个小脚本当然更可控,但为了改一个字符串专门写个二十行的脚本、还得考虑编码和异常,边际成本不对等;而xargs + sed -i这种组合,写出来就一行,不需要考虑"文件对象""缓冲区"这些概念,写完就能跑。

有个反直觉的结论值得先说:sed 不是万能的文本工具,它是一个专精的"逐行替换与筛选器"。它擅长的只有两件事——按行匹配,然后对匹配到的行做替换、删除、打印、插入。凡是超出这两件事的(求和、排序、字段计算、JSON 解析),都是它的能力边界之外,硬要用只会把自己写进坑里。第 6 节会专门讲这个边界。

1.2 学 sed 的正确顺序:先地址,后命令

绝大多数教程是从s命令开始讲的,因为替换最常用。但我的经验是,这个顺序会让人写出无数条"能跑但不知道为什么能跑"的命令。真正该先吃透的是地址(address)——也就是"这段命令作用在哪些行上"。

sed 的完整语法骨架是这样的:

sed [选项] '地址 命令' 文件

一个 sed 脚本是由若干条"地址 + 命令"组成的,不写地址就等于作用于所有行。比如:

sed 's/a/b/' file # 无地址,所有行都试一遍替换 sed '3s/a/b/' file # 只处理第 3 行 sed '/start/s/a/b/' file # 处理所有包含 start 的行

三条命令的长度差不多,但作用范围天差地别。现实中事故往往就出在这里:你想改配置文件里某一项,结果忘了加地址,把整份文件里所有同名 token 全替换了,包括注释里那行本来用于回滚的旧配置。

所以我的建议顺序是:先花半小时把地址的写法摸透,再去看s的各种标志位,最后才是多行和保持空间。地址搞明白了,你写出来的每一条 sed 命令都是可预期、可验证的;地址没搞明白,你就是在靠运气跑命令。

1.3 一条命令的四个零件:地址、命令、标志、选项

还是拿开头那条命令拆:

sed -i.bak '/^[[:space:]]*#/!s/old-svc\.internal/new-svc\.internal/g' *.conf

拆成四块看:

  • 选项-i.bak:就地修改,并留.bak备份。这是 sed 的选项,不属于脚本内容。
  • 地址/^[[:space:]]*#/!:匹配"行首可能有空白然后跟一个#"的行,后面的!表示取反。合起来就是"跳过所有注释行"。
  • 命令s/old-svc\.internal/new-svc\.internal/g:替换。
  • 标志g:一行里所有匹配都替换,不是只换第一个。

这四个零件里,地址和标志是 90% 问题的来源。正则写错会误伤,标志漏写会只换一半(g忘了是最经典的),-i用错会丢文件。命令字母本身反而简单,常用的就那么七八个。下面第二节就专门讲执行模型和地址,这是把 sed 从"玄学"变成"可推导"的关键。

2. 模式空间:理解所有"怪行为"的钥匙

2.1 一行一行读、默认打印、d 与 p 到底在干什么

sed 的执行模型可以用一句话概括:每次读一行到"模式空间",执行完所有命令后,若无特殊指令就打印,然后清空进入下一行。这个"自动打印"机制解释了 sed 最让人困惑的现象。

sed 's/a/b/' file # 输出整个文件(替换后的),因为每行都自动打印 sed -n 's/a/b/p' file # 只输出发生了替换的行

第二行命令里有两个关键动作:-n关掉了自动打印,p是手动打印。所以它变成了一条"过滤器"——只吐出来替换成功的行。这个组合是运维里做"从日志里捞出特定记录"最常用的写法之一:

sed -n '/timeout/p' app.log # 抓所有含 timeout 的行 sed -n '/2024-05-1[0-9]/p' app.log # 抓某几天的日志 sed -n '/ERROR/p' app.log | wc -l # 顺便数一下有多少条

而d命令是另一套逻辑:它把当前模式空间的内容删掉,并且立刻开始下一轮循环,后面的命令都不执行了,也不打印。所以sed '/^$/d' file的意思是"遇到空行就丢掉,不加进输出"。这是删空行、删注释行的标准写法:

sed '/^$/d' file # 删真空行 sed '/^[[:space:]]*$/d' file # 删只含空格和 Tab 的"假空行" sed '/^#/d' file # 删注释行 sed '/^#/d; /^$/d' file # 注释和空行一起删 sed '/^#/!d' file # 反过来,只保留非注释行

最后那条!取反很好用,等价于"把不匹配的行全删掉,只留匹配的",比grep -v套管道清爽。这类命令在日常里出现频率极高——检查配置文件时先把几十行注释刨掉,剩下的才是真正生效的部分。

2.2 地址的完整清单,以及那几个只有 GNU 才有的扩展

地址分两类:行号类和正则类,都能组成范围。下面这张表是我实际写脚本时最常用的形态,建议直接记这张:

地址形式含义典型用途
3第 3 行改某个固定位置
$最后一行尾部追加或删除
/re/包含匹配的行最高频
/re1/,/re2/从匹配 re1 的行到匹配 re2 的行(含)提取配置块
1,10第 1 到第 10 行文件头部处理
10,+5第 10 行及其后 5 行(GNU)已知偏移量
1~3从第 1 行起,每 3 行取一行(GNU)抽样
0,/re/从文件头到第一次匹配(GNU)只改第一次出现
/re/!取反跳过注释

范围地址有两个容易误解的行为,必须说清楚。

第一个:范围匹配是"状态机",不是"每行独立判断"。/start/,/end/的意思是"一旦遇到 start 就打开开关,直到遇到 end 才关闭"。这意味着如果同一行里既有 start 又有 end,那么范围只有这一行;如果 end 出现在 start 之前,那么 sed 会一直开到下一次出现 end 为止,行为会很怪。

第二个:第二条/re/只会在 start 之后的行里找,不会回头找。这个特性正好被用来提取配置块,比如从一堆虚拟主机配置里只挖出某一个:

sed -n '/^server {/,/^}/p' site.conf

-n配合p,输出就是从server {那一行到下一个顶层}之间的全部内容。这在排查某个站点的配置时非常好用,比awk写状态机短得多。

0,/re/这个 GNU 扩展值得单独提一句,它解决了一个非常现实的痛点:

# 只替换文件里第一次出现的 foo sed '0,/foo/s/foo/bar/' file # 对比:这条会把文件里所有 foo 都换掉 sed 's/foo/bar/' file

很多人以为不带g的s就是"只换一次",其实它是"每行只换第一次"。要整个文件只换一次,0,/foo/是 GNU sed 下最干净的写法。

2.3 保持空间与多行模式:什么时候才值得动用

模式空间(pattern space)是当前处理的那一行,保持空间(hold space)是一块额外的暂存区,h/H把内容存进去,g/G取出来,x交换两者。听起来很高级,但坦白讲,日常运维里 95% 的活不需要碰保持空间。用它的典型场景只有两个:整个文件级别的大改造(比如反转行序),和跨行拼接。

真正有点用的是多行模式,也就是N、D、P这几个命令。

N是"再读一行进来,和当前行用\n连接"。最常见的用途是把折行的日志拼回去:

# 把每两行合并成一行(用空格连接) sed 'N; s/\n/ /' file # 把整个文件合并成一行 sed ':a; N; $!ba; s/\n/ /g' file

第二条命令里的:a是标签,$!ba是"如果不是最后一行就跳回 a",这是一个标准的循环写法。它的效果是先把整个文件读进模式空间,再一次性把换行替换掉。注意这条命令会把整个文件读进内存,大文件慎用。

反过来的场景是把一行拆成多行,也就是在替换串里插入换行:

# GNU sed 支持在替换端直接写 \n sed 's/; /;\n/g' config.txt # 想真正写入文件而不是只显示,习惯性加 -i sed -i 's/; /;\n/g' config.txt

这里有个可移植性问题:GNU sed 的替换端支持\n表示换行,但一些老版本的 sed(比如某些 BSD 系)不支持,得用s/; /;\加回车再加/的写法。如果你写的脚本要跨环境跑,这一点得留意。

还有一条被低估但特别好用的:q提前退出。sed -n '/panic/{p;q}' app.log的意思是"找到第一条 panic 就打印并退出",在一个 5GB 的日志里找第一条记录,比grep -m1只在语义上更灵活,而且不会继续扫完全文,IO 上省得多。

3. 日常真正用得上手的写法清单

3.1 替换命令的四个标志位,以及跳过注释行的标准姿势

s命令的完整形态是s/正则/替换/标志,标志位常用的有四个:

标志作用备注
g一行内替换所有匹配漏写是最常见事故
p替换成功时打印该行必须配-n
N(数字)只替换第 N 个匹配如s/a/b/2
I或i忽略大小写GNU 扩展

g漏写的后果很具体:一行的内容如果是listen 10.0.0.1:80, 10.0.0.2:80;,你想把两个 IP 都换掉,sed 's/10\.0\.0\.1/10.0.0.9/'只会改第一个,第二个原样留着,而且命令不报错——这种"安静地改一半"是最难排查的。

数字标志在改配置时其实很有用:

# 只替换一行中第二次出现的关键字 sed 's/,/, /2' list.txt

&符号是替换端的一个特殊令牌,代表"整个匹配到的内容"。它的用途是"在匹配内容外围做包装":

# 给所有出现的数字加上方括号 echo "port 8080, timeout 30" | sed -E 's/[0-9]+/[&]/g' # 输出:port [8080], timeout [30] # 把所有 short 类型的变量声明包一层 sed -E 's/\bshort\b/[short]/g' header.h

但&也是坑之一,如果你真的要插入一个字面的&,必须写成\&。这个第三节的正则部分会再提。

跳过注释行是运维脚本里的刚需,标准写法就是开头那条:

sed '/^[[:space:]]*#/!s/old/new/g' file

拆解一下:^[[:space:]]*#匹配"行首零个或多个空白字符,然后一个#",覆盖了# xxx和# xxx两种形式;后面的!是地址取反,让s只作用在非注释行上。这里用自己的字符类[[:space:]]而不是\s,是因为\s是 GNU 扩展,在 shell 里还得双重转义,写起来反而更绕。

3.2 删除、插入、整行替换:改配置文件的三板斧

d之外,还有三个整行级命令:a(append,在after后追加)、i(insert,在前插入)、c(change,替换整行)。它们的共同点是操作对象是整行,不是行内片段,所以定位必须用地址。

# 在第 10 行后面追加一行 sed '10a\# added by deploy script' file # 在所有匹配 backup 的行前面插入一行 sed '/backup/i\# --- backup section ---' file # 把匹配到的那一行整个替换掉 sed '/^LOG_LEVEL=/c\LOG_LEVEL=INFO' file

c命令特别适合"某一项配置必须是固定值"的场景,比如把LOG_LEVEL=开头的行整行改成LOG_LEVEL=INFO。用s/^LOG_LEVEL=.*/LOG_LEVEL=INFO/也能实现,但c更直观,而且不用操心原行里到底有没有等号和值——只要匹配到,整行就换掉。

a命令有一个常见错误:在 GNU sed 里,a\后面跟文本是标准写法,但很多人会写成sed '10a # comment'(漏了反斜杠),这时候 GNU sed 会把第一部分当写文件,行为很怪。稳妥的写法是用单引号包住,并在a后面紧跟一个反斜杠和一个换行,或者用 GNU 支持的一行式sed '10a # comment'。跨平台脚本里我一般直接写:

sed '10a\ # added by deploy script' file

看起来丑,但 GNU 和 BSD 都能正确解析。

关机命令这一块不展开,重点说一个删除整块配置的常用组合。假设要删掉一个配置块:

# 删掉从 [backup] 段头到下一个段头之间的内容(不含下一个段头) sed -n '/^\[backup\]/,/^\[/{/^\[/!p}' file

这里用了一个小技巧:外层的范围定位到"段头到下一个段头",内层的/^\[/!p表示"不打印段头本身"。这样一来,输出结果里就把 backup 整段剔掉了,而其他段完好。这种"范围 + 内层过滤"的组合是 sed 处理结构化文本的核心手法,比堆awk状态机短很多。

3.3 打印、编号、加前后缀:那些看着不起眼但省事的小命令

=命令输出当前行号,配合别的命令可以用来做"带编号的列表":

sed = file | sed 'N; s/\n/\t/' # 输出相当于 cat -n file

单独用sed -n '$='是数行数最快的方式,比wc -l在某些场景下更稳(比如文件末尾缺换行符时,wc -l会少算一行,而sed -n '$='不会)。

行首行尾加字符是纯靠替换完成的:

sed 's/^/> /' file # 每行前面加 "> " sed 's/$/;/' file # 每行末尾加分号(做 SQL 或数据拼装时常用) sed -E 's/^(.*)$/ \1/' file # 每行统一缩进两格

最后那条用到了反向引用\1。反向引用是 sed 里另一把刀,后面第 4 节会详细讲,这里先记住形态:正则里用\(...\)(BRE)或(...)(-E模式)分组,替换端用\1到\9引用。

还有一个用得特别多的小组合:sed做简单的编码兜底。比如 Windows 上传过来的文件带 CRLF,直接用会让 shell 脚本里每一行的命令都多一个\r,报一堆"找不到命令"的错。修复只需要:

sed -i 's/\r$//' script.sh

反过来,处理带 BOM 的 UTF-8 文件,用:

sed -i '1s/^\xEF\xBB\xBF//' file.csv

这两个都是"看到症状才知道要找它"的典型,记下来能省不少排查时间。

3.4 就地修改的完整姿势:备份、批量、批量再备份

-i是 sed 线上作业的命门,它的完整用法有三种形态:

sed -i 's/a/b/' file # 就地改,无备份(GNU) sed -i.bak 's/a/b/' file # 就地改,生成 file.bak sed -i 's/a/b/' f1 f2 f3 # 一次改多个文件

第二种是我在生产环境里的默认选择。.bak后缀不是装饰,它能在你发现正则写错时,用一条反过来的sed -i.bak2 's/b/a/' *.bak或者直接cp回去把损失降到最小。尤其是在批量改几十上百个文件时,那点磁盘空间换来的回滚能力完全值得。

批量改的经典组合是和find/xargs一起用:

# 找出所有 .conf 文件并批量替换 find /etc/app -name '*.conf' -print0 | xargs -0 sed -i.bak 's/old/new/g'

这里用-print0和xargs -0是因为文件名里可能有空格。如果文件数量特别大(几千上万),xargs会分批调用 sed,每批都会重新启动一个进程,开销不小;这时候可以改用-exec ... +:

find /etc/app -name '*.conf' -exec sed -i.bak 's/old/new/g' {} +

+语法会把多个文件名拼成一次调用,比\;和xargs都更高效,而且不用担心参数长度限制。这一点很多教程没提,但在真机上一次跑几万个文件时,差距是肉眼可见的。

4. 正则这一关:BRE、ERE、贪婪匹配和转义

4.1 BRE 和 ERE 的差别,以及为什么建议默认加 -E

sed 默认用的是 POSIX 基本正则(BRE),-E(或 GNU 的-r)打开扩展正则(ERE)。两者的差别集中在元字符的转义上,列个表看清楚:

写法BREERE(sed -E)
分组\(...\)(...)
或|`
重复区间\{2,4\}{2,4}
一次或多次\++
零次或一次\??
反向引用\1\1

同样是"匹配两个或更多数字",两种写法是:

sed 's/[0-9]\{2,\}/NUM/' file # BRE,花括号要转义 sed -E 's/[0-9]{2,}/NUM/' file # ERE,干净

我的习惯是写复杂正则时一律加-E,原因很实际:bak 上面那些转义符号在双引号里还要被 shell 吃掉一层,\\{2,\\}这种写法一旦出错很难看出来。用-E之后至少少一层心智负担。

但也要知道-E的一个副作用:用-E之后,\(就是字面意义上的左括号,不再是分组符。所以不要混用,一个脚本里要么全 BRE 要么全 ERE。

另外要提醒的是,\s、\d、\w、\b这些是 GNU 的扩展,不是 POSIX sed 标准。在 Alpine 这类用 busybox 或者精简 sed 的环境里,sed 's/\s\+//'可能完全不生效,而且不报错。写要跨环境跑的脚本,老老实实用[[:space:]]、[0-9]、[[:alnum:]_]、\b的替代方案(左右用\(^\|[^a-zA-Z]\)包裹),能省掉一次深夜排查。

4.2 sed 没有非贪婪匹配,那该怎么办

这是从 PCRE 转过来的人最不适应的一点。.*?在 sed 里不存在,.*永远是贪婪的——它会一路吃到这一行能匹配的最远位置。举个最容易踩的例子:你想把一段文本里每个引号内的内容拎出来。

echo 'a="x1" b="x2" c="x3"' | sed -E 's/.*="(.*)"/[\1]/' # 输出:[x3]

结果只剩最后一个,因为.*=吃掉了前面所有的="x1" b="x2部分。正确写法是用"不包含引号的字符"来约束长度:

echo 'a="x1" b="x2"' | sed -E 's/="([^"]*)"/[\1]/g' # 输出:a=[x1] b=[x2]

[^"]*就是模拟"非贪婪"的标准手法——用取反字符类代替.*。常见的等价替换有:

想匹配别写应写
引号内内容".*""[^"]*"
行内最后一个路径段/.*//[^/]*$
到分号为止.*;[^;]*;
到空白为止.*[^ ]*

这条规则记牢了,sed 正则能力的上限会直接往上跳一大截。我处理过的一次事故就是这类:sed -E 's/id=(.*)&/id=XXX&/'本来只想把 URL 里 id 参数打码,结果.*一路吃到&,把后面几个参数全吞了,幸好是打印到终端,没写文件。

4.3 分隔符、&、反斜杠:三个高频事故的根因

第一个事故:分隔符冲突。s///的斜杠不是固定的,可以换成任意字符。改路径的时候如果不换,就得写成一堆\/:

sed 's/\/usr\/local\/bin/\/opt\/bin/g' file # 能跑,但没法看 sed 's#/usr/local/bin#/opt/bin#g' file # 干净 sed 's|/usr/local/bin|/opt/bin|g' file # 也可以

分隔符的选择原则很简单:挑替换内容里不出现的字符。改路径用#或|,改含#的内容(比如注释)就用/或@。如果两边都含,用\x01这种不可能出现的字符也行,但一般没必要。

注意一个细节:s#...#...#的时候,如果正则里要用#本身,还是得转义成\#。选分隔符只是让你少转义,不是免转义。

第二个事故:&被当成令牌。前面说过&在替换端代表整个匹配。如果替换内容本身含&,比如要把某个参数替换成一个带查询串的 URL:

sed 's/id=[0-9]*/id=100&type=1/' file

这条命令会把&展开成匹配到的原文,结果完全不是想要的。正确写法是\&:

sed 's/id=[0-9]*/id=100\&type=1/' file

这个坑在你处理 shell 脚本、SQL、URL 的时候几乎必然会遇到一次。

第三个事故:反斜杠的层数。sed 里的反斜杠至少要过三层——sed 自己一层,shell 双引号一层,如果 sed 脚本本身写在字符串里还要再加一层。所以经验法则是:sed 脚本永远用单引号包。单引号里 shell 不做任何解释,你只需要考虑 sed 那一层。必须插入 shell 变量时才切到双引号,并且心里清楚多了一层转义:

old="10.0.0.1"; new="10.0.0.9" # 因为 IP 里的点需要转义 sed 层,写成 \. 在双引号里又会变成 . sed -i "s/${old//./\\.}/${new}/g" file # 用 bash 的参数替换先把点转义好

这种写法绕但可靠。更省事的做法是用分隔符避开#,然后直接用双引号,前提是变量值里没有特殊字符:

sed -i "s#$old#$new#g" file

前提是你确信$old和$new里不含#、&、\。生产环境里这三点往往不能确信,所以我更倾向于把待替换的字符串写到临时 sed 脚本里,用sed -f script.sed file调用,彻底躲开 shell 转义。

5.-i背后的文件系统真相,以及我栽过的四个跟头

5.1 它不是"原地写",是临时文件加改名

-i这个名字很有误导性,它给人的感觉是"在文件的字节上原地替换"。实际不是。GNU sed 做的是:在同目录下创建一个临时文件,把处理结果逐行写进去,处理完成后把临时文件 rename 成原文件名。原来的文件内容从头到尾没被改过,只是被"换掉"了。

理解这一点,后面所有的坑都能自己推出来。

5.2 硬链接会断、扩展属性会丢、软链接会被替换

坑一:硬链接断开。如果a.conf和b.conf是同一个 inode 的两个硬链接,你对a.conf做sed -i,生成的是一个新 inode,b.conf仍然指向老 inode、老内容。改之前两个文件联动,改之后各走各的。这在一些用硬链接做多副本配置的环境里会非常隐蔽——你改了一个,另一个没变,而且还是"正确内容",排查时根本想不到。

坑二:文件元数据不完整。GNU sed 会尽量保留原文件的权限位和属主,但扩展属性(xattr)、POSIX ACL、SELinux 上下文这类元数据不保证保留。在启用了强制访问控制的机器上,sed -i之后文件标签变了对不上策略,服务起不来,报错还跟权限没关系,非常难查。遇到这种情况,改完记得用restorecon之类的工具把上下文还原。

坑三:软链接被替换成普通文件。如果current.conf是指向v2.conf的软链接,sed -i会直接把链接本身替换成一个普通文件,链接关系没了,而v2.conf原封不动。你以为改了实际生效的配置,其实只改了个影子。GNU sed 有--follow-symlinks选项可以跟随链接改目标文件,但这一步在很多脚本里被漏掉了。

我的做法是:批量改之前先跑一次find . -type l看看有没有软链接,有的话统一用--follow-symlinks,或者干脆先readlink -f拿到真实路径再改。

5.3 那条把文件清空的经典命令

这个坑几乎每个做运维的人都踩过或者见过:

sed 's/old/new/g' app.conf > app.conf

跑完打开一看,文件是空的。原因不复杂:shell 在启动 sed 之前就先把> app.conf执行了,文件被截断成 0 字节,然后 sed 去读这个空文件,读到 0 行,写出 0 行。整个过程没有任何报错。

正确的三种写法:

sed -i 's/old/new/g' app.conf # 首选,自带原子性 sed 's/old/new/g' app.conf > app.conf.tmp && mv app.conf.tmp app.conf sed -i.bak 's/old/new/g' app.conf # 最保险,有备份

如果不得不用临时文件,&& mv这一步不能省,否则中途出错会留下半截文件。

还有一个更隐蔽的变体:-i和一个本身从 stdin 读数据的命令混用。比如cat file | sed -i 's/a/b/',这里的-i没有意义(没有输入文件),sed 会直接报错退出。想通管道又要落盘,只能是sed ... > tmp && mv。

5.4 macOS 和 Linux 的语法分叉,以及能跨平台的写法

在 macOS 上跑sed -i 's/a/b/' file,会得到类似sed: 1: "file": invalid command code f的报错。原因是 BSD sed 的-i必须带参数,表示备份后缀;不想留备份就传一个空字符串:

sed -i '' 's/a/b/' file # macOS / BSD sed -i 's/a/b/' file # Linux / GNU

如果你的脚本要在两种环境都跑,有三种写法:

第一种是检测后分支:

if sed --version >/dev/null 2>&1; then sed -i 's/a/b/' file else sed -i '' 's/a/b/' file fi

sed --version在 GNU 上能输出,在 BSD 上会报错,这是一个可靠的分支判断。

第二种是干脆不用-i,统一用临时文件 +mv,兼容性最好:

sed 's/a/b/' file > file.tmp && mv file.tmp file

第三种是装 GNU 版的 sed(通常命名成gsed),脚本里统一用gsed。这在团队内部环境里最简单,但对外发脚本不友好。

还有一种跨平台的细节:sed -E在 GNU 和 BSD 上都支持,但更老的版本只认-r。如果目标环境不确定,优先用-E,-r反而更"GNU 专属"一些。

5.5 大文件与磁盘空间:一个容易忽略的成本

-i是临时文件加改名,意味着处理 N GB 的文件时,磁盘上需要额外 N GB 的空闲空间,而且写入量翻倍。改一个 20GB 的日志文件,如果磁盘只剩 5GB,sed -i会中途失败,留下一堆残留的临时文件(通常是sedXXXXXX),原文件倒是完好的。

这么大批量的处理,我的经验是先用sed -n做检查(不写盘),确认匹配范围正确,再考虑是否真的要落盘。如果只是清洗过滤而不是局部修改,直接用管道重定向到新文件、校验后替换原文件更稳:

sed '/pattern/d' big.log > big.log.clean && mv big.log.clean big.log

另外,GNU sed 的-i在 NFS 这类网络文件系统上的 rename 行为不一定原子,多台机器同时对同一文件做-i可能互相覆盖。共享目录里的配置文件,尽量别用-i改,走配置管理工具或者加锁。

6. 边界感:这些活儿不该硬塞给 sed

6.1 求和、分组、字段运算,该交给专门工具

sed 的定位是"逐行替换与筛选",它没有字段概念,没有变量,没有累加。有人试图用 sed 做去重、求和、按列统计,写出来的脚本长度和可读性都不忍直视。下面这些是明确的越界:

需求硬用 sed该用什么
按列求和写循环加保持空间awk '{s+=$2} END{print s}'
排序去重基本做不到sort -u
按字段过滤正则勉强能做awk '$3 > 100'
统计出现次数做不到sort | uniq -c
JSON 取值极其脆弱jq
YAML 改值会破坏格式yq
CSV 改列引号内含逗号就崩csvkit或专门解析器

这不是说 sed 弱,而是每把工具都有自己的射程。用awk做两列求和是 5 个字符,用 sed 写出来要几十行而且没人看得懂,这个选择其实不需要犹豫。

6.2 JSON、YAML、CSV:格式越结构化,越不能用 sed 硬改

sed 是纯文本正则层面的工具,它不理解结构。这三个格式的共同特点是存在嵌套和转义,一旦正则匹配跨进了不该进的层级,输出就不再是合法格式。

以 JSON 为例,表面上看sed 's/"port": 8080/"port": 9090/'能改,但实际上:"port": 8080可能出现在嵌套对象里,也可能是某个字符串值的一部分;紧凑格式里根本没有空格;修改后可能改变字段顺序或破坏缩进(虽然 JSON 语义上无所谓,但 diff 会变得很难看)。

YAML 更危险。缩进就是语法,一个不留神把两行之间的相对缩进改了,文件解析直接失败,而且报错位置往往离你改的地方很远。

CSV 的问题在引号。a,b,"c,d",e这种带引号包裹逗号的字段,用sed 's/,/|/g'会把引号里的逗号也换掉,数据就此损坏。真要处理 CSV,csvkit或者直接上 Python 的csv模块,几行代码的事,比调正则省心得多。

我的判断标准很粗暴:如果这个格式有解析器,就用解析器。只有在明确知道"我要改的位置在文件里只有一处、且这一处的上下文独一无二"时,才用 sed 做定点修改。

6.3 一份选型对照,以及我自己的默认决策路径

最后把决策路径整理一下,这部分是我这些年踩坑之后沉淀下来的默认判断:

场景首选备选
单行内字符串替换,范围明确sed 's'vim:%s
删注释行、空行、某类日志sed '/re/d'grep -v
提取两个标记之间的块sed -n '/a/,/b/p'awk状态机
按列处理、求和、统计awkPython
复杂查找(多条件、文件类型)find-
大文件抽样看前几条sed -n '1,20p'/sed qhead
批量改多文件find + xargs + sed -i.bak配置管理工具
结构化格式改值jq/yq专门脚本
需要幂等、可回滚的批量变更配置管理工具脚本 + 备份

这里有个经验值得单独讲:能用grep先验证就不用急着上sed -i。改配置前,先跑一遍grep -n 'old-svc' *.conf,看看命中了哪些文件哪些行,心里有个数;再用同样正则跑sed -n '/old-svc/p'确认 sed 视角下的匹配结果;两次对上,才敲-i。这个"两次确认"的习惯帮我避免过好几次改错文件的事故,成本不过多几秒钟。

另外还有一个我在实际使用中养成的习惯:批量改之前先算一下命中数量。

find /etc/app -name '*.conf' -exec grep -l 'old-svc' {} + | wc -l

如果这个数字和预期(比如你知道只有 37 个文件用到这个地址)对不上,就说明匹配条件写宽了或者写窄了,这时候停下来检查,比改完再回滚便宜太多。

sed 这个工具,学它花的时间其实很短,真正的成本在于建立"我这条命令到底作用在哪些行上"的确定性。地址想清楚,正则用取反字符类控制住长度,-i记得带备份、碰到软链接和结构化格式提前绕开,这三件事做到,它就能从"背过的命令"变成"敢在线上敲的手感"。至于更复杂的文本流转,交给awk和专门的解析器,各司其职,才是长期省命的方式。

返回列表