1. 从一次日志排查开始:为什么你必须掌握正则和三剑客
我先说个真实场景。上个月某业务服务半夜报警,一小时内日志刷了几十万行,上下游都在甩锅。我登上服务器,先用 grep 把错误码捞出来看分布,再用 awk 按分钟统计报错频率,最后用 sed 把脱敏字段替换掉,把精简后的日志甩给值班群。前后十分钟,问题定位到是某个接口的超时阈值配置错了。整个过程没用任何监控平台,就靠三句话:
grep -E "ERROR|Exception" app.log | awk '{print $1}' | sort | uniq -c sed -i 's/1[3-7][0-9]{9}/***/g' app.log awk -F '\t' '{count[$2]++} END {for (k in count) print k, count[k]}' app.log这就是 Shell 文本处理的价值。你不需要装 Python、不需要上 Kibana,只要服务器能跑 bash,正则表达式加上 grep、sed、awk 这三个命令,就能解决日常工作中八成以上的文本处理需求。
这篇文章我打算把这三件套从头到尾拆开讲。它会覆盖正则表达式的基础语法和常见陷阱、grep 的过滤和上下文控制、sed 的流式编辑和批量替换、awk 的字段统计和进阶数据处理。适合刚接触 Shell 脚本的入门者,也适合写了两三年脚本但总觉得自己在"背参数"的人。读完之后,你再看到"用 grep 捞日志、用 sed 改配置、用 awk 做统计"这类需求时,不会再去搜命令,而是直接上手写。
需要说明的是,本文所有示例基于 GNU 版本的 grep、sed、awk,也就是 Linux 发行版默认自带的版本。macOS 自带的 BSD 版本个别参数略有差异,我会在涉及的地方标注。
2. 正则表达式基础:字符、量词、分组与转义
正则表达式是三剑客的共同语言。grep 查匹配行、sed 做替换、awk 做字段分割,底层都依赖正则来定义"什么样的文本是我们要处理的"。这一章先把语法讲透,后面讲命令时才不会卡壳。
2.1 匹配单个字符:普通字符与字符类
正则里最简单的匹配就是普通字符。你写grep "error",它就逐行找包含 error 这五个连续字符的行。这里有个新手容易忽略的点:grep 默认是"包含即匹配",不是"整行匹配"。比如grep "error"会把error_code、internal_error都捞出来。想要精确匹配,要么用-w参数匹配整个单词,要么在正则里加上边界标记\b或\<、\>。
当我们需要匹配一类字符时,就要用字符类(Character Class)。方括号[]里列出候选字符,匹配其中任意一个:
grep "[Ee]rror" # 匹配 Error 或 error grep "[0-9]" app.log # 匹配任意数字 grep "[a-zA-Z_]" source # 匹配任意字母或下划线字符类里还有两个常用否定写法。[^0-9]表示"匹配任意非数字字符",这里的^放在方括号开头表示取反,和后面要讲的行首锚定含义完全不同。另外像\d、\w这类简写符,在 grep 的基本正则(BRE)里是不支持的,只有 grep 的扩展正则(ERE)或 Perl 兼容正则(PCRE)才支持,这点后文会专门讲。
2.2 量词与贪婪匹配:*、+、?、{}
量词决定前面的字符或字符类重复多少次。这是新手最容易出 bug 的地方,特别是*的语义。
| 量词 | 含义 | 示例 | 匹配结果 |
|---|---|---|---|
* | 前导字符重复 0 次或多次 | ab*c | ac、abc、abbc |
\+(ERE 为+) | 重复 1 次或多次 | ab+c | abc、abbc,不匹配ac |
\?(ERE 为?) | 重复 0 次或 1 次 | ab?c | ac、abc |
{n,m} | 重复 n 到 m 次 | a{2,4} | aa、aaa、aaaa |
注意上表里的转义写法。grep 默认使用基本正则(BRE),在 BRE 中+、?、|、{}这些字符如果要作为量词或逻辑符号,必须在前面加反斜杠。这导致了很多经典"坑":比如想匹配a+b这个字面字符串时,在 BRE 里会被误认为是"一个或多个 a 后面跟 b";想表示"至少一个"却写a+而不是a\+,结果啥也匹配不到。最直接的解决办法有两种:要么用 grep-E切换到 ERE,要么用grep -P使用 PCRE,后面不再用转义写法。
贪婪匹配是另一个值得单独强调的点。默认情况下,*和+都是贪婪的,会尽可能多地匹配字符。比如文本abc123abc456,模式a.*c会匹配整个abc123abc,而不是只匹配开头的abc。在 PCRE 中,.*?表示懒惰匹配,只要满足条件就立刻停止。grep 的 BRE 和 ERE 不支持懒惰量词,sed 的替换也不支持。这是三剑客和 Perl、Python 正则最大的差别之一,后面组合处理文本时要注意。
2.3 锚定与分组:^、$、()、|
^匹配行首,$匹配行尾,它们不消费字符,只确认位置。grep "^ERROR" app.log只找行首为 ERROR 的日志行,grep "timeout$"只找行尾是 timeout 的行。这两个锚定在日志关键字匹配里用途极大,因为日志前缀相同但内容不同的情况太多了,行首过滤能砍掉大量噪声。
分组用圆括号,作用有两个:一是把多个字符打包成一个整体,配合量词使用,比如(ab){2}匹配abab;二是捕获匹配内容,供后面引用。捕获后怎么引用分两种情况:
- 在正则内部用
\1、\2反向引用。比如grep -E "([a-z]+) \1"能找出"单词 空格 相同单词"的行。 - 在 sed 替换命令中,用
\1引用分组捕获的内容来拼装新的文本,这是 sed 做字符串提取和重排的根基,后面细讲。
|是"或"的意思,表示匹配左右任意一个分支。在 BRE 里要写\|,在 ERE 里直接写|。比如grep -E "error|warning"同时匹配两种级别的日志。|的优先级比较诡异:它几乎是最低的,低于拼接。所以error|warning实际是"error 或 warning",而不是"error 或 warning"的什么组合。想表达"匹配 error 或者 warning 开头的行",必须写成^(error|warning),否则^error|warning表示"error 开头的行"或"任意位置有 warning 的行"。
2.4 字符转义与特殊符号的坑:\b、\.、\/
正则里的特殊字符包括.、*、+、?、[]、()、{}、|、^、$、\。如果我们要匹配这些字符的字面含义,就得加反斜杠转义。最常见的一个例子是匹配 IP 地址或带点的域名:
grep -E "192\.168\.1\.[0-9]+" app.log192.168.1.1中如果写成192.168.1.1,小数点会被当成"匹配任意字符",那192x168x1x1也会匹配,这显然不对。所以点号必须写成\.。类似地,路径里常见的/在正则里没有特殊含义,一般不用转义,但 sed 的s///命令把/当作分隔符时,你要替换的内容里含/就要写成\/,或者换用其他分隔符,比如s#old/path#new/path#,后面讲 sed 时会演示。
另一个高频坑是转义层级。在 Shell 双引号内写正则时,\b里的反斜杠在双引号中不会被 Shell 特殊处理,能原样传给 grep;但在单引号内也一样。真正出问题的是在双引号里写"$var",变量里的反斜杠可能被 Shell 或正则两层吃掉。稳妥的做法是:正则表达式一律用单引号包裹,除非你需要 Shell 展开变量。
到这里,正则的基础语法已经覆盖到位。接下来进入三剑客各自的实战,我会把命令参数、常见用法、工作场景揉到一起讲。
3. grep 实战:从定位关键字到上下文分析
grep 是三剑客里曝光率最高的一个,但大多数人对它的理解停留在"搜索关键字"。实际上 grep 的参数设计非常精巧,把它用透了,能解决很多"写循环"才能干的脏活。
3.1 基础参数:-E、-P、-i、-v、-w的取舍
拿最基本的参数梳理。-E让 grep 进入扩展正则模式,解决 BRE 的转义烦恼;-P使用 PCRE,额外支持\d、\w、\s、懒惰量词、零宽断言。绝大多数 Linux 发行版的 grep 都编译了 PCRE 支持,直接用就是。我在生产环境里默认写grep -E,因为 ERE 已经覆盖九成需求,而且可读性比 PCRE 的\d要差一些的是[0-9],但我个人不排斥-P,它写起来确实短。只是要注意:-P在跨平台环境下并不一定可用,脚本要分发到别的机器时慎用。
-i忽略大小写,-v反选(输出不匹配的行),-w整词匹配。这四个参数组合起来能玩出很多花活。比如你想看日志里除了 INFO 之外的所有级别:
grep -viE "info|debug" app.log再比如统计日志中非空非注释行:
grep -vE "^\s*$|^#" config.conf3.2 上下文控制:-A、-B、-C,让报错信息完整浮出水面
日志排查时,只捞到一行 ERROR 往往不够,你还要看它前后的相关日志。-A n显示匹配行之后 n 行,-B n显示之前 n 行,-C n前后各 n 行。举例:
grep -E "OutOfMemoryError" -C 5 app.log这条命令会输出包含 OOM 的行及其前后各 5 行。-C 5是每次排查时我必带的参数——只捞关键字容易断章取义,看到上下文才能还原现场。有个小技巧:如果错误码是贯穿性的,但你想看清楚每次报错的前因后果,可以先grep -n看行号,再用sed -n切特定区间,这个组合后面会讲。
3.3 多文件与递归:-r、-l、-c、-h,从单文件到批量扫描
当你在一个项目目录里找关键字时,grep -r "TODO" src/会递归遍历所有子目录。加上-l只输出文件名,不输出匹配内容;加上-c只输出每个文件的行数统计;加上-n输出行号。这俩在批量检索里特别有用。比如你接手一个别人的项目,想搞清楚"哪些文件里调用了old_api":
grep -rn "old_api" --include="*.py" . | head -50--include限定文件类型,能跳过二进制和无关目录。同样可以--exclude-dir=node_modules之类的排除项。这里顺便提一句-l和-c的典型配合:先grep -rl拿到文件清单,再xargs或for循环对每个文件做后续处理——这是后面 awk 统计的常见前置步骤。
3.4 输出格式细节:-o只打印匹配的部分
-o是一个容易被忽视但极其有用的参数。它告诉 grep 不要输出整行,而是只输出匹配到的子串。配合正则的捕获分组,能实现类似"数据抽取"的效果。比如从日志中把所有手机号拉出来去重:
grep -oE "1[3-9][0-9]{9}" app.log | sort -u从 nginx 日志中提取所有访问 IP 并统计次数:
grep -oE "^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" access.log | sort | uniq -c | sort -rn | head有一类操作特别考验基本功:先grep -o抽字段,再sort | uniq -c计数,再sort -rn按次数降序排。这套管道是日志分析的万能骨架,能把"哪些 IP 打得最多""哪个接口调得最频繁""哪种错误出现最多"这类问题三行解决。我在给新人演示 Shell 处理能力时,一般就演示这个组合,效果很直观。
4. sed 实战:流式编辑的替换、插入与区间处理
sed 的全称是 stream editor,它不修改原文件(除非加-i),而是把文本按行读入模式空间(pattern space),处理后输出。理解这个"按行流式处理"的模型,是掌握 sed 的关键。它不像 vim 那样需要交互,适合在管道里对数据做变换。
4.1 核心命令s:替换与捕获引用
sed 最常用的就是替换命令s/旧/新/。默认只替换每行第一个匹配,行尾加g表示全局替换:
sed 's/foo/bar/' file # 每行第一个 foo 换成 bar sed 's/foo/bar/g' file # 每行所有 foo 都换 sed 's/foo/bar/2' file # 只替换每行第二个匹配替换的"新"部分可以用&代表整个匹配的字符串,也可以用\1代表第一个分组。比如把key=value改成value is key:
echo "name=zhangsan" | sed 's/\([a-z]*\)=\([a-z]*\)/\2 is \1/'输出结果就是zhangsan is name。这里有两个容易踩的坑。第一个:sed 默认使用 BRE,分组必须写成\(,写(反而会被当普通字符处理。想要写成 ERE 风格,需要sed -E。第二个:替换文本里如果有/字符,会直接和定界符冲突,解决方案是换定界符,比如用#或,:
sed 's#/usr/local#/opt#' paths.txt sed 's,/usr/local,/opt,' paths.txt4.2 区间寻址:只处理指定范围的行
sed 的一大优势是能精确定位"哪些行需要处理",而不是一股脑全改。寻址方式有四种:
- 行号:
sed -n '5p'打印第 5 行;sed '5d'删除第 5 行。 - 行号范围:
sed -n '10,20p'打印第 10 到 20 行。 - 正则范围:
sed -n '/^ERROR/,/^END/p'从 ERROR 开始一直打印到 END 为止的所有行。 - 混合:
sed -n '10,/^END/p'从第 10 行到第一次出现 END 的行。
这些操作在日志切割里很常用。比如某次报错的完整堆栈从Exception开始到第一个空行结束,你想把每段堆栈单独拎出来,就可以用/Exception/,/^$/作为寻址区间。
实际工作中,我最常用的是-n加p做"按正则抽片段"。举例,你想看 app.log 里"从今天凌晨 0 点开始到第一次出现 END 标记"之间的所有行:
sed -n '/^2026-01-01 00:00:00/,/^END/p' app.log这个用法比 grep 的上下文模式更精确,因为它是"到某个结束标记为止",而不是固定行数。
4.3 常用操作命令:d、a、i、c与原地修改-i的注意事项
sed 不止能替换,还能删除、追加、插入、修改。命令对照如下:
| 命令 | 作用 | 示例 | 说明 |
|---|---|---|---|
d | 删除行 | sed '/^#/d' file | 删除所有注释行 |
a | 行后追加 | sed '/^version/a\new_line' file | 在匹配行后追加一行 |
i | 行前插入 | sed '/^version/i\before_line' file | 在匹配行前插入一行 |
c | 整行替换 | sed '/^version/c\version=2.0' file | 把匹配行替换成新内容 |
-i原地修改是 sed 最危险也最有用的参数。说它危险,是因为很多新手直接sed -i 's/foo/bar/' config.conf,结果配置文件被改坏且没有备份。我的习惯是任何-i操作都显式加备份后缀:sed -i.bak 's/foo/bar/' config.conf,这样改错了还能用备份找回来,改对了再清理备份文件。这个习惯在线上服务器操作时尤其重要,不要嫌备份文件碍事。
4.4 处理反斜杠与特殊字符:一个配置文件修改的实际案例
给你一个综合案例。假设 PostgreSQL 的配置文件里有这么一行:
listen_addresses = 'localhost'你想把监听地址改成内网 IP192.168.1.10。如果直接写sed "s/listen_addresses = 'localhost'/listen_addresses = '192.168.1.10'/",并把 double quote 换成单引号,正则里的*不会出问题,但内含的'会让命令很别扭。更稳的写法是用变量和不同定界符:
new_ip="192.168.1.10" sed -i.bak "s/listen_addresses = 'localhost'/listen_addresses = '$new_ip'/" postgresql.conf注意这里我用了双引号,因为要展开$new_ip变量。如果 IP 值里恰好有正则特殊字符(比如点号),应该先转义再传入:
escaped_ip=$(echo "$new_ip" | sed 's/\./\\./g')这个"把用户输入转成正则安全串"的技巧,在写通用脚本时非常有用。它背后的逻辑是:凡是外部传入的变量进入 sed 替换文本,默认都要做转义处理,否则一个.就能让匹配范围从"精确 IP"变成"任意字符",改错文件就晚了。
5. awk 实战:字段统计与数据重塑
如果说 grep 是过滤、sed 是变换,那 awk 就是计算和聚合。awk 天生为"按列处理"而生,把每一行按分隔符切分成字段,然后可以用类 C 的语法做条件判断、循环、数组统计。它的能力远超很多人的预期,但前提是理解它的"模式-动作"结构。
5.1 工作原理与内置变量:FS、OFS、NR、NF、$0、$1
awk 的基本结构是:
条件 { 动作 }它对输入逐行执行:当某行满足条件时,就执行对应动作。省略条件表示对所有行执行动作。这里的"条件"可以是正则、表达式、区间,甚至直接是1(恒真)。几个内置变量必须刻在脑子里:
FS:字段分隔符,默认是任意空白符(空格或 tab)。可以用-F ','改为逗号分隔。OFS:输出字段分隔符,默认空格,影响print $1, $2时字段之间的分隔。NR:当前读取的总行号。NF:当前行的字段数量。$0:整行内容。$1、$2等:第 1 个字段、第 2 个字段。
最简单的案例:打印第 2 字段和第 4 字段。
awk -F ',' '{print $2, $4}' data.csv如果我想让输出用 tab 分隔,就设置 OFS:
awk -F ',' 'BEGIN {OFS="\t"} {print $2, $4}' data.csvBEGIN是一个特殊的模式,它会在读取任何输入之前执行一次,适合做一些初始化操作,比如设置分隔符、打印表头、初始化统计变量。
5.2 模式匹配与条件过滤:正则与比较运算组合查询
awk 和 grep 一样可以在动作之前加正则条件。区别是 awk 能根据某个字段的内容做更精细的控制。比如你要过滤出第 3 列大于 100 的所有行:
awk -F ',' '$3 > 100 {print}' data.csv再比如第 2 列以ERR开头的行,打印第 1 列和第 2 列:
awk -F ',' '$2 ~ /^ERR/ {print $1, $2}' app.log这里的~表示"匹配正则",!~表示"不匹配"。逻辑组合用&&和||:
awk -F ',' '$1 ~ /^192\.168\./ && $4 > 5000 {print}' data.csv这条命令把"来源 IP 是内网段且响应时间超过 5000ms"的记录全部筛出来,在接口性能排查里非常实用。与 grep 相比,awk 的优势在于:grep 是对整行做正则匹配,awk 是对某几列做判断。数据有结构时,awk 的表达力强太多。
5.3 数组与统计:用 awk 实现group by效果
awk 的数组(关联数组)是实现分组统计的杀手锏。想在日志里按访问接口统计请求次数,并输出 Top 10:
awk '{count[$7]++} END {for (url in count) print count[url], url}' access.log | sort -rn | head -10解读一下:每读一行,以第 7 列为 key,对关联数组count的对应项加一。END 模式在所有行读完后执行,遍历数组并输出。得到的结果再接sort -rn排序,完成一次标准的 Top 统计。这比写 Python 脚本或 SQL 查询都快,因为不需要额外工具,服务器上原生就有。
更进阶一点,做分组聚合。假设日志里有时间、接口、耗时三列,我想按接口统计平均耗时:
awk '{total[$2] += $3; num[$2]++} END {for (k in total) print k, total[k]/num[k]}' perf.log这就是"准 SQL"的能力。关键是理解 awk 的流式处理模型——它不必把整个文件放进内存,每一行处理完即丢弃,只保留分组状态,所以哪怕处理几十 GB 的文件也不会内存爆炸。这个特性是 Python 逐行解析很难天然具备的。
5.4 内置函数与格式化输出:printf、length、sub、gsub
awk 内置了一批函数,让你不用求助外部命令就能完成很多字符串处理。最常用的是printf格式化输出。它和 C 语言的 printf 用法一致:
awk '{printf "%-20s %8.2f\n", $1, $3}' data.txt%-20s表示左对齐占 20 字符宽度,%8.2f表示浮点数占 8 位且保留两位小数。这在生成对齐工整的报表时非常有用,打印出来的表格比默认的空格分隔好看得多。
字符串替换有sub和gsub。sub(/old/, "new")只替换每行第一个,gsub(/old/, "new")全局替换,返回值是替换次数。一个经典场景:把 CSV 文件中的空值""替换为NULL:
awk -F ',' '{gsub(/^""$/, "NULL", $0); print}' data.csvlength函数返回字符串长度或数组元素个数,配合条件可以做"过滤掉长度异常的字段"之类的校验:
awk -F ',' 'length($2) > 100 {print "字段过长:", NR, $2}' data.csv5.5 多文件处理与 FNR:循环遍历多个文件时的坑
当 awk 同时处理多个文件时,NR是累计行号,FNR是当前文件行号。这个差异在按文件分别处理时很容易踩坑。举例,你想在每个文件的第 10 行插入一个文件头标记:
awk 'FNR == 10 {print "=== file ==="} {print}' *.txt如果用NR == 10,那就只会在所有文件的第 10 个累计行触发一次,结果完全不对。另一个多文件场景是"根据第一个文件的内容筛选第二个文件的行",这需要把第一个文件的 key 读进数组,第二个文件里再判断。这个模式其实不复杂:
awk 'NR == FNR {keys[$1] = 1; next} {if ($1 in keys) print}' key_file.txt data_file.txtNR == FNR的判断逻辑是:当 awk 读取第一个文件时,行号等于累计行号;进入第二个文件后,FNR 重新计数,二者不再相等。这个技巧在关联两个表格时非常高效,很多需要join的活用这个就解决了。
6. 三剑客的协作:从管道组合到脚本化
三剑客单独用已经很强了,更高级的用法是通过管道把它们串起来,让每个命令做自己最擅长的一步。数据流的处理模式是:grep 负责筛选、sed 负责变换、awk 负责统计,最后统一输出。
6.1 管道串联的经典模式:grep 过滤后交给 awk 统计
最典型的协作就是"grep 过滤,awk 聚合"。比如从 nginx 日志里统计 500 错误码对应的请求 URL 分布:
grep -E '" 500 ' access.log | awk '{print $7}' | sort | uniq -c | sort -rn第一步用 grep 把包含" 500的行筛出来(引号是为了防止误匹配到 5000 这类状态码),第二步 awk 提取请求路径(第 7 列),再交给sort | uniq -c计数。这条链路的每一步都极简,但组合起来能回答"哪些接口最近在大量报 500"这个业务问题。
再如,从应用日志里统计最近一小时内每个服务实例的报错次数:
grep -E "^2026-01-01 10:" app.log | grep -E "ERROR|WARN" | awk '{print $4}' | sort | uniq -c这里$4是日志格式里的实例 ID 字段,具体第几列取决于你的日志模板。管道化思路的关键:不要试图用一个命令解决所有问题,而是把问题拆分成"筛选、提取、聚合"三个环节,每个环节各司其职。
6.2 用 sed 做数据清洗后交给工具处理
另一种协作方式是"sed 清洗,后续消费"。比如从 csv 文件中剔除引号内的逗号干扰(假设引号内的逗号不该作为分隔符),或者把字段之间的多个空格压成单空格,让 awk 按空格切分更干净:
sed 's/ */ /g' messy.txt | awk '{print $2, $5}'s/ */ /g把所有连续空格压成单个空格。这个操作在处理ps aux、df -h这类输出时很常用——它们默认用多个空格对齐列,直接用 awk 的默认 FS 其实也能处理(默认空白符包括多个空格),但如果你先清了空格,后续排序或进一步处理会更稳。
再举一个综合场景:日志里有敏感的手机号,需要脱敏后把统计结果发出去。做法是先用 sed 替换手机号中间四位为星号,再 awk 统计地域分布:
sed 's/1[3-9][0-9]{9}身份证号正则模式/1[3-9][0-9]{4}****[0-9]{4}/' raw.log | awk '{print $2}' | sort | uniq -c注意上面示例里的替换正则,手机号是 11 位,分成前 3 位、中间 4 位(替换为*)、后 4 位。正则写法可以直接写1[3-9][0-9]{9},但替换符号里的{4}可能被 sed 解释成量词,所以中间那个位置要么直接写四个*字符,要么用[0-9]{4}匹配后再替换。稳妥写法是:
sed -E 's/(1[3-9][0-9]{4})[0-9]{4}([0-9]{4})/\1****\2/' raw.log用-E扩展正则,用分组捕获前 7 位和后 4 位,中间四位置换成星号。这就是 sed 做数据清洗的标准姿势。
6.3 结合循环:对多个文件或参数批量处理
当你要处理多个文件、且每个文件的处理逻辑一致时,Shell 的 for 循环和三剑客是绝配。比如把所有.log.20260101到.log.20260107的文件里的 ERROR 提出来汇总成一个文件:
for f in app.log.2026010{1..7}; do grep -H "ERROR" "$f" done > errors_all.log或者每个文件单独统计后追加到一个汇总表:
for f in *.log; do count=$(grep -c "ERROR" "$f") echo "$f $count" done | sort -k2 -rn | head -5这种循环批量处理的模式,适合"多文件、固定逻辑、结果汇总"的需求。
6.4 性能对比:什么时候该用 grep,什么时候该用 awk
很多新手纠结"同一个需求到底该用 grep 还是 sed 还是 awk"。我直接给一个选型判断表:
| 需求类型 | 首选工具 | 原因 |
|---|---|---|
| 只筛选行,不需要改内容 | grep | 专门干这个,性能最好 |
| 按行做替换、删除、插入 | sed | 流编辑的强项 |
| 按字段统计、计算、聚合 | awk | 有字段概念和内置函数 |
| 需要跨文件关联数据 | awk | 用数组模拟 join |
| 只想要匹配到的子串 | grep -o | 比 sed 提取简单得多 |
| 对不规则的文本做多步变换 | 组合 | 一步命令解决不了就管道 |
从性能角度讲,grep 通常是最快的过滤工具,因为它的实现高度优化,能快速跳过不匹配的行。sed 第二,awk 最慢但功能最全。所以一个经验法则是:能用 grep 先过滤掉大部分行,就不要把全部数据塞给 awk。比如:
awk '/ERROR/ {print $3}' app.log这个完全可以写成:
grep "ERROR" app.log | awk '{print $3}'在某些极端情况下,后者的整体耗时可能不到前者的三分之一。数据量小时无所谓,但处理 GB 级日志时,这种优化立竿见影。
7. 实战案例:用一行命令组合完成访问日志分析
理论讲得再多,不如把整个分析流程走一遍。这里我用一个接近真实的访问日志分析案例,把前面所有知识点串起来。假设 nginx 的 access.log 格式是典型的组合日志:
192.168.1.10 - - [01/Jan/2026:10:15:22 +0800] "GET /api/user/info HTTP/1.1" 200 532 "https://example.com/page" "Mozilla/5.0" 192.168.1.11 - - [01/Jan/2026:10:17:45 +0800] "POST /api/login HTTP/1.1" 500 124 "-" "curl/7.68.0"列位依次是:IP、-、-、时间、请求行(方法+路径+协议)、状态码、响应字节数、Referer、User-Agent。我要分析几个问题:
问题一:每个 IP 的请求次数 Top 5。
awk '{count[$1]++} END {for (ip in count) print count[ip], ip}' access.log | sort -rn | head -5问题二:请求失败(状态码 >= 400)的接口 Top 10。
awk '$9 >= 400 {print $7}' access.log | sort | uniq -c | sort -rn | head -10这条命令里$9是状态码,$7是请求路径。awk 的条件过滤一步到位,不需要 grep 再包一层。
问题三:统计每小时(按日志时间前两小时?不,这里用小时间隔)的请求量,找到波峰时段。
先提取小时字段。日志时间形如[01/Jan/2026:10:15:22 +0800],小技巧是用gsub或者直接按:分割。简便做法:
awk '{split($4, t, ":"); hour=t[1] ":" t[2]; count[hour]++} END {for (h in count) print h, count[h]}' access.log | sortsplit函数把第 4 列的[01/Jan/2026:10:15:22按:分割,取第一部分里的日期和第 2 段的小时拼出10:15这种粒度。如果你想按整点统计,就只要t[2]:
awk '{split($4, t, ":"); count[t[2]]++} END {for (h in count) print h, count[h]}' access.log | sort -n | tail -5问题四:找出响应时间读起来像"过慢接口"的高延迟样本。
如果日志里有响应时间字段(假设第 10 列是耗时 ms),直接:
awk '$10 > 3000 {print $1, $7, $10}' access.log | sort -k3 -rn | head -20问题五:过滤掉健康检查请求,再做上述分析。健康检查通常会请求/healthz路径:
awk '$7 != "/healthz" {count[$1]++} END {for (ip in count) print count[ip], ip}' access.log | sort -rn | head -5这个案例覆盖了字段提取、条件过滤、分组统计、排序截断、排除特定值。做完这几步,访问日志的核心分析基本就不再需要其它工具了。
8. 踩坑清单:转义、编码、平台差异与性能陷阱
最后一部分,我把这些年实际工作中踩过的和三剑客相关的坑集中列一遍。这些坑有一个共性:不在语法书里,但在线上环境里一定会遇到。
8.1 转义层级:Shell 一层、正则一层
最常见的坑就是转义被 Shell 吃掉。你在双引号里写"\d+",Shell 可能会把\d原样传给 grep,也可能在某些 shell 里处理掉\d。更麻烦的是$符号——在双引号里$会被当成变量引用的开头,所以正则里的行尾锚定$必须写成\$或者用单引号:
# 错误示范 grep "error$" file # 如果 file 里有一行结尾是 error,双引号里 $ 会被 Shell 误判,大多数情况直接报错或匹配异常 # 正确示范 grep 'error$' file写 Shell 脚本时,一条铁律是:正则表达式除非要展开变量,否则一律用单引号。如果必须用双引号展开变量,那变量中可能存在的正则特殊字符要单独做转义处理,第八章里 sed 改配置的例子就是这么处理的。
8.2 中文和多字节字符的处理:locale 与编码
如果你处理的文本包含中文、日文等多字节字符,grep、sed、awk 的行为受 locale 影响很大。默认情况下,如果LANG=zh_CN.UTF-8,正则会按字符而不是字节去匹配,这通常是我们想要的。但如果 locale 设置不对(比如LC_ALL=C),正则里的.、[^x]这类元字符会按字节匹配,一个 UTF-8 中文占 3 个字节,.就只能匹配其中一个字节,结果就是匹配结果碎裂,输出乱码。
解决方法是:在脚本开头明确设置 locale:
export LC_ALL=C但注意,LC_ALL=C下正则.是按字节工作的,如果你明确要做字节级处理,这是优点;但需要按字符匹配时,要设成 UTF-8 的 locale。没有一个设置永远正确,取决于你的数据处理需求。如果文本是 UTF-8 中文,又想按字符匹配,就用:
export LC_ALL=en_US.UTF-8[0-9]在 UTF-8 locale 下还会匹配全角数字(某些语言环境),这可能是坑也可能不是。多数情况下,我只关心 ASCII 数字,这时写[0-9]还是[[:digit:]]都行,但后者语义更明确。
8.3 大文件下的性能:流式处理 vs 全文加载
grep、sed、awk 都是流式处理工具,理论上占用内存很小。但如果误用某些参数,也会把内存吃爆。最容易出问题的场景是grep -o配合超大单行文件。比如一个 5GB 的 JSON 文件里所有内容都在一行,grep -oE "某模式"会把这个长行切出来的所有匹配存进内存再输出,匹配数量巨大时内存可能直接打满。
另一个坑是 sed 的-i在超大文件上的行为:GNU sed 是原地处理,会生成临时文件再替换,所以磁盘需要至少两倍文件大小的空间。如果磁盘快满了,sed -i会中途失败。这时可以先df -h检查空间,不够就改成分批处理:
head -1000000 bigfile > part1 sed -E 's/foo/bar/' part1 > part1_new cat part1_new part2 > newfileawk 的数组统计遇到海量唯一 key 也会吃内存。比如统计每个 IP 的请求数,如果 IP 是伪造的、变化无穷,数组无限增长,内存压力巨大。这时可以先用sort | uniq -c这类外部排序工具把唯一的 key 先在磁盘上归并,再给 awk 喂聚合后的数据,能大幅降低内存占用。
8.4 GNU 与 BSD 差异:macOS 上的兼容性坑
Linux 和 macOS 都能跑 bash,但 grep、sed、awk 的实现不同。最典型的差异在 sed 的-i参数:GNU sed 支持-i(可加后缀),BSD sed 要求-i后面必须跟后缀,直接写sed -i 's/a/b/'会报错,因为 BSD 把's/a/b/'当成了后缀。所以跨平台的 sed 原地替换要么写成sed -i.bak,要么就用-e配合临时文件。
awk 在 macOS 上默认是 BWK awk(也叫 nawk),和 Linux 的 gawk 有一些功能差异:gawk 支持-v传入变量、asort、gensub等,nawk 不支持。写脚本时尽量只用 POSIX awk 共有的语法,否则就把目标环境钉死在 Linux。
8.5 空行、行尾符和 BOM 头:肉眼看不出来的敌人
文本处理里最隐蔽的问题是看不见的字符。Windows 制作的文件通常带 CRLF 行尾,Linux 下读到的每一行末尾都挂着一个\r。如果你用awk -F ',' '{print $NF}'提取末尾字段,字段里藏了\r,比较和输出都会出问题。解决办法是先洗数据:
sed -i 's/\r$//' file.csv或者直接在 awk 里用sub(/\r$/, "", $0)。另一个问题是 UTF-8 的 BOM 头,文件开头藏了三个不可见字节,导致第一个字段前面多了乱码。用 sed 去掉:
sed -i '1s/^\xEF\xBB\xBF//' file.csv这类问题排查时很难一眼看出来,你会觉得"明明字段内容看起来一样,为什么不等于"。但只要在脚本入口统一定时清理行尾符,就能避免大量莫名其妙的 bug。
9. 写在最后:把三剑客变成肌肉记忆
回到文章开头的那次线上排查。事后我把那台服务器上用到的命令整理成了一个小脚本,收在团队的运维工具目录里。后来遇到类似问题,同事们第一时间就会想到"跑一下那个脚本",而不是到处找监控面板。这就是 Shell 文本处理能力的价值——它不是某个场景的一次性解法,而是能内化成一套思维方式的工具集。
我个人的体会是,掌握三剑客的捷径不是记参数,而是反复做"清洗数据、提取字段、统计聚合"这三类基础练习。日志分析、配置批量修改、报表生成,本质上都是这三件事的排列组合。等你把grep -E、sed -E s///、awk '{print}'这几个最常见动作练到不看手册就能写出来,再去翻文档学冷门参数,会发现那些参数理解起来快很多。
最后再分享一个小技巧:避免在脚本里写超长的一行管道命令。每一条管道拆成多行,用反斜杠续行,中间加注释。线上脚本是要给别人维护的,命令的可读性往往比执行效率更重要。比如:
grep -E "ERROR|WARN" app.log \ | sed -E 's/^([0-9-]+ [0-9:]+).*/\1/' \ | sort | uniq -c \ | sort -rn | head -20这样写,三个月后的你(和你的同事)都能一眼看明白这段命令在干什么。