目录

Linux-08 grep、sed、awk:三套正则流派与各自的工作模型

grepsedawk 被称为「文本处理三剑客」。大多数人的状态是:会 grep xxx file,会 sed 's/a/b/g',会 awk '{print $1}',再复杂一点就开始搜索。

这一篇要解决的不是「记住更多参数」,而是三件事:三套正则流派的分歧sed 和 awk 各自的工作模型什么时候该用哪个

先看五个问题:

  1. 为什么 grep 'a+' 匹配不到 aaa,但 grep -E 'a+' 可以?
  2. sed 's/a/b/' 只替换每行第一个,g 是什么的缩写?为什么默认不是全局替换?
  3. awk '{print $1}' 是按什么切分字段的?为什么用它处理 CSV 会出错?
  4. 三个命令都能做替换和过滤,到底什么时候用哪个?
  5. grep -P 为什么在有些系统上报 invalid option

1. 三套正则流派:分歧的源头

这一章是本篇的基础。同一个正则表达式在 grepgrep -Egrep -P 里可能有三种不同的行为,原因是它们实现的是三套不同的标准。

1.1 为什么会有三套

历史顺序是这样的:

1970s   ed 编辑器带来了最早的正则         -> 后来标准化为 BRE(基本正则)
          ↓ 元字符很少,+ ? | 都是普通字符
1979    egrep 扩展了元字符                -> 后来标准化为 ERE(扩展正则)
          ↓ + ? | ( ) { } 成为元字符
1987    Perl 大幅扩展了正则能力            -> PCRE(Perl 兼容正则)
          ↓ \d \w \s、非贪婪、零宽断言、反向引用

BRE 之所以「元字符少」,是因为向后兼容ed 时代已经有大量脚本把 +?| 当普通字符用了,直接把它们变成元字符会破坏这些脚本。于是 POSIX 标准化时保留了这个行为,想用扩展功能就得转义\+\?\|)——这是「反向」的转义逻辑,也是最容易搞错的地方。

1.2 开篇第一问的答案

echo "aaa" | grep 'a+'        # (无输出)
echo "aaa" | grep -E 'a+'     # aaa
echo "a+"  | grep 'a+'        # a+          <- BRE 里 + 是【普通字符】
echo "aaa" | grep 'a\+'       # aaa         <- BRE 里要转义才是"一次或多次"

在 BRE 里 + 就是一个加号字符,所以 grep 'a+' 找的是字面的 a+

1.3 三套流派对照表

