只要你在Linux世界里待过哪怕一天,就绝对绕不开grep。哪怕现在各种日志平台、可视化排查工具满天飞,等你真被丢到一台纯净得连curl都没有的服务器上时,能救你命的还是这几条基础命令。我见过不少工作两三年的开发,grep用得倒是挺溜,但翻来覆去就是grep xxx加上grep -v,再问深一点就答不上来了。这篇不是给你念man手册,而是把我平时真正高频使用的grep姿势、背后的执行逻辑、还有我在生产环境里踩过的坑,一次说清楚。
有句话说“shell三剑客”是grep、sed、awk,这三者的分工其实很明确:grep负责“筛”,sed负责“改”,awk负责“算”。如果你把这个定位记牢了,后面学起来就不会乱。grep干的是最基础也最频繁的事——在文本流里找出你关心的那几行。别小看这个“筛”字,能不能筛得准、筛得快、筛得优雅,直接决定你排查问题的效率。这篇文章我会从grep的执行原理讲起,把高频参数、正则模式、组合实战和坑位都铺开讲,适合刚入门shell的小白,也适合用了好几年但一直停留在grep xxx的“熟练工”。
1. 先搞清楚grep在解决什么:一条命令背后的三层设计逻辑
很多人把grep当成一个“搜索关键字”的工具,这个理解大方向没错,但如果你只停在“搜索关键字”这个层面,就很难理解为什么grep后面能挂那么多奇怪的参数。我通常会从三个层面去理解grep,你把这三点记住了,后面所有参数都能串起来。
第一层,grep是模式匹配引擎。它做的事不是简单的“字符串相等”,而是拿着一个“模式”(pattern)去每一行里做匹配测试。这个模式可以是普通字符串,也可以是正则表达式。普通字符串就是完全字面量匹配,正则则是按某种规则去描述一类文本。比如grep "error" app.log匹配的是包含这四个连续字符的行,而grep -E "err(or|ors?)" app.log则能同时匹配error、errors、errror等变体,这是一种“模式”而不是“死字符串”。正因为它是模式匹配,所以grep不适合做那种“统计某个词出现多少次”的精确计数,它更擅长的是“哪些样本符合某种规律”。
第二层,grep是逐行流式处理器。它一次从输入里读一行,处理完立刻输出这一行,然后读下一行。注意这个词:流式。这意味着grep不需要像编辑器那样把整个文件加载到内存里,哪怕文件有10个G,grep的内存占用也几乎不变。这也是为什么在Linux上处理超大日志文件,大家第一反应就是grep而不是用Vim打开——Vim打开大文件可能会卡半天甚至直接卡死,而grep可以轻松地一秒扫完几个G的数据。理解了流式处理,你就知道为什么tail -f能和grep组合成实时日志监控:tail -f不断把新日志吐给grep,grep逐行消费,两边都是流式,配合起来天衣无缝。
第三层,grep是带退出状态码的命令。这一点是新手最容易忽略的。grep匹配到了,返回退出码0;没匹配到,返回退出码1;如果文件不存在或者出错了,返回退出码2。这个状态码在shell脚本里就是一条判断分支的依据。我见过很多人在脚本里写:
bash复制ps aux | grep "tomcat" | wc -l
然后拿这个数字去判断进程是否存在。这种写法其实又绕又不安全,因为你还要处理grep本身多出来的那一行。更地道的写法是:
bash复制if ps aux | grep -q "[t]omcat"; then
echo "tomcat is running"
fi
-q是quiet模式,匹配到了立刻返回0,不会再向标准输出打任何内容,grep扫到匹配行后甚至会提前终止。在多进程、多日志的场景下,这种“只想知道有没有”的用法反而比输出后数行数高效得多。这也是为什么我说grep的命令行参数,背后其实都对应着这三层设计逻辑里的某一层。你在用grep的时候,不妨先问一句:我现在的需求,是在做模式匹配,还是在处理流,还是只关心有没有?答案不同,选择的参数就完全不同。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 参数记忆地图:高频参数与冷门但好用的参数
grep的参数一共几十个,但如果按“实际使用频率”排序,你会发现真正天天用的其实就十几个。我不打算把man手册抄一遍,而是按场景给你画一张参数地图。
2.1 基本输出控制:-n、-v、-c、-o、-m
这些是日常用最多的参数。
-n:显示行号。排查日志时几乎必加。没有行号,你只知道哪一行有问题,还得再写个grep -n去定位,一步到位不好吗?grep -n "ERROR" app.log直接把行号打出来,后续用sed -n '123p' app.log单独看该行上下文就非常方便。
-v:反向匹配,筛掉匹配行。这个参数看起来简单,但有个经典误用场景:你想排除掉某个包的错误日志,结果grep -v "java.lang.NullPointerException"把所有包含这个字样的行都删了,但那些因为空指针导致后续连锁故障的日志行却没过滤掉,因为它们那一行文本里并没有“NullPointerException”这串字符。所以反向匹配筛的是“行”而不是“事件”,你得先确认你排除的内容真的能以整行的形式存在。
-c:只计数,不输出行内容。比如统计某类错误出现的次数,grep -c "TimeoutException" app.log直接返回数字。有人喜欢grep xxx | wc -l,但grep -c一个命令就搞定,还能少写一个管道,效率也更高。
-o:只输出匹配到的部分,而不是整行。这个参数在日志分析里简直是神器。比如日志里有几百行,每行都含有一串IP,你想把所有IP单独列出来去重统计,就可以:
bash复制grep -oE "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" access.log | sort | uniq -c | sort -rn
没有-o,你需要面对整行文本;有了-o,grep就变成了一台“文本提取机”。配合sort和uniq,你就有了一台简易的日志统计分析器。
-m:限制最大匹配行数。比如grep -m 10 "ERROR" app.log,匹配到10行之后就退出,不再继续扫描。对大日志文件排查“最早的几条错误”时,这个参数能让命令瞬间结束,不用傻等全文件扫完。
2.2 多个条件与模式管理:-e、-f、--include、--exclude
如果一次要匹配多个关键字,最简单的写法是:
bash复制grep -E "ERROR|WARN" app.log
但如果你的模式里本身带有|这样的正则元字符,或者条件特别多导致命令行变得很长,-e参数就派上用场了。它可以重复使用,每个-e指定一个模式,多个模式之间是“或”的关系:
bash复制grep -e "ERROR" -e "WARN" -e "FATAL" app.log
比写成一个超长的正则更清晰。如果你有一堆需要精确匹配的字符串,也可以把它们写进一个文件,每行一个,然后用-f批量加载。这个用法在“根据关键词清单去日志里捞数据”的场景特别舒服,比如从一堆订单号里筛出日志里出现过的订单:
bash复制grep -f order_ids.txt app.log
--include和--exclude这两个参数一般配合-r递归搜索用。在代码库里找变量定义时,不想看node_modules里的几万个小文件,也不想去翻图片文件,就可以这样:
bash复制grep -rn "tls_config" --include="*.go" --include="*.py" --exclude-dir=node_modules .
注意这里--exclude匹配的是文件名模式,--exclude-dir匹配的是目录名模式。很多新手只记住了--exclude,忘了还有--exclude-dir,结果他们想跳过node_modules目录,写的是--exclude="node_modules",屁用没有,因为node_modules是目录名,不是文件名。这也是一个我在代码搜索时经常看到的误区。
2.3 上下文控制:-A、-B、-C
单看一条错误日志往往不够,你还得看错误发生前后的日志。-A 2是显示匹配行后面的2行,-B 2是显示前面的2行,-C 2是前后各2行。排查线上问题时这个参数基本是标配,不然你只知道某行爆了一个错误,但完全不知道它前面发生了什么导致了这个错误。
bash复制grep -C 5 "OutOfMemoryError" app.log
输出会以--分隔符把每组命中的上下文块分开,非常适合批量浏览散落在日志各处的错误片段。另外要说一句,-C在有的grep实现里也可以用-B加-A组合代替,但既然GNU grep和BSD grep都支持-C,日常直接在命令行里用就是了。
2.4 高亮与彩色输出:--color
如果你直接在终端跑grep --color=auto "ERROR" app.log,匹配到的字符串会用红色标出来,一屏日志扫下来,眼睛轻松很多。--color=auto的意思是只在输出到终端时上色,一旦你把它重定向到文件或者管道给下一级命令,颜色代码就自动去掉,避免污染数据流。这个参数建议直接写进别名里:
bash复制alias grep='grep --color=auto'
我自己的shell配置里还加了GREP_OPTIONS,不过这个环境变量在部分新版本的grep里被移除了,所以最稳的还是写在alias里。还有一点,有些人会把--color=always写进脚本里然后重定向输出到文件,结果文件里全是\x1b[01;31m这种转义序列,整个文件基本报废。记住,脚本里老老实实用默认不带颜色,或者在重定向时区分好场景。
3. 正则这条主线:grep的三种正则模式与实战写法
grep最强大的地方在于支持正则,但正则也正是劝退很多人的地方。好消息是,你不需要把正则背得滚瓜烂熟,只要掌握三个层次,已经能覆盖90%的工作场景。
3.1 BRE与ERE:什么时候该转义
grep默认用的是基本正则表达式(BRE,Basic Regular Expression)。在BRE里,?、+、{、}、(、)、|这些元字符想要表达“正则含义”,反而要加上反斜杠。这跟大多数新手的直觉是反的。比如你想匹配“error”或“warning”,默认情况下得写:
bash复制grep "error\|warning" app.log
注意这个\|,在BRE里管道符不加反斜杠就是普通字符。而如果你用-E开启扩展正则(ERE,Extended Regular Expression),同样的逻辑就符合直觉了:
bash复制grep -E "error|warning" app.log
我平时几乎默认加-E。因为扩展正则的写法更接近现代正则的习惯,可读性更好,写复杂模式时不用纠结要不要转义。-E对应的命令是egrep,虽然egrep老版本里还能用,但它已经被标记为过时,新版grep虽然兼容,但官方推荐直接grep -E。
3.2 PCRE:进阶玩法
GNU grep还有一个-P参数,启用PCRE(Perl兼容正则)。PCRE比ERE更强大,支持环视(lookahead/lookbehind)、非贪婪匹配、命名分组等高级特性。比如你想提取某个关键字后面的数值:
bash复制grep -oP 'status=\K[0-9]+' app.log
这里的\K表示“之前匹配的部分不算在最终结果里”,相当于一个可变长度的后顾断言,在日志字段提取时非常好用。再比如你想匹配“error”这个单词但不是“errors”的一部分,而“s”是紧跟在后面的:
bash复制grep -P "error(?!s)" app.log
(?!s)是负向前瞻,指这个位置之后不能跟着s。这种正则写出、逻辑清晰,ERE也能写,但会绕很多。不过要提醒一句,-P依赖系统里的PCRE库,在Linux的GNU grep上基本都有,但macOS自带的BSD grep不支持-P。所以如果你在macOS上跑上面这条命令,会直接报错。跨平台时要么用-E写等效模式,要么用pcre2grep这样的独立工具。
3.3 字符类与贪婪陷阱
正则里最常见的一个坑是贪婪匹配。默认情况下.*会尽可能多地匹配字符,这在提取一行里的多个字段时会带来麻烦。举个例子,日志行是:
code复制2024-01-01 12:00:00 [INFO] user=alice action=login ip=10.0.0.1
你想提取user=后面的值到下一个空格之前,如果写成grep -oP "user=.* ",它会贪婪地匹配到最后一个空格,也就是把alice action=login ip=10.0.0.1全给吞了。正确写法是让.*变得“非贪婪”,PCRE里用.*?:
bash复制grep -oP "user=\K[^ ]+" app.log
或者换个思路,用字符类[^ ]+来表示“匹配一个或多个非空格字符”。这也是我在教别人写正则时强调的:能用字符类明确范围,就别依赖.*加非贪婪,因为字符类的写法更快、更稳定、也更好排错。另外,日志分析里IP地址提取、时间戳提取、邮箱匹配,都有对应的经典正则模板,收藏一份,遇到直接改参数用,比每次现场发挥靠谱得多。
4. 组合拳实战:日志排查与进程分析的标准操作
grep单用是一把刀,组合进管道里就是一条流水线。下面这几个组合,是我在实际工作中反复使用、几乎每周都要敲一遍的高频套路。
4.1 实时日志过滤:tail -f 与 grep 的流式结合
线上服务出了安全问题或用户反馈故障,最直接的操作就是盯实时日志。但tail -f app.log会刷得你怀疑人生,里面全是心跳、健康检查、无关请求。正确姿势是加一把grep过滤器:
bash复制tail -f app.log | grep "ERROR"
如果还要同时看WARN:
bash复制tail -f app.log | grep -E "ERROR|WARN"
这里有个小细节,grep默认是块缓冲,也就是说当它的输出不是终端(在管道里)时,它会攒一批数据再输出,而不是来一行打一行。这会导致实时性变差,看起来好像只匹配到了前几条,后面的迟迟不来。解决办法是加--line-buffered,让grep每匹配到一行就立刻冲刷到管道里:
bash复制tail -f app.log | grep --line-buffered "ERROR"
我踩过这个坑,当年排查一个线上偶发错误,tail -f | grep ERROR等了5分钟没反应,还以为是服务没打日志,其实日志早就滚过去了,只是grep在缓冲区里攒着没输出。直到我按了Ctrl+C,缓冲区一次性把几百行错误全吐出来,人直接傻掉。从那以后,凡是跟tail -f配合,我都会带上--line-buffered。
4.2 进程排查:ps aux 里的自我匹配问题
ps aux | grep xxx大概是Linux命令里的入门级组合,但它有个经典bug——grep匹配到了自己。你搜一个进程名“java”,结果输出里出现一行:
bash复制grep --color=auto java
这行其实是grep命令自己。解决办法有几种,最简单的是把进程名的第一个字符放进字符类里,让grep的正则模式无法匹配到它自己:
bash复制ps aux | grep "[j]ava"
因为ps输出里的那一行命令是grep [j]ava,正则[j]ava匹配的是字符j后面跟ava,而grep自身的命令行文本是[j]ava,方括号也是字符,所以匹配不上自己。这个方法巧妙在完全绕开了grep -v grep这种还要额外写一个管道的方案。如果你用的是pgrep、pkill这类专用工具,那就直接pgrep -f java,更规范,但ps aux配合grep的模式在要同时看进程CPU、内存占用、启动时间时无法替代,所以这个技巧还是得会。
4.3 端口排查:ss 与 grep 的结合
查端口被谁占用,我一般这样:
bash复制sudo ss -lntp | grep ":8080"
ss输出的每一行包含监听端口,grep直接帮你定位。如果要看某个进程到底监听了哪些端口:
bash复制sudo ss -lntp | grep "nginx"
这里有个小坑,端口匹配:8080时,你可能会把:80801这种也匹配进来。如果端口号一般是4位数,你最好写成:8080\s或者":8080 ",加个边界。实际用的时候我会写成:
bash复制sudo ss -lntp | grep -P ":8080\b"
\b是单词边界,80801后面跟着数字1,不算边界,所以不会被误匹配。
4.4 多个过滤条件的叠加:用多个grep代替复杂正则
很多场景下,一个grep塞一串复杂正则,远不如多个grep用管道串联清晰。比如我想找“ERROR级别的日志,包含timeout或refused,且不属于healthcheck”:
bash复制grep "ERROR" app.log | grep -E "timeout|refused" | grep -v "healthcheck"
这个写法逻辑清楚,每一级筛选都是独立的,读起来像流水线。写复杂正则时往往要同时考虑所有条件,堆出一个长串,难写也难维护。管道串联的好处是每一步都能单独验证,我先看第一个grep匹配了多少行,再叠加第二个条件看数量如何变化,排查效率高很多。缺点是多进程多管道,性能略差,但对普通日志量级完全无所谓。
4.5 递归搜索代码库:-r与文件过滤组合
在项目代码里找一个变量、函数定义或者配置项:
bash复制grep -rn "requestTimeOut" --include="*.java" --include="*.xml" .
加上-n直接显示行号,打开文件就能定位。如果项目用了git,你可以配合git log -S或git grep,这又是另一个话题了。但就算在git仓库里,git grep搜索的是已跟踪文件,而grep -r能顺带扫未跟踪的文件,各有各的使用场景,不是互相取代的关系。
5. 我踩过的grep坑:脚本里的隐藏杀手
这里专门开一节,讲讲我在脚本里遇到过的、稍不注意就会炸的问题。每一个都是我或我身边同事实实在在踩过的。
5.1 从Windows过来的文件为什么grep匹配不到
有一次写脚本处理一批从Windows机器上传过来的配置文件,里面明明有version=1.2.0,我用grep "version=1.2.0"却死活匹配不到。后来用cat -A一看,原来每行末尾都有一个^M,也就是\r(回车符)。Windows下文本文件的行结束符是\r\n,而Linux下是\n。你在grep里写version=1.2.0,但这一行实际的末尾是1.2.0\r,grep匹配整行时后面的\r会破坏锚点,尤其是用$结尾锚定时铁定匹配不上。
解决方法:先转换文件格式,用sed -i 's/\r$//' file.txt,或者dos2unix file.txt。如果你不想改文件,grep的正则里可以容忍这个回车符,比如:
bash复制grep "version=1.2.0\r\{0,1\}" file.txt
这个坑在脚本里特别隐蔽,因为它只在特定来源的文件上踩雷,平时一切正常,你很难想到是回车符在作祟。
5.2 匹配结果为空,但不是文件的错
一个更隐蔽的坑是locale环境变量导致grep匹配不到非ASCII字符。你的shell环境如果设置了LANG=zh_CN.UTF-8或者LANG=en_US.UTF-8,grep使用正则匹配中文时,字符类的行为在某些老版本grep上可能会跟你预期不一致。比如用grep -P "\p{Han}+"匹配中文,在PCRE库许可下可以工作,但如果你是grep "[一-龥]"这种区间,locale不正确时经常匹配不到。排查方法很简单:
bash复制echo "中文测试" | grep -P "\p{Han}+"
如果输出为空,检查locale命令的输出,把LANG=C.UTF-8或LANG=en_US.UTF-8设置好再试。我在生产容器里遇到过几次这种问题,容器基础镜像特别精简,默认locale是C,结果grep匹配中文全挂,一度以为是日志编码问题,后来才发现是locale没配。
5.3 管道后grep退出码134或141
在脚本里我写过这种命令:
bash复制cat huge.log | grep "pattern" | head -n 5
如果你开了set -o pipefail,会发现这个命令的退出码变成了141。141是什么?128+13,也就是SIGPIPE信号。因为head -n 5在读完5行后就直接退出了,不再读取管道里的数据,内核给还在拼命生产的grep发送SIGPIPE,grep收到信号后默认终止,退出码141。这个现象本身不算bug,head截断是正常行为,但如果你在脚本里对整个管道做错误检查,就会误以为出错了。解决方法是别对这类“提前截断”的管道用pipefail,或者改用grep -m 5:
bash复制grep -m 5 "pattern" huge.log
这样grep自己匹配5行就退出,生产端和消费端是同一个进程,不用再经过head,也不会产生SIGPIPE。同样的道理也适用于grep pattern file | head -1改成grep -m 1 pattern file。
5.4 grep -v的“负负得正”误伤
最经典的误伤场景是把exclude写成一个复合条件。比如我想排除所有包含“DEBUG”的日志行,但同时也想排除“TRACE”行,于是写了:
bash复制grep -v "DEBUG|TRACE"
记住,如果不加-E,这里的|是普通字符,实际匹配的是文本“DEBUG|TRACE”这四个字加一个管道符,而不是两个关键字。正确写法:
bash复制grep -vE "DEBUG|TRACE"
如果你用管道串联多个grep -v,逻辑是“且”的关系,也就是“既不能含DEBUG,也不能含TRACE”。但如果你用grep -vE "DEBUG|TRACE",逻辑也是“两样都不能含”。这两者在数学上是等价的。真正容易错的是场景:你想排除“含有DEBUG的行”或者“一行里有DEBUG关键字且又包含TRACE关键字”这类复杂条件,心态一乱就容易把逻辑写反。我的建议是:每写一个过滤条件,单独跑一遍,先确定它筛掉了哪些行,再叠加下一个条件。别试图一口气写出一条涵盖所有条件的超长正则,那是在给未来的你埋雷。
5.5 二进制文件干扰
grep默认对二进制文件的处理比较混乱。如果你在日志目录里混进了压缩包、图片之类的内容,grep可能会输出一堆“Binary file xxx matches”,甚至直接输出乱码。处理方式通常是加-a(--text)让grep把二进制文件也当文本处理,或者加-I让它完全跳过二进制文件。日志目录我不知道为什么总有人会把.tar.gz和.log混在一起,遇到这种场景我一般是:
bash复制grep -rnI "pattern" /var/log/
-I加进去,世界瞬间清净。这个参数在递归搜索时特别重要,不加-I的话,你可能会看到一堆二进制文件的匹配提示,干扰判断。
6. 性能认知:大文件与多文件搜索时的正确姿势
最后聊一下性能。grep已经很快了,但有几个认知能让你在超大文件和海量目录下更快、更省资源。
6.1 用grep -c代替wc -l
统计某个模式出现次数,新手常用grep xxx | wc -l。这个写法多一个进程,多一次管道拷贝,虽然性能损失不大,但没必要。用grep -c直接输出行数。注意-c统计的是“匹配的行数”不是“匹配的次数”,一行里出现10次也只算1。如果一行里多次出现、想统计所有出现次数,要用grep -o xxx | wc -l,这个组合无法被直接替代。
6.2 fgrep:当模式是纯字符串时更快
如果你的模式是固定字符串,没有正则符号,可以用grep -F(即fgrep)。-F强制按字面量匹配,不做正则解析,速度和内存占用都略优于普通grep。比如从几百万行日志里搜一个确定的订单号,grep -F "order_123456"会快一点。单个模式下差距不明显,但如果你用-f加载了几百个订单号,-F的差距就很可观了。注意-F不支持正则,所以那些|、.都会当普通字符处理,这在你的关键词里本来就带这些特殊符号时,反而更省心。
6.3 用--exclude-dir排除大型目录
在代码库里递归搜索时,性能消耗主要不是grep本身的扫描,而是它要打开无数个文件。如果你确定要搜的目标不会出现在某个目录,比如node_modules、.git、target、vendor,直接在命令里加排除项:
bash复制grep -rn "callApi" --exclude-dir={node_modules,.git,target,vendor} .
这个花括号展开是bash的语法,会展开成三个--exclude-dir参数。不同grep版本对多个--exclude-dir的兼容性可能有差异,但GNU grep一般没问题。加上这些排除项,搜索时间能从几十秒降到一两秒,体感极其明显。
6.4 大文件先过滤再处理
如果日志文件特别大,你关心的是“某个时间段内的错误”,可以先从时间戳上粗筛,再在结果上做细加工。比如日志行首都是时间戳,你可以:
bash复制grep "2024-01-01 12:" app.log | grep "ERROR"
先用第一层grep把范围缩小到某分钟,再筛错误级别。这比全文件grep一把梭、然后数据量大到下游处理工具卡死要优雅得多。同样的逻辑也适用于日志轮转后的多文件场景:cat app.log.1 app.log | grep可以把多个日志文件串成一个流再筛,或者直接grep "pattern" app.log.1 app.log,grep支持直接传多个文件参数,输出会带文件名前缀。
说到多文件搜索,我最后再分享一个实战小技巧。如果你在多个日志文件里搜同一个错误码,想统计每个文件里的出现次数,单靠grep要配合awk或循环脚本才行。但如果你只是想快速知道“哪些文件涉及这个错误码”,用-l比用-c更直观:
bash复制grep -l "ERR_10086" /var/log/app-*.log
-l只输出包含匹配内容的文件名,不输出匹配的行。反向版本是-L,输出不包含匹配内容的文件名。这俩参数在多文件场景里非常实用,比把每个文件的匹配行全打出来、再人肉找文件列表高效得多。我在排查一个分布式系统的报错时,经常先用-l定位哪些节点有问题,再针对性地去那些节点上看具体日志。
还有一个小细节,grep匹配到大文件时如果你想立刻停止,按Ctrl+C就能中断,这个不用多说。怕的是你明明只需要第一个匹配结果,却让grep把几G的日志全扫完。这就是-m 1的用武之地,搜到第一行就收工,时间成本从“全文件扫描”降为“跑到第一个匹配为止”。如果匹配恰好出现在文件末尾,那-m 1和普通grep耗时一样,但多数场景下,错误信息不会恰好全在文件末尾。这个参数的性价比极高,脚本里尤其值得用。
