如果你只让我留三个命令行工具,我会毫不犹豫地报出 grep、sed 和 awk。尤其是 awk,这些年帮我处理了不知道多少日志、报表和数据抽取的活,别人还在吭哧吭哧写几十行 Python 脚本的时候,我常常一条命令就已经拿到结果了。这篇文章就围绕 awk 使用这件事,从最基础的行处理模型讲起,一步步拆到字段分隔、模式匹配、数组统计、多文件处理,最后把我踩过的坑一并交代清楚。内容不挑背景,只要你接触过 Linux 命令行,就能跟着跑一遍,基础薄弱的一样能看懂。
适合谁看?三种人我觉得最需要:一是写 Shell 脚本但总是习惯用循环加 cut 拼凑的;二是整天跟日志文件打交道,想把过滤、统计、格式化一条龙解决的;三是知道 awk 很强大但每次用都要查语法,想系统过一次的人。我会尽量用"命令+输出+说明"的方式呈现,你可以直接复制到自己的环境里跑,感受会比光看文字更直观。
1. awk的执行模型:为什么一条命令能顶二十行脚本
1.1 按行读入、按模式匹配、按动作处理
awk 的基本工作方式可以用一句话概括:逐行读入数据,按模式匹配行,对匹配成功的行执行动作。这个模型看起来简单,却是整个工具的灵魂,理解透了你就能自己组合出各种玩法。
举个最直白的例子:
bash复制awk '{print $0}' /etc/passwd
这段命令的意思是:读入 /etc/passwd 的每一行,没有任何模式限制(所以每一行都匹配),对每一行执行 {print $0} 这个动作,把整行打印出来。效果上等价于 cat,但背后逻辑完全不同:awk 把输入当成"记录流",一行就是一个记录,然后对记录做"过滤+变换"。
再复杂一点的模型,是把 awk 程序想象成这样一个结构:
awk复制模式 { 动作 }
模式和动作都能省略:省略模式表示"所有行都匹配",省略动作表示默认动作 print $0。所以 awk '/root/' 其实是"匹配包含 root 的行,打印整行"的简写,和 grep root 效果一致。理解了这一点,你会发现 awk 的核心能力不是简单地查找,而是在查找的同时还能对行内的内容做字段拆分、赋值、累加、格式化输出。
1.2 和 grep、sed 的分工界限
很多人问过我:有 grep 和 sed 了,为什么还要学 awk?我的理解是,这三兄弟的分工非常清晰,却经常被用错场景。
grep擅长的是筛选:从文本里把符合条件的行捞出来。它不关心行内结构,只是"有没有"的问题。sed擅长的是替换和行级编辑:把某个字符串换掉、删除某些行、在指定行前后插入内容。它本质是"流编辑器",盯着行的内容做修改。awk擅长的是按字段做结构化处理:把一行按分隔符切成若干列,然后对列做计算、比较、汇总、重排。它本质是一个"迷你数据处理语言"。
举个例子就清楚了。假设 access.log 每行格式是:
code复制192.168.1.100 - - [10/May/2024:13:45:22 +0800] "GET /api/user HTTP/1.1" 200 512
如果你只想知道哪些请求返回了 500,grep ' 500 ' 就够了。但如果你想统计"每个 IP 各发了多少次请求",用 grep 就非常别扭,你得 grep -o 提取 IP 再排序去重统计,而 awk 可以一条命令搞定:
bash复制awk '{ip[$1]++} END{for (i in ip) print i, ip[i]}' access.log
这个例子提前剧透了 awk 的数组统计能力,后面我详细拆。先记住一个原则:数据里如果已经有"列"的概念,第一反应应该是 awk,而不是 grep 或 sed。
1.3 从 Hello World 看默认行为
新手学 awk 最容易被 $0、$1、NF 这些符号搞晕。其实这些符号有非常直观的语义:
$0表示整条记录(整行)$1表示第一个字段$2表示第二个字段NF表示这条记录里的字段总数,所以$NF就是最后一个字段NR表示当前处理到了第几行(从 1 开始)FNR表示当前文件处理到了第几行,多文件时和 NR 有区别,后面细说
先看一个改字段的例子:
bash复制echo "a:b:c:d" | awk -F: '{print $1, $3}'
输出是 a c。-F: 表示用冒号做字段分隔符,$1 是 a,$3 是 c。这个能力看起来简单,但在实际处理 /etc/passwd、CSV、日志分割字段时太常用了。
默认情况下,awk 的字段分隔符是空格或制表符,连续的空格会被当成一个分隔符处理。比如 " a b c",$1 是 a,$2 是 b,不会因为中间的空格数量不同就解析错。这一点比你自己写循环去 split 字符串要省心得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 字段、分隔符与内置变量:awk立身之本的第一课
2.1 字段编号和常用内置变量速查
awk 的设计者在刚发明这个工具时,核心目标就是"把一行文本拆成字段,然后按字段生成报表"。所以字段相关的概念是优先要掌握的。我整理了一张自己经常用到的内置变量表:
| 变量 | 含义 |
|---|---|
FS |
输入字段分隔符,默认是空格/制表符,也可以用 -F 指定 |
OFS |
输出字段分隔符,默认是空格,影响 print 拼接字段时的间隔 |
RS |
输入记录分隔符,默认是换行符,可以改成别的字符实现按段落读取 |
ORS |
输出记录分隔符,默认是换行符 |
NF |
当前行的字段数量 |
NR |
已经读取的总记录数,多文件时是累计的 |
FNR |
当前文件内的记录数,处理新文件时重新从 1 计数 |
FILENAME |
当前正在处理的文件名 |
这里最容易忽略的是 OFS。很多人用 awk 拼接字段时发现输出和预期不符合,其实就是 OFS 没搞明白。看下面这个例子:
bash复制awk -F: '{print $1, $3}' /etc/passwd
默认 OFS 是空格,所以 $1 和 $3 之间会输出一个空格。如果你希望输出用管道符分隔,可以这样:
bash复制awk -F: -v OFS='|' '{print $1, $3}' /etc/passwd
-v 是给 awk 变量赋值的通用方式,后面讲 shell 传参时还会用到。
2.2 分隔符设置与复杂日志的拆解技巧
现实中的数据往往没有 CSV 那么规整,日志里经常混着空格、引号、方括号。这时候直接设置 -F 有时不够,需要用到正则表达式作为分隔符。
比如前面那个 access.log 例子,要取出时间字段 [10/May/2024:13:45:22 +0800] 里的日期部分,可以这样:
bash复制awk '{print $4}' access.log
$4 正好是 [10/May/2024:13:45:22,然后你想把方括号和日期拆开,可以继续用 -F 或者用 sub 函数做清理。更优雅的做法是把 FS 设成一个正则:
bash复制awk -F'[][]' '{print $2}' access.log
这里的正则 [][] 表示"匹配左方括号或右方括号"。FS 一旦设置成正则,awk 会按正则匹配到的位置做切分,非常灵活。
再比如处理自定义格式的会话日志,每行是 user=zhangsan|action=login|code=200|cost=35ms,想提取 cost 的数值:
bash复制awk -F'cost=' '{split($2,a,"|"); print a[1]}' file.log
这行用了 split 函数把第二个字段再切一次。你会发现 awk 的灵活性就在这里:字段拆得不干净,你可以基于字段继续拆、继续处理,直到拿到目标值。
2.3 格式化输出:printf 与报表对齐
print 适合快速输出,但要对齐列、控制小数位、补零的时候,必须上 printf。awk 里的 printf 和 C 语言的 printf 几乎一样,这个知识点值得单独练一下。
假设你统计一堆请求的平均耗时,想把结果输出成 接口名=50.23ms 这种格式:
bash复制awk '{sum[$1]+=$2; count[$1]++} END{for (api in sum) printf "%s=%.2fms\n", api, sum[api]/count[api]}' api.log
%.2f 保留两位小数,\n 手动换行。如果不写 \n,所有输出会连成一行,这是新手最容易踩的坑。另一个常见需求是左对齐补空格:
bash复制awk 'BEGIN{printf "%-20s %8s\n", "Name", "Score"} {printf "%-20s %8d\n", $1, $2}' grade.txt
%-20s 表示左对齐占 20 个字符宽度,%8d 表示右对齐占 8 个字符宽度。拿这个去生成对齐美观的表格报表,效果比 print 拼接好得多。
3. 模式、条件判断与循环:从逐行扫描到复杂逻辑
3.1 用正则表达式做行筛选
awk 的模式匹配和 grep 类似,但出现在 awk 里时语法是斜杠包起来的正则,后面跟动作块。比如:
bash复制awk '/ERROR|FATAL/ {print $1, $2, $NF}' app.log
匹配包含 ERROR 或 FATAL 的行,输出第一列、第二列和最后一列。这种写法的好处是,你可以在匹配的同时做字段提取,而 grep 到 ERROR 之后再想提取字段还得靠 sed 或 cut。
还可以用 $ 锚定行。比如匹配以 FAILED 结尾的行:
bash复制awk '/FAILED$/ {print}' test.log
实际处理中我更喜欢用"匹配不成功才执行动作"的写法,用 ! 取反:
bash复制awk '!/成功/ {print $0}' log.txt
这在过滤掉某些无关行时特别有用。
3.2 数值条件与逻辑组合:不止是正则
awk 的模式还可以是任意表达式,不仅仅是正则。数值比较、字符串比较、逻辑运算都可以直接放在模式的位子上。比如,找出第二列数值大于 100 的行:
bash复制awk '$2 > 100 {print $1, $2}' data.txt
组合条件也很自然:
bash复制awk '$2 > 100 && $3 != "OK" {print}' data.txt
awk '$1 == "root" || $NF == "admin" {print}' data.txt
我最喜欢的一个用法是"按行号范围处理":
bash复制awk 'NR >= 10 && NR <= 20 {print NR, $0}' file.txt
这比 sed -n '10,20p' 更灵活,因为你可以一边筛行号一边做字段处理。比如只对第 10 到 20 行的第二列求和:
bash复制awk 'NR >= 10 && NR <= 20 {sum += $2} END {print sum}' file.txt
模式匹配还能和 next 配合跳过某些行。next 是 awk 里一个很有用的动作,表示"本轮处理结束,直接读下一行"。比如你想跳过所有以 # 开头的注释行:
bash复制awk '/^#/ {next} {print}' config.conf
注意这里第一行花括号里没有结束程序,而是调用 next 后,程序跳到下一行输入,不会继续执行后面的 {print}。这就是 awk 逐行处理模型下的常见套路。
3.3 内置流程控制:if、while、for
awk 不是只能写一行命令,它还包含完整的流程控制语句。if 的写法和 C 类似:
awk复制{
if ($3 > 90) {
grade = "A"
} else if ($3 > 60) {
grade = "B"
} else {
grade = "C"
}
print $1, grade
}
while 和 for 也都能用。比如逐字段遍历一行:
bash复制awk '{for (i = 1; i <= NF; i++) print $i}' file.txt
这种场景常用于把一行数据"压平"成多行。比如原始数据是 1 2 3 4 5,上面命令会输出:
code复制1
2
3
4
5
反过来,把多行合并成一行可以用 ORS 或 printf。我经常用这个能力做数据格式转换,比如把 MySQL 查询结果转成逗号分隔的字符串:
bash复制mysql -e "select id from users where status=1" | awk 'NR>1 {printf "%s,", $1}'
得到的输出再喂给下一个脚本,省去很多手拼字符串的时间。
4. 内置函数与数组:统计报表场景的屠龙刀
4.1 常用内置函数速查与示例
awk 的内置函数分几大类:字符串类、数值类、时间类等。我平时够用的主要是字符串类,列几个高频的:
length(s):返回字符串长度substr(s, start, len):截取子串,位置从 1 开始index(s, t):返回 t 在 s 中首次出现的位置,找不到返回 0split(s, array, sep):按分隔符拆分字符串到数组sub(旧, 新, s):把 s 中首个匹配替换掉gsub(旧, 新, s):把 s 中所有匹配替换掉toupper(s)/tolower(s):大小写转换
举个例子。日志里有一列字段带引号,比如 "GET /index.php HTTP/1.1",你想把引号去掉:
bash复制awk '{gsub(/"|\r/, "", $7); print $7}' access.log
这里 gsub 的正则 "|\r 表示替换所有双引号和回车符为空字符串。注意 gsub 是直接在原字段上做修改,不需要再赋值回去。
substr 也特别常用。比如取时间字段 [10/May/2024:13:45:22 +0800] 里的月份:
bash复制awk '{print substr($4, 4, 3)}' access.log
$4 是 [10/May/2024:13:45:22,第 4 个字符开始取 3 个字符,正好是 May。做日志分类统计的时候,这种按月分桶的做法效果很好。
4.2 关联数组:一行命令做去重和计数
awk 的数组不是传统意义上的"按数字索引的列表",而是关联数组——下标可以是任意字符串,本质上是一个哈希表。这是 awk 统计能力的核心基础。
最经典的去重计数用法:
bash复制awk '!seen[$0]++' file.txt
这个表达式看起来很晦涩,展开解释一下:seen[$0] 是一个以整行内容为键的计数器,第一次遇到某行时,seen[$0] 的值是 0,!0 为真,于是这一行被打印;同时后缀 ++ 把 seen[$0] 变成 1。第二次遇到相同行时,seen[$0] 已经是 1,!1 为假,不再打印。这一行命令就是高效的去重。
统计每个 IP 的请求次数也是一样的逻辑:
bash复制awk '{count[$1]++} END {for (ip in count) print ip, count[ip]}' access.log
这里先把每一行的第一个字段 $1 作为键累加计数,最后在 END 块里遍历数组输出。配合 sort -rn 就可以得到"请求次数最多的 IP 排名":
bash复制awk '{count[$1]++} END {for (ip in count) print count[ip], ip}' access.log | sort -rn | head -10
这种风格的写法在处理几百万行的日志时,耗时通常只有几秒钟,比用 Shell 循环加临时文件的方案快一个数量级。
4.3 分组求和、平均值与多周期对比
计数只是入门,统计报表里更常见的是分组求和。假设你有这么个文件 sales.txt:
code复制华东 100
华北 50
华东 80
华南 60
华北 30
想按区域汇总销售额:
bash复制awk '{sum[$1] += $2} END {for (region in sum) print region, sum[region]}' sales.txt
输出:
code复制华北 80
华东 180
华南 60
注意关联数组的遍历顺序是随机的,所以输出顺序每次可能不一样。如果需要按数值排序,可以管道交给 sort:
bash复制awk '{sum[$1] += $2} END {for (region in sum) print sum[region], region}' sales.txt | sort -rn
求平均、最大、最小同样很容易。一组数值要求平均值和最大最小值:
bash复制awk '{if ($2 > max) max = $2; if (min == 0 || $2 < min) min = $2; total += $2; n++} END {print "max=" max, "min=" min, "avg=" total/n}' data.txt
注意这里 min == 0 的判断是为了处理第一个数据,否则初始值 0 会让最小值永远是 0。这种细节就是实战和教程的差别。
另外说一个特别实用的场景:做时间段维度的统计。比如日志里时间字段是 13:45:22,想按小时统计请求量,可以先把小时截出来:
bash复制awk '{hour=substr($4, 14, 2); count[hour]++} END {for (h in count) print h "时", count[h]}' access.log
一行命令就是一个小时维度报表,这就是 awk 的威力。
5. 多文件、getline与awk脚本工程化
5.1 多文件处理的 NR 和 FNR 区别
awk 可以直接一次处理多个文件,比如:
bash复制awk '{print FILENAME, NR, FNR, $0}' file1.txt file2.txt
这里 NR 是全局行号,处理 file1 的 10 行后再处理 file2 时,NR 会从 11 开始;而 FNR 是文件内行号,处理 file2 时会重新从 1 开始。区别非常重要,很多脚本 bug 就是这两个变量混用导致的。
跨文件关联数据的典型场景:你想用第二个文件去补充第一个文件的字段。比如 a.txt 是用户 ID 列表,b.txt 是 ID 到用户名的映射:
bash复制awk 'NR==FNR {name[$1]=$2; next} {print $0, name[$1]}' b.txt a.txt
解释一下:处理第一个文件 b.txt 时,NR == FNR 成立,所以把映射存入数组 name,然后 next 跳过后续动作;处理第二个文件 a.txt 时,NR != FNR,走后面的打印分支,把 ID 对应的用户名拼接上去。这是一种非常经典的 awk 多文件处理技巧,相当于做了一次"小型 JOIN"。
5.2 getline 的使用边界:能不用就不用,但用起来很香
getline 是 awk 里一个比较绕的功能,官方文档对它的使用都反复提醒要小心。它做的事情是"显式读取下一行输入",但读取哪一行取决于调用形式:
getline:读取当前文件下一行,赋值给$0,同时更新NF、NR等getline var:读取下一行,赋值给变量 var,不改变$0getline var < "file":从指定文件读取一行
我实际中最常用的是第三种场景:在处理主文件的同时,按需读取另一个文件。比如标签对齐,主文件每行有一个 ID,需要从字典文件里找对应描述,而字典文件不能一次性全读进内存(或者想节省内存):
bash复制awk '{while ((getline line < "dict.txt") > 0) {split(line, d, " "); map[d[1]]=d[2]} close("dict.txt"); print $1, map[$1]}' main.txt
但必须提醒:getline 的行为在不同实现上存在细节差异,而且容易打乱主循环的记录读取逻辑,很多新手在这里踩坑后干脆绕开它。我的建议是:能用关联数组存的就先存,遇到"要读第二遍文件"时优先用 NR==FNR 技巧实现;getline 只用于"单行按需读取"这类简单场景。
5.3 把 awk 程序写成脚本文件
awk 程序写到命令行里,一旦超过两三行就会比较难维护。awk 支持把程序存成文件,用 -f 参数加载:
bash复制awk -f myreport.awk data.txt
脚本的格式很简单,就是一段一段的"模式+动作"。比如我写过一个统计接口响应时间的脚本 ta.awk:
awk复制BEGIN {
FS = "|"
OFS = "\t"
print "接口", "请求数", "平均耗时ms"
}
{
status[$1]++
cost[$1] += $3
}
END {
for (api in status) {
printf "%s\t%d\t%.2f\n", api, status[api], cost[api]/status[api]
}
}
这样写的好处是,模式、动作、格式化逻辑都一目了然,后续要增加维度、修改字段也方便,不用每次去改命令行。
还有两个命令行参数相关的点必须提。
第一个是 -v 传外部变量:
bash复制awk -v threshold=100 '$2 > threshold {print}' data.txt
Shell 变量传进去也是一样的:
bash复制keyword="error"
awk -v kw="$keyword" '$0 ~ kw {print}' app.log
注意这里如果用双引号把 awk 程序包起来,shell 会先展开 $0、$1 等,那整个逻辑就废了,所以 awk 程序本身必须用单引号包裹,而 -v 负责把外部变量安全地传进去。
第二个是 -F 和 -v FS= 是等价的,但在使用动态分隔符时建议写在 BEGIN 块里,更清晰。
6. 我在实际使用中踩过的坑与避雷心得
6.1 POSIX awk、gawk 和 macOS 自带 awk 的差异
Linux 上常见的 awk 是 GNU awk(gawk),macOS 自带的是 BSD awk,两者在部分特性上有差异。最明显的一个是,gawk 支持一些扩展函数比如 asort、strtonum,BSD awk 不支持;另一个是 -v 传参和 --csv 之类的选项在不同平台上行为不一致。
我遇到过最闹心的一次是,在开发机上正常跑的数组排序脚本,部署到 macOS 上直接报语法错误。排查半天发现是 asort 的问题。所以我的建议是:
- 如果脚本要跨平台使用,尽量只用 POSIX 基础语法,不要依赖 gawk 扩展;
- 在脚本开头可以先检测
awk --version判断环境,或者干脆统一用gawk安装到所有机器上。
实际部署时,我一般用 #!/usr/bin/env awk -f 作为脚本头,并且尽量保持脚本在 gawk 和 BSD awk 下都能运行。
6.2 浮点计算与数值精度的坑
awk 的数值运算用的是浮点数,日常求和没问题,但涉及比较或者需要精确整数的场景,会踩到精度坑。比如:
bash复制awk 'BEGIN {print 1.0 - 0.9}'
在一些实现里输出可能不是让人舒服的 0.1,而是一个近似值。做金额或计数累加时,如果数据特别大,浮点误差会累积。
应对策略有两个:
- 需要整数精确累加时,把值先转成整数(去掉小数点、乘以倍数),最后再除以倍数;
- 需要输出固定小数位时,用
printf "%.2f"做格式化,而不是直接print。
此外,awk 里变量默认是"数字"还是"字符串"有时候会引发莫名其妙的问题。比如从日志里提取的字段明明是数字字符串,直接做加法时 awk 会尝试数值转换,但如果字段里混入了非数字字符,结果会是 0。碰到这种情况用 $1 + 0 强制转换,或者用正则先过滤掉脏数据。
6.3 编码、不可见字符和性能建议
文本处理最烦遇到的就是不可见字符和编码问题。从 Windows 传过来的文件往往带 \r 回车符,你用 $NF 取最后一个字段时会出现字段值带着 \r,导致匹配和统计失败。建议在 BEGIN 块里直接清掉:
awk复制BEGIN {RS = "\r?\n"}
或者处理字段时统一做 gsub(/\r/, "")。字符集方面,纯中文环境下 awk 按字节处理,length 返回的是字节数而不是字符数,如果你需要按字符数截取,建议配合 locale 环境变量或者干脆用 Python 处理。
性能方面,awk 处理大文件的速度其实非常快,前提是别踩几个坑:
- 避免在
END块里做大量无序数组的字符串拼接,内存增长太快; - 避免在循环里反复打开和关闭文件,
getline < file用完记得close; - 尽量避免用
system()在每行都调用外部命令,那会慢得离谱,要调用外部命令也最好把结果先缓存在数组里。
最后说一句我自己的习惯:不管命令多短,我都会先在样本数据上跑一遍、看一眼输出,再直接上全量。awk 这种工具,单行命令写错了不会报错,它只是会安静地输出错误的结果,这种"静默错误"比直接 crash 更危险,冷静检查输出比看语法更快发现问题。
