
接触Shell编程的人多半会经历这样一个阶段脚本写熟了循环和判断都信手拈来但一到要提取日志里的IP、批量替换配置文件、从命令输出里捞一个字段就忍不住去翻正则语法或者干脆用cut加各种管道凑合。凑合多了脚本就变得又长又脆改起来也痛苦。我自己的体会是正则是Shell脚本从“能跑”到“好维护”的分水岭。这篇博文我想从一个常年写运维脚本、处理日志和配置文件的人的角度把我这些年用下来觉得最实用、最容易忽略、也最容易出错的点整理出来。内容定位在Linux Shell环境以grep、sed、awk三个工具为主线覆盖正则流派差异、字符类、常见匹配模式、脚本整合思路和避坑经验。不管你是刚学Shell的初学者还是已经写了不少脚本想补一补正则短板的开发者应该都能从中找到能直接拿去用的那部分。1. 在动手写正则之前先要把Shell的正则体系看清楚很多教材上来就讲[0-9]、^、$、*这当然没错但它忽略了一个Shell环境特有的问题你面对的不是一套正则而是好几套。普通的编程语言里你通常只需要学会语言内置的正则引擎。Shell则不一样grep、sed、awk各自有各自的正则解释规则而且历史包袱很重。不搞清楚这些就会出现“在grep里好好的一段表达式放到sed里就报错”的情况。1.1 三种正则流派BRE、ERE、PCREShell环境里最常见的是三种正则体系BRE基本正则、ERE扩展正则、PCREPerl兼容正则。它们的核心差异不在于功能强弱而在于“哪些符号需要转义”。先看BRE它是最古老的默认模式。在默认的grep命令里分组和量词的括号、花括号都需要反斜杠转义才能生效# BRE写法括号和花括号需要转义 grep ^\(ab\)\c file.txt这种写法放到今天来看确实反人类但它是历史传承。而ERE也就是grep -E或者egrep使用的模式把括号、加号、问号、竖线都当成了普通正则元字符不需要额外转义# ERE写法括号直接写 grep -E ^(ab)c|^xy file.txt至于PCRE主要出现在grep -P里GNU grep支持它带来了\d、\w、\s这种快捷写法以及更复杂的零宽断言。但要注意PCRE不是所有Shell自带工具都认。sed和awk的很多实现里你根本用不上PCRE只能老老实实用BRE或ERE的子集。我自己有一套选择原则需要复杂匹配时优先用grep -E配合ERE因为它兼顾可读性和兼容性。如果只是简单判断有没有某个字符串那连花括号都不需要直接默认模式就行。如果非要用\d这种写法记得当前环境必须是GNU grep否则换一台BSD的系统脚本可能就哑火了。正则这块稳定永远比炫技重要。1.2 POSIX字符类和语言环境的影响另一个经常被忽略的知识点是POSIX字符类。[[:digit:]]表示数字、[[:alpha:]]表示字母、[[:space:]]表示空白字符。为什么要绕一圈写这么长的字符类直接写[0-9]不行吗行但在某些locale环境下会出幺蛾子。比如当LANG被设置为某些非C的locale时[a-z]的范围不一定就真的只包含小写字母它取决于当前locale的排序规则可能把A到Z中间的一些字符也卷进来。而[[:alpha:]]这类POSIX字符类的语义更稳定是经过明确定义的字符集合。做严格匹配比如校验用户输入是否全为数字用[[:digit:]]比[0-9]稳妥。如果一个脚本需要在多语言、多locale环境下运行我的建议是两条脚本开头明确设置export LC_ALLC让排序和字符匹配行为可预期字符匹配尽量用[[:xxx:]]形式避免依赖范围。至于\d和[0-9]的选择还有一个细节\d在Perl类正则里表示数字在GNU grep的-P模式下可用但在BRE/ERE里不存在。所以能不用就少用用一个[0-9]或者[[:digit:]]替代损失不了几个性能点却能换来大得多的可移植性。2. 应用最多的三个命令grep、sed、awk里的正则实操正则不是孤立存在的它必须落到具体工具上才有意义。在Shell脚本里90%的正则使用场景都集中在grep、sed、awk这三个命令上。这三个命令各有偏重grep负责过滤sed负责流式替换awk负责字段级处理。但它们的正则语法底座并不完全一致需要分开说。2.1 grep过滤文本时把量词和反向引用用起来很多人用grep只会grep error这种固定字符串匹配。说实话能用但脚本稍一复杂就捉襟见肘。比如我想从日志里找出所有以数字开头的行grep -E ^[0-9] app.log这个-E就是切换到ERE模式然后^锚定行首[0-9]匹配单个数字。再进一步我想匹配“连续两到三个数字后面跟一个冒号”的行比如时间戳片段grep -E [0-9]{2,3}: app.log这里的{2,3}是ERE量词。如果你用默认BRE就得写成[0-9]\{2,3\}。这就是为什么我劝你写grep时养成加-E的习惯。反向引用在处理重复单词时有奇效。比如想找出日志里相邻重复词的行grep -E \b([a-z]) \1\b log.txt\1会引用第一个括号匹配到的内容。这个特性在某些文本清洗场景下非常实用比如检查“the the”这类错误。我建议所有grep实战都顺手加上-o只输出匹配的部分和--coloralways。配合-o你可以快速从一行日志里抽取出所有IP地址grep -oE ([0-9]{1,3}\.){3}[0-9]{1,3} access.log | sort | uniq -c | sort -nr这行命令能直接统计访问来源IP的次数排行。注意这里的IP匹配并不严格校验收敛范围0-255但对于日志里的粗略统计这个量级的精度足够性能也快。如果想要真正的IP合法性校验后面我再补充更严谨的版本。2.2 sed替换与提取靠分组引用sed里的正则最常用的场景是替换和范围行提取。替换的语法是s/正则/替换内容/而分组引用是它最值钱的能力。举个例子系统日志里经常有这种格式2024-05-11 10:23:45 ERROR user_123 this task failed我只想把ERROR后面的用户IDuser_123提取出来保持原来行的前缀不变可以这样sed -n s/\(ERROR \)\(user_[0-9]*\)/\2/p system.log这里-n表示不自动打印在替换成功后用p标志手动打印。\(ERROR \)和\(user_[0-9]*\)是两组\2就是第二组的内容。如果是ERE模式sed -E写起来清爽一点sed -nE s/(ERROR )(user_[0-9]*)/\2/p system.log我第一次用sed做这种提取的时候最大的困惑是为什么替换语句总是把整行打印出来而不是只输出匹配的部分。后来才意识到sed的核心语义是“整行处理”s命令会保留整行只是调整匹配的子串除非你把不该保留的内容整个吃掉。所以很多提取操作本质上不是“提取”而是“把不要的部分删光”。比如我想从一行keyvalue;key2value2里拿出第二个valueecho a1;b2 | sed -nE s/.*b([^;]*).*/\1/p这个写法就是典型的分组引用加“删掉其余部分”的组合拳。[^;]*的意思是“一连串不是分号的字符”这个写法在正则里非常高频后面还会派上用场。它之所以好用在于它把懒惰匹配的需求用字符取反射击掉了避免了贪婪匹配一路吃到底的问题。2.3 awk正则匹配与字段处理的协同作战awk的优势在于它把正则匹配和结构化字段处理结合在了一起。它在读取每一行的时候默认按空白把行拆成$1、$2等字段我们可以在匹配到指定模式的行上直接操作字段这是grep和sed做不到的优雅。假设有一个进程日志格式是INFO 2024-05-11 10:00:01 process_1 0.2s ERROR 2024-05-11 10:00:02 process_2 timeout我想把所有ERROR行里的进程名和错误信息打印出来awk /^ERROR/ {print $3, $4} app.log这里的/^ERROR/就是正则匹配行首。$3取第三个字段process_2$4取第四个字段timeout。这个思路简单直接但比管道串一堆grep加cut要利索多了。awk里还有两个工作频率很高的函数sub()和gsub()它们对每个字段做正则替换。比如把所有行里的IP地址打码成“IP”awk {gsub(/([0-9]{1,3}\.){3}[0-9]{1,3}/, IP)}1 data.log这里的结尾1是一个awk黑话表示始终为真相当于默认打印整行。我第一次看到这种写法时也很懵后来理解了awk的执行模型才明白pattern {action}当pattern等于1真且没有action时默认动作就是打印$0。另一个有用的点是通过match()函数获取匹配位置和内容。比如awk match($0, /user_[0-9]/) {print substr($0, RSTART, RLENGTH)}RSTART和RLENGTH是awk内置变量告诉你匹配的起始位置和长度配合substr就能把命中的子串掏出来。这个组合在处理不规则文本时比写超级复杂的单条正则要清晰得多。3. 怎么把这些能力揉进Shell脚本里单独讲命令用法当然重要但脚本里这些命令不是孤立出现的。它们会和for循环、while读取、变量、条件判断混在一起。这一节我讲几个自己项目中反复用到的组合套路都很基础但组合起来力量很强。3.1 可以抄走的常用正则模式我把平时最常写的几个正则模式整理成一个清单都是经过实际验证的不追求极端严谨但足够应对绝大多数日志分析和配置解析需求。匹配纯数字行^[0-9]$匹配带1到3位小数的数字^[0-9]\.[0-9]{1,3}$匹配IPv4地址粗略版([0-9]{1,3}\.){3}[0-9]{1,3}匹配IPv4地址严格限定0-255^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$匹配日期YYYY-MM-DD^[0-9]{4}-[0-9]{2}-[0-9]{2}$匹配时间戳HH:MM:SS^([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$匹配文件扩展名\.(log|txt|conf)$做严格IP校验那段可能有人会觉得复杂。它的设计思路其实很简单把0-255拆成25[0-5]250-255、2[0-4][0-9]200-249、1[0-9][0-9]100-199、[1-9]?[0-9]0-99然后用\.连接四段。你会发现在处理日志时粗略版配一次就够了因为日志本来就来自可信来源但处理用户输入时就必须用严格版做接口校验。3.2 配合for循环批量处理文件时的正则技巧Shell脚本里for循环和正则结合最典型的场景是遍历一群文件只对文件名或内容满足正则条件的文件做处理。比如我想统计当前目录下所有.log文件里有ERROR的文件数量count0 for file in *.log; do if grep -qE ERROR $file; then count$((count1)) fi done echo 有错误的日志文件数: $count注意这里我写了grep -q-q意味着不输出内容只根据退出码判断是否匹配。这个用法在脚本里非常关键因为在if条件里我们根本不需要grep打印任何东西只要判断返回码。再来一个批量提取所有配置文件里被注释掉的端口号。for file in /etc/service-*.conf; do while IFS read -r line; do if [[ $line ~ ^[[:space:]]*#.*port[[:space:]]*([0-9]) ]]; then echo $file: ${BASH_REMATCH[1]} fi done $file done这里用到了bash内置的~正则匹配语法配合BASH_REMATCH数组拿到分组内容。很多人不知道bash里其实可以直接做正则匹配不必每次为一个小判断去管道grep。注意~两边不要加引号加了引号整个模式会被当作普通字符串处理这是新手最容易踩的点。3.3 从命令行输出中提取关键内容运维脚本里很常见的诉求是从命令输出中抓取数据。比如从df -h的输出里找出使用率超过80%的分区或者从ps -ef里找出某个进程的PID再用该PID做后续操作。拿ps举例。我先定义一组想要匹配的模式然后提取进程号pid$(ps -ef | awk /myapp/ !/grep/ {print $2} | head -n1)为什么还要排除!/grep/因为ps -ef的结果里会包含grep myapp这个进程本身如果不排除就会把自己也抓进PID列表里。这个坑是所有写进程管理脚本的朋友都要面对的。再比如从free -m输出里提取可用内存数值avail$(free -m | awk /^Mem:/ {print $7})^Mem:对行首做锚定避免误匹配到Swap:那行$7取第七列。写成脚本变量后就可以做内存告警判断了if [ $avail -lt 512 ]; then echo 内存告警: 可用 ${avail}MB fi这些命令看起来短但它们的价值在于可以把人工看输出、手动复制的流程完全自动化。正则在这里扮演的角色是定位器帮你在海量输出中精确锁定目标行和字段。4. 工具虽好坑也不少Shell正则实战避坑清单每一个写过一段时间Shell脚本的人手里应该都有一张“自己踩过坑”的清单。正则相关的坑尤其隐蔽因为很多时候不是正则本身错了而是Shell的解析过程把表达式扭曲了。这一节我把最容易踩的坑集中列出来。4.1 引号和反斜杠谁先解析谁Shell和正则之间最经典的问题是转义。正则里经常要用\来表示特殊含义但Shell一样把\当作转义符。两者的叠加规则让人头大。我们先看单引号和双引号的区别。在单引号里所有字符都是字面量Shell不会做任何展开所以正则里的\b、\1这样的反斜杠会被原样传给grepgrep \bword\b file.txt而在双引号里Shell会先做变量替换、命令替换等操作再传给命令。除非你把反斜杠再加一层否则一个\b到grep那里可能就只剩下b# 这行传过去后\b 可能会丢失反斜杠 grep \bword\b file.txt所以我的习惯是正则表达式里只要包含反斜杠一律用单引号包裹。只有当正则里需要插入Shell变量时才改用双引号并且这时候要特别小心变量中的特殊字符。还有一个容易忽略的场景正则模式来自变量。假设我从外部读入一个关键字pattern然后想匹配它如果这个pattern里含有.、*等正则元字符grep会把它当作正则的一部分解释。想要变成单纯的关键字搜索可以先手动给元字符加转义或者干脆改用grep -F让grep把模式当固定字符串处理keyworda.b*c grep -F $keyword data.txt # -F: 固定字符串匹配这个-F选项平时没什么存在感但在处理用户输入、路径名这类不可控字符串时它的价值不亚于正则本身。4.2 ERE不支持懒惰匹配用取反表达式代替用过Python或者JavaScript正则的人一旦到了Shell的ERE环境最不适应的就是“懒惰匹配”用不了。.*?这种写法在grep -E和sed里都不存在它只存在于PCRE。比如我想匹配content这种双引号包裹的内容# 在Python中可以写 .? # 在 grep -E 里没有这种懒惰量词 # 替代做法 grep -oE [^]* file.txt[^]*的意思是“双引号、一段非双引号字符、双引号”这是用字符取反来模拟懒惰匹配的经典手段。它的原理很朴素懒惰匹配的目的就是别一口气吞得太远那我们干脆定义“要匹配的东西里面绝不能包含某类字符”从而把范围卡死。同理提取花括号里的内容用\{[^}]*\}提取HTML标签里的文字用tag[^]*/tag思路完全一样。这套手法在shell里比任何懒惰匹配技巧都可靠。4.3 grep -E、egrep、grep -P的兼容性陷阱egrep已经被废弃好多年了但很多老脚本里还在用。egrep本质上就是grep -E新版本GNU grep甚至会在命令行提示“egrep is deprecated”。我建议新脚本统一写grep -E这样风格上更统一也方便后人理解。grep -P是GNU grep对PCRE的支持它确实强大能直接用\d、\w、\s和零宽断言。但它的最大问题就是可移植性堪忧在BSD grep和macOS自带的grep里并没有-P选项。如果你写一个脚本要部署到多台不同发行版的主机上-P很容易成为那个“环境一换就崩”的隐藏炸弹。我自己的替代方案是需要复杂断言时先在本地用grep -P调试出逻辑来再改写成grep -E能表达的版本提交进脚本。实在改写不了就在脚本开头用grep --version做一次能力探测然后走不同的分支。核心原则是线上脚本的每个命令都要假定运行环境具备最基础的能力没有的就去兼容处理而不是默认它存在。4.4 正则匹配不到任何内容时的退出码与空值处理Shell脚本里正则命令匹配不到内容的时候退出码是非0的这一点既好用又危险。好用之处在于可以天然当条件if grep -qE ^ERROR app.log; then exit 1 fi如果匹配到ERROR就退出逻辑一目了然。但危险之处在于如果你开启了set -e那这个非0退出码会让脚本直接终止哪怕你只是想做一次普通检查。这种场景下要么写明grep -qE ... || true要么把检查放在if条件里因为if条件里的命令不会被set -e影响。另外捕获变量的情况也一样user_name$(echo $line | sed -nE s/.*user([^;]*).*/\1/p) if [ -z $user_name ]; then echo 未提取到用户信息 fi当sed没匹配到任何东西时输出为空变量为空字符串。此时后续如果直接拿这个变量做字符串拼接或者数值比较很容易出现意外。养成赋完值先判空的习惯在正则相关脚本里尤其重要。我觉得正则在Shell里真正难的地方从来不是记忆那些元字符而是理解“各路工具对同一套正则的不同解释”和“Shell解析与正则解析之间的抢食关系”。把这层窗户纸捅破脚本里的解析类任务会顺畅很多。最后再分享一个小习惯调试任何和正则相关的Shell脚本时先加一行set -x把实际执行的命令打出来看一遍很多所谓的“玄学失败”就都能一眼看穿。