功能 BRE(grepsed ERE(grep -Eawk PCRE(grep -P
任意字符 . . .
0 次或多次 * * *
1 次或多次 \+ + +
0 次或 1 次 \? ? ?
| | |
分组 \( \) ( ) ( )
重复 n 次 \{n\} {n} {n}
行首 / 行尾 ^ $ ^ $ ^ $
字符集 [abc] [^abc]
反向引用 \1 \1 \1
数字 [0-9] [[:digit:]] \d
单词字符 [[:alnum:]_] \w
空白 [[:space:]] \s
单词边界 \b \< \> \b \b
非贪婪 *? +?
零宽断言 (?=) (?!) (?<=) (?<!)
\K(丢弃已匹配部分) \K
多行模式 (?s) (?m)
# 同一个需求的三种写法:提取 "port=8080" 里的数字
grep -o 'port=[0-9]\+' f            # BRE:+ 要转义
grep -oE 'port=[0-9]+' f            # ERE:+ 直接用
grep -oP 'port=\K\d+' f             # PCRE:\d 简洁,\K 丢弃前缀只留数字
#              ^^ 输出 8080 而不是 port=8080

1.4 POSIX 字符类:为什么不用 \d

\d\w\sPerl 的发明,不是 POSIX 标准。POSIX 定义的是这套写法:

POSIX 类 等价于 含义
[[:digit:]] [0-9] 数字
[[:alpha:]] [a-zA-Z] 字母
[[:alnum:]] [a-zA-Z0-9] 字母数字
[[:space:]] [ \t\n\r\f\v] 空白
[[:blank:]] [ \t] 空格和 tab
[[:upper:]] / [[:lower:]] [A-Z] / [a-z] 大小写
[[:punct:]] 标点
[[:xdigit:]] [0-9a-fA-F] 十六进制数字
[[:cntrl:]] 控制字符
[[:print:]] / [[:graph:]] 可打印 / 可见(不含空格)

注意双层方括号[[:digit:]] 里外层的 [] 是字符集,内层的 [: :] 才是类名。所以「数字或下划线」要写成 [[:digit:]_]

# 为什么该用 POSIX 类而不是 [a-zA-Z]
echo "café" | grep -o '[a-z]*'        # caf      <- é 不在 a-z 范围
echo "café" | grep -o '[[:alpha:]]*'  # café     <- 尊重 locale,能匹配带音标的字母

# 但要注意 locale 的影响
echo "café" | LC_ALL=C grep -o '[[:alpha:]]*'      # caf   <- C locale 下只认 ASCII
echo "café" | LC_ALL=en_US.UTF-8 grep -o '[[:alpha:]]*'   # café

1.5 开篇第五问:grep -P 为什么可能不可用

grep -P '\d+' file
# grep: support for the -P option is not compiled into this --disable-perl-regexp binary

-P 需要 libpcre 库,而它是 grep可选依赖

# 检查你的 grep 是否支持
grep -P '' /dev/null 2>&1 && echo "支持 -P" || echo "不支持"
ldd $(which grep) | grep pcre        # 看是否链接了 libpcre

# 常见不支持的场景
# ① Alpine 的 busybox grep —— 根本没有 -P
# ② 某些精简发行版编译时禁用了
# ③ macOS 自带的 BSD grep —— 没有 -P(要用 brew install grep 得到 ggrep)

# 可移植的替代方案
grep -oP 'port=\K\d+' f                    # 只在有 PCRE 的地方能用
grep -oE 'port=[0-9]+' f | cut -d= -f2     # ✅ 可移植
sed -n 's/.*port=\([0-9]*\).*/\1/p' f      # ✅ 可移植
awk -F'port=' '{split($2,a,/[^0-9]/); print a[1]}' f   # ✅ 可移植
perl -nle 'print $1 if /port=(\d+)/' f     # perl 一定支持 PCRE

实践建议:交互式排查随便用 -P(方便),脚本里避免用 -P(可移植性),改用 -E + cut/sed,或者直接上 perl/python

1.6 贪婪、非贪婪与零宽断言

这三个是 PCRE 独有的能力,值得单独讲。

# ① 贪婪 vs 非贪婪
echo '<a><b>' | grep -oE '<.*>'        # <a><b>     <- 贪婪:尽可能多匹配
echo '<a><b>' | grep -oP '<.*?>'       # <a>        <- 非贪婪:尽可能少
#                            ^^ ? 让 * 变成非贪婪                <b>

# BRE/ERE 没有非贪婪,只能用"排除法"绕开
echo '<a><b>' | grep -oE '<[^>]*>'     # <a>  <b>   <- 用 [^>] 代替 .
#                          ^^^^^ 匹配非 > 的字符,天然就"停在第一个 > 前"

# ② 零宽断言:匹配位置而不是字符
# (?=...)  正向肯定:后面【是】...
# (?!...)  正向否定:后面【不是】...
# (?<=...) 反向肯定:前面【是】...
# (?<!...) 反向否定:前面【不是】...

echo "price=100 cost=200" | grep -oP '\d+(?= )'      # 100    <- 后面是空格的数字
echo "price=100 cost=200" | grep -oP '(?<==)\d+'     # 100    <- 前面是 = 的数字
#                                                      200
echo "foo.log foo.txt" | grep -oP 'foo(?=\.log)'     # foo    <- 只匹配 .log 前的 foo

# ③ \K:丢弃它之前已匹配的内容(比 (?<=) 更灵活,支持变长)
echo "user=alice pass=xxx" | grep -oP 'user=\K\w+'   # alice
echo "user=alice pass=xxx" | grep -oP '(?<=user=)\w+' # alice  <- 等效,但 (?<=) 要求定长
grep -oP 'Content-Length:\s*\K\d+' headers.txt        # \K 后面可以是变长的空白

零宽断言的实际价值:提取时不带上定位用的前后缀,省掉一次 cut/sed

1.7 常用元字符速查(以 ERE 为准)

.          任意单个字符(不含换行)
*          前一项 0 次或多次
+          前一项 1 次或多次
?          前一项 0 次或 1{3}        恰好 3{2,5}      25{2,}       至少 2[abc]      a 或 b 或 c
[^abc]     除了 abc
[a-z0-9]   范围
^          行首
$          行尾
|()         分组 + 捕获
\1 \2      反向引用(引用第 1、2 个分组)
\b         单词边界
\<  \>     单词开始 / 结束(GNU 扩展)
\.         转义,匹配字面的点

# 反向引用的实用例子
grep -E '(.)\1' file                  # 找出含连续重复字符的行(如 aa、11)
grep -E '\b(\w+)\s+\1\b' file         # 找出重复单词(如 "the the")
sed -E 's/(\w+)@(\w+)/\2@\1/' file    # 交换 @ 两边的内容
echo "2026-08-12" | sed -E 's/(....)-(..)-(..)/\3\/\2\/\1/'   # 12/08/2026

2. grep:过滤与提取

2.1 名字来源

grep = global regular expression print。它来自 ed 编辑器里的一条命令:

g/re/p        在 ed 里的含义:global(全局)搜索 re(正则),并 print(打印)
^ ^^ ^

Ken Thompson 把这个高频操作单独做成了一个程序,名字就直接用了这串命令。理解这个名字就理解了它的定位:它是「过滤器」,不是「编辑器」——只负责挑出符合条件的行,不修改内容。

2.2 完整参数表

全称 作用
匹配模式
-E --extended-regexp extended ERE(等价 egrep
-F --fixed-strings fixed 固定字符串,不解析正则(等价 fgrep最快
-P --perl-regexp perl PCRE(需 libpcre)
-G --basic-regexp basic 用 BRE(默认
匹配控制
-i --ignore-case ignore case 忽略大小写
-v --invert-match invert 反向:输出不匹配的行
-w --word-regexp word 整词匹配(等价两端加 \b
-x --line-regexp line 整行完全匹配
-e PAT --regexp=PAT 指定模式(多个 -e = 或,也用于模式以 - 开头时)
-f FILE --file=FILE 从文件读模式(一行一个,批量匹配必用
输出控制
-o --only-matching only matching 只输出匹配的部分(提取而非过滤)
-c --count count 只输出匹配的行数
-l --files-with-matches 只输出含匹配的文件名
-L --files-without-match 只输出不含匹配的文件名
-n --line-number number 显示行号
-b --byte-offset 显示字节偏移
-H / -h --with-filename / --no-filename 强制显示 / 隐藏文件名
-q --quiet quiet 静默,只返回退出码(脚本里判断存在性)
-s --no-messages 不显示「文件不存在」等错误
-m N --max-count=N max 匹配 N 次后停止(大文件里找前几个)
上下文
-A N --after-context=N after 显示匹配行 N 行
-B N --before-context=N before 显示匹配行 N 行
-C N --context=N context 前后各 N 行(等价 -N
递归与筛选
-r --recursive recursive 递归目录(不跟符号链接
-R --dereference-recursive 递归且跟随符号链接
--include=GLOB 只搜匹配的文件
--exclude=GLOB 排除文件
--exclude-dir=GLOB 排除目录node_modules.git
二进制与编码
-a --text text 把二进制当文本处理
-I 忽略二进制文件
-z --null-data 输入按 \0 分隔(当作单行处理)
-Z --null 输出文件名后加 \0(配合 xargs -0
颜色
--color=auto 匹配部分高亮(管道中自动关闭)
--color=always 强制着色(管道给 less -R 时用

2.3 高频用法

# ── 基本过滤 ──
grep ERROR app.log
grep -i error app.log                    # 忽略大小写
grep -v DEBUG app.log                    # 排除 DEBUG 行
grep -w cat file                         # 只匹配整词 cat,不匹配 category
grep -x "exact line" file                # 整行完全相等

# ── 多模式(或)──
grep -E 'ERROR|WARN|FATAL' app.log
grep -e ERROR -e WARN app.log            # 等价,多个 -e
grep -F -f patterns.txt app.log          # 从文件读几千个模式(-F 让它极快)

# ── 多条件(与):用管道 ──
grep ERROR app.log | grep timeout        # 同时含两者
grep ERROR app.log | grep -v test        # 含 ERROR 但不含 test

# ── 提取而不是过滤(-o)──
grep -o 'user=[a-z]*' app.log            # 只输出匹配部分
grep -oE '[0-9]{1,3}(\.[0-9]{1,3}){3}' app.log     # 提取所有 IP
grep -oP '"status":\K\d+' app.log        # PCRE + \K,只要值

# ── 统计 ──
grep -c ERROR app.log                    # 匹配的行数
grep -o ERROR app.log | wc -l            # 匹配的【次数】(一行多个也算多次)
grep -c '' file                          # 总行数(比 wc -l 准,见 05 篇)

# ── 上下文 ──
grep -n -A5 -B2 panic app.log            # 前 2 行后 5 行 + 行号
grep -C3 --color=always ERROR app.log | less -R

# ── 递归搜索代码 ──
grep -rn 'TODO' .                                    # 递归 + 行号
grep -rn --include='*.go' 'func main' .              # 只搜 .go
grep -rn --exclude-dir={.git,node_modules,vendor} 'TODO' .
grep -rl 'deprecated' . | xargs -r sed -i 's/deprecated/legacy/g'
#       ^^ 只列文件名,交给 sed 批量改

# ── 脚本里判断 ──
if grep -q ERROR app.log; then echo "有错误"; fi
#         ^^ -q 静默,找到第一个就退出(大文件上快得多)

# ── 找不到匹配的文件(-L)──
grep -rL 'package main' --include='*.go' .   # 哪些 go 文件不是 main 包

2.4 三个必须知道的坑

# ① grep 没匹配到时返回 1,在 set -e 脚本里会导致整个脚本退出
set -e
grep ERROR app.log         # 没有 ERROR -> 返回 1 -> 脚本直接死掉
# ✅ 三种修法
grep ERROR app.log || true
grep ERROR app.log || :
if grep -q ERROR app.log; then ...; fi      # 条件里的失败不触发 set -e

# 退出码约定:0=有匹配  1=无匹配  2=出错(文件不存在、正则语法错)
grep ERROR nonexist.log; echo $?    # 2

# ② --color=auto 在管道里会自动关闭(这是特性)
grep --color=auto ERROR app.log | less        # 没有颜色
grep --color=always ERROR app.log | less -R   # ✅ 强制着色 + less 保留颜色
# 但 --color=always 的转义码会混进数据,别用在需要进一步处理的管道里
grep --color=always ERROR app.log | awk '{print $1}'   # ❌ $1 里混了颜色码

# ③ 二进制文件不输出内容
grep pattern /bin/ls
# Binary file /bin/ls matches
grep -a pattern /bin/ls          # ✅ 当文本处理
grep -I pattern *               # 反过来:直接跳过二进制文件

2.5 性能:为什么 -F 快,什么时候用 rg

# -F 不解析正则,用的是 Aho-Corasick 多模式字符串匹配算法
time grep -F -f 10000-patterns.txt huge.log      # 0.8s
time grep -E -f 10000-patterns.txt huge.log      # 45s   <- 慢几十倍

# 只要模式里没有正则元字符,就该加 -F
grep -F 'user.name@example.com' app.log     # . 和 @ 不用转义了

# 缩小搜索范围
grep -rn --include='*.go' pattern .         # 比全量递归快
grep -m 1 pattern huge.log                  # 找到第一个就停

# 用 LC_ALL=C 跳过 UTF-8 处理(纯 ASCII 数据时能快数倍)
LC_ALL=C grep pattern huge.log

# 现代替代:ripgrep 默认并行 + 自动跳过 .gitignore 和二进制
rg pattern                                  # 通常比 grep -r 快 5~10 倍
rg -t go 'func main'

3. sed:流式编辑

3.1 工作模型:理解这个才会用 sed

sed = stream editor(流编辑器)。它的核心是一个循环

   读取一行到「模式空间」(pattern space),【不含末尾换行】
             |
             v
   对模式空间依次执行脚本里的每条命令
             |
             v
   自动输出模式空间的内容(除非用了 -n)
             |
             v
   清空模式空间,读下一行  ──────> 循环直到文件结束

   另外还有一个「保持空间」(hold space),像一个暂存寄存器,
   可以与模式空间交换/追加内容,用来做跨行处理(见 3.6)

两个关键点

  1. 默认会输出每一行(不管有没有被修改)—— 所以 sed -n + p 才是「只输出想要的」
  2. 默认一次只处理一行 —— 跨行操作需要 N(读入下一行)或保持空间
# 验证"默认输出全部"
printf 'a\nb\nc\n' | sed 's/b/X/'
# a
# X
# c                    <- a 和 c 没被修改,但仍然输出了

printf 'a\nb\nc\n' | sed -n 's/b/X/p'
# X                    <- -n 关闭自动输出,p 只打印被替换的行

3.2 s 命令:替换

sed 's/旧/新/标志' 文件
#    ^ substitute

开篇第二问:gglobal 的缩写。 为什么默认不是全局?因为 sed 继承自 ed,而 eds 命令历史上就是「替换本行第一个匹配」,g 是后来加的修饰符。这个默认值保留至今是为了兼容。

echo "a a a" | sed 's/a/X/'          # X a a      <- 只替换第一个
echo "a a a" | sed 's/a/X/g'         # X X X      <- global
echo "a a a" | sed 's/a/X/2'         # a X a      <- 只替换第 2 个
echo "a a a" | sed 's/a/X/2g'        # a X X      <- 从第 2 个开始全部替换

s 的所有标志

标志 全称 作用
g global 替换行内所有匹配
N 只替换第 N 个匹配
Ng 从第 N 个开始替换到行尾
p print 替换成功则打印(配合 -n
i / I ignore case 忽略大小写(GNU 扩展)
w FILE write 替换成功的行写入文件
e execute 把替换结果当 shell 命令执行(危险)
m / M multiline 多行模式下 ^/$ 匹配每行首尾

分隔符可以换,处理路径时非常有用:

# ❌ 路径里的 / 要转义,很难读
sed 's/\/usr\/local\/bin/\/opt\/bin/g' f

# ✅ 换个分隔符(s 后面第一个字符就是分隔符)
sed 's#/usr/local/bin#/opt/bin#g' f
sed 's|/usr/local/bin|/opt/bin|g' f
sed 's,/usr/local/bin,/opt/bin,g' f

替换内容里的特殊字符

&                # 代表【整个匹配的内容】
\1 \2            # 反向引用捕获组
\n \t            # 换行、制表符(GNU)
\U \L \E         # 转大写 / 小写 / 结束转换(GNU 扩展)

echo "hello" | sed 's/.*/[&]/'                 # [hello]     <- & 是整个匹配
echo "hello world" | sed 's/\(\w*\) \(\w*\)/\2 \1/'    # world hello
echo "hello" | sed 's/.*/\U&/'                 # HELLO       <- 转大写
echo "a:b" | sed 's/:/\n/'                     # a 换行 b
echo "path=/a/b" | sed 's/=.*/&:extra/'        # path=/a/b:extra

3.3 寻址:指定作用范围

命令前面可以加「地址」,限定它只对某些行生效:

sed '3s/a/b/' f              # 只在第 3 行替换
sed '3,7s/a/b/' f            # 第 3~7 行
sed '3,$s/a/b/' f            # 第 3 行到最后一行($ = 末行)
sed '$d' f                   # 删除最后一行
sed '/ERROR/d' f             # 删除所有含 ERROR 的行(正则地址)
sed '/start/,/end/d' f       # 从匹配 start 的行到匹配 end 的行,整段删除
sed '/ERROR/!d' f            # ! 取反:删除【不含】ERROR 的行
sed '1~3d' f                 # GNU:从第 1 行开始每隔 3 行删(步进地址)
sed '0~2d' f                 # 删除所有偶数行
sed '2,+3d' f                # 第 2 行及其后 3 行
sed '/ERROR/,+5p' f          # 匹配行及后 5 行(看错误上下文)
sed -n '/BEGIN/,/END/p' f    # 只打印 BEGIN 到 END 之间的内容  ← 常用

3.4 常用命令

命令 全称 作用
s/// substitute 替换
d delete 删除模式空间(不再输出,直接读下一行)
p print 打印模式空间
a text append 在当前行追加
i text insert 在当前行插入
c text change 替换整行
y/abc/xyz/ transliterate 逐字符替换(类似 tr
q / q N quit 退出(可带退出码)
Q 退出且不输出当前行
= 打印当前行号
l list 显示不可见字符(类似 cat -A
r FILE read 读入文件内容追加到当前行后
w FILE write 把模式空间写入文件
n next 读下一行到模式空间(先输出当前的)
N 追加下一行到模式空间(用 \n 连接)
D 删除模式空间第一行并重新开始循环
P 只打印模式空间的第一行
h / H hold 模式空间覆盖/追加到保持空间
g / G get 保持空间覆盖/追加到模式空间
x exchange 交换模式空间与保持空间
b / t / :label branch 跳转(实现循环)
# 增删改
sed '/^#/d' nginx.conf                   # 删除注释行
sed '/^$/d' f                            # 删除空行
sed '/^[[:space:]]*$/d' f                # 删除空白行(含只有空格的)
sed '/^#/d; /^$/d' nginx.conf            # 多个命令用分号分隔  ← 看配置文件常用
sed '2i\新的一行' f                       # 在第 2 行前插入
sed '$a\最后追加' f                       # 在末行后追加
sed '/listen/a\    server_name x.com;' f # 在匹配行后追加
sed '/old_line/c\new_line' f             # 整行替换
sed 'y/abc/ABC/' f                       # 字符级替换

# 提取
sed -n '10,20p' f                        # 只看 10~20 行
sed -n '$p' f                            # 只看最后一行
sed -n '/ERROR/,/RECOVERED/p' app.log    # 从错误到恢复之间的日志
sed -n 's/.*version=\([0-9.]*\).*/\1/p' f    # 提取版本号(-n + p)

# 提前退出(大文件上性能关键)
sed -n '1000q;500,600p' huge.log         # 打印 500~600 行后立即退出
sed '1000q' huge.log                     # 只要前 1000 行(比 head 慢,但可组合)

3.5 原地编辑 -i

sed -i 's/old/new/g' file                # 原地修改
sed -i.bak 's/old/new/g' file            # 先备份成 file.bak  ← 推荐
sed -i'' -e 's/old/new/g' file           # macOS/BSD 的写法(-i 必须带参数)

四个必须注意的点

# ① macOS/BSD 与 GNU 的差异(05 篇提过)
sed -i    's/a/b/' f       # GNU ✅   BSD ❌(会把 's/a/b/' 当备份后缀)
sed -i '' 's/a/b/' f       # BSD ✅   GNU ❌
sed -i.bak 's/a/b/' f      # ✅ 两边都能用(可移植写法)

# ② -i 会【断开软链接】
ls -l config.yaml          # config.yaml -> /real/config.yaml
sed -i 's/a/b/' config.yaml
ls -l config.yaml          # 变成普通文件了!软链接被替换
# 原因:sed -i 的实现是"写临时文件 + rename 覆盖"(见 04 篇 5.1),
#      rename 替换的是链接本身,不是它指向的目标
# ✅ 解法:先解析真实路径
sed -i 's/a/b/' "$(readlink -f config.yaml)"
# 或用 --follow-symlinks(GNU 特有)
sed -i --follow-symlinks 's/a/b/' config.yaml

# ③ -i 会改变文件的 inode、属主和权限
stat -c '%i %U %a' f       # 记录原值
sed -i 's/a/b/' f
stat -c '%i %U %a' f       # inode 变了;用 sudo 跑时属主可能变成 root
# ✅ 对权限敏感的文件(如 /etc/shadow)不要用 sed -i,用临时文件 + install 保权限

# ④ 先演练再执行
sed 's/old/new/g' file | diff file -      # 看会改成什么样
sed -n 's/old/new/gp' file                # 只打印会被改的行
sed -i 's/old/new/g' file                 # 确认后执行

3.6 保持空间:跨行处理

大多数人不知道 sed 有第二个缓冲区。它让 sed 能做「跨行」的事:

# 五个相关命令
h    # 模式空间 -> 保持空间(覆盖)      Hold
H    # 模式空间 -> 保持空间(追加)
g    # 保持空间 -> 模式空间(覆盖)      Get
G    # 保持空间 -> 模式空间(追加)
x    # 两者交换                        eXchange

# 经典用法
sed 'G' f                    # 每行后加一个空行(保持空间是空的,G 追加了一个 \n)
sed 'G;G' f                  # 每行后加两个空行
sed -n '1!G;h;$p' f          # 【反转文件行序】(等价 tac)
#        ^^^^ 除第一行外把保持空间追加到模式空间,然后存回保持空间,最后打印
sed '$!N;s/\n/ /' f          # 每两行合并成一行
sed -n 'N;P;D' f             # 滑动窗口处理
sed '/pattern/{x;p;x}' f     # 在匹配行前插入保持空间的内容

# 更实际的例子:只保留每组重复行的最后一行
sed -n '$!N; /^\(.*\)\n\1$/!P; D' f       # 等价 uniq

说实话:保持空间的语法晦涩,可读性极差。需要跨行处理时,直接用 awk 或 Python——sed 的保持空间更多是「知道它存在」的知识点,实际工程里不推荐。

3.7 常用参数

作用
-n --quiet / --silent 不自动输出(配合 p
-e SCRIPT --expression 指定脚本(多个 -e 表示多条命令
-f FILE --file 从文件读脚本
-i[SUF] --in-place 原地编辑(可带备份后缀)
-E / -r --regexp-extended ERE(不用转义 +?(){})← 强烈推荐
-s --separate 多文件时分别处理($ 指每个文件的末行)
-z --null-data \0 分隔(处理整个文件为一行)
--follow-symlinks -i 时跟随软链接
--debug 打印执行过程(GNU 4.6+,调试复杂脚本用)
# -E 让正则可读得多
sed 's/\([0-9]\+\)-\([0-9]\+\)/\2-\1/' f        # BRE:满屏反斜杠
sed -E 's/([0-9]+)-([0-9]+)/\2-\1/' f           # ERE:清爽  ← 用这个

# 多条命令的三种写法
sed -e 's/a/b/' -e 's/c/d/' f
sed 's/a/b/; s/c/d/' f
sed 'script.sed' -f f

4. awk:字段与记录

4.1 名字与定位

awk 的名字来自三位作者的姓氏首字母:Aho、Weinberger、Kernighan(就是《C 程序设计语言》那个 K&R 的 K)。

它不只是命令,是一门完整的编程语言——有变量、数组、函数、控制流。它与 sed 的分工是:

  • sed 面向(把每行当成一个字符串做替换)
  • awk 面向字段(自动把每行切分成 $1 $2 $3...,适合处理表格型数据)

4.2 工作模型

   执行 BEGIN { } 块                    (读文件【之前】,用于初始化)
             |
             v
   读入一行(一条「记录」record)
   按 FS 自动切分成字段 $1 $2 $3 ...
             |
             v
   依次检查每条「模式 { 动作 }」规则:
      模式为真 -> 执行动作
      只有模式没有动作 -> 默认 print $0
      只有动作没有模式 -> 对每一行都执行
             |
             v
   读下一行 ──────> 循环到文件结束
             |
             v
   执行 END { } 块                      (读完【之后】,用于输出汇总)
awk 'BEGIN{print "开始"} {print NR": "$1} END{print "共 "NR" 行"}' file
#    ^^^^^^^^^^^^^^^^^^  ^^^^^^^^^^^^^^^^^  ^^^^^^^^^^^^^^^^^^^^^^^^
#    初始化               对每行执行           汇总

4.3 字段与内置变量

$0        整行
$1 $2 ... 第 1、2 个字段
$NF       最后一个字段(NF 是字段数,$NF 就是最后一个)
$(NF-1)   倒数第二个字段
变量 全称 含义
NF Number of Fields 当前行的字段数
NR Number of Record 当前是第几行(跨多个文件累加
FNR File NR 当前文件内的行号(多文件时会重置)
FS Field Separator 输入字段分隔符(默认:连续空白)
OFS Output FS 输出字段分隔符(默认:一个空格)
RS Record Separator 输入记录分隔符(默认:换行)
ORS Output RS 输出记录分隔符(默认:换行)
FILENAME 当前文件名
SUBSEP 多维数组下标分隔符(默认 \034
ENVIRON["X"] 环境变量
ARGC / ARGV 命令行参数个数 / 数组
RSTART / RLENGTH match() 的结果位置与长度

4.4 开篇第三问:默认分隔符不是空格

FS 的默认值是「一个或多个连续的空白字符(空格或 tab)」,并且会忽略行首行尾的空白。 这不等于「一个空格」:

echo "a    b   c" | awk '{print NF}'          # 3    <- 连续空格算一个分隔符
echo "   a b   " | awk '{print $1}'           # a    <- 行首空白被忽略
echo "a::b::c"   | awk -F: '{print NF}'       # 5    <- 显式指定 : 后,空字段【会】被计入
#                                                       (a, "", b, "", c)

所以处理 CSV 会出错,有两个原因:

# 原因一:CSV 用逗号分隔,空字段有意义,但 -F, 后行为与默认不同(这点是对的)
echo "a,,c" | awk -F, '{print NF; print "["$2"]"}'
# 3
# []              <- 空字段正确保留

# 原因二(真正的问题):CSV 允许【字段内含逗号】,用引号包裹
echo 'Smith,"New York, NY",30' | awk -F, '{print $2}'
# "New York       <- ❌ 被逗号切断了!

# ✅ 方案一:GNU awk 4.0+ 的 FPAT(定义"字段是什么"而不是"分隔符是什么")
echo 'Smith,"New York, NY",30' | awk -v FPAT='([^,]*)|("[^"]+")' '{print $2}'
# "New York, NY"                                    <- 正确

# ✅ 方案二:用专门的工具(推荐)
csvcut -c 2 data.csv          # csvkit
mlr --csv cut -f city data.csv    # miller
python3 -c "import csv,sys; [print(r[1]) for r in csv.reader(sys.stdin)]" < data.csv

结论:awk 适合处理「空白或单字符分隔的规整数据」(日志、/etc/passwd、命令输出),不适合处理真正的 CSV。

4.5 指定分隔符的几种方式

awk -F: '{print $1}' /etc/passwd          # 单字符
awk -F'\t' '{print $1}' data.tsv          # tab
awk -F'[:,]' '{print $2}' f               # 【正则】:冒号或逗号都算分隔符
awk -F' *, *' '{print $2}' f              # 正则:逗号前后的空格一起吃掉
awk -F'FS-is-a-string' '{print $2}' f     # 多字符字符串
awk 'BEGIN{FS=":"} {print $1}' f          # 在 BEGIN 里设(等价 -F:)
awk -v FS=: '{print $1}' f                # 用 -v 设变量

# 输出分隔符
awk -F: '{print $1, $3}' /etc/passwd              # 默认 OFS 是空格:root 0
awk -F: 'BEGIN{OFS="\t"} {print $1, $3}' /etc/passwd    # 用 tab
awk -F: -v OFS=, '{print $1, $3}' /etc/passwd     # 用逗号
awk -F: '{print $1 ":" $3}' /etc/passwd           # 或者手动拼(注意逗号 vs 无逗号)
#              ^^^ 用逗号 print 会插入 OFS,不用逗号就是直接拼接

# ⚠️ 只有修改了字段才会用 OFS 重建 $0
echo "a b c" | awk -v OFS=- '{print $0}'          # a b c    <- 没改,原样输出
echo "a b c" | awk -v OFS=- '{$1=$1; print $0}'   # a-b-c    <- $1=$1 触发重建
#                             ^^^^^ 这是个经典技巧

4.6 模式:决定哪些行被处理

# ── 正则模式 ──
awk '/ERROR/' app.log                     # 含 ERROR 的行(等价 grep ERROR)
awk '!/DEBUG/' app.log                     # 不含 DEBUG(等价 grep -v)
awk '/ERROR/ {print $1, $NF}' app.log      # 匹配的行才执行动作
awk '$3 ~ /^5/ {print}' access.log         # 【第 3 字段】匹配正则  ← 比 grep 精确
awk '$3 !~ /^2/' access.log                # 第 3 字段不匹配

# ── 比较模式 ──
awk '$3 > 1000' data                       # 数值比较
awk '$1 == "root"' /etc/passwd             # 字符串相等(用 == 不是 =)
awk -F: '$3 >= 1000 && $3 < 60000' /etc/passwd    # 普通用户(uid 范围)
awk 'NF > 5' f                             # 字段数超过 5 的行
awk 'length($0) > 200' app.log             # 超长行(排查异常日志)
awk 'NR % 2 == 0' f                        # 偶数行

# ── 范围模式 ──
awk '/BEGIN/,/END/' f                      # 从 BEGIN 到 END 之间(含两端)
awk 'NR==10,NR==20' f                      # 第 10~20 行
awk '/2026-08-12 21:/,/2026-08-12 22:/' app.log   # 时间段截取

# ── 特殊模式 ──
awk 'BEGIN{...}'                           # 读文件前
awk 'END{...}'                             # 读文件后
awk '{...}'                                # 无模式 = 所有行

# ── 组合 ──
awk '/ERROR/ && $3 > 500' f
awk '$1=="GET" || $1=="POST"' f

4.7 常用内置函数

函数 作用
length(s) 字符串长度(不给参数时是 $0 的长度)
substr(s, i, n) 取子串(下标从 1 开始,不是 0)
index(s, t) ts 中的位置(0 表示没找到)
split(s, arr, sep) sep 切分到数组,返回元素个数
sub(re, rep, s) 替换第一个匹配,返回替换次数
gsub(re, rep, s) 全局替换,返回替换次数
gensub(re, rep, h, s) GNU:返回替换后的字符串(不改原变量),支持 \1
match(s, re) 匹配位置,同时设置 RSTARTRLENGTH
sprintf(fmt, ...) 格式化成字符串
printf(fmt, ...) 格式化输出(不自动换行,要写 \n
toupper(s) / tolower(s) 大小写转换
int(x) 取整
systime() 当前 Unix 时间戳(GNU)
strftime(fmt, ts) 格式化时间戳(GNU)
mktime("YYYY MM DD HH MM SS") 转成时间戳(GNU)
system(cmd) 执行 shell 命令
close(f) 关闭文件/管道
# 字符串处理
echo "hello world" | awk '{print toupper($1), length($2)}'        # HELLO 5
echo "2026-08-12" | awk '{print substr($0,1,4)}'                  # 2026
echo "a.b.c" | awk '{n=split($0,arr,"."); print n, arr[2]}'       # 3 b
echo "aaa" | awk '{n=gsub(/a/,"X"); print $0, n}'                 # XXX 3
echo "path=/a/b" | awk '{sub(/path=/,""); print}'                 # /a/b

# printf 格式化(比 print 可控)
awk '{printf "%-20s %8.2f %5d\n", $1, $2, $3}' data
#            ^^^^^ 左对齐 20 字符
#                  ^^^^^ 右对齐 8 字符,2 位小数
#                        ^^^^ 右对齐 5 字符整数
awk '{printf "%s\t%s\n", $1, $2}' f
awk 'BEGIN{printf "%5.1f%%\n", 99.456}'          # 99.5%   (%% 输出百分号)

# 时间处理(GNU awk)
awk 'BEGIN{print strftime("%Y-%m-%d %H:%M:%S", systime())}'
awk '{print strftime("%H:%M", $1)}' timestamps.txt

4.8 数组:awk 最强的部分

awk 的数组是关联数组(哈希表),下标可以是任意字符串。这让它能一行完成分组统计:

# 统计每个状态码出现次数
awk '{count[$9]++} END{for(code in count) print code, count[code]}' access.log
#     ^^^^^^^^^^^^        ^^^^^^^^^^^^^^^^ for-in 遍历
# 200 15234
# 404 89
# 500 12

# 排序输出(awk 的 for-in 顺序不确定,要排序就管道给 sort)
awk '{c[$9]++} END{for(k in c) print c[k], k}' access.log | sort -rn

# 按字段求和(分组聚合)
awk '{sum[$1] += $2} END{for(k in sum) printf "%s: %.2f\n", k, sum[k]}' data

# 去重(比 sort -u 快,且保持原顺序)
awk '!seen[$0]++' file
#    ^^^^^^^^^^^^ 第一次见到时 seen[$0] 是 0(假),! 变真 -> 打印并自增
#                 再次见到时 seen[$0] 是 1(真),! 变假 -> 不打印

awk '!seen[$1]++' file                   # 按第 1 字段去重
awk 'seen[$0]++' file                    # 反过来:只输出重复的行

# 多维数组(本质是用 SUBSEP 拼接下标)
awk '{count[$1][$2]++}' f                        # GNU awk 支持真多维
awk '{count[$1 SUBSEP $2]++} END{for(k in count){split(k,a,SUBSEP); print a[1],a[2],count[k]}}' f

# 数组常用操作
awk '{a[NR]=$0} END{for(i=NR;i>=1;i--) print a[i]}' f    # 反转文件(等价 tac)
awk 'BEGIN{if("k" in arr) ...}'                   # 判断键存在(不要用 arr["k"],那会创建它)
awk '{delete arr[key]}'                           # 删除元素
awk 'END{print length(arr)}'                      # 数组元素个数(GNU)

4.9 参数与选项

参数 作用
-F FS 指定输入字段分隔符(可以是正则)
-v var=value 传变量进去(在 BEGIN 之前生效)
-f script.awk 从文件读脚本
-i inplace GNU awk 4.1+:原地编辑(需 gawk
--posix 严格 POSIX 模式
-M 任意精度算术(GNU)
# -v 传变量(唯一正确的传参方式)
threshold=500
awk -v t="$threshold" '$3 > t' data              # ✅
awk '$3 > '"$threshold" data                      # ❌ 拼字符串,值含空格/引号就崩

# 传日期
awk -v today="$(date +%F)" '$1 == today' log

# ⚠️ -v 会处理转义序列
awk -v p='a\tb' 'BEGIN{print p}'                  # a<tab>b   (\t 被解释了)
awk 'BEGIN{p=ARGV[1]; print p}' 'a\tb'            # a\tb      (原样)

# 从文件读长脚本
cat > report.awk <<'EOF'
BEGIN { FS=":"; printf "%-15s %s\n", "USER", "UID" }
$3 >= 1000 { printf "%-15s %s\n", $1, $3 }
END { print "---" }
EOF
awk -f report.awk /etc/passwd

4.10 getline 与外部命令

# 从命令读取
awk 'BEGIN{ "date +%Y" | getline year; print "今年是", year }'

# 输出到命令
awk '{print $1 | "sort"}' f                       # 把输出管道给 sort
awk '{print > "/tmp/out_"$1".txt"}' f             # 【按字段分文件输出】← 很实用

# 从文件读取(做 join)
awk 'NR==FNR{map[$1]=$2; next} {print $0, map[$1]}' mapping.txt data.txt
#    ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 读第一个文件时建映射表(NR==FNR 只在第一个文件成立)
#                                ^^^^^ next 跳过后续规则
#                                       ^^^^^^^^^^^^^^^^^^ 处理第二个文件时查表

# 这个 NR==FNR 技巧是 awk 做「两文件关联」的标准写法,值得记住
# 例:用 uid->用户名 映射表给日志加上用户名
awk -F: 'NR==FNR{name[$3]=$1; next} {print $0, name[$2]}' /etc/passwd uidlog.txt

4.11 三个 awk 实现的区别

awk --version | head -1
# GNU Awk 5.1.0        <- gawk,功能最强(FPAT、多维数组、strftime、-i inplace)
# awk version 20200816 <- BWK awk / onetrue awk(macOS 自带)
# (busybox awk)       <- Alpine 里的,功能最少

# Debian/Ubuntu 默认是 mawk(快但功能少),需要 gawk 的话
sudo apt install gawk
update-alternatives --config awk

# 常见的 gawk 专有功能(脚本里注意可移植性)
FPAT / FIELDWIDTHS      # CSV / 定宽字段
gensub()                # 返回替换结果
strftime() / mktime()   # 时间函数
length(arr)             # 数组长度
delete arr              # 清空整个数组
arr[a][b]               # 真多维数组
-i inplace              # 原地编辑
RS 为正则               # 多字符记录分隔符

5. 三者的分工:开篇第四问

5.1 决策表

需求 首选 理由
过滤含某关键词的行 grep 最快,专门优化过
提取匹配的片段 grep -o / grep -oP -o 直接给你片段
判断「是否存在」 grep -q 找到就退出
简单替换 sed 's///' 一行搞定
按行号删除/插入 sed 寻址能力强
提取行区间 sed -n 'N,Mp' head+tail 直接
按列/字段操作 awk 自动切分字段
数值计算、求和、平均 awk 内置算术
分组统计、去重计数 awk 关联数组 一行完成 group by
两个文件关联(join) awk NR==FNR 标准技巧
多行/跨行处理 awk(不是 sed sed 的保持空间可读性太差
JSON jq(都不用) 三剑客处理 JSON 是错误选择
CSV(含引号转义) csvkit / mlr / Python awk -F, 会被字段内逗号切断
逻辑超过 20 行 Python / Go 可读性和可测试性

5.2 一个需求的三种写法

# 需求:从 /etc/passwd 提取 uid >= 1000 的用户名

grep -E ':[0-9]{4,}:' /etc/passwd | cut -d: -f1     # grep + cut,勉强
sed -n '/:[0-9]\{4,\}:/s/^\([^:]*\).*/\1/p' /etc/passwd    # sed,难读
awk -F: '$3 >= 1000 {print $1}' /etc/passwd         # ✅ awk,最清晰
#         ^^^^^^^^^^ 直接表达"第3字段>=1000"这个语义

判断标准:只要涉及「第几个字段」或「数值比较」,就用 awk grepsed 眼里只有「一行字符串」。

5.3 什么时候该放弃三剑客

# ❌ 用 awk 解析 JSON(脆弱且不可维护)
cat app.log | awk -F'"status":' '{split($2,a,","); print a[1]}'

# ✅ 用 jq
cat app.log | jq -r '.status'
jq -r 'select(.level=="error") | .msg' app.log
jq -s 'group_by(.status) | map({status: .[0].status, count: length})' app.log

# Go 服务的结构化日志(slog/zap 输出 JSON)应该配 jq
journalctl -u myapp -o cat | jq -r 'select(.level=="ERROR") | "\(.time) \(.msg)"'

# ❌ 超过 20 行的 awk 脚本
# ✅ 换 Python:可读、可测、有库

6. 实战:nginx 日志分析

日志格式(默认 combined):

1.2.3.4 - - [12/Aug/2026:21:30:45 +0800] "GET /api/users?id=1 HTTP/1.1" 200 1234 "https://ref" "Mozilla/5.0..." 0.023
$1      $2$3 $4                  $5      $6   $7                $8      $9  $10   $11         $12                $13
# ── 基础统计 ──
wc -l access.log                                    # 总请求数

awk '{print $9}' access.log | sort | uniq -c | sort -rn        # 状态码分布
# 15234 200
#   892 404
#    12 500

awk '$9 >= 500' access.log | wc -l                  # 5xx 数量
awk '$9 ~ /^[45]/ {print $7}' access.log | sort | uniq -c | sort -rn | head
#                                                     # 出错最多的 URL

# ── TOP N ──
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -10    # TOP IP
awk '{print $7}' access.log | cut -d? -f1 | sort | uniq -c | sort -rn | head -20
#                             ^^^^^^^^^^^ 去掉 query string 再聚合    # TOP URL

# ── 流量 ──
awk '{sum += $10} END {printf "%.2f GB\n", sum/1024/1024/1024}' access.log
awk '{traffic[$7] += $10} END {for(u in traffic) print traffic[u], u}' access.log \
    | sort -rn | head -10                           # 最耗流量的 URL

# ── 响应时间(假设 $13 是 request_time)──
awk '{sum+=$13; n++} END {printf "平均 %.3fs\n", sum/n}' access.log
awk '$13 > 1 {print $13, $7}' access.log | sort -rn | head -20         # 慢请求 TOP20
awk '{print $13}' access.log | sort -n | awk '{a[NR]=$1} END{print "P95:", a[int(NR*0.95)]}'
#                                          ^^^^^^^^^^^^^^^^^^^^^^^^ 计算 P95

# ── 按时间聚合(QPS)──
awk '{print substr($4, 2, 20)}' access.log | uniq -c | sort -rn | head
#          ^^^^^^^^^^^^^^^^^^^ 提取 [12/Aug/2026:21:30:45 里的时间
# 每分钟请求数
awk '{split($4,a,":"); print a[2]":"a[3]}' access.log | uniq -c
# 每小时
awk '{split($4,a,":"); print a[2]}' access.log | uniq -c

# ── 时间段截取 ──
sed -n '/12\/Aug\/2026:21:/,/12\/Aug\/2026:22:/p' access.log
awk '$4 >= "[12/Aug/2026:21:00:00" && $4 <= "[12/Aug/2026:22:00:00"' access.log

# ── 组合分析:找出某个 IP 在错误高峰期做了什么 ──
awk '$9 >= 500 {print $1}' access.log | sort | uniq -c | sort -rn | head -1
# 假设是 1.2.3.4
grep '^1\.2\.3\.4 ' access.log | awk '{print $7, $9, $13}' | sort | uniq -c | sort -rn | head

# ── 一次输出多个指标 ──
awk '
{
    total++
    status[$9]++
    bytes += $10
    time += $13
    if ($13 > 1) slow++
}
END {
    printf "总请求: %d\n", total
    printf "总流量: %.2f MB\n", bytes/1024/1024
    printf "平均耗时: %.3f s\n", time/total
    printf "慢请求(>1s): %d (%.2f%%)\n", slow, slow*100/total
    printf "错误率: %.2f%%\n", (status["500"]+status["502"]+status["503"])*100/total
}' access.log

7. 知识点扩展:配套的小工具

三剑客常与这些命令组合,它们各自只做一件事:

7.1 cut:按列切分

cut -d: -f1 /etc/passwd            # d=delimiter 分隔符, f=fields 字段
cut -d: -f1,3 /etc/passwd          # 多个字段
cut -d: -f1-3 /etc/passwd          # 范围
cut -d: -f3- /etc/passwd           # 第 3 个到最后
cut -c1-10 file                    # 按【字符】位置切
cut -b1-10 file                    # 按【字节】位置切
cut -d: -f1 --complement /etc/passwd    # 除了第 1 个字段
cut -d: -f1 --output-delimiter=' ' f    # 换输出分隔符

# ⚠️ cut 的两个局限(这就是 awk 存在的理由)
# ① 不支持"连续空白算一个分隔符"
echo "a    b" | cut -d' ' -f2       # (空)  <- 第 2 个字段是空的
echo "a    b" | awk '{print $2}'    # b       <- awk 自动处理连续空白
echo "a    b" | tr -s ' ' | cut -d' ' -f2    # b   <- 要先用 tr -s 压缩
# ② 不能重排字段顺序
cut -d: -f3,1 /etc/passwd           # 仍按 1,3 顺序输出!
awk -F: '{print $3, $1}' /etc/passwd    # ✅ awk 可以任意顺序

7.2 sort:排序

参数 作用
-n 数值排序(默认是字典序,10 会排在 9 前面)
-h 人类可读的大小排序(1K 2M 3G
-r 逆序
-k N 按第 N 列排序(-k2,2 只用第 2 列)
-k2,2nr 第 2 列,数值,逆序(修饰符跟在列号后)
-t C 指定分隔符
-u 排序并去重(等价 sort | uniq,但更快)
-f 忽略大小写
-b 忽略行首空白
-V 版本号排序(v1.10 > v1.9
-M 按月份名排序(Jan、Feb…)
-s 稳定排序(相等元素保持原顺序)
-c 只检查是否已排序
-z \0 分隔
-o FILE 输出到文件(可以是输入文件本身,安全的原地排序)
--parallel=N 并行
-S SIZE 内存缓冲大小(大文件调优)
-T DIR 临时文件目录(默认 /tmp,大文件排序时可能撑爆)
sort -k2,2nr -k1,1 data              # 先按第2列数值逆序,第2列相同时按第1列字典序
sort -t: -k3 -n /etc/passwd          # 按 uid 排序
du -sh * | sort -h                   # 按大小排序(-h 认识 K/M/G)
sort -u file                         # 去重
sort -o file file                    # ✅ 原地排序(-o 可以是输入文件)
LC_ALL=C sort huge.txt               # 跳过 locale 处理,快数倍
sort -S 2G -T /data/tmp huge.txt     # 大文件调优

7.3 uniq:去重(必须先排序)

sort file | uniq                      # 去重(uniq 只能去掉【相邻】的重复行!)
sort file | uniq -c                   # c=count 统计每行出现次数  ← 最常用
sort file | uniq -c | sort -rn        # 按频次排序   ← 经典组合
sort file | uniq -d                   # d=duplicated 只显示重复的行
sort file | uniq -D                   # 显示重复行的【所有】副本
sort file | uniq -u                   # u=unique 只显示出现一次的行
uniq -i file                          # 忽略大小写
uniq -w 10 file                       # 只比较前 10 个字符
uniq -f 2 file                        # 跳过前 2 个字段再比较
uniq -s 5 file                        # 跳过前 5 个字符

# ⚠️ uniq 只去除【相邻】重复,所以必须先 sort
printf 'a\nb\na\n' | uniq             # a b a    <- 没去掉!
printf 'a\nb\na\n' | sort | uniq      # a b      ✅
printf 'a\nb\na\n' | awk '!s[$0]++'   # a b      ✅ 且保持原始顺序(不用排序)

7.4 tr:字符级转换

tr 'a-z' 'A-Z' < file                 # 转大写
tr -d '\r' < win.txt > unix.txt       # d=delete 删除字符(去 CRLF)
tr -s ' ' < file                      # s=squeeze 压缩连续重复字符
tr -c 'a-zA-Z0-9\n' '_' < file        # c=complement 取反:非字母数字都换成 _
tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 16    # 生成随机密码
tr '\0' '\n' < /proc/1/environ        # NUL 转换行(读 /proc 常用)
tr ':' '\n' <<< "$PATH"               # 拆分 PATH

# ⚠️ tr 只处理【单个字符】,不能替换字符串
echo "hello" | tr 'llo' 'xyz'         # hexyz    <- 是逐字符映射 l->x l->y o->z
echo "hello" | sed 's/llo/xyz/'       # hexyz    <- sed 才是字符串替换

7.5 paste / join / column / tee

# paste:把多个文件【按列】拼接
paste a.txt b.txt                     # 用 tab 连接对应行
paste -d, a.txt b.txt                 # 用逗号
paste -s a.txt                        # s=serial 把所有行合并成一行
seq 1 6 | paste - - -                 # 3 列输出(每个 - 代表一列)

# join:按公共字段关联两个【已排序】文件
join -t: -1 1 -2 1 file1 file2        # 按各自第 1 字段关联
join -t: -a1 file1 file2              # 左连接(保留 file1 未匹配的行)
# 实际上 awk 的 NR==FNR 更灵活,不要求预排序

# column:格式化成对齐的表格
mount | column -t                     # t=table 自动对齐   ← 让杂乱输出变整齐
cat /etc/passwd | column -t -s:       # 指定分隔符
printf 'a b\nlonger x\n' | column -t

# tee:分流(一份给屏幕,一份给文件)
cmd | tee output.log                  # 同时显示和保存
cmd | tee -a output.log               # a=append 追加
cmd | tee out1.log out2.log | grep x  # 多路分流后继续管道
cmd 2>&1 | tee build.log              # 连 stderr 一起保存
echo "x" | sudo tee /etc/sysctl.d/x.conf     # ✅ 用 sudo 写文件(重定向没法提权)

7.6 grep / sed / awk 参数速查

grep:见 2.2 的完整表格。最常用:-i -v -n -r -o -c -l -q -E -F -A -B -C --include --exclude-dir

sed-n(不自动输出)、-E(扩展正则)、-i(原地)、-e(多命令)、-f(脚本文件)、-s(多文件分别处理)、-z(NUL 分隔)

awk-F(分隔符)、-v(传变量)、-f(脚本文件)、-i inplace(gawk 原地编辑)

8. 面试题

Q:为什么 grep 'a+' 匹配不到 aaagrep -E 'a+' 却可以?

因为 grep 默认用 BRE(基本正则),而 BRE 里 + 是普通字符,所以 grep 'a+' 找的是字面的 a+ 这个字符串。要在 BRE 里表达「一次或多次」得写 a\+反向的转义逻辑)。grep -EERE(扩展正则)+?|(){} 都是元字符,不用转义。

BRE 保留这个「反直觉」行为的原因是向后兼容ed 时代已有大量脚本把 + ? | 当普通字符用,POSIX 标准化时不能破坏它们。

三套流派的归属:BREgrep/sed 默认;EREgrep -E/egrep/awkPCREgrep -P,独有 \d \w \s、非贪婪 *?、零宽断言 (?=)\K

Q:grep -P 为什么在有些系统上不可用?脚本里该怎么写?

-P 依赖 libpcre,而它是 grep可选编译依赖。常见不可用场景:Alpine 的 busybox grep(根本没这选项)、精简发行版编译时禁用、macOS 自带的 BSD grep

脚本里应该避免 -P,用可移植的替代:

grep -oE 'port=[0-9]+' f | cut -d= -f2        # ERE + cut
sed -n 's/.*port=\([0-9]*\).*/\1/p' f         # sed
awk -F'port=' '{...}' f                        # awk
perl -nle 'print $1 if /port=(\d+)/' f        # perl 一定有 PCRE

交互式排查时随便用 -P(方便),写进脚本或 Dockerfile 就要考虑目标环境

Q:sed 的工作模型是什么?为什么 sed 's/a/b/' 默认只替换每行第一个?

sed = stream editor,核心是一个循环:读一行到「模式空间」→ 依次执行脚本命令 → 自动输出模式空间 → 清空、读下一行。另有一个「保持空间」作为暂存寄存器,用于跨行处理。

两个关键推论:① 默认会输出每一行(不管有没有被修改),所以「只输出想要的」必须用 -n + p;② 一次只处理一行,跨行要用 N 或保持空间。

s 命令默认只替换第一个匹配,是继承 ed 的行为——eds 历史上就是「替换本行第一个」,gglobal)是后来加的修饰符,为兼容而保留了默认值。相关标志还有 N(只替换第 N 个)、Ng(从第 N 个起全替换)、p(替换成功则打印)、i(忽略大小写)。

Q:sed -i 有哪些坑?

四个:

  1. -i 在 GNU 和 BSD 上语法不同 —— GNU 是 sed -i 's/a/b/',BSD/macOS 必须 sed -i '' 's/a/b/'。可移植写法是 sed -i.bak(两边都支持,还顺带留了备份)。
  2. 会断开软链接 —— sed -i 的实现是「写临时文件 + rename 覆盖」,rename 替换的是链接本身。解法:sed -i 's/a/b/' "$(readlink -f f)" 或 GNU 的 --follow-symlinks
  3. 会改变 inode、属主和权限 —— 用 sudo sed -i 改文件后属主可能变成 root。对权限敏感的文件(/etc/shadow)应该用临时文件 + install 保权限。
  4. 不可逆 —— 执行前先演练:sed 's/old/new/g' f | diff f -sed -n 's/old/new/gp' f

Q:awk 的默认字段分隔符是什么?为什么用它处理 CSV 会出错?

默认 FS 是**「一个或多个连续空白(空格/tab)」,并忽略行首行尾空白**——注意这不等于「一个空格」,所以 echo "a b" | awk '{print $2}' 能正确得到 b,而 cut -d' ' -f2 得到空字符串。

处理 CSV 出错的根因是:CSV 允许字段内含逗号,用引号包裹,而 awk -F, 只会机械地按逗号切分:

echo 'Smith,"New York, NY",30' | awk -F, '{print $2}'
# "New York          <- 被切断了

解法:GNU awk 4.0+ 的 FPAT(定义「字段长什么样」而不是「分隔符是什么」),或者直接用 csvkit/mlr/Python 的 csv 模块。结论:awk 适合空白或单字符分隔的规整数据(日志、/etc/passwd、命令输出),不适合真正的 CSV。

Q:awk '!seen[$0]++' 是什么意思?它和 sort -u 有什么区别?

这是 awk 的经典去重写法。原理:seen 是关联数组,第一次遇到某行时 seen[$0] 是未定义(视作 0,假),!0 为真 → 执行默认动作 print,同时 ++ 把它变成 1;再次遇到时 seen[$0] 是 1(真),!1 为假 → 不打印。

sort -u 的区别:

  • awk '!seen[$0]++' 保持原始顺序,且只需一次遍历(O(n)),但要把所有唯一行存进内存
  • sort -u 会改变顺序(按排序结果),需要排序(O(n log n)),但能用外部排序处理超过内存的文件

日志去重通常要保序,所以 awk 版本更合适。变体:awk '!seen[$1]++' 按第 1 字段去重,awk 'seen[$0]++' 反过来只输出重复行。

Q:sort | uniq -c | sort -rn 这个组合为什么这么常见?uniq 为什么必须先 sort

因为它是「统计每个值出现多少次,按频次排序」的标准管道——统计状态码分布、TOP IP、最频繁的错误,都是这个模式。

uniq 只能去除相邻的重复行(它的实现是「与上一行比较」,只需常数内存),所以不排序的话分散在各处的重复值不会被合并:

printf 'a\nb\na\n' | uniq          # a b a   <- 没去掉
printf 'a\nb\na\n' | sort | uniq   # a b     ✅

这个设计是有意的:uniq 保持了流式处理的特性(内存 O(1)),把「需要全局视野」的工作交给 sort。要保持原顺序去重就用 awk '!seen[$0]++'(代价是内存 O(n))。

Q:grepsedawk 该怎么选?什么时候都不该用?

判断标准很简单:

  • 只要涉及「第几个字段」或「数值比较」,用 awk —— grepsed 眼里只有「一行字符串」,awk 眼里才有字段
  • 纯过滤/查找用 grep(最快,专门优化)
  • 简单替换、按行号操作用 sed
  • 分组统计、求和、两文件关联用 awk 的关联数组和 NR==FNR
  • 跨行处理用 awk 而不是 sed —— sed 的保持空间(h H g G x)可读性太差

都不该用的场景:JSON 用 jq(用 awk 切 JSON 是脆弱且不可维护的)、真 CSV 用 csvkit/mlr逻辑超过 20 行换 Python/Go。Go 服务如果输出结构化 JSON 日志,配套工具应该是 jq 而不是三剑客。


小结

  • 三套正则流派:BRE(grep/sed 默认,+ ? | () {} 都要转义)、ERE(grep -E/awk)、PCRE(grep -P,独有 \d、非贪婪、零宽断言、\K)。BRE 的反直觉来自向后兼容
  • \d \w \s 是 Perl 的,不是 POSIX —— POSIX 用 [[:digit:]] 这套,注意双层方括号
  • grep -P 依赖 libpcre,可能不可用(busybox、BSD grep),脚本里避免
  • grep -F 快几十倍(不解析正则,用 Aho-Corasick),模式里没有元字符时就该加
  • sed 的模型:读一行到模式空间 → 执行命令 → 自动输出 → 循环。所以「只输出想要的」要 -n + p
  • sed -i 四个坑:GNU/BSD 语法不同(用 -i.bak 可移植)、断软链接、改 inode 与属主、不可逆
  • awk 的模型:BEGIN → 逐行(模式{动作})→ END,自动切分字段
  • awk 的默认 FS 是「连续空白」,这是它比 cut -d' ' 好用的原因;但它不能正确处理带引号的 CSV
  • awk 最强的是关联数组{c[$1]++} END{for(k in c)...} 一行完成 group by,!seen[$0]++ 保序去重,NR==FNR 做两文件关联
  • 选择标准:涉及字段或数值就用 awk;JSON 用 jq;超过 20 行换 Python

下一篇讲 管道、重定向与 shell 展开的 7 个阶段2>&1 为什么必须写在后面、> 在命令执行前就发生了、为什么 cmd > f 2>&1cmd 2>&1 > f 完全不同、以及 shell 展开顺序如何解释一大批「引号该怎么加」的问题。