grep、awk 与 sed 生产实战:选择、管道与性能对比

很多人只熟练掌握 grep、awk、sed 中的一个,然后拿它硬套所有任务。于是本来用 cut 零点几秒就能完成的取列操作,最后写成了一段复杂的 sed。我们在一份 200 万行访问日志上分别计时:提取客户端 IP 列时,cut 用时 0.18 秒,awk 0.55 秒,sed 2.8 秒,grep -o 约 10 秒。

本文说明三种工具各自适合什么任务,所有示例都基于先创建的三个样本文件,内容包括 grep 模式匹配、awk 取列与汇总、sed 流式编辑、管道组合、mawk 与 gawk 差异以及性能测试。全部命令于 2026 年 10 月在 Ubuntu 26.04 上运行,版本为 GNU grep 3.12、GNU sed 4.9、gawk 5.3.2 和 mawk 1.3.4。

按任务选择 grep、awk 或 sed

应按你向文本提出的问题选工具,而不是按最熟悉哪个来选。三者在各自领域都快速且易读,但在另外两种任务上会变得笨拙,因为它们围绕不同的数据模型设计:grep 回答“哪些行匹配”,awk 回答“匹配行中的第 3 个字段是什么、求和后是多少”,sed 回答“文本流经过时如何改写”。当任务同时需要两类答案时,一条短管道通常胜过在单一工具里写一条“聪明”的命令。

工具 最适合 输入模型 典型输出 何时改用其他工具
grep 查找模式匹配的行 行 匹配行、计数、文件名 需要取列或求和
awk 列、条件、汇总、分组 拆成字段的记录 计算或重排后的数据 任务只是一次普通替换
sed 查找替换、删除或插入行 行 编辑后的文本 需要算术或逐字段逻辑

最后一列比第一列更重要。grep 可以用 -o 提取,sed 可以用捕获组打印字段,awk 可以用 gsub() 替换,但这些都属于慢或别扭的路径。后文基准测试中,grep -o 为一项 awk 0.55 秒即可完成的任务花了 10 秒,因此文件变大后,选错工具不只是风格问题。

创建样本文件

所有示例都使用三个文件:Apache 风格访问日志、员工 CSV 和 INI 配置。IP 来自文档保留网段和私有网段,不指向真实主机。先创建临时目录,因为后文有命令会原地修改文件。

mkdir -p ~/textlab && cd ~/textlab

先创建访问日志。这里使用 printf 和逐行引号参数,而不是 heredoc,因为从网页复制的 heredoc 很可能在结束标记带上多余空格而失效。单引号还能阻止 shell 展开日志行中的内容。

printf '%s\n' \
'203.0.113.10 - admin [17/Oct/2026:10:15:32 +0000] "GET /dashboard HTTP/1.1" 200 5432' \
'198.51.100.55 - - [17/Oct/2026:10:15:33 +0000] "POST /api/login HTTP/1.1" 401 128' \
'203.0.113.10 - admin [17/Oct/2026:10:15:34 +0000] "GET /api/users HTTP/1.1" 200 2048' \
'10.0.1.22 - - [17/Oct/2026:10:15:35 +0000] "GET /images/logo.png HTTP/1.1" 304 0' \
'198.51.100.55 - - [17/Oct/2026:10:15:36 +0000] "GET /admin/config HTTP/1.1" 403 256' \
'203.0.113.10 - admin [17/Oct/2026:10:15:37 +0000] "GET /api/reports HTTP/1.1" 200 8192' \
'10.0.1.22 - - [17/Oct/2026:10:15:38 +0000] "DELETE /api/users/5 HTTP/1.1" 500 512' \
'198.51.100.55 - - [17/Oct/2026:10:15:39 +0000] "GET /dashboard HTTP/1.1" 200 5432' > access.log

接着创建员工 CSV。第一行是表头,多个 awk 示例会故意跳过它,以便观察有表头和无表头时的差异。

printf '%s\n' \
'id,name,department,salary,city' \
'1,Alice Johnson,Engineering,95000,Portland' \
'2,Bob Smith,Marketing,72000,Seattle' \
'3,Carol Davis,Engineering,102000,Portland' \
'4,Dan Wilson,Sales,68000,Denver' \
'5,Eve Martinez,Engineering,98000,Seattle' \
'6,Frank Brown,Marketing,75000,Portland' \
'7,Grace Lee,Sales,71000,Denver' \
'8,Hank Taylor,Engineering,110000,Seattle' > employees.csv

配置文件包含三个由空行分隔的段,这是服务器上最常见的 INI 结构。

printf '%s\n' \
'[database]' 'host = 10.0.1.100' 'port = 5432' 'name = appdb' 'user = appuser' 'password = ChangeMe123' '' \
'[redis]' 'host = 10.0.1.101' 'port = 6379' 'maxmemory = 256mb' '' \
'[app]' 'debug = false' 'workers = 4' 'log_level = info' 'secret_key = replace-me' > config.ini

继续之前,确认三个文件存在且行数正确。

wc -l access.log employees.csv config.ini

应得到 8、9、17 行。如果数字不对,通常粘贴时丢了引号,应删除该文件并重新粘贴对应代码块。本文所有输出都假定样本完全一致,因为多出一行就会改变后面 sed 示例打印的行号。

   8 access.log
   9 employees.csv
  17 config.ini
  34 total

