
写Shell脚本这些年我越来越觉得正则表达式就是Shell的“文本手术刀”。很多人一提到正则就头大觉得符号太多记不住实际上只要抓住几个核心概念再加上在grep、sed、awk里的实际用法你就能解决日常开发运维里九成以上的文本处理需求。这篇文章我不打算给你一本正则语法大全而是从真实脚本场景出发把Shell正则表达式中最常用、最容易踩坑的知识一次讲透适合刚接触Shell脚本的朋友也适合写了几年脚本但总在正则上翻车的同学。1. 从一次实际需求说起为什么Shell脚本离不开正则表达式1.1 我印象最深的一个需求日志文件里提取时间戳有一次线上排查问题要在一个5GB的日志文件里把所有的请求时间、响应码、耗时结构化提取出来。日志长这样2024-11-03 09:15:22 INFO Request #8921 response_code200 cost_ms128 2024-11-03 09:15:23 WARN Request #8922 response_code500 cost_ms3097如果只靠cut -d和awk去按空格拆列遇到字段有增减的日志就得重写脚本。但用正则表达式就不一样我可以精确描述“日期时间”的模式grep -E [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} app.log就这么简单的一行就能把日志里所有带标准时间戳的记录全部筛出来。这个需求让我意识到正则表达式不是纯理论工具它直接决定了Shell脚本能处理多复杂、多杂乱的文本。1.2 正则到底解决了什么问题正则表达式本质上是“描述字符模式的微型语言”。它解决的核心问题只有一个在大量文本中用一段模板精准地找到或修改符合某种格式的内容。Shell脚本本来就以文本处理见长管道、重定向、循环配合grep、sed、awk能把复杂的文本加工过程压缩成几行命令。正则表达式正是这套文本处理能力的“逻辑大脑”。没有正则Shell脚本就只能做按行、按空格拆分的粗活有了正则哪怕数据格式很混乱你也能写出稳定的提取规则。适合学这篇文章的人大概有三类一是刚接触Linux命令行的新手想搞懂正则怎么用二是写Shell脚本做自动化运维、数据处理、批量操作的同学三是有一些基础但经常在转义、BRE/ERE流派上出错的人。这篇文章的重点不是堆语法而是帮你建立“看到文本脑子里能快速匹配出对应正则”的直觉。2. 正则表达式的核心语法先吃透这几个概念再上实战很多人学正则失败不是智商问题而是把精力浪费在背那些一辈子用不上几次的冷门结构上。我只建议先把五个核心概念吃透再往shell场景里套。2.1 字符类别精确匹配“一个字符”正则匹配的最小单位是“一个字符”。a就匹配字母a1匹配数字1。但实际场景里你往往想匹配“任意数字”、“任意字母”、“任意非空字符”这时候就需要字符类别。在Shell常用的ERE扩展正则表达式中最基础的是中括号表达式[0-9]匹配一个数字等价于[0123456789][a-z]匹配一个小写字母[A-Za-z]匹配一个字母[^0-9]匹配“不是数字”的任意一个字符[^...]里的^表示取反这个符号放的位置决定了它的含义很多人第一次都在这翻车。还有一个更省事的预定义字符类别在grep和awk里可以直接用grep -E [[:space:]] file.txt # 匹配空格、制表符等空白字符 grep -E [[:alpha:]] file.txt # 匹配字母 grep -E [[:digit:]] file.txt # 匹配数字注意这些POSIX字符类必须写成[[:digit:]]这种双层中括号形式不是[:digit:]。原因很简单外层中括号是“字符集合”语法内层[:digit:]是一个字符类名。这个写法在Linux自带工具里通用没有Perl正则里\d那么简洁但胜在稳定、可移植。2.2 量词控制出现次数字符类别解决了“匹配哪种字符”量词解决“出现多少次”。两者一组合才真正有威力。量词含义示例说明*前一个字符出现0次或多次ab*c匹配ac、abc、abbc前一个字符出现1次或多次abc匹配abc、abbc不匹配ac?前一个字符出现0次或1次ab?c匹配ac、abc{n}恰好出现n次[0-9]{4}匹配4位数字{n,}至少出现n次[0-9]{2,}匹配2位及以上的数字{n,m}出现n到m次[0-9]{1,3}匹配1到3位数字我日常写脚本*、、{n}这三个用的频率最高{n}尤其适合匹配IP地址、日期、手机号这类长度固定的字段。举个例子匹配IPv4地址时直接写[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}就比[0-9.]精确得多。后者会把999.999.999.999也匹配进来前者虽然也没校验数值范围但至少格式是对了。2.3 锚点让匹配发生在正确的位置量词和字符类别控制“匹配什么”锚点控制“在哪里匹配”。^匹配行首$匹配行尾这两个符号在Shell脚本里太常用了。比如检查一个变量是否以http开头if echo $url | grep -qE ^https?://; then echo 合法URL fihttps?表示“https”再加一个可选的s所以能同时匹配http://和https://。还有一个容易被忽略的锚点用法grep -c统计匹配行数时如果想统计非空行就用grep -cE . file.txt或者干脆grep -cE \S file.txt。这里的逻辑是行首不带^限制时正则可以在行内任意位置匹配只有加上锚点才能精确控制边界。2.4 分组、或关系、转义让表达式有结构分组是正则表达式从“匹配单个模式”升级为“匹配复杂模式”的关键。(...)把一组字符或表达式打包成一个整体|表示或关系\转义特殊字符举一个真实例子提取日志里的响应码可能是200、404、500也可能是“timeout”这种字符串grep -E response_code(200|404|500|timeout) app.log注意这里括号是必须的。如果写成response_code200|404那正则引擎会理解为“要么匹配response_code200要么匹配404”完全不是你想表达的意思。这类因为没加括号导致的逻辑错乱我见过太多次了。转义这块是Shell正则最容易暴雷的点。.在正则里是“匹配任意一个字符”想匹配字面意义上的小数点就必须写成\.。比如提取带小数点的数字echo price19.99 | grep -oE [0-9]\.[0-9]{2}这里\.就是字面点号如果没有转义[0-9].[0-9]{2}也能匹配到但同时也可能误匹配19a99这种格式所以该转义的地方一定不能偷懒。3. grep、sed、awk三件套的正则实战正则表达式单独存在没有意义它必须寄生在某个工具里才发挥威力。Shell生态里最核心的三个正则载体是grep、sed、awk。我把它们分开讲清楚因为这三个工具的用法完全不同。3.1 grep先过滤再干活最常用的命令grep的定位是“按正则过滤文本行”它输出的是匹配行或者匹配片段不会修改原文件。写Shell脚本时我最常用grep做三件事第一日志关键字过滤grep -E ERROR|FATAL /var/log/app.log | wc -l第二从命令输出里提取信息free -m | grep -E ^Mem: | awk {print $2}第三提前判断一个文件是否包含指定模式配合if语句使用if grep -qE ^version2\. config.ini; then echo 旧版本配置需要升级 fi注意grep -q这个参数它表示“只要找到匹配就立刻返回成功不输出任何内容”特别适合做条件判断还省时间。很多初学者喜欢写grep -E ... file /dev/null效果差不多但-q更规范也更高效。grep有几个参数必须记牢-E启用扩展正则表达式-o只输出匹配的部分而不是整行-i忽略大小写-v反向选择不匹配的行。其中-o在配合管道做数据提取时尤其重要echo user_id12345actionlogin | grep -oE [0-9]这段命令能直接抽出12345而不是把整行打印出来。如果一行里有多个数字grep -oE会一个一行输出后续可以继续用管道处理。3.2 sed替换与剪辑的利器sed的正则主要用在两件事替换内容、删除或打印特定行。最基本的替换echo hello world | sed s/world/sh/输出结果是hello sh。s是替换命令格式是s/目标正则/替换内容/。在Shell脚本里sed最常见的用途是改配置文件。比如将nginx.conf里的默认端口80改成8080sed -i s/^listen 80;/listen 8080;/ nginx.conf-i直接修改文件^listen 80;限定了必须是在行首出现的listen避免把其他位置的listen 80也误改掉。这里又体现了锚点的价值。sed还经常用来删除批量的临时行。例如删除所有以#开头的注释行sed -i /^#/d nginx.conf当你发现正则里包含大量/符号时会很难受比如替换路径/usr/bin为/usr/local/bin如果你写成s/\/usr\/bin/\/usr\/local\/bin/满屏的转义斜杠。sed支持改变分隔符把/换成#或|sed -i s|/usr/bin|/usr/local/bin| profile.sh这样可读性就好多了。这个技巧我用了很久才意识到网上很多教程照样给你写一堆\/属于完全没有实操经验的表现。sed的正则匹配范围也可以按行号组合比如只处理第5到第10行sed -i 5,10s/old/new/g file.txt3.3 awk处理结构化文本的王者awk本身就是一门小语言它对文本的处理逻辑跟grep、sed完全不同。grep按行过滤sed按行编辑awk则是把每行按“字段”拆分后做计算和格式化输出。awk的默认字段分隔符是空格或制表符$1是第一列$2是第二列$0是整行。但在正则场景里awk最强大的地方是可以让“某个字段”匹配指定正则再做处理。比如从/etc/passwd里找出登录shell是bash的用户awk -F: $NF ~ /bash$/ {print $1} /etc/passwd-F:指定冒号为分隔符$NF表示最后一个字段~ /bash$/表示该字段匹配正则“以bash结尾”。这段脚本会比grep管道sed的组合更直观、更好维护。awk还支持在匹配行里做条件判断和数学运算。统计日志中耗时超过1秒的请求数量awk $0 ~ /HTTP/ {if ($NF 1000) count} END {print count} app.log这里$NF是每一行最后一列假设它存的是耗时毫秒数。awk的正则表达式采用ERE标准跟grep -E基本一致但没有grep -P的Perl增强特性。一句话总结三者的分工需要“查整行”用grep需要“改内容”用sed需要“提取列并算数”用awk。三者配合管道使用才是Shell脚本的高效打开方式。4. 我的高频正则清单20个可以直接用的表达式在Shell脚本里有些正则我几乎每个月都要写好几次。为了避免每次现想现查我把它们整理成了一个清单。你不需要全背下来只要能看懂结构用到时过来抄即可。场景正则表达式说明邮箱简单版[A-Za-z0-9._%-][A-Za-z0-9.-]\.[A-Za-z]{2,}覆盖绝大多数日常邮箱格式IP地址([0-9]{1,3}\.){3}[0-9]{1,3}格式校验不校验数值范围URLhttps?://[A-Za-z0-9./?_%:-]*匹配http或https开头的链接日期YYYY-MM-DD[0-9]{4}-[0-9]{2}-[0-9]{2}日志过滤常用时间HH:MM:SS[0-9]{2}:[0-9]{2}:[0-9]{2}标准24小时制手机号中国大陆1[3-9][0-9]{9}以1开头第二位3-9共11位纯数字^[0-9]$用于校验整行内容是不是数字正负小数^-?[0-9](\.[0-9])?$支持负数和小数端口号^([0-9]{1,4}[1-5][0-9]{4}空白行^[[:space:]]*$匹配纯空行和只包含空格的行以某词开头^ERROR锚点示例以某词结尾\.log$匹配.log结尾中文字符[一-龥]注意编码环境引号里的内容[^]*匹配双引号包裹的内容提取数字[0-9]grep -oE配合使用提取#号后内容#.*$匹配从#开始到行尾压缩多余空格用sed${1// / }处理字符串更直观Unix文件路径/([A-Za-z0-9_.-]/)[A-Za-z0-9_.-]匹配绝对路径常见格式MAC地址([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}冒号分隔的MAC版本号[0-9]\.[0-9]\.[0-9]匹配x.y.z格式这个表格里的表达式我建议你有空在终端里逐个试一遍尤其观察一下grep -oE和grep -E在输出上的区别这对理解“匹配整行”和“匹配子串”的差异很有帮助。5. 实战三个来自真实脚本的完整案例理论讲再多不如动手看三个完整脚本案例。这三个案例都来自我自己的日常工作分别对应日志处理、字符串提取、批量重命名覆盖了Shell正则最常见的三个使用方向。5.1 脚本获取当前日期时间并转换为数字串热搜词里有“shell脚本获取当前日期时间并转换为数字串”这个场景在生成备份文件名、日志文件名时经常遇到。#!/bin/bash # 获取当前时间并转换为格式数字串例如20241103152608 datetime_str$(date %Y%m%d%H%M%S) echo 备份文件名: backup_${datetime_str}.tar.gz这条命令里完全没有正则但如果你希望从任意格式的日期里“提取”出数字串正则就派上用场了。比如有个文件名是report-2024-11-03-15-26-08.txt你想把连接符全部去掉再组成新文件名#!/bin/bash filereport-2024-11-03-15-26-08.txt digits$(echo $file | grep -oE [0-9] | tr -d \n) echo $digits这段命令先把文件里的所有数字段全部提取出来再用tr -d \n把grep的行分隔符删除最终得到一串连续数字。这个方法比date %s更容易复用到“从历史文件里提取时间”的场景。另一种常见写法是用sed直接删除所有非数字字符digits$(echo $file | sed s/[^0-9]//g)[^0-9]表示非数字//g表示把所有匹配到的非数字字符全部替换为空。这个写法更简洁但只能在明确知道“要保留什么”的时候用。5.2 从混合文本中提取数字及#号后的字符串热搜词里有“提取出中间的数字及#符号后的字符串”我写一个接近实际脚本的例子。假设有这样一个文本# 订单号: #A12345, 商品编号: 67890需要分别提取12345#号后的数字和67890商品编号数字并拼接到一起。#!/bin/bash text订单号: #A12345, 商品编号: 67890 hash_num$(echo $text | grep -oE #[A-Z][0-9] | grep -oE [0-9]) # 合并两个数字 combined${hash_num}-67890 echo $combined更通用的提取“#号后的所有内容”可以直接用echo notehello #world 123 | grep -oE #.*$#.*$从第一个#开始一直匹配到行尾所以输出是#world 123。如果只想取#后面的纯单词echo notehello #world 123 | grep -oE #[A-Za-z]这个正则的意义在于grep -oE配合“锚定到行尾”的模式能把一行里的“尾段内容”精确截出来避免误匹配到行中间的其他#号。5.3 用正则批量重命名文件并在for循环中使用热搜词里有“linux用shell重命名文件”和“shell脚本for循环”。假设你有一堆文件命名格式是IMG_20241103_152608.jpg你想把文件名里的日期部分从_20241103_改成-20241103-并且统一小写扩展名。#!/bin/bash for file in IMG_*.jpg; do newname$(echo $file | sed s/^IMG_\([0-9]\{8\}\)_/img-\1-/) if [ $file ! $newname ]; then mv $file $newname echo 重命名: $file - $newname fi done这里正则^IMG_\([0-9]\{8\}\)_使用括号分组捕获了8位日期数字\1在替换时引用第一组捕获的内容。注意sed默认使用的是BRE基础正则表达式所以分组要写成\(...\)而不是(...){8}也要写成\{8\}。这个问题卡了我半小时的教训在grep -E里能用的写法直接搬进sed的替换表达式里就可能失效因为两者默认的正则流派不一样。如果你非要在sed里用ERE语法需要加-E参数newname$(echo $file | sed -E s/^IMG_([0-9]{8})_/img-\1-/)加了-E之后括号和花括号就不需要转义了可读性高很多。我建议在脚本里统一加-E避免来回适配流派语法。6. Shell正则的坑引号、转义、通配符与正则的边界最后一节专门讲坑。Shell正则容易出错很多时候不是正则本身写错了而是Shell解析环境跟正则语法互相干扰。6.1 单引号、双引号对美元符号和反斜杠的影响在Shell脚本里正则表达式通常写在引号里。如果写成双引号Shell会先对$、、\做一层解释然后再把结果交给grep或sed。这会导致正则里的$表示行尾和\转义符出现各种灵异现象。例如你想用grep匹配以$符号结尾的行写成双引号grep foo\$ file.txt这里\$是为了让Shell不把$当成变量符号但到了正则引擎它又需要把\$转回字面意义的$。这个双重转义非常容易错。我的建议是正则表达式全部使用单引号包裹。grep foo\$ file.txt单引号内Shell不做任何变量替换和转义解释你能直接按照正则语法书写。这能省掉一半以上的怪问题。如果确实需要在正则里插入Shell变量比如动态匹配用户输入的内容再切换到双引号keyword$1 grep -E error.*$keyword app.log这时候$keyword会被Shell展开成实际值但注意正则里的.*等结构在双引号里通常不会出问题。6.2 通配符不是正则别把*和?搞混很多从文件操作接触Shell的人会把通配符和正则混为一谈。在文件名匹配里*表示任意多个字符?表示一个字符在正则里*表示“前一个字符出现0次或多次”?表示“前一个字符出现0次或1次”。举个例子在命令行里执行ls *.txt这里的*.txt是通配符能匹配所有以.txt结尾的文件。但如果你写grep *.txt file这里的*只会让前面的某个字符重复不是“任意字符”。想匹配“任意字符”正则里用的是.*。这个混淆很难debug因为你看到的结果完全不符合预期。排查办法很简单先问自己“这条命令是在匹配文件名还是在匹配文件内容”。匹配文件名用通配符匹配内容用正则两条路别混用。6.3 grep的正则流派差异BRE、ERE、PCRELinux里同一件事可以用不同实现正则也分流派。最常遇到的三个流派是BRE基础正则表达式grep和sed的默认流派。()和{}需要转义才能当分组和量词用写起来很别扭。ERE扩展正则表达式grep -E、sed -E、awk使用。()、{}、、?直接生效。PCREPerl兼容正则表达式grep -P使用。支持\d、\w、(?...)等现代语法功能最强。日常写脚本我尽量统一用grep -E、sed -E因为ERE覆盖了绝大多数需求语法简单还不会踩BRE的坑。只有遇到复杂的反向预查、懒惰匹配这种需求时才切到grep -P。但有一点必须注意grep -P依赖的PCRE语法非常丰富但它的某些特性在处理超大文件时性能可能较差。日志分析这种场景能用ERE解决的就别硬上PCRE。6.4 常见的“明明写对了却不匹配”排查思路正则不匹配的时候不要急着改表达式先按下面顺序检查一遍。第一检查引号。正则是不是被Shell的变量替换或转义搞乱了优先改用单引号。第二检查流派。断言行号用的是\{n\}还是{n}确认当前工具该用哪种。第三检查锚点。^和$是否把匹配位置限得太死比如Windows换行符CRLF环境下$可能匹配不到行尾。第四检查贪婪与数据格式。正则里.默认不匹配换行符如果你处理的是多行文本很多写法就会失效。还有一个我经常见到的低级错误在grep里用[0-9]匹配中文数字或全角数字。如果日志里混入了全角字符[0-9]是永远匹配不到的这时候需要考虑用字符类别[[:digit:]]或直接匹配Unicode范围。最后分享一个小技巧调试正则时不要直接在5GB的日志上跑先head -n 100 file | grep -E 你的正则在几行样本上快速验证模式对不对再放到全量数据上执行。另外可以试着用grep -oE单独查看匹配到的片段这能帮你判断是“整行匹配逻辑错了”还是“表达式范围不对”。我靠这个方法省下了大量无效排查时间。正则这东西光看永远学不会。我个人的习惯是每周都能碰到一个需要用正则解决的文本问题就顺手把它记成一个脚本片段。积累久了你在Shell里处理文本的速度会明显提升写出来的脚本也更健壮、更少出怪问题。希望这些内容对你有实实在在的帮助。