Linux-08 grep、sed、awk:三套正则流派与各自的工作模型
grep、sed、awk 被称为「文本处理三剑客」。大多数人的状态是:会 grep xxx file,会 sed 's/a/b/g',会 awk '{print $1}',再复杂一点就开始搜索。
这一篇要解决的不是「记住更多参数」,而是三件事:三套正则流派的分歧、sed 和 awk 各自的工作模型、什么时候该用哪个。
先看五个问题:
- 为什么
grep 'a+'匹配不到aaa,但grep -E 'a+'可以? sed 's/a/b/'只替换每行第一个,g是什么的缩写?为什么默认不是全局替换?awk '{print $1}'是按什么切分字段的?为什么用它处理 CSV 会出错?- 三个命令都能做替换和过滤,到底什么时候用哪个?
grep -P为什么在有些系统上报invalid option?
1. 三套正则流派:分歧的源头
这一章是本篇的基础。同一个正则表达式在 grep、grep -E、grep -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(grep、sed) |
ERE(grep -E、awk) |
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、\s 是 Perl 的发明,不是 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} 2 到 5 次
{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)
两个关键点:
- 默认会输出每一行(不管有没有被修改)—— 所以
sed -n+p才是「只输出想要的」 - 默认一次只处理一行 —— 跨行操作需要
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
开篇第二问:g 是 global 的缩写。 为什么默认不是全局?因为 sed 继承自 ed,而 ed 的 s 命令历史上就是「替换本行第一个匹配」,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 |
替换成功则打印(配合 -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 |
打印模式空间 | |
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) |
t 在 s 中的位置(0 表示没找到) |
split(s, arr, sep) |
按 sep 切分到数组,返回元素个数 |
sub(re, rep, s) |
替换第一个匹配,返回替换次数 |
gsub(re, rep, s) |
全局替换,返回替换次数 |
gensub(re, rep, h, s) |
GNU:返回替换后的字符串(不改原变量),支持 \1 |
match(s, re) |
匹配位置,同时设置 RSTART、RLENGTH |
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。 grep 和 sed 眼里只有「一行字符串」。
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+' 匹配不到 aaa,grep -E 'a+' 却可以?
因为 grep 默认用 BRE(基本正则),而 BRE 里 + 是普通字符,所以 grep 'a+' 找的是字面的 a+ 这个字符串。要在 BRE 里表达「一次或多次」得写 a\+(反向的转义逻辑)。grep -E 用 ERE(扩展正则),+、?、|、()、{} 都是元字符,不用转义。
BRE 保留这个「反直觉」行为的原因是向后兼容:ed 时代已有大量脚本把 + ? | 当普通字符用,POSIX 标准化时不能破坏它们。
三套流派的归属:BRE 是 grep/sed 默认;ERE 是 grep -E/egrep/awk;PCRE 是 grep -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 的行为——ed 的 s 历史上就是「替换本行第一个」,g(global)是后来加的修饰符,为兼容而保留了默认值。相关标志还有 N(只替换第 N 个)、Ng(从第 N 个起全替换)、p(替换成功则打印)、i(忽略大小写)。
Q:sed -i 有哪些坑?
四个:
-i在 GNU 和 BSD 上语法不同 —— GNU 是sed -i 's/a/b/',BSD/macOS 必须sed -i '' 's/a/b/'。可移植写法是sed -i.bak(两边都支持,还顺带留了备份)。- 会断开软链接 ——
sed -i的实现是「写临时文件 +rename覆盖」,rename替换的是链接本身。解法:sed -i 's/a/b/' "$(readlink -f f)"或 GNU 的--follow-symlinks。 - 会改变 inode、属主和权限 —— 用
sudo sed -i改文件后属主可能变成 root。对权限敏感的文件(/etc/shadow)应该用临时文件 +install保权限。 - 不可逆 —— 执行前先演练:
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:grep、sed、awk 该怎么选?什么时候都不该用?
判断标准很简单:
- 只要涉及「第几个字段」或「数值比较」,用
awk——grep和sed眼里只有「一行字符串」,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+psed -i四个坑:GNU/BSD 语法不同(用-i.bak可移植)、断软链接、改 inode 与属主、不可逆awk的模型:BEGIN → 逐行(模式{动作})→ END,自动切分字段awk的默认 FS 是「连续空白」,这是它比cut -d' '好用的原因;但它不能正确处理带引号的 CSVawk最强的是关联数组:{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>&1 和 cmd 2>&1 > f 完全不同、以及 shell 展开顺序如何解释一大批「引号该怎么加」的问题。
xingliuhua