文件就绪后,后续每个输出块都应与你看到的结果一致。

检查当前 awk 实现

在 Ubuntu 上,awk 是由 update-alternatives 管理的符号链接,不一定指向 GNU awk。mawk 是 Ubuntu 标为必需的包,因此最小镜像或容器可能只有 mawk。我们的 Ubuntu 26.04 服务器还安装了 gawk(server seed 通过 bcache-tools 拉入),alternatives 优先级为 gawk 10、mawk 5。

readlink -f "$(command -v awk)"

在本机虚拟机上,链接指向 gawk。

/usr/bin/gawk

这会在本文三个地方产生影响。

  • group-by 输出顺序不同

  • 错误字段分隔符会产生不同报错

  • 所有计时任务中 mawk 都不慢于 gawk

如果希望各处行为一致,可执行 sudo apt install gawk 安装,并在脚本中直接调用 gawk,避免依赖主机默认链接。gensub()、asort()、--csv、PROCINFO 等 GNU 专有特性在 mawk 中不存在,使用它们的脚本一旦进入最小容器镜像就会失败。有些失败很明确,用 gensub() 最容易验证。

mawk -F, 'NR>1 {print gensub(/ /, ".", "g", $2)}' employees.csv

mawk 尚未读取输入就以状态 2 退出,而同一程序在 gawk 下会输出 Alice.Johnson、Bob.Smith 等结果。

mawk: line 2: function gensub never defined

更糟的是有些特性会静默失败;后文 group-by 示例中的 PROCINFO 就是如此。

用 grep 查找文本

grep 逐行读取并打印所有匹配行。在这个任务上它是三者中最快的,因为根本不需要拆分字段;只要问题形式是“内容是否存在、在哪里”,就应先用它。只判断是或否时它还会提前停止(grep -q),所以 shell 脚本常用它作为 if 条件。GNU grep 手册记录了全部参数,下文覆盖日常使用中的大多数;本站在 grep 速查中也有更完整的一页参考。

执行基本搜索

普通搜索会打印任何位置包含模式的完整行,这既是优点,也是接下来会看到的陷阱。

grep "admin" access.log

管理员用户有三次请求,按理应出现三行,实际却返回四行。

203.0.113.10 - admin [17/Oct/2026:10:15:32 +0000] "GET /dashboard HTTP/1.1" 200 5432
203.0.113.10 - admin [17/Oct/2026:10:15:34 +0000] "GET /api/users HTTP/1.1" 200 2048
198.51.100.55 - - [17/Oct/2026:10:15:36 +0000] "GET /admin/config HTTP/1.1" 403 256
203.0.113.10 - admin [17/Oct/2026:10:15:37 +0000] "GET /api/reports HTTP/1.1" 200 8192

第三行匹配的是 URL /admin/config,不是用户字段。grep 不知道“列”的概念,因此当词语可能出现在多个字段中时,要么把模式收紧为带空格的 " admin ",要么改用 awk 直接测试字段。真实日志中,这是 grep 计数偏高的最常见原因。

使用忽略大小写、反向匹配与计数

-i 忽略大小写,因此小写模式也能找到 GET 请求。

grep -i "get" access.log

八行中有六行是 GET;尽管日志使用方法名大写,-i 仍然全部命中。当不同来源的日志大小写不一致时,这能省去手写 [Gg][Ee][Tt] 的麻烦。

203.0.113.10 - admin [17/Oct/2026:10:15:32 +0000] "GET /dashboard HTTP/1.1" 200 5432
203.0.113.10 - admin [17/Oct/2026:10:15:34 +0000] "GET /api/users HTTP/1.1" 200 2048
10.0.1.22 - - [17/Oct/2026:10:15:35 +0000] "GET /images/logo.png HTTP/1.1" 304 0
198.51.100.55 - - [17/Oct/2026:10:15:36 +0000] "GET /admin/config HTTP/1.1" 403 256
203.0.113.10 - admin [17/Oct/2026:10:15:37 +0000] "GET /api/reports HTTP/1.1" 200 8192
198.51.100.55 - - [17/Oct/2026:10:15:39 +0000] "GET /dashboard HTTP/1.1" 200 5432

-v 反转匹配,看起来可以快速列出所有非 200 响应。

grep -v "200" access.log

结果中保留了 401、304、403、500,因此在这个样本文件上看似正确。

198.51.100.55 - - [17/Oct/2026:10:15:33 +0000] "POST /api/login HTTP/1.1" 401 128
10.0.1.22 - - [17/Oct/2026:10:15:35 +0000] "GET /images/logo.png HTTP/1.1" 304 0
198.51.100.55 - - [17/Oct/2026:10:15:36 +0000] "GET /admin/config HTTP/1.1" 403 256
10.0.1.22 - - [17/Oct/2026:10:15:38 +0000] "DELETE /api/users/5 HTTP/1.1" 500 512

这里能工作,但很脆弱:响应大小 2000 字节或年份 2000 的时间戳同样包含“200”,也会从结果中消失。统计状态码应使用后文的 awk 字段测试。-c 统计匹配行数,-n 为匹配行编号。

grep -n "203.0.113.10" access.log

冒号前是行号,因此可直接用 vim +6 access.log 或 sed -n '6p' 跳转。只关心数量时用 -c,它不输出匹配行,更适合脚本比较。下面是编号后的匹配结果。

