ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Shell脚本进阶:正则表达式与grep/sed/awk实战指南

Shell脚本进阶:正则表达式与grep/sed/awk实战指南 很多人学Shell学到变量、for循环、条件判断就停住了觉得自己写脚本已经够用。直到某天发现同事在终端里用一条管道命令把几百兆的日志拆得明明白白而自己还在用循环逐行处理、各种字符串截取拼凑才意识到正则表达式和文本处理器才是Shell脚本的真正分水岭。我这些年写了大量运维脚本和自动化工具最大的体会是Shell脚本的复杂度从来不在语法而在文本处理。而文本处理的根基就是正则加上grep、sed、awk这三把刀。今天这篇文章不打算给你背语法表我想用实际排障和批量处理的场景把正则、grep、sed、awk串起来讲透最后再分享几个我真实踩过的坑。无论你是刚入门Shell的脚本新人还是已经写了几年脚本但总感觉文本处理使不上劲的开发者这篇都应该能帮你把短板补上。1. 先把话说透正则在Shell里到底解决什么问题1.1 字符串处理的两条路线先想一个最普通的场景手头有一个access.log想统计哪些IP访问次数最多。大部分刚入门的人会怎么写先for循环读取每一行再用cut按空格切分取出IP然后自己维护一个关联数组去计数。写出来大概是这样#!/bin/bash declare -A count while read line; do ip$(echo $line | cut -d -f1) count[$ip]$(( count[$ip] 1 )) done access.log for ip in ${!count[]}; do echo $ip ${count[$ip]} done | sort -k2 -rn | head这段代码逻辑没错但你能明显闻到一股”吃力感”每次处理一行都要启动一次echo和cut子进程数据量大了就非常慢代码里全是过程式的切片、计数细节维护起来也费劲。而正则表达式解决的就是另一条路线的问题你不必告诉脚本“按空格切、取第一列”你只需要描述“我要什么格式的内容”。配合grep、awk这类本身内置正则引擎的工具一行管道就能完成同样的事awk {count[$1]} END {for (ip in count) print ip, count[ip]} access.log | sort -k2 -rn | head这就是正则和文本处理器带来的核心转变从“逐字符操作”变成“按模式匹配”。你要处理的是数据里的规律而不是数据里的每一个字节。1.2 正则表达式是一套“描述文本规律”的语言正则表达式Regular Expression常简写为regex、regexp本质上不是Shell的语法而是一套独立的模式描述语言。它用少量元字符组合出极度浓缩的匹配规则Shell通过grep、sed、awk、以及bash自带的~操作符来使用它。我习惯用一个类比帮新人理解如果说文本是微观世界里的字符串河流普通字符串匹配等于”拿着一个特定形状的渔网捞鱼”正则则是一套可以按“鱼的种类、大小、活动水域”动态组合的渔网规则。比如^[0-9]{1,3}\.能精准匹配以1到3位数字加句点开头的内容这一条规则就能替代几十行字符串判断。理解了这层你就明白为什么说正则文本处理器是Shell的“内功”。它不是某一个具体工具的功能而是一种通用的数据筛选思维方式——你描述规律工具替你执行。2. 正则的两套“方言”BRE与ERE这才是最容易踩的坑2.1 为什么同一个正则换个工具就失效正则有一件很坑的事Shell里的正则其实有两套语法标准分别是基础正则表达式BREBasic Regular Expression和扩展正则表达式EREExtended Regular Expression。我见过太多人抄网上的正则命令结果在grep里能跑换到sed里就报错或者相反。原因不是命令写错而是工具默认启用的正则“方言”不一样。比如grep默认使用BREgrep -E才切换为EREsed默认使用BREsed -r切换为EREawk默认直接使用EREgrep的具体行为在不同Unix版本微有差异但主流Linux发行版遵循这套约定。用一句话概括BRE和ERE的核心差异BRE里? | ( ) { }这些扩展元字符必须加反斜杠转义才具备特殊含义而ERE里它们直接就是元字符不需要转义。2.2 正则元字符的统一理解框架不管BRE还是ERE正则表达式的元字符可以归为几类我建议按类别记忆而不是零散地背类别元字符含义匹配示例字符匹配.匹配任意单个字符h.t匹配hat、hot字符集合[abc][a-z][^0-9]匹配集合中任一字符^开头表示取反[0-9]匹配一位数字量词*?{n,m}前一个字符出现的次数a{2,3}匹配aa或aaa锚点^$匹配行首、行尾^root匹配以root开头的行分组与引用( )\1分组匹配反向引用捕获内容(ab)\1匹配abab或运算匹配左边或右边这里有个关键点要提醒在BRE里?和如果直接写会被当成普通字符?和去匹配而不是量词。这非常反直觉。所以你在grep a?时匹配的其实是包含字符a?的行而不是“可能有a”想表达“可有可无的a”必须写成a\?。我早期在这个细节上翻车不止一次。2.3 字符集合里的陷阱转义与语系影响字符集合[ ]内部是个特殊区域。这里有两个高频坑第一个坑是转义规则不同。在[ ]内.、*、?等大部分元字符会失去特殊含义变成普通字符。所以grep [.*]匹配的是包含句点或星号的行而不是任意字符。但^放在开头表示取反放在其他位置才是普通字符]本身需要转义或放在第一个位置。第二个坑是Locale语系影响字符范围。[a-z]在英文环境里就是a到z但在某些设置了中文或其他语系的环境里可能匹配范围会超预期把一些带重音字符也算进去。我在生产服务器上遇到过脚本在开发机正常、在客户服务器上匹配结果完全不对最后发现就是LANG环境变量不同导致的。生产环境脚本务必显式加上export LC_ALLC让字符范围有确定性的行为。2.4 分组与反向引用正则里的“记忆功能”分组()是正则威力最大的特性之一因为它能把匹配到的内容“记住”后续用\1、\2引用。比如提取配置文件里的键值对echo timeout 30 | grep -E ^([a-z]) * *([0-9])$配合-o加\1就能只输出捕获到的部分。这个特性在sed的替换命令里尤其好用后面讲sed时再展开。3. grep第一剑从“搜关键字”到“搜模式”3.1 grep的正确打开方式过滤链路的第一环grep是Linux里使用频率最高的文本搜索工具但很多人只用了它10%的能力。基础用法是grep keyword file但在脚本里grep的定位应当是整条数据流水线的第一道过滤器——先把无关行去掉再交给sed或awk做精细处理。常用的实用参数我按优先级列一下-E使用ERE扩展正则写复杂模式时几乎必加-v反向匹配输出不匹配的行本质是“排除法”-o只输出匹配到的部分而不是整行适合提取内容-c输出匹配行数而不是内容适合统计-A/-B/-C输出匹配行之后的、之前的、前后各N行上下文-i忽略大小写-l/-L列出包含或不包含匹配内容的文件名适合批量扫描-r递归搜索目录。3.2 实战从一堆日志里精确找“故障上下文”我拿之前处理过的一个线上问题举例。某天服务报错日志里疯狂刷ERROR: upstream response timeout但同样关键字也会出现在正常轮询日志里。直接grep ERROR error.log会得到几千行里面大部分是干扰。这时候需要把“粗过滤”升级成“模式匹配”grep -E ERROR: upstream response timeout error.log | grep -v health check第一层先锁定关键报错第二层用-v把健康检查相关的正常日志排除掉。接着想看某次超时前后发生了什么把上下文行也拉出来grep -E -C 3 ERROR: upstream response timeout error.log | grep -A 5 -B 1 10\.0\.1\.25这里-C 3先圈出超时报错前后三行再次grep按IP精确找是哪台后端导致的。你看grep的用法一旦从“搜一个词”变成“按模式组装过滤条件”整个问题定位效率完全不一样。3.3 用-o提取字段而不是整行grep默认输出整行但很多时候我们只要匹配到的片段。比如从配置里提取所有IPgrep -oE [0-9]{1,3}(\.[0-9]{1,3}){3} config.conf | sort -u这条命令从配置里提取所有类似IP地址的字符串再用sort -u去重。-oE的组合在写脚本时特别常用它等于把grep变成了一个“模式提取器”。3.4 脚本里用grep要注意的返回码脚本里常需要判断“是否匹配到”比如if echo $line | grep -q ERROR; then echo alarm fi引入-q非常关键quiet模式不输出内容只要返回码。grep的返回码0表示找到匹配1表示没找到2表示文件错误。很多脚本出问题就是把grep输出重定向到/dev/null或再捕获却忽略-q让管道把内容又打了一遍性能白白浪费。小经验在循环里逐行grep是很浪费的能一次grep完再处理绝不在循环里重复启动grep。数据量小无所谓几十万行日志时性能差距是数量级的。4. sed第二剑不打开编辑器也能安全改文件4.1 sed的定位面向“行”的流式文本编辑器sedStream Editor和grep最大的不同是grep负责“选”sed负责“改”。它按行读取、按规则处理、逐行输出整个过程像流水线一样。正因为它是“流式”的处理大文件时不会把全部内容加载进内存非常适合脚本里批量改文件。sed的基本调用格式是sed [选项] 寻址命令 文件。寻址决定了“对哪些行操作”命令决定了“做什么操作”。这两个维度一组合sed的表达能力就出来了。4.2 寻址方式行号、正则、区间sed支持三种常见寻址方式行号寻址3代表第3行$代表最后一行3,8代表第3到8行正则寻址/pattern/代表匹配该正则的行还可以用/pattern1/,/pattern2/表示从匹配pattern1的行到匹配pattern2的行之间的区间混合寻址3,/ERROR/p从第3行匹配到第一个出现ERROR的行。寻址最实用的是区间模式。比如要删除从“BEGIN_MARK”到“END_MARK”之间的所有配置段sed -i /^# BEGIN_MARK/,/^# END_MARK/d config.conf这条命令一条搞定手写循环要好几行还容易出错。4.3 替换命令ssed最核心的招式sed里90%的使用场景都围绕着s/查找/替换/标志这条命令。它的完整语义是在寻址到的行里把匹配“查找正则”的内容替换为“替换文本”存在几个关键细节s命令默认只替换每行第一个匹配加上g标志才替换该行所有匹配查找部分支持正则替换部分支持代表整个匹配内容和\1代表第一个分组捕获加上i标志忽略大小写加上n标志只输出替换成功的那一行常配合-n使用。实际案例批量把配置里的旧域名换成新域名同时保留其余不变sed -i s|https://old\.example\.com|https://new.example.com|g /etc/nginx/sites-enabled/*注意这里替换分隔符没用常见的/我用了|因为URL本身包含斜杠再用/当分隔符就得疯狂转义可读性极差。sed的分隔符可以换成任意字符这是脚本整洁度的重要细节。另外记得在域名里把句点转义为\.否则正则里.会匹配任意字符可能出现误替换。4.4 用-i原地修改前先想好备份-i选项让sed直接修改原文件非常方便但也非常危险。一旦正则写错尤其配合g全局替换可能把整份配置改废还无法恢复。我的习惯是任何对生产配置的批量修改先加备份后缀sed -i.bak s/old/new/g app.conf这样会先复制一份app.conf.bak再执行修改。等确认结果没问题再删除备份文件。这个习惯帮我挽回过好几次手滑失误。4.5 进阶用sed做“有条件的跨行处理”sed还有两个模式空间相关的命令N和P能做跨行合并操作。比如把每行结尾的续行符处理后合并或者把多行日志合并成一条记录。不过说实话跨行处理用awk更直观sed更适合单行内的定位与修改。工具要选对场景不要因为有个锤子就把一切当钉子。我通常的原则是按行筛选用grep按行修改用sed按列计算用awk。5. awk第三剑按列拆数据按条件算统计5.1 awk的本质一门内置在文本处理里的小语言awk是三剑客里最“重”的一个因为它不只是过滤器还内置了变量、数组、循环、分支这些编程元素。很多人把awk当成“按列打印工具”其实那只是它的入门功能。awk更适合干的是“分析”按列切分、条件判断、聚合统计、生成报表。awk的基本结构是模式 { 动作 }当某行满足“模式”时执行花括号里的“动作”。模式可以省略省略就表示所有行都执行动作是print、循环、数组操作等。5.2 内置变量$0、$1、NF、NR、FSawk按空白或指定的分隔符把每行切成多个字段几个内置变量必须烂熟变量含义$0整行内容$1、$2…第1、2个字段NF当前行字段总数$NF表示最后一个字段NR已读过的总行数FS输入字段分隔符也可用-F指定OFS输出字段分隔符默认是空格RS输入行分隔符默认是换行符最常见的坑就是搞不清FS是“列分隔符”而RS是“行分隔符”。默认情况下awk把连续空白空格和Tab当作列分隔符但如果你用-F:指定冒号分隔连续冒号之间会被认为是空字段不会自动合并。处理一个典型场景从/etc/passwd里找出所有普通用户UID大于等于1000的用户名和家目录awk -F: $3 1000 {print $1, $6} /etc/passwd-F:告诉awk用冒号切分$3是UID$6是家目录。这比grep加cut再加循环的连招利落太多。5.3 模式与动作的组合逻辑awk的“模式”部分不只是正则/pattern/还可以是表达式、范围、甚至逻辑组合/error/匹配包含error的行$1 admin第一列等于admin的行$NF 5最后一个字段大于5的行NR 10 NR 20第10到20行/error/ || $3 100匹配error或第3列大于100。动作部分可以是一条或多条语句BEGIN和END块边角也很重要BEGIN在所有行处理前执行一次初始化变量、打印表头END在所有行处理完后执行一次输出统计结果。5.4 实战分析访问日志里的TOP IP与状态码分布这是awk最典型的应用统计日志、生成报告。假设access.log格式为IP - - [时间] 请求 状态码 字节数统计每个IP访问次数并输出TOP10awk {count[$1]} END {for (ip in count) print ip, count[ip]} access.log | sort -k2 -rn | head -10拆解一下count[$1]用IP做数组下标每次出现就加1END块在全部行读完后执行遍历数组打印。数组下标用了字符串IP地址这在awk里天然支持不需要像bash里那样声明关联数组。管道末尾接sort -k2 -rn按第二列次数数字降序排序再取前10。再看状态码分布同时统计成功和失败请求的数量awk {code[$9]} END {for (c in code) printf %s %d\n, c, code[c]} access.log | sort -rn这里$9是状态码所在列按状态码聚合。printf支持C语言风格的格式化比print更适合对齐输出。如果你还想同时知道失败请求的占比END块里加一行计算就行awk {total; code[$9]} END {for (c in code) printf %s %d (%.2f%%)\n, c, code[c], code[c]*100/total} access.log这就体现出awk和“循环一步步切片”的本质区别了awk是声明式聚合你只需要声明“按什么维度、累加什么数据”剩下的交给它内部处理。5.5 在awk里用正则兼用匹配与提取awk本身支持ERE既可以在模式里用正则也可以在内置函数里动态匹配。比如找出包含多个error关键字中任意一个的行awk /ERROR|timeout|refused/ {print NR, $0} app.log还可以通过match()、sub()、gsub()这些函数进一步处理字段内容。比如把某一列里的引号全部去掉awk -F| {gsub(//, , $3); print $1, $3} data.csvgsub是“全局替换”函数意思和sed的s///g一致但它能在一行里连续多次处理指定字段用着比sed更顺手。遇到字段内含分隔符的CSV时用awk也能做基础清洗不过再复杂的结构化解析还是建议交给专门的解析库。6. 三剑客联手日志分析流水线的一次完整实践6.1 一条命令从原始日志到可读报表单独讲完三个工具必须演示它们的协作。以我处理过一次的Nginx错误日志分析为例目标是从几千行混合日志里找出“哪些接口在什么时间段发生5xx错误最多”并生成一份可读的摘要。假设日志格式类似2025/06/12 10:23:45 [error] 12345#12345: *678 connect() failed (111: Connection refused) while connecting to upstream, client: 192.168.1.50, server: api.example.com, request: GET /v1/users HTTP/1.1, upstream: http://10.0.0.15:8080, host: api.example.com第一步用grep做第一层粗过滤先圈出所有5xx上游错误相关的行grep -E \[error\].*upstream.*5xx|connect\(\) failed|Connection refused error.log err_filtered.log因为错误日志里可能同时存在其他error先用宽模式把候选行捞出来。第二步用sed清洗格式。原始日志一行太长字段混杂我希望能把request:后面的接口路径提取出来并且去掉请求行前后没用的部分。先用sed把请求行单独拆出来sed -n s/.*request: \(GET\|POST\|PUT\|DELETE\) \([^]*\).*/\2/p err_filtered.log requests.log这条sed解释一下-n关闭默认输出只有替换成功才打印p标志.*贪婪匹配到request: 前\(GET\|POST\|PUT\|DELETE\)匹配HTTP方法\([^]*\)捕获请求路径到下一个双引号为止\2输出第二个分组也就是路径本身。第三步用awk按路径聚合统计每个路径的错误次数并且列出最多报错的前10个awk {count[$0]} END {for (path in count) print count[path], path} requests.log | sort -rn | head -10$0在这里是整行上一步输出的路径字符串。虽然日志里路径可能带不同查询参数但通过grep和sed的预处理已经过滤干净了所以这里直接按路径聚合并输出次数。这三步连成一条管道写成一段脚本后就是一条完整的分析流水线#!/bin/bash # usage: ./analysis.sh error.log LOG$1 grep -E \[error\].*upstream.*5xx|connect\(\) failed|Connection refused $LOG \ | sed -n s/.*request: \(GET\|POST\|PUT\|DELETE\) \([^]*\).*/\2/p \ | sort | uniq -c | sort -rn | head -10这里我简化了一下前半段用grep | sed把原始日志变成“纯路径列表”后半段直接交给uniq -c统计频次再排序。能看到吗管道设计好后工具各司其职代码量往往比想象中少得多。grep负责粗粒度筛选sed负责提取字段uniq和sort负责统计排序awk在这个场景就无需登场了。6.2 为什么我推荐“grep优先、sed次之、awk兜底”的顺序这三剑客的协作顺序有讲究能用grep先过滤掉的就不留给后面处理因为grep是三者里最轻的实在要按行做字段提取和替换时用sed等到需要聚合、计算、按列统计了再上awk。这个顺序能让正则的逻辑最清晰、性能也最好。举个例子如果你直接在awk里用/ERROR/过滤再提取awk处理的行数还是全量日志行数性能不会差多少但如果你先用grep把日志量缩小到十分之一管道后半段的处理时间会显著缩短。所谓“过滤链路”的本质就是尽早剔除无关数据。6.3 在脚本里把工具串起来for、date、重命名的综合场景除了日志分析批量文件处理也是文本处理器的重头戏。结合热搜里的几个常见场景比如“shell脚本获取当前日期时间并转换为数字串”和“linux用shell重命名文件”我写一个实用脚本把当前目录下所有*.log文件压缩并按日期重命名归档。#!/bin/bash # 归档今天的日志access.log - access_20250612.log.gz TODAY$(date %Y%m%d) for f in *.log; do [ -e $f ] || continue base${f%.log} gzip -c $f ${base}_${TODAY}.log.gz echo archived $f - ${base}_${TODAY}.log.gz done这里用到几个关键点${f%.log}是bash的字符串截断语法去掉后缀date %Y%m%d生成紧凑日期串配合正则思维理解%Y是四位年份等等都是“模式”的另一种体现。如果你还想只统计文件大小超过一定阈值的日志用find加-size过滤后接while read循环就行。文本处理和文件系统的操作经常要搭档熟悉管道就能把两套能力拼起来。7. 我在脚本里反复踩过的正则与文本处理器的坑7.1 贪婪匹配导致取到超范围内容这是正则新手最常遇到的问题。拿前面的日志提取场景举例如果我用sed -n s/request: \(.*\).*/\1/p err_filtered.log.*是贪婪的会尽可能多地匹配遇到第一个不会停下而是匹配到最后一个之前的所有内容。结果就是取出来的请求串包含了后面upstream里的内容整个提取结果一团乱。解决方法是用否定字符集限定匹配范围把.*换成[^]*后者明确表示“任意字符除了双引号”这样匹配到双引号就会自然停止。这个技巧在处理带分隔符的日志时几乎是保命的。7.2 sed -i 在符号链接上的坑sed -i有一个隐蔽行为当目标文件是符号链接时sed不会修改链接指向的原文件而是先解析链接、替换原文件这会破坏链接关系。我踩过一回把/etc/nginx/nginx.conf的符号链接原地改完重启nginx直接报错发现链接变成了普通文件幸好有备份才恢复。如果你生产环境里要批量改符号链接指向的配置最好先readlink -f解析出真实路径再执行sed或者用--follow-symlinks参数GNU sed支持。更保险的做法是改完再用ls -l确认链接状态没被破坏。7.3 grep与awk正则优先级引发“怪运行”举例grep -E a|bc运行时毫无问题因为ERE里|是元字符。但如果你忘了-Egrep a|bc实际匹配的是包含普通字符a|bc的行结果一条都匹配不到。另一类坑是ERE里(ab|cd)这种组合括号的优先级、量词的绑定范围一旦写错匹配对象大相径庭。我建议写完任何稍复杂的正则先用小样本文件做一次dry-run测试再放到脚本和真实数据上跑。比如echo 测试字符串 | grep -E 你的正则一分钟能验证的问题放到生产环境要用一小时排查。7.4 大小写与语系导致匹配范围膨胀前面提到过LC_ALL的影响我再展开一下。某个客户环境把LANG设成了中文UTF-8脚本里grep [a-z]在某次升级后意外匹配到了全角字符。排查过程非常痛苦因为本地环境永远复现不了。最后定位到问题后我在所有脚本头部统一加了export LC_ALLC从此字符范围固定、排序行为一致。文本处理类的脚本一定要显式设置locale不要依赖环境的默认值。7.5 大量行处理时别忽略性能差异最后提醒一下脚本性能。写循环逐行调用外部命令是最常见的性能杀手# 低效每行启动一次grep进程 while read line; do echo $line | grep -q ERROR ... done big.log # 高效一次grep扫全文件 grep ERROR big.log | ...正确处理姿势是尽量让grep、sed、awk在管道一次完成任务而不是在循环里反复调用。正则表达式本身也有性能差异复杂的回溯型正则比如(a)这种嵌套量词在极端输入下可能性能退化实际脚本里尽量不要写“量词套量词”的模式用分组边界条件替代。写在最后的一些操作习惯从前端到后端、从日常命令到自动化脚本正则和三个文本处理器是绕不开的基本功。写脚本时我一直坚持几件事先分析数据格式再设计过滤链路最后才动手写工具调用每个正则先做最小样本测试所有批量修改尽量留备份。这套习惯让我在日志分析、批量配置修改、数据清洗这些高频场景里少走特别多弯路。如果你刚开始学别急着背所有参数。先把grep -E、sed -i、awk -F这几个核心组合用熟再逐步往-o、反向引用、awk数组这些进阶方向扩展。工具是练出来的正则的语感更是踩坑踩出来的慢慢就能做到看一眼数据格式心里已经有处理链路了。
返回列表