资讯动态

awk用户自定义函数实战:从单行命令到可维护脚本库

发布时间:2026/10/27 6:42:09 来源:尧图企业网站定制
awk 这门工具有个挺有意思的现象入门的门槛低到几乎没有awk {print $1}这种命令看一遍就会但真正把它用到能交付、能交给同事维护的程度很多人写了几年还是停在一行命令的水平。卡在中间的那道坎往往就是用户自定义函数user-defined function。当你需要按 IP 归并、按时间窗口切日志、按会话去重、按数值排序统计 TopN 的时候代码会从三行暴涨到两三百行全塞在 BEGIN 段、主规则和 END 段里改一个逻辑要在五个地方同步测一段逻辑只能靠造数据跑全流程。awk 的函数机制就是把这团东西拆开的那把剪刀。这篇文章聊的是实操层面的事怎么定义和调用 awk 函数、在一个没有块级作用域的语言里怎么造出局部变量、标量和数组两条完全不同的传参规则、几个可以直接抄走的成品函数IP 转整数、网段判断、CSV 字段提取、递归快排以及把函数拆成库文件之后怎么做自检。适合已经能写 awk 单行命令、想让脚本活得更久一点的读者完全没碰过 awk 的也能看懂语法部分但建议先把字段切分和模式匹配练熟再往下看。1. 为什么要专门把 awk 代码写到函数这一层1.1 一行命令的天花板出现在哪里先说个很典型的演进过程。最开始的需求是把访问日志里的状态码和耗时挑出来一行awk {print $9, $NF} access.log收工。过了两天要加个过滤只留 5xx于是变成awk $9 500 {print $9, $NF}。再过一周要看每个 IP 的请求数和平均耗时代码变成三行还能接受。真正的转折点出现在需求变成按 IP 聚合、算 P95、同时输出总量和 TopN、还要把时间戳转成本地可读格式的时候——这时代码大概率会膨胀成这样BEGIN 里初始化一堆数组主规则里做解析和累加END 里排序输出。三个位置共用同一套解析逻辑你复制粘贴了三遍后来发现时间格式解析写错了要改三处。这就是天花板。不是 awk 能力不够而是代码组织方式跟不上需求复杂度。函数解决的恰恰是这个问题同一段逻辑只写一次起个名字三个地方都调它改的时候只改函数体调用点一个字都不用动。另一个隐性成本是可测试性。没有函数的 awk 脚本想验证IP 转整数这段逻辑对不对只能造一份完整的输入文件跑全流程然后从输出反推中间值对不对。有了函数awk BEGIN{ print ip2int(192.168.1.1) }一行就能单测出问题的地方一秒定位。1.2 函数化之后多出来的三种能力第一是逻辑复用。这不是废话awk 的复用场景比想象中多。比如时间解析日志正文里有一处时间戳文件名里可能还有一处BEGIN 段要算窗口边界又要用一次再比如字段清洗主规则里要清洗END 输出前还要清洗一遍表头。这些地方天然需要同一个函数。第二是状态隔离。awk 的全局变量是真全局——你在主规则里写了个i当循环变量函数里不小心也用了i调用完主规则的循环就乱了。这种 bug 极难排查因为报错位置和出错位置隔着几十行。函数配合形参当局部变量的技巧能把这类污染彻底关掉具体做法在第 2 节展开。第三是可交接性。脚本要交给别人维护的时候一个有函数、有命名规范、有注释块的文件和一个两百行的面条代码维护成本差着一个数量级。这不是情怀问题是实打实的时间成本。1.3 不同 awk 实现对函数的支持差异这点必须先说清楚不然写完发现目标机器跑不了很浪费时间。主流环境里能碰到的 awk 大致是这几类实现函数定义递归数组传参典型场景gawk完整支持支持引用传递大多数 Linux 发行版默认mawk完整支持支持引用传递Debian/Ubuntu 系默认速度快busybox awk支持支持引用传递嵌入式、容器精简镜像各 BSD 自带 awk支持支持引用传递类 Unix 环境基础的function语法在四者上都成立这部分可以放心写。差异集中在扩展函数上mktime()、strftime()、asort()、gensub()、位运算and()/or()/lshift()、match()的第三个数组参数这些基本是 gawk 独有delete arr整数组删除在较老的 busybox 里可能不认。所以如果你的脚本要在容器里跑最稳的策略是只用 POSIX 定义的语法和函数扩展功能全部自己实现。本文后面给的函数实现都是按这个标准来的。提示不确定目标环境是哪个实现跑一句awk -W version或awk --version看看。Ubuntu 镜像里默认的 awk 通常是 mawkgawk 需要额外装。2. awk 函数的语法骨架与作用域真相2.1 定义与调用的最小可运行例子awk 函数的定义位置很自由可以写在 BEGIN 之前、规则之间、甚至文件末尾解析器在真正执行前会先把整个脚本扫一遍收集函数定义所以先调用后定义是合法的。唯一的硬性约束是函数定义不能写在规则的动作块内部也就是不能出现在{ }里。# 定义注意 function 关键字和函数名之间的空格函数名和左括号之间不要加空格 function trim(s, r) { r s sub(/^[ \t\r\n]/, , r) sub(/[ \t\r\n]$/, , r) return r } # 调用 { name trim($2) if (name ! ) count[name] } END { for (n in count) print trim(n), count[n] }这段代码里有两个细节值得说。第一个是函数名和左括号之间不要加空格。POSIX 语法上允许但部分老实现尤其是一些嵌入式环境自带的 awk会在这上面直接抛语法错误所以从业者的默认习惯是不加。第二个是s后面的那四个空格——它不是格式化对齐而是语法意义上的分隔符用来把真正的参数和局部变量分开。这一点是 awk 函数机制里最反直觉的地方下一节重点讲。再补一句关于参数个数的awk 调用函数时实参个数可以少于形参个数没传的那些会被初始化为空字符串在数值上下文里当 0 处理。这个特性本身是缺陷但被整个 awk 社区用成了局部变量的标准做法。2.2 没有局部变量就用多余形参造一个awk 的作用域规则只有一句话除了函数形参所有变量都是全局的。没有local关键字没有块作用域函数体里直接写for (i 1; i n; i)里的i是一个全局变量函数返回之后它还在下次调用不会自动清零其他函数和主规则也看得见它。官方给出的解法就是多声明几个形参调用时不传。因为形参在每次函数调用时都会重新初始化为空天然具备局部变量的语义。# 约定竖线之前是真实参数竖线之后的都是局部变量调用时绝不传 function parse_kv(line, out, n, arr, i, kv) { n split(line, arr, ) for (i 1; i n; i) { split(arr[i], kv, ) out[kv[1]] kv[2] } return n }调用方式是parse_kv($0, result)两个实参后面五个形参全部留空。arr、i、kv在每次调用开始时都是干净的互不干扰递归调用时也不会互相覆盖。这个约定的关键在纪律不在语法。因为调用时多传一个参数并不会报错只会让那个局部变量带上一个初始值——如果写错位置比如parse_kv($0, result, 0)那n的初始值就是 0 而不是空逻辑立刻变形而且编译器不会提醒你。我的做法是在函数头固定用四个空格把参数区和局部变量区分开再在文件顶部的注释块里写清楚这个约定团队里其他人接手时至少能看懂这个格式意味着什么。注意递归函数里如果漏掉了局部变量声明会出非常隐蔽的 bug。外层调用的tmp被内层调用覆盖返回后继续用就拿到脏值。现象是结果偶尔对偶尔错取决于数据。见到这类症状先查递归函数的形参列表。2.3 标量传值、数组传引用两条完全不同的规则awk 的参数传递分两套规则混用是新手最容易栽的地方。标量字符串、数字传值。函数拿到的是副本函数内怎么改都不影响调用方的变量。这和大多数语言的值传递一致好理解。数组传引用。函数拿到的是同一个数组的句柄函数内arr[1] x会直接改掉调用方的数组。这个设计的好处是可以把数组当输出参数用实现多返回值的效果——因为 awk 的return只能返回一个标量返回数组是 gawk 也不支持的操作。# 通过输出参数回填多个结果 function stat(values, n, result, i, sum, mx, mn) { sum 0; mx values[1]; mn values[1] for (i 1; i n; i) { sum values[i] if (values[i] mx) mx values[i] if (values[i] mn) mn values[i] } result[sum] sum result[avg] (n 0) ? sum / n : 0 result[max] mx result[min] mn return n }调用方写法是stat(nums, cnt, res)函数执行完res里就有四个键。注意res在调用前不需要预先声明成数组awk 会根据函数内的用法自动把它变成数组但如果这个变量之前已经被当成标量用过比如res 就会报attempt to use array in scalar context——这个报错信息在各种 awk 实现里措辞略有不同但意思都一样。还有一个边界数组只能整体传不能传数组的某个片段。foo(arr)合法foo(arr[1])传进去的是元素的值标量变成值传递。想让函数只处理数组的前 n 个元素就把 n 也传进去函数内部自己控制循环边界。2.4 return 的返回值规则和几个容易踩的坑return不带表达式时返回空字符串在数值上下文里当 0 用。看起来无害但配上 awk 的类型比较规则就会出问题。function find_index(target, arr, k) { for (k in arr) if (arr[k] target) return k # 找不到就走到这里隐式返回空字符串 } # 调用方 idx find_index(x, data) if (!idx) print not found # 危险idx 是 0 时也会被判为 not found如果find_index恰好返回字符串0数组下标为 0虽然 awk 数组下标很少是 0但字符串键完全可能!idx会是真逻辑就错了。稳妥的写法是显式返回一个不会和正常值冲突的哨兵比如返回-1表示未找到或者配一个单独的存在性标志。另一个坑是awk 的隐式类型转换。下面这段是经典function check(v) { return v 0 } # 传入 abc 时字符串转数字得 0返回 0 # 传入 10 时字符串看起来像数字按数值比较返回 1 # 传入 10 时带空格部分实现不认作数字串按字符串比较结果不确定awk 的规则是如果一个值是来自输入的字符串比如$1称为 strnum且看起来像数字就比较时按数值处理如果是纯字符串常量则按字符串比较。函数参数的传递会丢掉 strnum 标记这也是同一个比较在函数内和函数外结果不同的常见原因。解决方式是显式强制类型v 0 0强转数值v 强转字符串。3. 四个能直接抄走的函数实现3.1 字符串清洗三件套trim、判空、截断这三个是所有日志处理脚本的基础件写一次能用很久。trim 前面给过了这里补一个安全截断和一个字段补齐。# 截断到指定长度超出部分丢弃按字符算非字节 function clip(s, n, len) { len length(s) if (len n) return s return substr(s, 1, n) } # 截断并加省略标记注意标记本身也占长度 function ellipsis(s, n, mark, m) { m mark if (m ) m ... if (length(s) n) return s if (n length(m)) return substr(m, 1, n) return substr(s, 1, n - length(m)) m }length()和substr()都是 POSIX 定义的按字符计数在多字节环境下是否按字符取决于 locale这也是个坑后面第 5 节会说。这两个函数的价值在于防御性输入日志里偶尔会出现超长字段直接拼进报表会撑爆列宽或者让下游解析出错。clip里之所以要多声明一个len当局部变量是为了避免length(s)被调用两次——虽然现代 awk 实现不至于慢到哪去但把不变的量算一次是基本功。3.2 IP 转整数与网段判断把 32 位运算掰开算这是 awk 函数最出名的实战场景之一。日志里要判断某个请求是不是来自内网、是不是来自某个网段直接做字符串前缀匹配会出问题10.0.0.1和10.0.0.10前缀相同但不是一回事正确做法是转成整数再做掩码比较。# IP 点分十进制转 32 位无符号整数非法输入返回 -1 function ip2int(ip, a, n, i, r) { n split(ip, a, .) if (n ! 4) return -1 r 0 for (i 1; i 4; i) { if (a[i] !~ /^[0-9]$/) return -1 if (a[i] 0 255) return -1 r r * 256 a[i] } return r }核心那一行r r * 256 a[i]就是移位累加第一个字节乘 256 的三次方第二个乘 256 的二次方以此类推。写成循环是 256 的累乘等价于左移 8 位。之所以用乘法不用lshift()是因为那是 gawk 扩展mawk 和 busybox 上没有。值域上四个字节最大是 255.255.255.255也就是 4294967295落在双精度浮点能精确表示整数的范围2 的 53 次方内所以用浮点数做 32 位整数运算是安全的不会出现精度丢失。这一点可以让不少人放心。有了 ip2int网段判断就简单了# 判断 ip 是否在 net/prefix 网段内返回 1/0参数非法返回 -1 function in_cidr(ip, net, prefix, ipi, ni, shift) { ipi ip2int(ip) ni ip2int(net) if (ipi 0 || ni 0) return -1 if (prefix 0 || prefix 32) return -1 if (prefix 0) return 1 shift 32 - prefix # 用整除代替按位与两个数除以 2^shift 后取整相等说明前缀一致 return (int(ipi / (2 ^ shift)) int(ni / (2 ^ shift))) }这里用整除代替与运算是个实用技巧。2 ^ shift在 awk 里是幂运算^是乘方不是异或这点和 C 完全不同别搞混prefix 24时shift 82^8 256两个 IP 除以 256 取整后相等就意味着前 24 位一致。省掉了位运算函数跨实现可用。边界情况值得单独验一下prefix 0时2^32 4294967296任何小于它的 IP 整除后都是 0两边都是 0返回 1符合0.0.0.0/0 匹配所有的语义prefix 32时2^0 1整除等于自身只有完全相同才返回 1。逻辑自洽。3.3 时间窗口过滤绕开时间库的字符串比较法日志分析里另一个高频需求是只看最近一小时的请求。gawk 有mktime()和strftime()做这件事很轻松但 mawk 和 busybox 没有硬用会直接报function not defined。如果手上是 gawk标准写法是这样function to_epoch(s, a, b, t) { # 输入格式 2024-05-01T12:34:56 或 2024-05-01 12:34:56 gsub(/[T:]/, , s) split(s, a, ) # mktime 需要 YYYY MM DD HH MM SS return mktime(a[1] a[2] a[3] a[4] a[5] a[6]) }但更通用、更省事的办法是根本不做时间转换。只要把时间统一规范化成YYYY-MM-DD HH:MM:SS这种定长格式字符串的字典序就等于时间序直接拿字符串比较就能做窗口过滤。这个技巧能省掉整个时间库依赖还能避开时区换算的麻烦。# 把各种常见格式规范成 YYYY-MM-DD HH:MM:SS失败返回空串 function normtime(s, d, t) { if (s !~ /^[0-9][0-9][0-9][0-9][-\/][0-9][0-9][-\/][0-9][0-9]/) return gsub(/\//, -, s) gsub(/T/, , s) return substr(s, 1, 19) } # 用法过滤窗口内的记录 BEGIN { lo normtime(start_arg) hi normtime(end_arg) } { ts normtime($1) if (ts ) next if (lo ! ts lo) next if (hi ! ts hi) next count }normtime只做格式归一不做任何历法运算所以它天然跨实现可用。这个思路值得记住能靠格式约定解决的问题别引入计算。3.4 递归快排不依赖 gawk 的 asortgawk 有asort()可以直接给数组排序但 mawk 和 busybox 上没有。要在受限环境里做数值排序比如算 P95、取 TopN就得自己写。快排是个合适的候选代码短递归结构清晰正好能演示形参当局部变量在递归里的必要性。# 交换两个元素 function swap(arr, i, j, t) { t arr[i]; arr[i] arr[j]; arr[j] t } # 原地快排按数值升序区间为 [lo, hi] function qsort(arr, lo, hi, i, last, pv, v) { if (lo hi) return pv arr[hi] 0 # 基准值强制转数值 last lo for (i lo; i hi; i) { v arr[i] 0 if (v pv) { swap(arr, i, last) last } } swap(arr, last, hi) qsort(arr, lo, last - 1) qsort(arr, last 1, hi) }调用方式{ n split($0, nums, ,) qsort(nums, 1, n) print nums[1], nums[int((n 1) / 2)], nums[n] }这段代码里每个形参都有明确用途i、last、pv、v全部是局部变量递归时每一层有自己的副本互不干扰。如果图省事把i写成全局变量深递归下就会变成灾难——外层循环的i被内层递归改掉排序结果随机错乱而且数据量小时可能测不出来。arr[hi] 0和arr[i] 0里的 0也不能省。awk 数组里存的可能是字符串直接比较走的是strnum 规则10 9在纯字符串比较下是真因为1 9强制转数值之后才是10 9为假。想要字符串排序就去掉 0两种排序方式各有用途但一定要显式决定用哪个。递归深度上快排在基本有序的数据上会退化每层只减少一个元素深度接近 n。数据量上万条时建议先洗牌或者改用三数取中。实际日志场景里值往往是有序的时间戳、递增 ID这个风险不算低。4. 把函数组织成可维护的库文件4.1 用多个 -f 拆分函数定义全局可见函数写好之后下一步是别把所有东西塞进一个文件。awk 支持多个-f按顺序加载所有文件里的函数定义在解析阶段就全部收集完毕所以文件之间的函数可以互相调用加载顺序不影响函数可见性。awk -f lib/str.awk -f lib/net.awk -f lib/sort.awk -f main.awk input.log这段命令的行为要理解清楚解析阶段先把四个文件的语法结构扫一遍建立函数表执行阶段按顺序跑每个文件里的BEGIN块然后进入主循环最后按顺序跑END块。所以BEGIN的执行顺序是跟文件顺序相关的函数定义则无关。这个区别在写初始化逻辑时很重要。还有个小细节-f可以和-v混用变量在 BEGIN 之前就注入完成主程序可以直接用。但如果变量在BEGIN里被重新赋值注意先后。提示函数名在多个文件之间是全局的。两个库文件定义了同名函数后加载的会覆盖先加载的gawk 在--lint模式下可能给出警告其他实现往往悄无声息。这就是下一节命名规范的由来。4.2 命名前缀、注释块和函数清单库文件一多函数名冲突就是必然。这个问题的解法在 C 语言时代就已经成熟了加模块前缀。所有字符串相关的函数用str_开头网络相关用net_时间和日期用dt_排序和统计用agg_。这样即使两个作者各自写了 trim一个是str_trim一个是txt_trim也不会打架。每个函数上面配一个注释块写清楚四件事用途、参数含义、返回值、副作用。副作用那一条尤其重要因为 awk 数组传引用函数可能会修改调用方的数据不写清楚就是埋雷。# net_in_cidr(ip, net, prefix) # 用途判断 IPv4 地址是否属于指定网段 # 参数ip/net 为点分十进制字符串prefix 为 0-32 的整数 # 返回1 属于 / 0 不属于 / -1 参数非法 # 副作用无 function net_in_cidr(ip, net, prefix, ipi, ni, shift) { ... }再把整个库的函数列成一张表放在文件头部接手的人扫一眼就知道有什么可用的不用往下翻。这个习惯在单人项目里显得多余但只要脚本活过三个月、换过一次手就会知道值。函数名用途返回值str_trim去掉首尾空白新字符串str_clip按字符截断新字符串net_ip2intIP 转整数整数或 -1net_in_cidr网段归属判断1/0/-1dt_normtime时间格式归一定长字符串agg_qsort原地数值快排无原地修改4.3 用 --lint 和 --dump-variables 做自检代码写完不等于没问题尤其是全局变量污染这种不报错但结果错的问题。gawk 提供了两个非常好用的自检开关。--lint会提示可疑写法比如函数参数个数不匹配、变量在赋值前被使用、正则写法可疑等等。它不是万能的但能在写代码阶段挡掉一批低级错误gawk --lint -f lib/net.awk -f main.awk input.log--dump-variables更狠它会把程序运行结束时所有全局变量的值导出到文件默认awkvars.out让你一眼看出哪些变量被意外创建了。正常情况下全局变量应该只有你主动声明的那些如果列表里出现一堆i、tmp、k这种短名说明有函数漏了局部变量声明正在污染全局空间。gawk --dump-variablesvars.txt -f lib/str.awk -f main.awk input.log这个检查建议在脚本交付前跑一遍成本很低收益很高。还有一个调试用的小技巧gawk -o- -f script.awk会把脚本重新格式化输出能把一些因为排版导致的语法误读暴露出来比如function foo ()里那个多余的空格。虽然日常用得不多但排查诡异语法错误时可以试试。5. 踩坑实录函数相关的报错和排查路径5.1 全局变量被递归污染症状是结果偶尔错先说一个我印象比较深的案例。一个日志聚合脚本要用递归把嵌套的目录路径展开成层级结构逻辑本身没问题但输出里偶尔会有层的统计数字翻倍。查了很久才发现递归函数里有个cnt变量没写进形参列表——外层递归正在累加内层递归一进来就把它清零了返回后外层继续在一个被篡改的值上加。这类问题的特点是不报错、看日志也不容易发现、数据量小时可能不复现。排查路径是先定位可疑函数检查形参列表和函数体内所有赋值语句把所有临时用一下的变量都加进形参。经验是一个函数里如果出现三个以上未声明的赋值目标基本可以断定有问题。顺带说一句--dump-variables对这种问题是有效的递归跑完之后如果那些临时变量出现在了全局变量列表里就等于自首了。5.2 数组参数的两类报错和清空技巧第一类报错是attempt to use array x in a scalar context或者反过来的attempt to use scalar x as array。原因通常是同一个名字既被当数组用又被当标量用。典型场景是这样function collect(line, out, i, f) { split(line, f, ,) for (i 1; i length(f); i) out[f[i]] 1 } BEGIN { out # 错误把 out 变成了标量 collect(a,b,c, out) }out 这一句就把out定死成标量了后面当数组传进去必然报错。修法是把初始化语句去掉或者用split(, out)显式把它初始化成空数组。第二类问题是数组清空。gawk 支持delete arr删除整个数组但老版本 busybox 不认。跨实现的稳妥写法是split(, arr)——把一个空字符串按任意分隔符切分结果就是零个元素效果等同于清空而且在所有 POSIX awk 上都有效。还有一个不太常见但确实存在的坑函数内不要给数组参数整体重新赋值。arr x这种写法会试图把数组变成标量行为在不同实现下不一致。需要重置就用split(, arr)需要复制就手动循环。5.3 函数名撞车、括号前的空格、内置函数表下面这张表里的错误信息看到基本能秒定位报错或现象根因解法function xxx redefined多个库文件定义了同名函数加模块前缀重命名syntax error定位在 function 那一行函数名和左括号之间有空格去掉空格function length redefined用了内置函数名当自定义函数名换名加前缀attempt to use array in scalar context变量同时当数组和标量用统一用法用 split 清空函数调用后结果没变以为标量是引用传递改成数组参数回填内置函数名这条要单独提一下因为列表比想象中长。POSIX 定义的有length、substr、index、split、sub、gsub、match、sprintf、sin、cos、atan2、exp、log、sqrt、int、rand、srand、tolower、toupper、system、close、fflush。gawk 还额外占用gensub、strtonum、asort、asorti、mktime、strftime、systime、and、or、xor、compl、lshift、rshift、patsplit。这些名字一律不能用作自定义函数名第二个都别想着占。加前缀是最省心的规避方式。5.4 函数调用的性能代价什么时候该合并函数调用在 awk 里不是零成本尤其 mawk 在这一点上的表现和 gawk 略有差异。百万行级别的日志每行调用十几个小函数累积起来的开销是能测出来的。但这不代表就应该放弃函数化。我的实践判断标准是这样的瓶颈几乎永远在正则匹配和字段切分上函数调用一般不是主因。一行日志如果只有五六个字段按空格切分本身就比函数调用便宜不了多少。真正值得注意的是那种一个极短的小函数在每行都被调用一次的情况比如str_trim只做两次sub这时候可以考虑把它内联到主循环里只在 BEGIN 和 END 里保留函数版本。还有一个容易被忽视的点在函数里做getline或者system()会严重拖慢速度因为涉及进程创建。如果脚本里有这类操作优化方向应该是减少调用次数比如批量处理而不是减少函数数量。6. 完整串讲一个日志统计脚本的组装过程6.1 需求拆解和函数清单确认假设有这么个需求读一份访问日志格式大致是时间 IP 方法 路径 状态码 耗时空格分隔路径字段可能带引号且内部含空格。要求输出三样东西按 IP 聚合的请求数 Top10、状态码分布、以及耗时字段的 P95。拆开来是这些动作切分字段需要处理引号、解析时间需要格式归一、按 IP 累加、按状态码累加、收集耗时数组、排序取 TopN、算分位。对应的函数清单是csv_get带引号的字段提取、dt_normtime、agg_qsort、agg_pct加上一个纯粹的字段解析函数。6.2 带引号字段的提取函数日志里的路径字段经常是这样GET /api/v1/items?qa,b HTTP/1.1。直接按空格或逗号切分会把字段切碎所以要写一个能识别引号的状态机。# csv_get(line, want, delim) 取第 want 个字段delim 为单字符分隔符 function csv_get(line, want, delim, i, n, c, f, inq, cur, nx) { if (delim ) delim , n length(line); f 1; inq 0; cur for (i 1; i n; i) { c substr(line, i, 1) if (inq) { if (c \) { nx substr(line, i 1, 1) if (nx \) { cur cur \; i } else inq 0 } else { cur cur c } } else { if (c \) { inq 1 } else if (c delim) { if (f want) return cur f; cur } else { cur cur c } } } if (f want) return cur return }这个函数里有三处值得说。inq标记当前是否在引号内部遇到成对的转义时把游标多加一位并输出一个引号i那一步是手动跳过转义的第二字符因为外层for还会再i一次最后一段if (f want)处理的是最后一个字段后面没有分隔符的情况这个边界漏掉会让最后一列永远取不到。如果分隔符就是空格而且字段本身不含空格那还不如直接用$1、$2性能好得多。函数化不是无差别地把所有东西都包起来这一点要克制。6.3 拼装主脚本和参数选择把函数组装起来的主脚本大概长这样# 主规则 { ip $2 ts dt_normtime($1) code csv_get($0, 5, ) cost $NF 0 if (ts ) next if (lo ! ts lo) next ip_cnt[ip] code_cnt[code] cost_all[nc] cost } END { # 耗时排序与分位 agg_qsort(cost_all, 1, nc) p50 cost_all[int(nc * 0.50) 1] p95 cost_all[int(nc * 0.95) 1] # IP TopN for (k in ip_cnt) { top[nt] ip_cnt[k] } agg_qsort(top, 1, nt) # 后面再按名次反查 IP或者直接输出计数值序列 }这里有个实际问题agg_qsort排的是数值数组排完之后就丢了 IP 和计数的对应关系。想输出 IP 计数 的 TopN需要额外做一次反查先算出第 N 大的值再遍历ip_cnt把不小于这个值的 IP 打出来。这是 awk 排序的一个结构性限制——它没有对关联数组按值排序的能力asort也只返回值的数组。分位数的下标计算也值得验证一下。int(nc * 0.95) 1在nc 100时是 96取第 96 个元素作为 P95符合至少 95% 的数据小于等于它的定义习惯。不同口径有的用最近秩法有的用插值结果会差一点点报表里最好注明用了哪种口径免得下游对不上。6.4 交付前的自检清单脚本写完交付前我一般会跑这么几遍第一遍用--lint过语法把所有可疑告警清掉。第二遍用--dump-variables看全局变量清单确认没有奇怪的临时变量泄漏。第三遍造三份数据正常数据、含空字段的数据、含超长字段的数据确认不崩。第四遍用time跑一次全量看一眼耗时是否在可接受范围。第四遍里如果发现慢先看是不是正则写得太凶比如用了.*嵌套再看是不是每条记录都调了getline或system()。函数数量本身很少是瓶颈这一点前面说过。还有一条容易忘的换个 awk 实现跑一遍。如果脚本只在 gawk 上测过最好在 mawk 或者 busybox 环境下也跑一次看看有没有不知不觉用了扩展功能。awk --posix模式也能帮忙做这个检查它会把非 POSIX 的写法报警出来。我在实际项目里踩过最冤枉的一次坑是脚本在开发机上跑得好好的上了容器就报function not defined——原因是用了strftime()而那台容器的 awk 是 busybox 的精简版。后来所有涉及时间处理的逻辑都改成了字符串归一方案一次改完再没出过类似问题。这件事让我形成了一个习惯凡是能靠纯字符串操作和基本算术解决的问题就不引入任何扩展函数跨环境的稳定性比代码短几行重要得多。如果你打算把函数这条路继续走下去下一步值得研究的是 gawk 的间接函数调用和多维数组模拟。前者能用变量名动态调用函数相当于一个小型分发器写插件式的解析器很方便后者用一个拼接的键来模拟二维表配合函数能做出相当结构化的数据处理层。这两个特性用完awk 就不只是文本处理的锤子而是一个能承载小型数据处理流程的完整工具了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