1. 先把最容易被带偏的认知掰回来:Shell正则不是编程语言里的正则
如果你是从Python、JavaScript或者Java转过来学Shell的,我猜你大概率在grep、sed、awk这三兄弟上栽过跟头。我自己刚入行那年,写过一条自认为很完美的正则去匹配IP地址,在Python里跑得好好的,贴到grep -E里直接报错,当时整个人是懵的。
问题出在哪?出在很多人默认“正则表达式就一套规则,换了个工具照样用”。这个认知偏差,是Shell正则学习路上最大的一道坎。
先说结论:Shell里常说的正则表达式,跟编程语言里的正则,底层逻辑是同一套,但语法流派、转义规则、工具适配完全不是一回事。换句话说,你会写正则不代表你会用Shell正则,但你会用Shell正则,回头再写别的语言的正则,那基本是一通百通,因为Shell的正则处在整个正则体系里最“原生”的位置,理解了它,你才能理解正则表达式的底层逻辑。
Shell正则通常指的是POSIX规范下的正则,分为两派:基础正则表达式(BRE,Basic Regular Expression)和扩展正则表达式(ERE,Extended Regular Expression)。BRE是传统老派,grep、sed默认走的就是BRE;ERE是现代派,grep -E、sed -r、awk走的是ERE。两者最大的差异就在几个元字符上:+、?、|、()这些,在BRE里默认是普通字符,必须加了反斜杠转义才具备特殊含义;而在ERE里这些本身就是特殊字符,不需要转义。
很多人写grep正则的时候不加-E,然后写a+b去匹配一个或多个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.开头的内网IP:
grep "^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明显更符合绝大多数人脑子里对正则的认知。除了-E,grep还有几个高频选项,我平时基本是组合着用:
| 选项 | 作用 |
|---|---|
-E | 使用扩展正则表达式 |
-i | 忽略大小写 |
-v | 反向匹配,输出不匹配的行 |
-o | 只输出匹配到的部分,而不是整行 |
-c | 统计匹配的行数 |
-n | 显示行号 |
-r | 递归搜索目录下的所有文件 |
--color=auto | 匹配结果高亮显示 |
-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模式下写替换,括号、加号、花括号这些全部需要转义。比如要把形如id=12345的字段替换成id=SKIP,BRE写法是:
sed 's/id=[0-9]\{5,\}/id=SKIP/' data.txtERE写法是:
sed -E 's/id=[0-9]{5,}/id=SKIP/' 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。不加g,sed默认只替换每一行里第一个匹配到的内容;加了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_ALL=C:
export LC_ALL=C在这个环境下,[a-z]就是小写字母a到z,[A-Z]就是大写字母A到Z,行为可预测,排错也快。我写的所有Shell脚本,只要涉及文本处理和正则,开头必设LC_ALL=C,这是一个成本极低但收益极高的习惯。
另外,在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开始是各个捕获组的内容。这在解析命令行参数、校验输入格式时非常实用:
input="PORT=8080" if [[ "$input" =~ ^([A-Z]+)=([0-9]+)$ ]]; then key="${BASH_REMATCH[1]}" value="${BASH_REMATCH[2]}" echo "变量名: $key, 值: $value" fi另一个常见的实战场景,是用=~校验IP地址格式。虽然用grep也能做,但在脚本内部直接判断、不需要额外起进程,性能更好、写法更紧凑:
ip="192.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_file="server_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 2>&1 && 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就真正锋利了。