1:203.0.113.10 - admin [17/Oct/2026:10:15:32 +0000] "GET /dashboard HTTP/1.1" 200 5432
3:203.0.113.10 - admin [17/Oct/2026:10:15:34 +0000] "GET /api/users HTTP/1.1" 200 2048
6:203.0.113.10 - admin [17/Oct/2026:10:15:37 +0000] "GET /api/reports HTTP/1.1" 200 8192

grep 模式中的点表示“任意字符”,因此 203.0.113.10 也会匹配 203a0b113c10。这个样本中无所谓,但在真实日志里搜索 IP 或版本号时,应加 -F 按固定字符串处理。该模式还会匹配 203.0.113.100,若只想精确匹配一个主机,应配合 -w。

只输出匹配部分

-o 只打印匹配到的片段,-E 启用扩展正则,使三位重复量词(由花括号包围的 3)和 | 无需额外反斜杠。裸 [0-9]{3} 也会匹配三位响应大小,因为 grep 无法辨别数字所处字段。用请求末尾的引号作为锚点,可以把模式限制在状态字段。

grep -oE '" [0-9]{3} ' access.log | grep -oE '[0-9]{3}'

第一条 grep 的匹配包含引号和空格,因为它们是状态字段的锚点;第二条再把这些字符剥掉,只留下纯数字,供 sort | uniq -c 使用。

200
401
200
304
403
200
500
200

用 | 做选择可以一次找到两种方法,比分别运行两条 grep 更省,因为文件只读取一次。

grep -E "POST|DELETE" access.log

两条会修改数据的请求都出现,而且都失败。真实日志中值得告警,因为写操作失败通常表示认证或后端问题,而不是 URL 拼写错误。这里 401 是登录失败,500 是服务器删除时崩溃。

198.51.100.55 - - [17/Oct/2026:10:15:33 +0000] "POST /api/login HTTP/1.1" 401 128
10.0.1.22 - - [17/Oct/2026:10:15:38 +0000] "DELETE /api/users/5 HTTP/1.1" 500 512

-P 启用 Perl 兼容正则,增加 \d、前瞻和回顾。这里提取每行开头的 IP,得到干净的客户端列表。-P 是 GNU 扩展,不要依赖它运行在 BSD 或 busybox grep 上。

grep -oP '^\d+\.\d+\.\d+\.\d+' access.log

每个请求的客户端地址各占一行,保持日志顺序。只需唯一客户端时再接 sort -u,因为同一地址会在每次请求中重复。

203.0.113.10
198.51.100.55
203.0.113.10
10.0.1.22
198.51.100.55
203.0.113.10
10.0.1.22
198.51.100.55

这方法可用,但只适合小输入。在 200 万行日志上,grep -o 是提取第一列最慢的方案,因为它让正则引擎扫描每个字节,而不是按分隔符拆字段;awk 或 cut 快得多。

获取匹配附近上下文

单看一行错误通常无法说明发生了什么。-B1 -A1 打印前后各一行,-C2 是前后各两行的简写。多个匹配距离很近时,grep 会合并上下文,并用 -- 分隔组。

grep -B1 -A1 "500" access.log

失败的 DELETE 位于其他客户端的正常请求之间,因此这里的邻近行可以排除全面故障。忙碌服务器上错误附近往往是无关请求,所以 -C 最好用于已按客户端或请求 ID 过滤的日志。

203.0.113.10 - admin [17/Oct/2026:10:15:37 +0000] "GET /api/reports HTTP/1.1" 200 8192
10.0.1.22 - - [17/Oct/2026:10:15:38 +0000] "DELETE /api/users/5 HTTP/1.1" 500 512
198.51.100.55 - - [17/Oct/2026:10:15:39 +0000] "GET /dashboard HTTP/1.1" 200 5432

查看 systemd 服务日志也同理,但 journalctl 自带更快的过滤条件,不必把所有日志都管道给 grep。

列出匹配文件与整词匹配

-r 递归搜索目录下所有文件,适合在只知道配置值时查找它被写在哪里。以普通用户搜索 /etc/ssh,会得到匹配项,同时因私有主机密钥不可读而产生权限错误。

grep -r "PermitRootLogin" /etc/ssh/

两个匹配都是注释,说明主文件中没有覆盖该设置。不要据此下结论,应检查 /etc/ssh/sshd_config.d/,因为 Ubuntu 会在 sshd_config 顶部引入这些片段,sshd 采第一个读到的值。三条错误来自仅 root 可读的私有主机密钥。

/etc/ssh/sshd_config:#PermitRootLogin prohibit-password
/etc/ssh/sshd_config:# the setting of "PermitRootLogin prohibit-password".
grep: /etc/ssh/ssh_host_rsa_key: Permission denied
grep: /etc/ssh/ssh_host_ecdsa_key: Permission denied
grep: /etc/ssh/ssh_host_ed25519_key: Permission denied

加 -s 可屏蔽这些错误;确实需要搜索不可读文件时则用 sudo 运行。无论如何,即使找到匹配,退出码仍是 2 而不是 0,这对依赖 grep 返回值的脚本很重要:-s 只隐藏消息,不消除失败。-l 只列出包含匹配的文件名。

grep -rl "port" *.ini *.csv *.log

两个文件匹配;若只想到配置文件,第二个结果会令人意外。

