尧图精选

Shell正则表达式实战指南:grep、sed、awk与通配符辨析

🕒 发布时间:2026/11/4 10:00:34 📁 来源:尧图网络
1. 先把最容易被带偏的认知掰回来Shell正则不是编程语言里的正则如果你是从Python、JavaScript或者Java转过来学Shell的我猜你大概率在grep、sed、awk这三兄弟上栽过跟头。我自己刚入行那年写过一条自认为很完美的正则去匹配IP地址在Python里跑得好好的贴到grep -E里直接报错当时整个人是懵的。问题出在哪出在很多人默认“正则表达式就一套规则换了个工具照样用”。这个认知偏差是Shell正则学习路上最大的一道坎。先说结论Shell里常说的正则表达式跟编程语言里的正则底层逻辑是同一套但语法流派、转义规则、工具适配完全不是一回事。换句话说你会写正则不代表你会用Shell正则但你会用Shell正则回头再写别的语言的正则那基本是一通百通因为Shell的正则处在整个正则体系里最“原生”的位置理解了它你才能理解正则表达式的底层逻辑。Shell正则通常指的是POSIX规范下的正则分为两派基础正则表达式BREBasic Regular Expression和扩展正则表达式EREExtended Regular Expression。BRE是传统老派grep、sed默认走的就是BREERE是现代派grep -E、sed -r、awk走的是ERE。两者最大的差异就在几个元字符上、?、|、()这些在BRE里默认是普通字符必须加了反斜杠转义才具备特殊含义而在ERE里这些本身就是特殊字符不需要转义。很多人写grep正则的时候不加-E然后写ab去匹配一个或多个a后跟b结果发现什么都匹配不到。为什么因为BRE里只是字面意义上的加号。这就是认知错位导致的经典翻车场景。还有一个容易忽略的点Shell正则匹配的是文本流不是字符串对象。你在Python里写re.match(r^\d$, s)匹配的是一个完整字符串但在Shell里grep一行一行地读文件每一行末尾还带着换行符^和$锚定的对象是“行”不是“整个文本内容”。这个差异直接决定了你写正则时的思路——你是在处理一行的文本不是处理一段内存里的字符串。搞清楚这个底层的认知差异之后我们再往下拆逐个工具把Shell正则真正用顺。注意后文所有示例都在Linux环境下、使用GNU版本的grep/sed/awk验证过。macOS自带的BSD版本工具在某些参数和行为上略有差异如果是在mac上练习可以考虑先装GNU coreutils或者直接用一个Linux虚拟机/容器来练不然容易遇到“我按教程写了怎么结果不对”的困惑。2. grep是入门最快、也最容易出成就感的切入点如果说Shell正则是一栋房子grep就是大门。进了这道门你才谈得上在sed、awk里折腾。2.1 从最简单的匹配开始理解“筛选”这件事grep的核心职责就一个字筛。从一堆文本里挑出符合条件的行。它最朴素的用法根本不涉及正则——直接给一个普通字符串grep error /var/log/nginx/error.log这个命令会把nginx错误日志里包含error字符串的所有行打印出来。但请注意这种字符串匹配本质上已经是正则的退化形态这里的error就是一个没有任何元字符的正则表达式。从这一行开始你已经在使用正则了。想真正发挥grep的威力必须把正则元字符用起来。我建议初学者按这个顺序去练用.匹配任意单个字符用*匹配前一个字符的任意次重复包括0次用^和$锚定行首和行尾用[]定义字符集用[^]做排除匹配这五个知识点的组合能覆盖日常60%以上的grep使用场景。我举个实际例子——查看日志里所有以10.0.开头的内网IPgrep ^10\.0\. /var/log/app/access.log注意这里的点都加了反斜杠转义因为不加的话.会匹配任意字符那10x0x这种行也会被匹配出来这显然不是我们想要的。这是一个非常典型的新手错误也是我面试Shell岗位候选人时必考察的一个点。2.2 用-E切到ERE告别大量反斜杠的折磨默认的BRE模式下、?、|、()都需要转义才能用写多了满屏反斜杠肉眼根本分不清哪些是转义、哪些是路径。我的建议是做日志分析和文本筛选的时候直接养成加-E的习惯用ERE模式写正则代码可读性提升一个档次。看个对比匹配以192.168开头、后面跟1到3个十进制数字的行# BRE写法注意加号和花括号都要转义 grep ^192\.168\.[0-9]\{1,3\} /var/log/network.log # ERE写法清爽多了 grep -E ^192\.168\.[0-9]{1,3} /var/log/network.log两种写法效果完全一样但ERE明显更符合绝大多数人脑子里对正则的认知。除了-Egrep还有几个高频选项我平时基本是组合着用选项作用-E使用扩展正则表达式-i忽略大小写-v反向匹配输出不匹配的行-o只输出匹配到的部分而不是整行-c统计匹配的行数-n显示行号-r递归搜索目录下的所有文件--colorauto匹配结果高亮显示-o这个选项极其好用但它也是很多人没意识到的强大能力。平时我们看grep的默认输出是整行打印、匹配部分高亮但如果你做的是数据提取而不是行筛选加-o就能把匹配到的片段单独摘出来# 从配置文件中提取所有端口号 grep -oE port[[:space:]][0-9] /etc/nginx/nginx.conf # 从日志里提取所有状态码 grep -oE \ [0-9]{3} /var/log/nginx/access.log | awk {print $2} | sort | uniq -c | sort -rn2.3 字符组和预定义字符类一个细节省一半事正则里[]字符组很好理解但很多Shell新手不知道POSIX规范里还有一套预定义的字符类长得跟其它语言不太一样比如[[:digit:]]、[[:alpha:]]、[[:space:]]、[[:alnum:]]。当你用grep -E [0-9]能匹配数字的时候为什么还要用[[:digit:]]区别在于字符集和locale问题。在纯英文环境下两者等价但在某些locale环境下[0-9]匹配的范围可能超出预期或者反过来匹配不上某些字符。尤其是处理中文、多语言混排的日志时老老实实用[[:digit:]]这种POSIX字符类能规避一些奇奇怪怪的坑。另外在Shell正则里用-i忽略大小写时可以配合字符组做更精细的控制。比如你只想忽略前三个字符的大小写、后面严格匹配那可以这么写grep -E [Ll][Oo][Gg][0-9] /var/log/custom/app.log这一节最后提醒一件事grep匹配中文时字符组直接写中文没问题比如grep -E 登录[0-9]次 app.log但注意你的文件编码必须是UTF-8且终端locale设置正确否则可能出现匹配不到、甚至乱码的情况。这是我实际处理中文日志时踩过的坑有时候明明是同一套正则换台服务器结果就不一样了查到最后发现是LANG环境变量的问题。3. sed与awk里的正则转义和字符组的那些坑一次讲透grep解决的是“筛选行”而sed和awk解决的是“修改内容和按列处理”。正则在这两个工具里的用法又跟grep略有不同这里面的坑比较多我单独拉一章出来细说。3.1 sed从正则匹配到替换看明白反斜杠的来龙去脉sed最常用的功能是替换核心语法就一行sed -E s/正则/替换内容/标志 文件名注意这个-E跟grep的-E一样切到ERE模式。但很多人学sed是从老教程学的写的是sed -r在GNU sed上-r和-E是等价的但在macOS的BSD sed上只认-E。所以统一推荐写-E兼容性更好。不切-E的话你在BRE模式下写替换括号、加号、花括号这些全部需要转义。比如要把形如id12345的字段替换成idSKIPBRE写法是sed s/id[0-9]\{5,\}/idSKIP/ data.txtERE写法是sed -E s/id[0-9]{5,}/idSKIP/ data.txt我个人极其推荐后者。在sed里写正则并用反斜杠做分组捕获是另一个高频场景替换时用\1、\2引用捕获组# 把 2025-01-15 重排成 01/15/2025 echo 2025-01-15 | sed -E s/([0-9]{4})-([0-9]{2})-([0-9]{2})/\2\/\3\/\1/这里有个特别容易出错的地方在替换部分里\1这种引用是全局生效的但字面意义的/符号必须转义。上面的例子里替换部分有两个/我都写成\/了。如果你偷懒不转义sed会把替换参数截断然后报错或者产生诡异的结果。初学者很容易在这个地方卡很久因为报错信息并不直观。sed里还有个非常实用但容易被忽略的标志位g。不加gsed默认只替换每一行里第一个匹配到的内容加了g才替换全部。这个问题我见过太多人在生产环境里翻车——写了个sed s/foo/bar/想批量替换结果每行只替换了一处数据半新半旧非常难受。3.2 awk正则匹配之外的几个冷门细节awk天然使用ERE不需要开关选项。它跟grep最大的不同是awk可以把一行拆成多个字段按字段做正则匹配和提取。这是Shell文本处理里最核心的组合技。最基本的用法是awk /正则/ {动作}斜杠之间是正则匹配成功的行才执行后面花括号里的动作。再配合$1、$2这样的字段引用就能做结构化提取# 从access日志里提取响应时间大于1000ms的请求的URL awk $NF 1000 {print $7} /var/log/nginx/access.log这里$NF表示最后一个字段NF是内置变量代表字段总数。这种写法不是正则但是处理文本时经常跟正则混用建议一起掌握。awk里正则的坑主要在几个地方第一awk表达式里的正则需要使用~操作符。比如$2 ~ /^192\.168\./表示第二个字段匹配这个正则。很多人从grep转过来不习惯在awk里还需要指定“作用于哪个字段”直接写/正则/结果是对整行做匹配而不是对特定字段。对整行匹配本身没错但一旦养成习惯后面做按列筛选时就容易犯迷糊。第二awk的正则里如果想引用变量不能直接写在//里面。awk $2 ~ /$pattern/这种写法经常出问题。正确做法是用~配合字符串拼接pattern^192\.168\. awk -v p$pattern $2 ~ p {print} data.txt用-v把外部变量传进去然后在$2 ~ p里直接用变量名不需要斜杠。这个细节对写动态正则脚本的人来说极其有用很多网上教程根本没讲清楚。第三awk里用match()函数取子串时RSTART和RLENGTH这两个内置变量是理解正则提取的关键# 提取一段文本里的第一个IP地址不管它出现在第几个字段 echo client from 10.20.30.40 at 2025-01-01 | awk { if (match($0, /[0-9]\.[0-9]\.[0-9]\.[0-9]/)) { print substr($0, RSTART, RLENGTH) } }RSTART是匹配开始的位置RLENGTH是匹配的长度substr($0, RSTART, RLENGTH)就能把匹配到的IP精确切出来。这种方式不受字段分隔符的影响处理不规则文本时是真正的救命招式。3.3 字符组在sed/awk里的实际行为差异同样的正则在grep、sed、awk里遇到字符组时还有一些微妙差异。比如[a-z]在某些locale下不仅匹配小写字母还可能匹配大写字母这取决于你的locale设置。为了避免这类不确定性我建议在脚本里显式设置LC_ALLCexport LC_ALLC在这个环境下[a-z]就是小写字母a到z[A-Z]就是大写字母A到Z行为可预测排错也快。我写的所有Shell脚本只要涉及文本处理和正则开头必设LC_ALLC这是一个成本极低但收益极高的习惯。另外在awk里[]字符组内的反斜杠转义规则跟grep不完全一样特别是想要匹配字面意义上的]或^时需要把特殊字符放在字符组里的特定位置。比如匹配一个右方括号可以写成[]]原理是把]放在字符组的第一个位置它就不再是“结束符号”了。这类边角知识平时用不到但真遇到时能帮你少走至少半小时弯。4. Shell脚本编程里正则和通配符的边界感必须分清楚这一章我要讲一个特别容易被忽略、但实际工作中坑死人的知识点Shell的命令行通配符globbing跟正则表达式完全是两码事。这两个概念一旦混淆轻则脚本跑不出预期结果重则把生产文件删错。4.1 通配符不是正则*在不同场景下的含义在Shell里当你写rm *.log的时候这个*不是正则里的“任意次数重复”而是“匹配任意字符串”的通配符。它的展开时机也完全不同——通配符是在命令执行前由Shell解释的正则是在命令运行后由工具解释的。看个例子区分这两者# 这是通配符Shell会先把 *.log 展开成 a.log b.log c.log再传给 rm rm *.log # 这是正则grep 拿到的是字面意义的 *.log echo abc.log | grep *.log第二条命令你猜结果是什么匹配不到。因为*在正则里表示“前一个字符重复任意次”*.log表示“前面是任意个*字符后面跟.log”。正确写法是grep .*\.log。很多从Python转过来的朋友会在Shell脚本里下意识地写*.log去匹配文件名结果发现没有任何输出就是没搞懂这两者的分工。4.2 什么时候该用哪个一个决策框架判断标准很简单你的对象是文件名还是文件内容操作文件名ls、rm、cp、find的-name参数用Shell通配符。操作文件内容grep、sed、awk、以及find的-regex参数用正则。find的-name用的是通配符find的-regex用的是正则很多人在这一步混乱过。举一个实际场景。我要在项目目录里找出所有以test_开头、以.sh结尾的脚本# 用通配符找文件名 find . -name test_*.sh -type f # 用正则找路径 find . -regex .*/test_[^/]*\.sh -type f注意-name后面跟的test_*.sh是通配符模式*不需要转义而-regex后面跟的.*/test_[^/]*\.sh是正则表达式点要转义成\./要作为普通字符处理。一个不小心把这两个混了结果就完全对不上。4.3 Shell的[[ ~ ]]内置正则脚本里最实用的正则操作除了外部工具Bash自身也提供了正则能力这个我必须单独讲因为它是写自动化脚本时的利器——就是[[ 字符串 ~ 正则 ]]这个条件判断结构。它的用法如下if [[ $filename ~ ^test_.*\.sh$ ]]; then echo 这是一个测试脚本 fi这里~右边的正则不需要加引号加了引号反而会被当成普通字符串处理。这个细节极其重要无数人在这上面翻车[[ abc ~ ^a ]]的结果是False因为带引号的正则被当成了字面字符串^a去匹配。~正则匹配的结果可以通过BASH_REMATCH这个数组拿到。下标0是完整匹配下标1开始是各个捕获组的内容。这在解析命令行参数、校验输入格式时非常实用inputPORT8080 if [[ $input ~ ^([A-Z])([0-9])$ ]]; then key${BASH_REMATCH[1]} value${BASH_REMATCH[2]} echo 变量名: $key, 值: $value fi另一个常见的实战场景是用~校验IP地址格式。虽然用grep也能做但在脚本内部直接判断、不需要额外起进程性能更好、写法更紧凑ip192.168.1.1 if [[ $ip ~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then echo IP格式看起来没问题 fi当然这种写法只能校验格式不能校验每一段是否在0到255之间。要严格校验还得把四段都取出来逐一比较。但作为第一道防线这个正则判断已经能挡掉90%的错误输入。4.4 循环里用正则提取数据脚本自动化的一个真实案例结合前面说的内容我现在演示一个稍复杂的脚本场景。假设我要写一个自动化部署脚本需要从server_list.txt文件里读取服务器信息格式是“IP地址 空格 主机名”然后对每台机器执行ping测试#!/bin/bash server_fileserver_list.txt # 校验文件存在 if [[ ! -f $server_file ]]; then echo 错误: 文件 $server_file 不存在 exit 1 fi # 逐行读取并处理 while read -r line; do # 跳过空行和注释行 [[ $line ~ ^#.*$ ]] continue [[ -z $line ]] continue # 用正则提取IP和主机名 if [[ $line ~ ^([0-9]\.[0-9]\.[0-9]\.[0-9])[[:space:]]([a-zA-Z0-9_-])$ ]]; then ip${BASH_REMATCH[1]} hostname${BASH_REMATCH[2]} echo 正在测试 $hostname ($ip) ... ping -c 1 -W 1 $ip /dev/null 21 echo 结果: 在线 || echo 结果: 不通 else echo 警告: 无法解析行内容: $line fi done $server_file这个脚本里的正则^([0-9]\.[0-9]\.[0-9]\.[0-9])[[:space:]]([a-zA-Z0-9_-])$用到了四段IP捕获、[[:space:]]字符类、第二捕获组的主机名字符集。整个脚本不依赖外部工具解析文本只靠Bash内置的正则能力就够了。这种风格写多了之后你会发现Shell脚本的健壮性和可维护性提升非常明显。5. 正则从“写出来”到“用正确”一个综合实战案例讲了这么多知识点最后用一个完整案例把它们串起来。这个案例是我去年处理一个线上日志分析需求时实际用过的方案虽然细节做了脱敏处理但核心逻辑完整保留。我认为看完这个过程你就能理解前面提到的所有知识点在真实工作中的协作方式。5.1 需求背景和数据结构当时的需求是从Nginx的access.log里统计出所有50x错误对应的请求URL、客户端IP和响应时间并按错误类型归类输出。日志每行格式大致如下10.20.30.40 - - [15/Jan/2025:14:23:11 0800] GET /api/user/12345 HTTP/1.1 502 112 - curl/7.68.0 0.234字段结构为客户端IP、时间、请求行、状态码、响应字节数、Referer、User-Agent、响应时间。字段之间用空格分隔但时间里有[和]请求行里也有双引号直接用空格列提取会对不准列。5.2 逐步拆解方案第一步先用grep -E快速筛选出所有50x状态码的行grep -E HTTP/1\.1\ 50[0-9] /var/log/nginx/access.log error_50x.log这里的正则HTTP/1\.1\ 50[0-9]定位到了状态码的位置——它前面必然是HTTP/1.1加一个空格后面如果直接跟空格和数字则是响应字节数。用这种方法筛选比简单粗暴地grep 502精准得多避免误匹配到URL参数或者客户端IP里的502。第二步用sed -E把每行格式标准化把方括号里的时间戳提取出来、把双引号去掉方便后续按空格分列sed -E s/\[([^]])\]//; s///g error_50x.log cleaned.log这一步的[^]]是一个有趣的字符组写法[^]表示“除右方括号以外的任意字符”[^]]整体表示“匹配一个或多个非右方括号的字符”正好对应时间戳方括号内的内容。这个写法看起来不太直观但在实际日志解析里极其常用。第三步用awk做最终的结构化提取因为此时每行已经可以被空格干净地分成多列了awk { # 第1列是IP第6列是请求方法第7列是URL第8列是协议 # 第9列是状态码最后一列是响应时间 ip $1 url $7 status $9 time $NF if (status ~ /^50[0-9]$/) { print status, ip, url, time } } cleaned.log | sort | uniq -c | sort -rn最终输出每个50x错误出现的次数、对应的IP、URL和响应时间按次数降序排列。这份统计结果直接定位到了问题接口整个排查过程从原始日志到结论耗时不超过5分钟。5.3 这个案例里正则使用的心得回看这个案例整个过程有三个关键的正则决策点第一筛选状态码时为什么不用grep 502这种简单写法因为在日志里502可能出现在IP段、响应字节数、甚至请求参数中不加上下文直接匹配必然产生噪音数据。加上前后锚定内容用HTTP/1\.1\ 50[0-9]这种方式把状态码的上下文一并纳入匹配准确性完全不一样。第二清理日志时sed -E s/\[([^]])\]//这个表达式把整块时间戳删掉后面; s///g是第二个替换表达式删掉所有双引号。分号分隔多个替换操作是sed的常见组合用法初学者容易忘记可以在一个-e表达式里串联多个s/.../.../或者直接用分号连起来。第三awk里最后的status ~ /^50[0-9]$/其实是冗余的——因为我们第一步已经筛过一遍了但保留这句话是为了让脚本脱离第一个grep步骤也能独立工作。这种“不依赖前序步骤、每步可独立运行”的脚本设计习惯在大规模数据处理时能省掉大量调试时间。5.4 关于性能正则写不好日志文件大时会被拖死日志分析还有个躲不开的问题性能。正则写得好不好在几十MB的日志上可能只差几秒但一旦上了GB级别的日志文件差别就是分钟级别甚至量级性的。我自己总结过三个实用优化点能筛选就筛选能用grep先过滤掉80%不需要的行就别让awk和sed处理全量日志。避免灾难性回溯在写匹配长文本的正则时尽量避免嵌套量词和模糊边界。比如.*?虽然非贪婪但在大型文本里反复尝试也会拖慢速度。[0-9]后面建议明确接一个定界符而不是直接用.*接.*。不要重复扫描文件处理的中间结果先落盘下一步再读取比每一步都在原文件上反复grep要快得多。上面案例里的第一步先把50x筛出来存成文件而不是每次分析都全量扫原始日志就是出于这个考虑。实际生产环境里日志文件动辄几个GB每多一次全量遍历就是几十秒的代价。做数据处理的人必须对数据量保持敏感。6. 写在最后一个提升Shell正则效率的实用技巧技术内容讲完了最后分享一个我用了很多年的小习惯它直接改变了我的Shell正则编码效率。在调试正则的时候不要直接在真实的大文件上反复实验。先用小测试文本快速验证。我通常会在终端里用echo或printf造一条样例数据然后用管道传给grep/sed/awk正则写对了再接文件跑全量。# 先这么试 echo 192.168.1.100 GET /api/login 502 | grep -oE [0-9]\.[0-9]\.[0-9]\.[0-9] # 确认没问题了再上真实数据 grep -oE [0-9]\.[0-9]\.[0-9]\.[0-9] /var/log/nginx/access.log | sort | uniq -c这个习惯帮我避免了很多次“正则写错—全量跑完没结果—再排查到底哪里写错”的低效循环。另外线上操作时如果你对正则实在没把握grep -E加-o加-n三个选项的组合会帮你快速定位问题——它只输出匹配到的那一部分并带上行号报错归因会快很多。还有一个技巧很多命令支持--include或--exclude参数比如grep -r --include*.log在目录里搜文件时能大幅缩小扫描范围效果等同于“在正则前先做了一次通配符过滤”。两者的设计目标一致缩小数据量降低后级正则的负担。Shell正则这东西看着是语法细节实则是文本处理思维的体现。上面这些经验和教训都是我这些年在一线环境里一点一点踩坑踩出来的分享出来就是希望你能少走几步冤枉路。先把grep的BRE和ERE弄明白再上手sed -E和awk最后在[[ ~ ]]里把正则变成脚本的内建能力这条路走通了你手里的Shell就真正锋利了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →