2024 年有一次线上接口报错率突然升高,我需要从一台服务器的 Nginx 访问日志里把问题摸清楚:哪个接口在报错、报的什么状态码、是哪些 IP、集中在哪个时间段、响应有多慢。这个日志文件有十几万行、好几百兆。当时我们的日志还没接入 ELK,手上只有一台终端。就在这种"裸奔"的环境里,我靠着 grep、awk、sed 这 Linux"三剑客",硬是把整件事的来龙去脉一行一行捞了出来。事后我把这套文本处理的套路认真整理了一遍——它是每个工程师都该有的硬功夫。本文用这次实战复盘把三剑客讲清楚。
问题背景
环境:CentOS 7,一台 Nginx 服务器
日志文件 access.log,约 15 万行,420 MB
日志格式(典型的 Nginx combined 格式):
192.168.1.10 - - [14/May/2024:15:30:12 +0800] "GET /api/order HTTP/1.1" 200 1532 "-" "okhttp/3.14" 0.045
# 字段顺序:
# $1 客户端IP $4 时间 $6 请求方法 $7 URL $9 状态码
# $10 响应字节 $12 UserAgent 最后一列 0.045 是响应耗时(秒)
要查清的问题:
- 报错(5xx)的请求有多少,占比多大
- 报错集中在哪个接口
- 报错来自哪些 IP,是不是某个客户端在搞事
- 报错集中在哪个时间段
- 哪些接口响应最慢
手上的工具:只有一台终端,没有 ELK。
靠的就是 grep + awk + sed 三剑客。
修复 1:grep——先把目标行"捞"出来
# === grep 的定位:从海量文本里【筛选出匹配的行】 ===
# === 最基本:找出所有 5xx 报错的行 ===
$ grep ' 500 ' access.log
# 注意状态码两边带空格,避免匹配到 "5001" 之类
# 用正则匹配一整类:5 开头的三位状态码
$ grep -E '" 5[0-9]{2} ' access.log
# -E 启用扩展正则
# === grep 几个高频选项 ===
$ grep -c '" 5[0-9]{2} ' access.log # -c 只输出匹配的【行数】
$ grep -n 'timeout' access.log # -n 显示行号
$ grep -i 'error' access.log # -i 忽略大小写
$ grep -v '200' access.log # -v 反选:输出【不】匹配的行
$ grep -E '500|502|504' access.log # 或的关系
# === 出错时,看看前后文(上下文)===
$ grep -A 3 -B 3 ' 502 ' access.log
# -A 3 匹配行后 3 行,-B 3 前 3 行,-C 3 是前后各 3 行
# 排错时这个特别有用,能看到出错请求前后发生了什么
# === 多文件 / 整个目录里搜 ===
$ grep -rn 'OutOfMemory' /var/log/
# -r 递归子目录,-n 带行号
# === 我们的第一步:报错总量和占比 ===
$ total=$(wc -l < access.log)
$ err=$(grep -cE '" 5[0-9]{2} ' access.log)
$ echo "总请求 $total,5xx 报错 $err"
# 总请求 152340,5xx 报错 3877
# 报错率约 2.5% —— 确实异常,平时不到 0.1%
修复 2:awk——按"列"切开,做统计
# === awk 的定位:把每行按【列】切开,做提取和统计 ===
# grep 管"挑出哪些行",awk 管"对这些行做什么"。
# === awk 最核心的概念:$1 $2 ... 是【列】 ===
# awk 默认按空格/制表符把一行切成若干字段,
# $1 是第 1 列,$2 第 2 列... $0 是整行,NF 是列数。
# === 例:只打印 IP 和状态码两列 ===
$ awk '{print $1, $9}' access.log
# === awk 的结构:'条件 { 动作 }' ===
# 例:只打印状态码是 500 的行的 IP
$ awk '$9 == 500 {print $1}' access.log
# $9==500 是条件,{print $1} 是动作
# === 杀手锏:用数组做"分组计数" ===
# 统计每个状态码各出现了多少次:
$ awk '{count[$9]++} END {for (c in count) print count[c], c}' \
access.log | sort -rn
# 148001 200
# 3877 500
# 460 502
# count[$9]++ :以状态码为 key 在数组里累加
# END {...} :所有行处理完后,统一输出
# 这就是 awk 最常用的套路:数组计数 + END 输出
# === 问题1:报错集中在哪个接口 ===
$ awk '$9 ~ /^5/ {print $7}' access.log | \
sort | uniq -c | sort -rn | head -5
# 3201 /api/order/detail
# 410 /api/order/list
# $9 ~ /^5/ :状态码以 5 开头(~ 是正则匹配)
# 真相浮现:报错高度集中在 /api/order/detail
# === 问题2:报错来自哪些 IP ===
$ awk '$9 ~ /^5/ {print $1}' access.log | \
sort | uniq -c | sort -rn | head -5
# 1980 10.0.2.55
# 210 10.0.2.56
# 近一半报错来自同一个 IP —— 值得重点查这个客户端
# === 问题4:哪些接口最慢(最后一列是耗时)===
# 算每个接口的平均响应耗时:
$ awk '{sum[$7]+=$NF; cnt[$7]++}
END {for(u in sum) printf "%.3f %d %s\n", sum[u]/cnt[u], cnt[u], u}' \
access.log | sort -rn | head -5
# $NF 是最后一个字段(耗时),sum 累加、cnt 计数,
# END 里算平均。printf 控制输出格式。
修复 3:awk 进阶——时间段与条件过滤
# === 问题3:报错集中在哪个时间段 ===
# 时间字段是 $4,形如 [14/May/2024:15:30:12
# 我们想按"小时"聚合,需要从中切出小时。
# === 用 awk 的 substr 截取子串 ===
$ awk '$9 ~ /^5/ {print substr($4, 14, 2)}' access.log | \
sort | uniq -c
# 120 09
# 3100 15 <-- 15 点这一小时爆了
# 210 16
# substr($4, 14, 2):从 $4 第 14 个字符起取 2 个字符
# [14/May/2024:15:30 -> 第14位开始的 "15"
# 结论:报错几乎全挤在 15:00-16:00 这一小时
# === 组合条件:15 点 + /api/order/detail + 5xx ===
$ awk '$9 ~ /^5/ && $7 ~ /order\/detail/ && substr($4,14,2)=="15"' \
access.log | wc -l
# && 是与,|| 是或,可以把多个条件叠起来精确锁定
# === awk 也能做范围筛选:找耗时超过 1 秒的慢请求 ===
$ awk '$NF > 1.0 {print $NF, $9, $7}' access.log | sort -rn | head
# 把响应耗时 > 1 秒的请求挑出来,按耗时倒序
# === BEGIN / END:开头和结尾各做一次的动作 ===
$ awk 'BEGIN{print "=== 慢请求清单 ==="}
$NF > 1.0 {n++; print $7, $NF}
END{print "共", n, "条"}' access.log
# BEGIN 在处理任何行之前执行(打表头、初始化)
# END 在处理完所有行之后执行(打汇总)
# === 指定分隔符:-F ===
# 如果日志不是空格分隔,比如 CSV:
$ awk -F',' '{print $3}' data.csv
# -F',' 告诉 awk 用逗号切列
修复 4:sed——按"行"做编辑和替换
# === sed 的定位:对文本做【流式编辑】——替换、删除、抽取 ===
# === 最常用:替换 s/旧/新/ ===
$ sed 's/HTTP\/1.1/HTTP\/2/g' access.log
# s/.../.../g 替换,g 表示一行里所有匹配都换(不加只换第一个)
# 注意:sed 默认只是把结果打到屏幕,【不改原文件】
# === 真正改文件要加 -i(务必先备份!)===
$ sed -i.bak 's/old/new/g' config.txt
# -i.bak:改原文件,同时把原文件备份成 config.txt.bak
# 直接 -i 不带后缀也行,但没备份,改错就回不去了
# === 按行号 / 范围抽取 ===
$ sed -n '100,200p' access.log # 只打印第 100-200 行
# -n 取消默认输出,p 表示打印。组合起来 = 只看这一段
$ sed -n '50p' access.log # 只看第 50 行
# === 按内容删除行 ===
$ sed '/health-check/d' access.log
# 把含 health-check 的行删掉(d = delete)
# 排查时常用它先剔除掉健康检查、监控探测之类的噪音行
# === 我们的实战用法:抽取关键时间窗口 ===
# 先定位到 15:00 的第一行和 16:00 的第一行行号,
$ grep -n '14/May/2024:15:00' access.log | head -1
98012:...
$ grep -n '14/May/2024:16:00' access.log | head -1
131890:...
# 再用 sed 把这一段精确切出来单独分析:
$ sed -n '98012,131890p' access.log > window.log
# 这样后续所有分析都只针对故障那一小时,又快又聚焦
# === 提取匹配的一部分(配合分组)===
# 从日志里只把 URL 抽出来:
$ sed -nE 's/.*"(GET|POST) ([^ ]+) HTTP.*/\2/p' access.log | head
# (...) 是分组,\2 引用第 2 个分组,只打印替换成功的行
修复 5:管道——把三剑客串成一条流水线
# === 三剑客真正的威力,在于用管道 | 把它们串起来 ===
# 每个工具只做自己最擅长的一件事,数据在它们之间流动。
# === 一条龙:故障那一小时,最频繁报错的接口 Top5 ===
$ sed -n '98012,131890p' access.log \
| grep -E '" 5[0-9]{2} ' \
| awk '{print $7}' \
| sort | uniq -c | sort -rn | head -5
# 拆解这条流水线:
# sed -> 先切出 15:00-16:00 这一段
# grep -> 从中筛出 5xx 报错的行
# awk -> 每行只取出 URL($7)那一列
# sort -> 排序(uniq 要求相邻才能去重,所以先排)
# uniq -c -> 相邻相同行合并,并数出每组的数量
# sort -rn -> 按数量倒序
# head -5 -> 只要前 5 名
# === 这套组合是日志分析的"万能模板" ===
# grep 筛行 -> awk 取列 -> sort | uniq -c | sort -rn
# 把它记牢,以后"统计 XX 出现频率"几乎都是套这个。
# === 再来一条:报错 IP + 接口的组合分布 ===
$ grep -E '" 5[0-9]{2} ' access.log \
| awk '{print $1, $7}' \
| sort | uniq -c | sort -rn | head -10
# 同时看 IP 和接口两列,能发现"某个 IP 专打某个接口"
# === 统计去重数量:到底有多少个不同的报错 IP ===
$ grep -E '" 5[0-9]{2} ' access.log \
| awk '{print $1}' | sort -u | wc -l
# sort -u 去重,wc -l 数行 -> 不同 IP 的个数
# === 实时跟日志 + 过滤(故障还在持续时)===
$ tail -f access.log | grep --line-buffered -E '" 5[0-9]{2} '
# --line-buffered:让 grep 别缓存,实时一行行吐出来
# 一边发生一边看,报错出现立刻就能看到
修复 6:这次排查的结论与处置
# === 三剑客把事实一条条摆了出来 ===
# 1. 总请求 15.2 万,5xx 报错 3877,报错率 2.5%(平时 <0.1%)
# 2. 报错 83% 集中在 /api/order/detail 这一个接口
# 3. 报错 51% 来自同一个 IP 10.0.2.55
# 4. 报错几乎全挤在 15:00-16:00 这一小时
# 5. /api/order/detail 平均耗时从 50ms 涨到了 2.3s
# === 顺着事实定位根因 ===
# 把 10.0.2.55 在那一小时的请求单独拉出来看:
$ sed -n '98012,131890p' access.log \
| awk '$1=="10.0.2.55" {print $4, $7}' | head -20
# 发现它在用一个空的查询条件疯狂翻页拉 order/detail,
# 每秒几十次 —— 是对方一个批处理脚本写错了,
# 把本该凌晨跑的全量拉取,配成了白天每秒高频请求。
# === 处置 ===
# ① 应急:先对这个 IP 限流
$ # 在 Nginx 里对该接口加 limit_req,挡住异常高频
# ② 沟通:通知对方修正它的批处理脚本和调度时间
# ③ 根治:给 /api/order/detail 加上分页大小上限和
# 必填查询条件校验,不允许无条件全量翻页
# ④ 补课:推动把日志接入 ELK ——
# 这次靠三剑客硬扛下来了,但下次该有更顺手的工具
# === 复盘心得 ===
# 没有 ELK 的那一个小时,三剑客就是我的 ELK。
# grep 筛行、awk 取列做统计、sed 切片做编辑,
# 再用管道串起来 —— 这套硬功夫,任何一台 Linux
# 终端上都现成可用,关键时刻不掉链子。
命令速查
需求 命令
=============================================================
筛出匹配的行 grep '关键字' file
正则筛行 grep -E '5[0-9]{2}' file
统计匹配行数 grep -c '关键字' file
反选不匹配的行 grep -v '关键字' file
看匹配行的上下文 grep -A3 -B3 '关键字' file
递归搜目录 grep -rn '关键字' dir/
打印指定列 awk '{print $1, $9}' file
按条件过滤行 awk '$9 ~ /^5/' file
分组计数(核心套路) awk '{c[$9]++} END{for(k in c)print c[k],k}'
截取子串 awk '{print substr($4,14,2)}'
指定分隔符 awk -F',' '{print $3}' file
替换文本 sed 's/old/new/g' file
改原文件并备份 sed -i.bak 's/old/new/g' file
打印指定行范围 sed -n '100,200p' file
删除匹配的行 sed '/noise/d' file
频率统计万能模板 grep ... | awk '{print $N}' | sort | uniq -c | sort -rn
口诀:grep 筛行 -> awk 取列做统计 -> sed 切片做编辑 -> 管道串起来
避坑清单
- 分清三剑客分工:grep 管筛行,awk 管按列提取统计,sed 管按行编辑替换
- grep 匹配状态码等数字要带边界(两边空格或用正则),否则会误匹配 5001 之类
- grep -A/-B/-C 看上下文,排错时能看清出错请求前后发生了什么
- awk 的 $1 $2 是列,NF 是最后一列,NF 是列数,别记混
- awk 数组计数 + END 输出是分组统计的核心套路,务必记牢
- awk 多条件用 && 和 ||,能把时间、接口、状态码叠起来精确锁定
- sed 默认只打到屏幕不改文件,要改文件必须 -i,且强烈建议用 -i.bak 留备份
- sed -n 配合 p 才能精确抽取行范围,单独的 p 会导致重复输出
- uniq 只能合并相邻的相同行,所以 uniq 前必须先 sort,否则统计不准
- 频率统计的万能模板是 grep 筛 → awk 取列 → sort | uniq -c | sort -rn,套用即可
总结
这次没有 ELK、只靠一台终端硬扛下来的日志排查,让我重新认识了 grep、awk、sed 这"三剑客"的分量。在平时,我们习惯了把日志一股脑丢进 ELK、丢进各种花哨的可视化平台,点几下鼠标就有图有表,久而久之,很容易产生一种错觉,觉得离开了这些平台就没法分析日志了。可这次的现实是,日志还没接入 ELK,故障又必须当场查清,我手上就只有一台 Linux 终端和一个十几万行、几百兆的文本文件。也正是在这种"裸奔"的处境里,我才真切地体会到,三剑客这套东西不是什么过时的老古董,而是一种任何一台 Linux 机器上都现成可用、关键时刻绝不掉链子的硬功夫。要用好它们,第一件事是把三者的分工想清楚,因为它们各管一摊、互不替代:grep 管的是"筛行",它的使命是从浩如烟海的文本里把你关心的那些行挑出来;awk 管的是"取列",它会把每一行按列切开,让你能精确地拿到第几列,并且用数组做分组统计;sed 管的是"按行编辑",替换、删除、按行号抽取范围,都是它的活。把这个分工刻进脑子里,你拿到一个文本处理需求时,就能立刻知道该派哪一把"剑"上场。在这三者里,awk 是最值得花力气吃透的,因为真正的统计分析几乎都靠它,而 awk 里又有一个堪称"万能套路"的模式:用数组以某个字段为 key 做累加计数,再在 END 块里把结果统一输出。这次排查里,"哪个接口报错最多""哪些 IP 在报错""报错集中在哪个小时",本质上全都是同一个动作——分组计数,全都是这一个套路的变体。第二件要想清楚的事,是三剑客的威力其实不在于单独某一把剑有多强,而在于用管道把它们串成一条流水线。这次我用得最多、也最该被记住的,就是那条"频率统计万能模板":先用 grep 把符合条件的行筛出来,再用 awk 把你要统计的那一列取出来,然后 sort 排序、uniq -c 数出每组的数量、再 sort -rn 按数量倒序。这条流水线背后的哲学很美,就是 Unix 一以贯之的理念——每个工具只做一件事、并且把它做到极致,工具与工具之间通过管道让数据像水一样流动;你不需要一个无所不能的庞然大物,你只需要把几个小而锐利的工具,按正确的顺序拼起来。这里还藏着一个非常容易被忽略、却屡屡让人统计出错的细节:uniq 这个命令只能合并"相邻"的相同行,所以在 uniq 之前,你必须先 sort 把相同的行排到一起,否则数出来的数字是错的——这个坑我希望自己永远记住。回到这次故障本身,三剑客最终把一连串冰冷而确凿的事实一条条摆在了我面前:报错率从平时的不到千分之一飙到了百分之二点五,百分之八十三的报错死死集中在一个接口上,超过一半的报错来自同一个 IP,而且几乎全都挤在某一个小时之内。这些事实彼此印证、互相收敛,根因也就藏不住了——是对方一个批处理脚本配错了调度,把本该在凌晨低峰跑的全量拉取,变成了白天每秒几十次的高频无条件翻页。我想说的是,排查的功夫,从来不是去"猜"一个根因,而是用工具把一条条客观事实捞出来、摆到一起,让根因在事实的交叉点上自己浮现。当然,这次的经历也让我下定决心去推动把日志接入 ELK,因为靠三剑客在终端里硬扛终究是辛苦的、是不可持续的。但我同时也无比清楚:就算以后 ELK 用得再顺手,grep、awk、sed 这套功夫我也绝不会丢,因为平台会宕机、会还没搭好、会在你最需要的那个深夜恰好连不上,而那一台终端、那三把剑,永远都在,永远都能用。这,就是硬功夫的意义。
—— 别看了 · 2026