config.ini
access.log

访问日志匹配是因为 /api/reports 包含“port”。这种子串命中就是递归搜索 /etc 返回大量噪声的原因。-w 限制为整词,因此只有真正名为 port 的设置会命中。

grep -w "port" config.ini

只打印两个真实 port 设置,因为 -w 要求匹配两侧是非单词字符或行边界。

port = 5432
port = 6379

grep 也是服务器上“这个设置在哪里”的主要工具。若搜索前还要按文件时间、所有者或大小过滤,应结合 find,而不是 grep 根目录下所有文件。

用 awk 选择列

awk 把每行当作记录并拆成字段:$1 是第一列,$NF 是最后一列,$0 是整行。默认分隔符是空白,-F 可设置其他字符。一旦按字段思考,条件、汇总和分组就会自然出现,因此任何类似表格问题都适合 awk。

选择列并设置分隔符

数一下日志行中按空格拆分的字段:第 1 个是客户端,第 7 个是请求路径,因为 GET 前的引号属于第 6 个字段。

awk '{print $1, $7}' access.log

行内其他内容全部丢弃;print $1, $7 中的逗号会在两个值之间输出一个空格。

203.0.113.10 /dashboard
198.51.100.55 /api/login
203.0.113.10 /api/users
10.0.1.22 /images/logo.png
198.51.100.55 /admin/config
203.0.113.10 /api/reports
10.0.1.22 /api/users/5
198.51.100.55 /dashboard

处理 CSV 需要 -F,,NR>1 跳过表头,因为 NR 是当前记录号。

awk -F, 'NR>1 {print $2, $3}' employees.csv

名称和部门会输出且不含表头。若不写 NR>1,第一行会输出为“name department”,多余表头会破坏后续汇总,因此后面示例都保留该判断。

Alice Johnson Engineering
Bob Smith Marketing
Carol Davis Engineering
Dan Wilson Sales
Eve Martinez Engineering
Frank Brown Marketing
Grace Lee Sales
Hank Taylor Engineering

-F, 只适用于简单 CSV。字段若被引号包裹且内部包含逗号(如 “Smith, Jr.”)就会拆错;这类文件应使用 gawk 的 --csv 或真正的 CSV 工具。

测试字段值

花括号前的模式决定动作作用于哪些行,因此一个程序即可完成过滤和输出。下面列出工资高于 90000 的员工。

awk -F, 'NR>1 && $4 > 90000 {print $2, $4}' employees.csv

四个人通过判断。awk 把 $4 当作数字,因为它看起来是数字且与数字比较,所以 102000 会高于 98000,而不是像按文本比较那样排在其后。

Alice Johnson 95000
Carol Davis 102000
Eve Martinez 98000
Hank Taylor 110000

同样的思路可以修复前面脆弱的 grep -v "200",因为字段测试不会被行内其他内容误命中。

awk '$9 != 200 {print $1, $7, $9}' access.log

所有非 200 请求都会列出客户端、路径和状态,并且即使响应大小或时间戳包含数字 200,这个版本仍保持正确。

198.51.100.55 /api/login 401
10.0.1.22 /images/logo.png 304
198.51.100.55 /admin/config 403
10.0.1.22 /api/users/5 500

这是处理日志时应养成的习惯:字段周围的数据变化时,字段测试仍然可靠。

对列求和、求平均与分组

END 块在最后一行之后执行一次,适合计算总和与平均值。

awk -F, 'NR>1 {sum += $4; count++} END {printf "Employees: %d\nAverage salary: $%.2f\n", count, sum/count}' employees.csv

两个数字都来自对文件的一次扫描,因此 awk 内存中最多只需保留一行,同样命令也可用于 10 GB 数据导出。

Employees: 8
Average salary: $86375.00

关联数组可实现 group-by:每个部门名作为两个数组的键,一个保存累计值,一个保存人数,END 块再相除。这个任务里 grep 和 sed 都无法竞争,因为它们不能做算术。

awk -F, 'NR>1 {sum[$3]+=$4; count[$3]++} END {for (dept in sum) printf "%s: $%.2f (%d employees)\n", dept, sum[dept]/count[dept], count[dept]}' employees.csv

在本次运行中,gawk 按以下顺序打印部门。

Marketing: $73500.00 (2 employees)
Sales: $69500.00 (2 employees)
Engineering: $101250.00 (4 employees)

mawk 输出相同三行,但顺序为 Marketing、Engineering、Sales。两种顺序都不算错,因为 awk 不定义 for (key in array) 的遍历顺序。顺序重要时应接 sort,或在 gawk 的 BEGIN 块中设置 PROCINFO["sorted_in"];mawk 接受该程序却忽略设置,所以只有 sort 可移植。下面是 gawk 下使用排序设置的示例。

gawk -F, 'BEGIN{PROCINFO["sorted_in"]="@ind_str_asc"} NR>1 {sum[$3]+=$4; count[$3]++} END {for (dept in sum) printf "%s: $%.2f (%d employees)\n", dept, sum[dept]/count[dept], count[dept]}' employees.csv

现在键始终按字母顺序输出,因此比较不同日期的报告才有意义。

Engineering: $101250.00 (4 employees)
Marketing: $73500.00 (2 employees)
Sales: $69500.00 (2 employees)

只要报告会被人或 diff 工具重复阅读,排序就是安全默认值。

