1. 先把“模式”这两个字吃透
很多人学 awk 是背命令,awk '{print $1}'、awk -F: '{print $3}'这么一条条记,记到后面记混了,一遇到“只要第 3 行到第 10 行”“只要字段里带 error 的行”就卡住。我当初也是这样,真正让 awk 用起来顺手的转折点,是把pattern(模式)这个概念的层级想明白了:awk 不是“打印某一列”的工具,它是“逐行扫描 + 条件筛选 + 动作执行”的流水线,而“模式”就是这条流水线上那道闸门,闸门开不开、开多久,全由模式决定。
这篇文章聊的就是这道闸门——awk 的常用匹配模式。awk 的全称是 Aho、Weinberger、Kernighan 三人姓氏缩写,1977 年成型,到今天依然是各类 Unix 系系统里默认装着的文本处理工具之一,几乎任何一个能跑 shell 的环境都有它。它能做什么?一句话概括:按行读、按字段拆、按你给的条件判断、按你写的动作输出,中间还能累加、去重、排序、格式化。它解决的问题是“把一段有规律的文本,快速变成另一段有规律的文本”。适合谁来读?写过几条 Linux 命令、被 awk 的$1、$NF弄得有点懵,想让自己的脚本从“能用”变成“稳、快、好维护”的那批人。零基础也能跟上,我会把每个模式的意图和踩坑点都拆开讲,不假设你已经背熟了语法表。
先给一个最关键的心智模型,后面所有内容都建立在它上面:
awk '模式 { 动作 }' 文件这条语句读起来是“对每一行,如果这一行满足‘模式’,就执行‘动作’”。模式可以省略(省略时对每一行都执行动作),动作也可以省略(省略时默认动作是print $0,即打印整行)。这里最容易忽略的一点是:模式不是“过滤器的附属品”,它和动作是对等的两个半区,/error/、NR>10、$3=="GET"、BEGIN、/start/,/end/全都属于“模式”这一个位置的合法取值。理解到这一层,你就不会再把 awk 当成“带条件的 print”了。
1.1 一条 awk 语句的执行顺序,比语法更重要
我建议每个刚上手的人都先把执行顺序在脑子里跑一遍,因为它直接决定了很多“看起来莫名其妙”的现象。awk 处理输入的过程是这样的:先执行所有的BEGIN块(可以有好几个,按书写顺序),然后开始循环——读一条记录,按FS切成字段,依次测试每一个“模式 + 动作”对,命中的执行动作,全部测试完再读下一条记录,直到输入读完,最后执行END块。这个顺序解释了三件事:为什么BEGIN里能初始化变量而不用等数据;为什么在动作里改了$2,后面的print $0会跟着变(字段被赋值后整条记录会按OFS重建);为什么END里能放心地打印累加结果。
注意:
BEGIN块里的FS赋值会影响第一条记录的切分,这是极少数“在数据读入前必须做完”的设置;而如果你在普通动作块里写FS=":",它只影响下一条记录,当前这条已经是切好的了,这个坑我见过不止一个人踩。
还有一个容易忽视的点:多个“模式 + 动作”对之间是并列关系,不是 if-else。同一行可以同时命中三个模式,三个动作都会执行。想让后面的不再执行,得靠next或exit主动跳出去。这个特性在写“默认打印 + 特殊情况特殊处理”的逻辑时特别好用,但也是造成“输出重复了好几遍”的常见原因。
1.2 不同实现的差异,得先摸清楚
awk 有几个常见实现:gawk(GNU 版,功能最全)、mawk(Debian 系默认,轻快)、busybox awk(精简环境里常见)、以及某些系统上的nawk。日常的匹配模式这几个实现都能跑,但边界处差别不小,我列几个真会咬人的:
| 特性 | gawk | mawk | busybox awk |
|---|---|---|---|
IGNORECASE忽略大小写 | 支持 | 不支持 | 部分支持 |
区间量词{n,m} | 默认开 | 支持 | 视版本 |
FPAT按字段内容切分 | 支持 | 不支持 | 不支持 |
nextfile | 支持 | 支持 | 较新版本支持 |
--csv直接吃 CSV | 5.1+ 支持 | 不支持 | 不支持 |
| 数组遍历顺序 | 可PROCINFO["sorted_in"]控制 | 不可控 | 不可控 |
这张表的意义不在于让你背,而在于提醒:写可移植脚本时,别依赖IGNORECASE,老老实实用tolower($0) ~ /error/;需要按列数、按内容切字段时,先确认目标机器上到底是哪个 awk(awk --version或ls -l $(which awk)看软链)。我自己吃过一次亏:在开发机上用IGNORECASE=1写得舒服,部署到一台精简容器里,版本不认识这个变量,直接当成空值忽略,结果大小写混着的数据全漏了,排查了半小时才发现是“变量被静默忽略”而不是“正则写错”。
2. 常用匹配模式的分类与选型
2.1 五类模式,一张表理清
awk 的模式位置能放的东西不算多,我把它们归成五类,这是我认为最好记的分法:
| 类别 | 写法示例 | 触发条件 | 典型用途 |
|---|---|---|---|
| 特殊模式 | BEGIN、END | 处理前 / 全部处理完 | 初始化、汇总输出 |
| 表达式模式 | NR==1、$3>100、NF>5 | 表达式值为真(非 0 非空) | 按行号、字段值筛选 |
| 正则模式 | /error/、$0 ~ /^2024/ | 正则匹配成功 | 关键字、格式筛选 |
| 范围模式 | /BEGIN/,/END/ | 从匹配起点到匹配终点 | 抓取区块 |
| 组合模式 | /error/ && $2=="POST" | 布尔组合为真 | 多条件精确筛选 |
这张表里最值得多说一句的是“表达式模式”的真假判定规则。awk 里,数字 0 为假,空字符串""为假,其他都为真。麻烦的地方在于“看起来像数字的字符串”:awk 有个 strnum 概念,从输入里读出来的字段如果长得像数字,比较时会按数字比;而字符串常量按字符串比。所以$1 > 10和$1 > "10"在边界上结果可能不同,"9" > "10"是字符串比较,结果为真(因为'9' > '1'),9 > 10是数字比较,结果为假。我在按大小筛日志里的耗时字段时踩过这个坑,$NF > 100结果冒出一堆 9 毫秒的记录,原因就是那一列混了非数字内容,awk 退化成字符串比较了。稳妥的写法是先做一次数值转换:($NF+0) > 100,加个+0强制转数字,语义清晰,也不会被脏数据带偏。
2.2 选型的判断顺序,我一般这么走
拿到一个筛选需求,我的判断顺序是固定的:能按行号 / 字段数定位的,优先用表达式,别上正则。行号和字段数是结构性信息,判断成本低,也不会因为内容里恰好出现某个词而误伤;需要按键字或格式匹配的,用正则常量;需要跟某个字段比对内容而不是整行比对的,用~锚到具体字段;需要抓一段区间的,才用范围模式;最后,任何需要"前后各做一次"的场景,套BEGIN/END。
举个具体例子说明这个顺序的价值。要从一份访问日志里取“第 2 列是某个接口名、且状态码是 5 开头”的记录。新手容易写成awk '/api/ && /5[0-9][0-9]/',问题在于整行里任何位置出现 5 开头的三位数都会命中,比如响应体长度 500、时间戳里的 500。按字段锚定就干净了:awk '$2 ~ /api/ && $9 ~ /^5[0-9][0-9]$/'。后者的可读性和抗干扰能力都高一个量级,代价只是多敲几个字符。这类“锚定到字段”的习惯,是我建议从第一天就养成的。
3. 逐个拆解:高频匹配模式的写法与坑
3.1 BEGIN 与 END:把“前后各一次”用到位
BEGIN和END是两种不带输入匹配的特殊模式,BEGIN在读取任何数据之前执行,END在全部数据读完之后执行。它们的价值不在语法,而在把“一次性动作”从“每行动作”里剥离出来。最常见的用法有三类:一是设置分隔符和输出格式,比如BEGIN{FS=","; OFS="\t"};二是初始化累加变量,虽然 awk 里未定义变量默认就是""/0,但显式写出来可读性更好;三是在END里输出汇总,比如计数、求和、求平均。
# 统计状态码为 5xx 的请求数,并输出占比 awk ' BEGIN { FS = " "; total = 0; bad = 0 } { total++ } $9 ~ /^5[0-9][0-9]$/ { bad++ } END { if (total > 0) printf "total=%d bad=%d rate=%.2f%%\n", total, bad, bad * 100 / total else print "no input" } ' access.log这段代码里的if (total > 0)是必须的。输入为空时total是 0,直接做除法会抛出一个运行时警告(gawk 下输出类似division by zero attempted,结果为inf),而不是让你安静地拿到 0。这种“空输入保护”在写自动化脚本时尤其重要,因为脚本跑在定时任务里,没人盯着看报错,结果文件里静悄悄多出一行rate=inf才是最难受的。
还有个细节:BEGIN和END可以写多个,按出现顺序执行。我有时会把“参数校验”和“格式设置”分成两个BEGIN块,逻辑上更清楚。END里能访问所有的累加变量,但如果中途用exit提前跳出,END依然会执行——这一点非常有用,比如某个文件读到第一万行就够用了,exit之后在END里统一输出,不用在中间写重复的输出逻辑。反过来,如果你的exit是想彻底终止、连END都不跑,那就得自己加个标志变量在END里判断,这是少数必须记住的例外。
3.2 表达式模式:最稳、最快、最该优先用
表达式模式是模式位置放一个求值表达式,结果为真就执行动作。它覆盖面极广:行号判断(NR==1、NR%2==0)、字段数判断(NF>5、NF==0)、字段值比较($3=="GET"、$4+0>1.5)、算术组合((NR-1)%100==0每百行取一条),都能写。相比正则,它的优势是明确指向结构而非内容,不受数据内容干扰,执行也更快。
几个我高频使用的表达式模式,值得单独记:
awk 'NR > 1' file # 跳过表头 awk 'NF > 0' file # 跳过空行(注意 NF 对空行的判定) awk 'NR % 2 == 1' file # 取奇数行 awk 'NR==FNR{next} {print}' a b # 用第一个文件做过滤白名单,第二个文件做输出 awk 'length($0) > 80' file # 按整行长度筛选NR==FNR{next}这个写法值得展开讲,它是 awk 里处理“两个文件关联”的经典套路。原理是:NR是累计行号,FNR是当前文件内的行号,读第一个文件时两者相等,读第二个文件时NR已经大于FNR。所以NR==FNR成立时说明正在处理第一个文件,next表示跳过后续所有模式动作,进入下一行。这个套路的坑在于第一个文件必须非空——如果第一个文件是空的,NR==FNR在第二个文件的第一行会短暂成立(都是 1),导致第一行被误跳过。我一般会在前面加一句防护,或者干脆用ARGIND(gawk 特有)来避免。
提示:
NF > 0和length($0) > 0不是一回事。如果FS是单个空格,一行全是空格的情况下NF是 0,但整行长度不为 0。用哪个取决于你是不是要把“纯空白行”也算作空。我处理日志时倾向用NF > 0,因为它更符合“有没有实际字段”的语义。
3.3 正则模式:常量、锚定字段、动态构造
正则模式是 awk 最常被使用、也最容易写错的一类。它有两种形态:正则常量/pattern/,和匹配操作符表达式 ~ /pattern/。前者等价于$0 ~ /pattern/,也就是拿整行去比;后者可以指定任意表达式,最常见的用法是锚定到某个字段$3 ~ /pattern/,或者用变量动态构造$0 ~ key。
先讲一个几乎人人都会遇到的问题:正则元字符的处理。.匹配任意字符,*+?是量词,[]是字符集,^$是锚点,()|是分组和或,{}是区间量词,\是转义。要匹配字面量的点,必须写\.;要匹配字面量的斜杠,在/.../常量里要写\/。我见过最常见的误写是拿 IP 地址或版本号直接当正则,比如/1.2.3/本来以为只匹配 “1.2.3”,结果把 “1x2y3” 也匹配上了。这种时候要么转义成/1\.2\.3/,要么改用字段精确比较$1=="1.2.3"。
然后是锚点。/error/会匹配 “no error here”,/^error/才只匹配行首。做日志筛选时,锚点几乎是必需品,因为关键字往往出现在非预期位置。同理$锚行尾,$9 ~ /^5[0-9][0-9]$/这种写法能避免把 “5000” 误判为 5xx 状态码。字符集里的^是取反,[^0-9]表示非数字,这个和锚点的^是两套语义,位置不同含义完全不同,别混。
动态正则是个进阶但很好用的能力:模式部分可以是个表达式,只要结果是个字符串就当正则用。比如从外部传进来一个关键字-v key=error,然后写$0 ~ key。这让同一份脚本能适配不同的筛选目标,不必每次改代码。需要注意两点:一是关键字里如果含有正则元字符,会被当正则解释,如果需要字面匹配,得先做转义(gawk 里有gensub可以用,或者用index($0, key) > 0做纯字符串查找);二是~右边的表达式求值发生在每一行,如果构造过程复杂,会拖慢性能,能提到BEGIN里预先算好的就别放在动作里。
3.4 范围模式:抓一段区间,但要知道它怎么结束
范围模式写成/起点/,/终点/,含义是“从匹配起点的行开始,一直到匹配终点的行结束,全都命中”。它在抓配置段、抓代码块、抓某段日志时非常顺手:
# 抓取配置文件中某个段落 awk '/^\[database\]/,/^\[/' config.ini # 抓取某次请求前后的日志 awk '/request id: 7f3a/,/request end/' app.log用范围模式有一个必须记住的行为:它的两端是“行级”判定,起止可以出现在同一行。如果某一行同时匹配起点和终点,这一行会被输出,然后范围立即结束,下一行就不在范围内了。这在抓“单行成对”的场景下会让人困惑——比如日志里一行同时有begin和end,你以为抓到了整段,实际上只抓到了这一行。
另一个更隐蔽的行为是:范围模式一旦启动,就不会因为起点再次出现而重启,它会一路走到终点匹配为止;如果终点始终没出现,它会一直输出到文件末尾。这个特性在抓日志时经常导致“多抓了后面一大片”,尤其是终点关键字在文件里根本不存在的情况下。我一般的做法是:抓之前先用grep -c确认起点和终点的出现次数是否匹配,或者在END里检查一下有没有“未闭合”的情况,输出个提醒。
还有个小技巧:范围模式的两端都可以是任意“模式”,不一定是正则。比如NR==10,NR==20就是取第 10 到 20 行,/start/,0这种写法(用常量 0 当终点)等于“从 start 开始一直到文件末尾”,因为 0 永远为假,终点永不匹配。这个写法比写复杂的结束条件省事,我在做“取某个标记之后的全部内容”时经常用。
3.5 特殊变量与文件切换:让模式判断有依据
模式要判断,得有依据,awk 提供的内置变量就是这批依据。最常用的几个:NR总行号、FNR当前文件行号、NF字段数、$0整行、$n第 n 个字段、FILENAME当前文件名、ARGIND当前文件序号(gawk)、RSTART/RLENGTH配合match()用。此外还有几个影响读入行为的:FS输入字段分隔符、RS输入记录分隔符、OFS输出字段分隔符、ORS输出记录分隔符、SUBSEP多维数组下标分隔符。
这里面FS的规则值得专门讲,因为它是“分隔符匹配”这件事的核心。规则有三条:默认值是一个空格,此时 awk 会把连续的空格和制表符都当作一个分隔符,并且忽略行首空白——这是唯一“特殊对待”的取值;设成单个字符(如FS=":")时按该字符切分,连续两个冒号会产生一个空字段;设成多字符字符串(如FS="::")时按字面整体匹配;设成正则(如FS="[ \t]+")时按正则切分。我在处理 CSV 之外的变长空白分隔文件时,一定写FS="[ \t]+",而不是留着默认值——因为默认值对制表符和空格的混合处理虽然也能用,但在跨平台拷贝、行尾有\r的情况下容易出现字段数对不上。
RS用得少但威力大。默认按换行切记录,把RS设成空字符串""会进入“段落模式”,空行作为记录分隔符,适合处理那种一段一段的文本;设成某个特殊字符串,就能按自定义分隔符切记录,比如按</entry>切 XML 片段。RS的坑在于它和FS的联动:切完记录之后,字段是按FS在这条记录内部切的,如果记录里带换行而FS又是默认的空格,字段会被换行切开,导致$1的语义和你想的不一样。这种时候要么改FS,要么先用gsub把换行处理掉。
4. 完整实操:从一份服务日志里捞出想要的数据
光讲模式还是虚,得有个能跑通的完整链路。下面这份场景是纯虚构的模拟示例,但结构和真实日志很接近,我按“看清结构 → 分段构建条件 → 补输出 → 封装复用”四步走一遍。
4.1 先看清数据结构,别急着写正则
假设日志长这样,空格分隔,字段依次是时间、级别、模块、请求方法、路径、状态码、耗时(毫秒):
2024-05-11T09:00:01 INFO gateway GET /api/order/list 200 35 2024-05-11T09:00:02 WARN gateway POST /api/order/create 500 1203 2024-05-11T09:00:03 INFO cache GET /api/user/profile 200 8 2024-05-11T09:00:04 ERROR gateway GET /api/order/detail 503 3011第一步永远是数清楚字段,而不是直接写$5。我习惯用awk '{print NF}' file | sort -u看一眼字段数是否稳定,再用awk 'NR<=3{for(i=1;i<=NF;i++) print i, $i}' file把前几行的字段逐个打出来对照。这一步花 30 秒,能省掉后面 30 分钟的“为什么取错了列”。如果字段数不稳定(比如路径里带空格),就得考虑改用固定分隔符,或者用FPAT(gawk)按内容模式切分,而不是硬套空格分隔。
4.2 分段构建匹配条件,别一次写完
需求定为:找出“状态码是 5 开头、耗时超过 1000 毫秒、路径是/api/order前缀”的记录。我不会一次写出完整命令,而是分段叠加,每加一个条件就看一眼输出量级是否合理:
# 第一步:只筛状态码 5xx awk '$6 ~ /^5[0-9][0-9]$/' app.log # 第二步:叠加耗时阈值,注意 +0 强制数值比较 awk '$6 ~ /^5[0-9][0-9]$/ && ($7 + 0) > 1000' app.log # 第三步:叠加路径锚定,注意 `^` 锚在路径开头 awk '$6 ~ /^5[0-9][0-9]$/ && ($7 + 0) > 1000 && $5 ~ /^\/api\/order/' app.log第三步里\/的转义容易看漏。在/.../正则常量里,斜杠必须转义,否则会提前结束正则。另一个选择是把路径前缀放进变量,用字符串比较更省心:substr($5, 1, 10) == "/api/order",可读性更好,也完全避开了正则转义。这种“能用字符串比较就别上正则”的取舍,我在处理固定前缀时一律这么选。
为什么要分段?因为一次性写长条件,一旦输出为空,你根本不知道是哪一段把它卡死了。分段之后,每一步的输出条数就是天然的中间检验点,这个方法我用了很多年,比事后print调试高效得多。
4.3 输出与格式化,让结果能直接给人看
筛出来只是开始,输出格式往往决定这份结果能不能直接用。我会用printf控制列宽和对齐,并在前后加上汇总:
awk ' BEGIN { FS = "[ \t]+"; OFS = "\t"; count = 0; sum = 0 } $6 ~ /^5[0-9][0-9]$/ && ($7 + 0) > 1000 && $5 ~ /^\/api\/order/ { count++ sum += $7 printf "%-19s %-6s %-8s %-4s %-20s %s\n", $1, $2, $3, $6, $5, $7 } END { if (count > 0) printf "---- matched=%d avg_cost=%.1fms max_cost tracked below ----\n", count, sum / count else print "---- no matched record ----" } ' app.log这里FS = "[ \t]+"而不是默认值,是为了容忍日志里可能出现的不规则空白;OFS = "\t"配合手写printf其实用途不大,但在同时用print输出其他行的混合脚本里就很重要了,因为print $1, $2会用OFS拼字段。%-19s这类左对齐宽度,是我固定使用的写法,宽度按最长时间戳的长度定,改格式时只改一处。
一个实测有效的经验:printf里不要用变量拼格式串,比如printf fmt, $1这种写法,如果fmt来自外部输入且含有%,会引发意料之外的格式解析甚至安全风险。格式串永远写成字面量,需要动态宽度的用*传参:printf "%*s\n", width, $1。这个细节看起来小,但在写会被别人复用的脚本时相当关键。
4.4 封装成可复用脚本,参数外置
一次性命令的寿命通常很短,第二天就想不起来当时怎么写的。我的习惯是把高频筛选逻辑固化成脚本,把变化的部分做成参数,用-v传进去:
#!/bin/sh # 用法:./scan.sh app.log 500 1000 /api/order set -eu file=$1 code_prefix=${2:-5} min_cost=${3:-1000} path_prefix=${4:-/api} awk -v code="$code_prefix" -v cost="$min_cost" -v path="$path_prefix" ' BEGIN { FS = "[ \t]+"; n = 0 } $6 ~ ("^" code "[0-9][0-9]$") && ($7 + 0) > cost && index($5, path) == 1 { n++ printf "%-19s %-6s %-8s %s\n", $1, $2, $6, $5 } END { printf "matched=%d\n", n } ' "$file"这里有两个细节值得说。第一,动态正则用("^" code "[0-9][0-9]$")拼接,code是纯数字所以拼接安全;如果参数可能含正则元字符,就别拼正则,改用substr判断前缀。第二,index($5, path) == 1比$5 ~ "^" path更安全,它是纯字符串查找,path里有.或/都不会被当元字符解释,这个替换在参数来自外部时几乎是必须的。set -eu是 shell 层的防护,避免脚本在变量拼错时拿着空值继续跑。
5. 常见问题与排查速查
5.1 正则写对了但匹配不上,先查这四处
匹配失败的案例里,九成以上不是正则语法问题,而是“看起来同一个东西其实不是”。我按排查频率排个序:
| 现象 | 可能原因 | 快速验证 | 处理方式 |
|---|---|---|---|
| 明明有该字段却匹配不上 | 行尾有\r(Windows 换行) | awk '{print length($0)}'看是否比预期长 | sub(/\r$/, "")或读入前dos2unix |
| 字段数比预期多 | FS不是你以为的那个 | awk -F' ' '{print NF; exit}' | 显式-F或BEGIN{FS=...} |
| 大小写混着就漏了 | 没做大小写统一 | 对比~ /error/和~ /Error/ | tolower($0) ~ /error/ |
| 关键字里含元字符 | .+(被当语法 | 换成index()试 | 转义或改字符串比较 |
第一行那个\r的问题,我在跨系统拷文件时遇到过太多次。现象是“肉眼看着一模一样,正则就是不匹配”,因为\r是不可见字符,通常跟在行尾最后一个字段后面,导致$NF ~ /error$/失败。最快的验证是awk 'END{print length($0)}'对比预期长度,或者cat -A看行尾有没有^M。
还有一个高频误解是“/pattern/会匹配所有字段”。它只匹配$0整行,如果那一行被FS切成了多段,整行里仍然包含分隔符,所以/a b/在FS=" "的情况下确实能命中两个相邻字段的边界,看起来很神奇,实际上是整行字符串匹配的结果。理解这一点之后,/foo/和$1 ~ /foo/的区别就不会再混了。
5.2 变量、引号与转义,脚本里最容易翻车的地方
awk 脚本通常被单引号包在 shell 里,这就形成了两层转义:shell 一层,awk 一层。我列几条实战中反复出现的:
# 错:单引号里想用 shell 变量,结果原样传给 awk awk '{print $'"$col"'}' file # 对:用 -v 传,语义清楚 awk -v c="$col" '{print $c}' file # 对:需要 awk 内部识别为数字,注意 -v 的数值转换 awk -v n="$num" 'BEGIN{ if (n + 0 > 10) print "big" }'用-v传值有个隐含行为:gawk 的-v会处理反斜杠转义,比如-v s='a\tb'会变成真正的制表符;而通过ARGV或环境变量传入的不会。这个差异在传包含路径或正则的值时会造成困惑。如果不想被转义处理影响,用ENVIRON读环境变量是个干净的办法:k=$KEY awk 'BEGIN{ if (ENVIRON["k"] ~ /x/) ... }',注意这里ENVIRON拿的是字符串原值,不经过转义加工。
另一个隐蔽的坑是$在 shell 里的展开。awk 的$1、$NF在单引号里是安全的,但一旦你为了插值把脚本拆成双引号拼接,$1就会被 shell 当成位置参数展开,变成空字符串,于是print后面跟了个孤零零的=。这类问题的特点是“不报错,只是输出不对”,排查起来很费时间。我的习惯是:awk 脚本一律单引号整体包裹,需要外部值全部走-v,从不拼接。
5.3 性能与内存,什么时候该换工具
awk 处理几百 MB 的文本毫无压力,但有几个写法会让它明显变慢。第一是“在动作里反复执行同样的正则匹配”,比如循环里对同一个字段多次~ /.../;第二是“用$0整行匹配代替字段锚定”,整行匹配的字符串更长,正则引擎的工作量更大;第三是“在END里做本该边读边算的事”,把全部数据存进数组再处理,内存会随行数线性增长。
大文件场景下我一般会做这几件事:设LC_ALL=C,让字符串比较走字节而非多字节字符集,实测在纯 ASCII 数据上有可观提速;用next尽早短路,把最可能命中的、最便宜的条件写在前面,让后面的判断直接被跳过;需要去重时用关联数组,但注意数组键的数量,如果唯一值有几百万个,内存会吃紧,这时候sort -u可能更合适。
至于“什么时候不该用 awk”,我的判断标准是:需要复杂的分组聚合再排序输出,用sort+awk管道通常比纯 awk 数组清爽;需要解析严格嵌套的结构(JSON、XML),用专门的解析工具,别拿正则硬啃,这类需求用 awk 写出来的东西过两周自己都看不懂;需要逐行调用外部命令的,那个外部命令的开销会盖过 awk 本身的优势,不如换个思路。
6. 几个我踩过坑之后固化下来的习惯
写 awk 这些年,语法记不全不要紧,man awk随时能查,但有几种习惯是真正影响产出的。
第一个习惯是先确认数据形状再写模式。NF的分布、行尾有没有\r、分隔符是不是真的单空格,这三项检查花不到一分钟,但能挡掉后面大半的“为什么不对”。我现在写任何一段超过两行的 awk,都会先跑一次awk '{print NF}' file | sort | uniq -c,看一眼字段数稳不稳定,稳了再动手。
第二个习惯是条件永远锚定到字段,不锚到整行。$6 ~ /^5/永远比/5[0-9][0-9]/可靠,因为你知道它在看哪一列。整行匹配只在“真的想在任何位置找关键字”时用,比如全文搜索某个错误码。这条习惯让我少写了大量的调试时间,代价只是多敲几个字符。
第三个习惯是把BEGIN当成“配置区”。FS、OFS、阈值、格式化前缀、正则变量,全都在BEGIN里声明清楚。这样脚本读起来像一份配置加一段逻辑,别人接手时不用满篇找“这个变量在哪赋的值”。而且BEGIN里声明的FS对第一条记录生效,这个行为上的确定性本身就值得依赖。
第四个习惯是给每个END加空输入保护。除法、max/min的初值、数组遍历的输出,只要输入可能为空,就在END开头判断一下计数。定时任务里跑出inf或nan没人会立刻发现,等到有人看报表时才回头查,成本高得多。
最后说一个容易被忽略的扩展方向:把常用模式沉淀成函数。gawk 支持自定义函数,把“判断是否 5xx”“判断路径前缀”“格式化耗时”写成函数,主逻辑会变得很短,也方便在多份脚本之间复用。函数里的参数是值传递(数组是引用传递),return一个布尔值就能当模式用,这个写法在我维护的那几个日志分析脚本里已经成了标配,改动一处、全部生效,比复制粘贴强太多。