使用内置变量与字符串函数

NR 是记录号,NF 是当前行字段数,因此 NF 可快速发现畸形行。$NF 始终取最后一个字段,无论有多少列,所以在中间新增列后仍然有效。它最常用于 ls -l 和 df 输出,因为所需名称总在最后。

awk -F, 'NR>1 {print $2, $NF}' employees.csv

每个名称与最后一列的城市配对。若某行缺少字段,$NF 会悄悄取到错误值,因此可用 awk -F, 'NF != 5' 快速检查坏行,再信任报告。当前文件所有行都有五个字段,因此不会输出任何内容。

Alice Johnson Portland
Bob Smith Seattle
Carol Davis Portland
Dan Wilson Denver
Eve Martinez Seattle
Frank Brown Portland
Grace Lee Denver
Hank Taylor Seattle

字符串函数可省去再调用 tr 或 cut 的步骤,让筛选和格式化一次完成。下面输出大写部门、姓名长度和名称前五个字符。

awk -F, 'NR>1 {print toupper($3), length($2), substr($2,1,5)}' employees.csv

length 会把姓名中的空格计入,因此 “Hank Taylor” 是 11 个字符,“Alice Johnson” 是 13 个字符。

ENGINEERING 13 Alice
MARKETING 9 Bob S
ENGINEERING 11 Carol
SALES 10 Dan W
ENGINEERING 12 Eve M
MARKETING 11 Frank
SALES 9 Grace
ENGINEERING 11 Hank 

printf 可生成对齐列,便于把报告粘贴到工单或邮件。

awk -F, 'NR>1 {printf "%-15s %-12s $%9.2f\n", $2, $3, $4}' employees.csv

格式字符串中的宽度让每行对齐,因为 %-15s 将姓名填充到 15 个字符,%9.2f 将工资按总宽 9 右对齐。

Alice Johnson   Engineering  $ 95000.00
Bob Smith       Marketing    $ 72000.00
Carol Davis     Engineering  $102000.00
Dan Wilson      Sales        $ 68000.00
Eve Martinez    Engineering  $ 98000.00
Frank Brown     Marketing    $ 75000.00
Grace Lee       Sales        $ 71000.00
Hank Taylor     Engineering  $110000.00

多数临时报告只需要这几个格式说明符。

设置多个字段分隔符

-F 接受正则表达式,因此方括号表达式可一次按多个字符拆分。下面按 [ 和 : 拆分日志行,以取得日期和小时。双反斜杠看似多余,但单引号会把 \\[ 原样交给 awk,awk 的字符串处理再把它变成字符类中的转义左方括号 \[。

awk -F'[\\[:]' '{print $2, $3}' access.log

样本中每个请求都发生在同一小时,所以八行的第 3 个字段都是 10。对全天日志使用相同拆分并接 sort | uniq -c,就能得到每小时请求数,这通常是故障后的首个统计。

17/Oct/2026 10
17/Oct/2026 10
17/Oct/2026 10
17/Oct/2026 10
17/Oct/2026 10
17/Oct/2026 10
17/Oct/2026 10
17/Oct/2026 10

左方括号必须转义。若写成较短的 -F'[[:]',gawk 会把 [: 当作 [:digit:] 这类字符类的开头,随后报错。

awk: fatal: invalid regexp: Unmatched [, [^, [:, [., or [=: /[[:]/

mawk 会以 regular expression compile failed (bad class -- [], [^] or [) 拒绝同一分隔符。两者都以状态 2 退出且不输出内容,因此基于这条单行命令的 cron 任务会悄悄生成空报告。保留反斜杠,并让脚本检查 awk 退出状态。

用 sed 编辑文本流

sed 在每行经过时应用编辑命令并打印结果。默认不修改文件,因此可安全试验,只有 -i 才写回。推荐流程是先在 stdout 上反复调整表达式,输出正确后再加 -i.bak 对真实文件执行一次。GNU sed 手册列出全部命令;替换、删除、范围打印和插入已覆盖几乎所有实际需求。

替换文本

s/old/new/g 替换每行中的全部匹配;没有 g 时只替换每行第一处。

sed 's/admin/root/g' access.log

注意第五行,URL 也变了,因为 sed 不知道哪一段是用户字段。对真实日志全局替换会改写你根本不想改的路径。

203.0.113.10 - root [17/Oct/2026:10:15:32 +0000] "GET /dashboard HTTP/1.1" 200 5432
198.51.100.55 - - [17/Oct/2026:10:15:33 +0000] "POST /api/login HTTP/1.1" 401 128
203.0.113.10 - root [17/Oct/2026:10:15:34 +0000] "GET /api/users HTTP/1.1" 200 2048
10.0.1.22 - - [17/Oct/2026:10:15:35 +0000] "GET /images/logo.png HTTP/1.1" 304 0
198.51.100.55 - - [17/Oct/2026:10:15:36 +0000] "GET /root/config HTTP/1.1" 403 256
203.0.113.10 - root [17/Oct/2026:10:15:37 +0000] "GET /api/reports HTTP/1.1" 200 8192
10.0.1.22 - - [17/Oct/2026:10:15:38 +0000] "DELETE /api/users/5 HTTP/1.1" 500 512
198.51.100.55 - - [17/Oct/2026:10:15:39 +0000] "GET /dashboard HTTP/1.1" 200 5432

让模式足够具体,只命中用户字段;这里通过在两侧包含空格实现。

sed 's/ admin / root /' access.log

现在 /admin/config 保留下来,只有用户列变化。自由文本中通常需要用 ^ 或周围字符锚定,才能精确替换。始终先不加 -i 运行并阅读输出,这种预演没有成本。

203.0.113.10 - root [17/Oct/2026:10:15:32 +0000] "GET /dashboard HTTP/1.1" 200 5432
198.51.100.55 - - [17/Oct/2026:10:15:33 +0000] "POST /api/login HTTP/1.1" 401 128
203.0.113.10 - root [17/Oct/2026:10:15:34 +0000] "GET /api/users HTTP/1.1" 200 2048
10.0.1.22 - - [17/Oct/2026:10:15:35 +0000] "GET /images/logo.png HTTP/1.1" 304 0
198.51.100.55 - - [17/Oct/2026:10:15:36 +0000] "GET /admin/config HTTP/1.1" 403 256
203.0.113.10 - root [17/Oct/2026:10:15:37 +0000] "GET /api/reports HTTP/1.1" 200 8192
10.0.1.22 - - [17/Oct/2026:10:15:38 +0000] "DELETE /api/users/5 HTTP/1.1" 500 512
198.51.100.55 - - [17/Oct/2026:10:15:39 +0000] "GET /dashboard HTTP/1.1" 200 5432

一旦替换输出完全符合预期,就可以应用到真实文件。

带备份地原地编辑文件

-i 把结果写回文件。给它加后缀(如 -i.bak)后,sed 会在旁边保留原文件,这就是修改 /etc 下任何文件或重启读取该文件的服务前的撤销手段。这里先在副本上操作,以便后续示例仍能看到原始配置。

cp config.ini config_test.ini
sed -i.bak 's/workers = 4/workers = 8/' config_test.ini
grep workers config_test.ini config_test.ini.bak

新值写入文件,旧值留在备份中;若修改出错,用一次 mv 即可恢复。

config_test.ini:workers = 8
config_test.ini.bak:workers = 4

GNU sed 接受裸 -i,但 macOS 的 BSD sed 会把下一个参数当作后缀,因此 sed -i 's/a/b/' file 在那里会失败。sed -i.bak 在两者上都能工作,这也是推荐写法。

删除和打印行

d 删除地址选中的行,多条命令可用 ; 串联。下面去掉段标题和空行,只保留设置。没有 -i 时文件本身不会被修改,sed 只打印结果。

sed '/^\[/d; /^$/d' config.ini

剩下十二行键值设置,这是快速查看冗长注释配置实际生效内容的方法。

host = 10.0.1.100
port = 5432
name = appdb
user = appuser
password = ChangeMe123
host = 10.0.1.101
port = 6379
maxmemory = 256mb
debug = false
workers = 4
log_level = info
secret_key = replace-me

要删除特定设置时应按模式删除,而不是按行号。sed '/^password/d' config.ini 会删除任意位置的密码行;sed '5d' 删除第五行,而此文件第五行是 user = appuser,不是密码。每次有人编辑文件,行号都会变化,因此脚本中按编号删除迟早会出错。

-n 关闭 sed 的自动打印,因此只有 p 指定的内容会输出。CSV 第 2 至第 4 行是前三名员工。

sed -n '2,4p' employees.csv

第 1 行是表头,因此范围从 Alice 开始。没有 -n 时,sed 会先打印全部内容,再把第 2 至第 4 行打印第二次。

1,Alice Johnson,Engineering,95000,Portland
2,Bob Smith,Marketing,72000,Seattle
3,Carol Davis,Engineering,102000,Portland

这种行范围适合在编写过滤条件前抽样查看大 CSV 的前几行。

在匹配行前后添加内容

i\ 在地址行前插入,a\ 在后面追加。给每个 port 设置追加 timeout 只需一条命令,不必知道每段从哪里开始。预览正确后再配合 -i.bak。

sed '/^port/a\timeout = 30' config.ini

两个 port 行下方都添加了新设置,因为地址 /^port/ 匹配两次,a\ 每次命中执行一次。把文本直接放在 a\ 同一行是 GNU 扩展;BSD 和 macOS sed 要求在文本前加反斜杠和换行。

[database]
host = 10.0.1.100
port = 5432
timeout = 30
name = appdb
user = appuser
password = ChangeMe123

[redis]
host = 10.0.1.101
port = 6379
timeout = 30
maxmemory = 256mb

[app]
debug = false
workers = 4
log_level = info
secret_key = replace-me

预览正确后,用同一地址配合 -i.bak 写入。

用捕获组重排文本

在 -E 下,圆括号捕获文本,\1、\2 可引用捕获组。交换名和姓只需一次替换;-n 与 p 标志只打印发生替换的行。基本正则会把手写圆括号当作普通字符,因此不用 -E 时每个组都要写成 \( 和 \),多数 sed 单行命令也因此变得难读。

sed -E -n 's/^[0-9]+,([A-Za-z]+) ([A-Za-z]+),.*/\2, \1/p' employees.csv

每名员工都转换为“姓, 名”,表头消失,因为 ^[0-9]+, 要求行首有数字 ID。

Johnson, Alice
Smith, Bob
Davis, Carol
Wilson, Dan
Martinez, Eve
Brown, Frank
Lee, Grace
Taylor, Hank

捕获组还可用于重排日期、交换键值顺序或给值加引号。

选择配置中的单个段

地址范围 /start/,/end/ 会选择两个模式之间包括首尾在内的全部内容。把结束地址设为 [redis] 后面的空行比设成下一个段标题更干净,因为无需再用第二条 sed 删除该标题。下面只打印 redis 段。

sed -n '/^\[redis\]/,/^$/p' config.ini

该段连末尾空行一起输出,因为结束地址本身也在范围内。

[redis]
host = 10.0.1.101
port = 6379
maxmemory = 256mb

这仅在段之间有空行时有效。若没有空行,可用 /^\[redis\]/,/^\[/p 结束到下一个标题,再通过 sed '$d' 去掉该标题行。

用 grep、awk 与 sed 组合管道

管道让每个工具做最擅长的工作:grep 缩小输入,awk 选列并计算,sed 改写。以下四种模式覆盖大多数场景。

  • 按字段计数:awk 提取,再接 sort | uniq -c | sort -rn;先过滤再提取:grep 缩小输入,awk 输出字段;重塑名称:awk 选择、sed 改写、awk 再格式化;转换配置:用单个 awk 程序跨行跟踪状态。

按客户端统计请求数是最经典的管道,也是使用频率最高的组合之一。

awk '{print $1}' access.log | sort | uniq -c | sort -rn

两个客户端各有三次请求并列第一。uniq -c 只统计相邻重复项,因此第一个 sort 不能省。最后的 sort -rn 把最忙客户端放在顶部。

      3 203.0.113.10
      3 198.51.100.55
      2 10.0.1.22

若只想列出 4xx 和 5xx 错误及详情,可用 grep 过滤、awk 提取。

grep -E '" (4[0-9]{2}|5[0-9]{2}) ' access.log | awk '{print $1, $7, $9}'

304 被排除,因为它是缓存响应而非错误。模式中的引号可避免响应大小 4096 被当作状态码,因为大小出现在状态码之后,不可能紧接在结束引号后。

198.51.100.55 /api/login 401
198.51.100.55 /admin/config 403
10.0.1.22 /api/users/5 500

把 Engineering 团队转换为邮箱地址需要用到全部三个阶段。

awk -F, 'NR>1 && $3=="Engineering" {print $2}' employees.csv | sed 's/ /./g' | awk '{print tolower($0) "@example.com"}'

awk 选取并提取,sed 把空格换成点,第二个 awk 转为小写并添加域名。

alice.johnson@example.com
carol.davis@example.com
eve.martinez@example.com
hank.taylor@example.com

把 INI 转成环境变量时,管道已不再是正确答案。常见版本 grep -E "^[a-z]" config.ini | sed 's/ = /=/' | awk '{print "export " toupper($0)}' 会把值也转成大写(密码变成 CHANGEME123),且每个段都会重复导出 HOST。用一个记住当前段的 awk 程序可同时修复两个问题。

awk -F' = ' '/^\[/{gsub(/[][]/,""); sec=toupper($0); next} NF==2 {print "export " sec "_" toupper($1) "=\"" $2 "\""}' config.ini

每个变量都带段前缀,值保持原大小写,并用双引号包裹,因此含空格的值仍会作为一个字符串加载。

export DATABASE_HOST="10.0.1.100"
export DATABASE_PORT="5432"
export DATABASE_NAME="appdb"
export DATABASE_USER="appuser"
export DATABASE_PASSWORD="ChangeMe123"
export REDIS_HOST="10.0.1.101"
export REDIS_PORT="6379"
export REDIS_MAXMEMORY="256mb"
export APP_DEBUG="false"
export APP_WORKERS="4"
export APP_LOG_LEVEL="info"
export APP_SECRET_KEY="replace-me"

用进程替换把变量加载到当前 shell,再读取几个值确认。

source <(awk -F' = ' '/^\[/{gsub(/[][]/,""); sec=toupper($0); next} NF==2 {print "export " sec "_" toupper($1) "=\"" $2 "\""}' config.ini)
echo "$DATABASE_HOST $REDIS_PORT $APP_LOG_LEVEL"

这些值来自环境。若值中含 $、反引号或双引号,shell 仍会展开,因此只能加载你信任的配置文件。

10.0.1.100 6379 info

当这类单行命令逐渐发展为带函数和错误处理的脚本时,一本好参考书会很快回本;本站 Bash 脚本书单适合从管道过渡到正式脚本时使用。

测试 grep、awk 与 sed 的速度

几千行文件上三者都快得来不及眨眼,应按可读性选择。几百 MB 的日志才真正体现速度差异,因此我们在测试机(2 vCPU、2 GB 内存、Ubuntu 26.04)上生成 158 MB、200 万行的访问日志,并用三种工具分别执行四个相同任务。数据都是在文件已进入页缓存后取三次运行的最佳值,输出接到 wc -l 或 sort,避免终端绘制干扰。

生成器使用固定随机种子的 gawk,因此可复现同样的 200 万行;mawk 的随机生成器即使同种子也会产生不同内容。请在临时目录运行,因为它会写入 158 MB。

gawk 'BEGIN{srand(42); split("GET GET GET GET POST DELETE PUT",m," "); split("200 200 200 200 200 304 401 403 404 500",s," "); split("/dashboard /api/login /api/users /api/reports /images/logo.png /admin/config",u," "); for(i=1;i<=2000000;i++){ip="10.0." int(rand()*250) "." int(rand()*250); printf "%s - - [17/Oct/2026:10:%02d:%02d +0000] \"%s %s HTTP/1.1\" %s %d\n", ip, int(rand()*60), int(rand()*60), m[int(rand()*7)+1], u[int(rand()*6)+1], s[int(rand()*10)+1], int(rand()*9000)}}' > big.log

然后对想比较的命令计时,这里测试 grep 计数。

time grep -c '" 500 ' big.log

下表中的时间单位为秒,每个单元格取三次运行最佳值。

任务 grep sed mawk gawk 其他
统计状态码为 500 的行 0.19(grep -c) 0.40(sed -n + wc -l) 0.55 0.76
打印客户端 IP 列 10.05(grep -oE) 2.80(s/ .*//) 0.55 0.55 0.18(cut)
把每个 GET 替换成 FETCH 0.75(s///g) 0.43(gsub) 1.20(gsub)
按状态码统计请求 2.53(grep -o + sort + uniq -c) 0.43 0.72

所有工具返回的答案一致:含 500 的行 200453 行,IP 共 2000000 个。有三点值得据此调整习惯:单纯查找或计数行时 grep 最快,约为 awk 的三倍;按单个分隔符提取列时 cut 最快,awk 是灵活的次选,而 grep -o 和 sed 都较慢,因为它们让正则引擎扫描每个字节,而不是按分隔符拆分;mawk 在每个 awk 任务中都不慢于 gawk,替换任务快近三倍,因此默认 awk 指向 gawk 的主机可在大批量一次性处理时显式调用 mawk。

慢任务中,locale 的影响甚至比工具更大。服务器镜像使用 C.UTF-8,桌面通常使用 en_US.UTF-8 等,任何 UTF-8 locale 都会让 grep 和 sed 处理多字节字符。对这份纯 ASCII 日志强制使用按字节处理的 C locale,可以让两者显著加速。

命令 C.UTF-8 LC_ALL=C
grep -oE '^[^ ]+' big.log 10.10 秒 3.50 秒
sed 's/ .*//' big.log 2.81 秒 1.04 秒

固定模式下的简单 grep -c 没有变化(两种情况均 0.19 秒),因为 GNU grep 已对固定字符串走快速路径。针对可确认是 ASCII 的日志执行大量正则扫描时使用 LC_ALL=C;含非英文字符的文本不要这样设置,因为 C locale 会把多字节字符按单个字节处理,字符类行为也会改变。

grep awk sed versions and benchmark timings on Ubuntu 26.04

截图中同一台虚拟机展示了工具版本和第一张表中的取列耗时。你的绝对秒数会随 CPU 和磁盘变化,但每次运行的排序都一致,因此更应相信相对排名,而不是具体秒数。

grep、awk 与 sed 快速参考

下表是上文使用过的形式,并按最可能使用的顺序排列。通用行是模板,每种形式都在前文示例中配合真实输出出现过。

任务 命令
搜索字符串 grep "pattern" file
按固定字符串搜索(不解释正则) grep -F "10.0.1.22" file
忽略大小写 grep -i "pattern" file
排除匹配行 grep -v "pattern" file
统计或编号匹配行 grep -c / grep -n "pattern" file
只打印匹配部分 grep -oE '[0-9]{3}' file
显示匹配上下文 grep -C2 "pattern" file
列出包含匹配的文件 grep -rl "pattern" /path/
整词匹配 grep -w "port" file
打印指定列 awk '{print $1, $7}' file
自定义分隔符并跳过表头 awk -F, 'NR>1 {print $2}' file
按字段过滤 awk '$9 != 200' file
对列求和 awk '{s+=$4} END {print s}' file
分组汇总 awk '{a[$1]+=$2} END {for (k in a) print k, a[k]}' file
打印最后一个字段 awk '{print $NF}' file
替换文本 sed 's/old/new/g' file
原地编辑并备份 sed -i.bak 's/old/new/' file
删除匹配行 sed '/pattern/d' file
打印指定行范围 sed -n '2,4p' file
在匹配行后追加 sed '/pattern/a\new line' file
用捕获组交换内容 sed -E 's/(a) (b)/\2 \1/' file
打印两个模式之间的内容 sed -n '/start/,/end/p' file

若只从基准测试中记住一条规则,就是:查找和计数用 grep,计算用 awk,改写用 sed,而只需要一列时优先用 cut。若想深入了解三者共享的正则表达式,LPIC-1 搜索文本指南并列讲解了基本与扩展语法。

Avatar photo

关于 木子

Email: muzi@vip.rockylinux.cn 微信:VebinLee QQ: 2306867585
Founder of the Rocky Linux Chinese community, MVP、VMware vExpert、TVP, advocate for cloud native technologies, with over ten years of experience in site reliability engineering (SRE) and the DevOps field. Passionate about Cloud Computing、Microservices、CI&CD、DevOps、Kubernetes, currently dedicated to promoting and implementing Rocky Linux in Chinese-speaking regions.
用一杯咖啡支持我们,我们的每一篇[文档]都经过实际操作和精心打磨,而不是简单地从网上复制粘贴。期间投入了大量心血,只为能够真正帮助到您。
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