为什么用 tail 看日志看了半天也看不出问题
大部分站长排查线上故障的习惯动作是 tail -f access.log 或者 tail -f error.log,然后眼睛盯着一屏一屏刷过去的数据。这个办法在流量小的时候还算能用,一旦站点 QPS 上百,日志滚动的速度远超人的阅读速度,能看到的只有最近几十行,而故障往往是半小时前、某个特定条件下才出现的少量请求。
更麻烦的是,nginx 的 access_log 默认只记录一条一行,它不告诉你哪个请求耗了 3 秒、哪个 IP 在十分钟内请求了 8000 次、哪个 URL 的 499 特别多。这些信息全在日志里,但被平铺成一行行文本以后,肉眼根本提取不出来。
这篇文章讲的不是「装个 Prometheus + Grafana」,而是在一台 2G 内存的小 VPS 上,怎样用 awk 一行命令把日志变成可读的报告。全流程只需要 Linux 自带的 awk/sort/uniq,不需要装任何东西,CPU 开销几乎为零,跑完一份 50 万行的日志也就几秒钟。
下面所有例子都基于我自己的日志格式,你照着替换字段号即可。先确认格式,很多人照抄别人的 awk 命令却数错了字段:
第一步:先搞清楚你的日志到底长什么样
看一眼默认的 combined 格式:
203.0.113.45 - - [19/Oct/2026:09:12:31 +0000] "GET /1639.html HTTP/1.1" 200 34210 "https://www.example.com/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
按空格切分后的字段位置是这样的(用 awk 的 $N 表示):
$1 = 203.0.113.45 客户端 IP
$4 = [19/Oct/2026:09:12:31 时间(含左方括号)
$6 = "GET 方法
$7 = /1639.html 请求路径
$9 = 200 状态码
$10 = 34210 响应体字节数
这里有个非常经典的坑:在 combined 格式里,$7 才是路径,$6 是方法(带引号)。网上流传的很多教程直接写 awk '{print $7}' 是对的,但如果你加了 log_format 自定义字段(比如 $request_time、$upstream_response_time),字段号就会整体后移,之前的命令全部失效。
所以第一件事永远是 awk '{print NF}' access.log | sort -n | uniq -c 看一眼字段数是否一致。如果同一个文件里字段数出现两种值,说明日志里有带空格的异常请求(常见于被扫描器发的畸形 URI,或者 $http_user_agent 里出现未转义的空格),这种情况下必须先清洗,否则后面所有统计都会错位。
给日志加时间字段:不打这针,后面的定位全是瞎猜
默认格式里没有耗时。排查「慢」必须先有 $request_time。我在站点的 server 块里加了这样一个日志格式:
log_format main_ext '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'rt=$request_time uct=$upstream_connect_time '
'uht=$upstream_header_time urt=$upstream_response_time';
access_log /var/log/nginx/access.log main_ext;
加完后字段位置变成:$7 是路径,$9 是状态码,$10 是字节数,rt= 出现在 $13(引用来源和 UA 都带空格,所以它们会各自占多个字段)。打印一下确认:
awk '{print NR": "$13" "$14" "$15" "$16}' access.log | head -3
确认 rt= 落在哪个字段后,把下面命令里的 13 换成你的实际位置。不要偷懒用固定字段号,换一次日志格式就全军覆没。
顺带说一个实战细节:$request_time 是从 nginx 收到第一个字节开始算到最后一个字节发完,包含了客户端接收的时间。如果一个请求 rt=8.5 但 urt=0.02(上游只花了 20 毫秒),那问题不在 PHP 或数据库,而在客户端网络太差或者响应体太大。这个区分能帮你少排查半天。
高频 IP:找出谁在偷你的带宽
最基础也最有用的一条。按 IP 统计请求数并排序取前 20:
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -20
注意 uniq -c 之前必须先 sort,因为 uniq 只合并相邻的重复行。这个顺序错了会得到一堆零散计数,是最常见的错误。
输出大概长这样:
8134 203.0.113.45
2210 198.51.100.7
980 192.0.2.88
八千多次请求,在一个日均三千 IP 的小站上属于明显异常。但先别急着封,看它到底在干什么:
grep '^203\.0\.113\.45 ' access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head -10
如果结果是一串文章页,均匀分布,那可能是某个搜索引擎的爬虫(记得先反查 IP 归属,dig -x 203.0.113.45 或 whois)。如果结果是同一个 URL 刷了几千次,或者全是 /wp-login.php、/.env、/admin 这种路径,那就是扫描器。真正的爬虫不会在十分钟内把同一个页面抓八百遍。
再看它的 UA 和状态码分布,基本就能定性了:
grep '^203\.0\.113\.45 ' access.log | awk '{print $9}' | sort | uniq -c | sort -rn
全是 404 —— 扫描器。全是 200 且带 bytes 都很大 —— 有人在批量扒你的图。这种才是真的该封。我的处理方式是先用 nginx 的 limit_req 给单 IP 上一个温和的限速(比如 5r/s),比直接拉黑更稳妥,因为很多扫描器会换 IP,而正常用户被误限速的代价远大于封掉一个 IP 的收益。
limit_req_zone $binary_remote_addr zone=perip:10m rate=5r/s;
server {
location / {
limit_req zone=perip burst=20 nodelay;
...
}
}
burst=20 nodelay 的意思是允许瞬间 20 个请求的突发(正常用户打开一个页面会并发加载几十个静态资源),超过的直接返回 503。nodelay 很关键,不加的话突发请求会被排队延迟处理,反而让正常用户的页面变慢。
状态码分布:一眼看出有没有出事
awk '{print $9}' access.log | sort | uniq -c | sort -rn
四个数字要重点关注:
5xx 比例。如果 502/504 加起来超过总请求的 0.5%,说明后端有问题,去翻 PHP-FPM 的慢日志。504 通常是 fastcgi_read_timeout 到了,502 是 FPM 进程池被打满或者 PHP 崩了。
499。这是 nginx 特有的状态码,表示客户端在服务端返回响应之前就主动断开了连接。499 多通常意味着两件事之一:用户在页面卡住的时候点了关掉,或者上游响应太慢用户等不及。我在做性能优化的那段时间,499 从每天 300 多降到 40,恰好和 TTFB 从 1.2 秒降到 0.3 秒同步发生——499 是一个非常灵敏的慢请求指标,比看平均响应时间有用得多。
404 突增。可能是你改了 URL 结构忘了做 301,也可能是被扫描器扫了。两者处理方式完全相反,所以一定要配合 URL 明细看:
awk '$9 == 404 {print $7}' access.log | sort | uniq -c | sort -rn | head -20
如果 404 集中在 /old-path/* 这种你确实下线的路径上,那就是你自己的锅,赶紧补重定向;如果集中在 /wp-admin、/phpmyadmin、/*.sql 之类,无视即可。
444。如果你配了 return 444 挡恶意请求,这个数字就是你的拦截效果统计。我一般每周看一眼,如果某个 IP 段每天触发几千次 444,说明该段直接整段 drop 更省事。
慢请求排行:这才是排查性能的正道
有了 $request_time,可以直接把最慢的 30 个请求揪出来:
awk '{rt=$13; sub("rt=","",rt); if (rt+0 > 2) print rt" "$7" "$9" "$1}' access.log \
| sort -rn | head -30
sub("rt=","",rt) 是把 rt=2.431 里的前缀去掉,剩下的 2.431 让 awk 当数字比较。rt+0 里的 +0 是强制转数值,不加的话 awk 会按字符串比较,"10.5" 会排在 "2.4" 前面,这个坑非常隐蔽。
但只看「最慢的」会有一个问题:一个一天只被访问一次的页面耗时 8 秒,和一个一天被访问一万次的页面耗时 0.9 秒,后者对用户体验的总伤害大得多。真正该优化的是「慢 × 热」的交集。做法是先按 URL 聚合出平均耗时和请求数:
awk '{
rt=$13; sub("rt=","",rt);
n[$7]++; s[$7]+=rt;
if (rt+0 > 1.0) slow[$7]++;
}
END {
for (u in n)
if (n[u] > 100)
printf "%8.2f ms %6d hits %6d slow %s\n", s[u]*1000/n[u], n[u], slow[u]+0, u;
}' access.log | sort -rn | head -25
这条命令做四件事:统计每个 URL 的请求数、累加总耗时、统计超过 1 秒的次数、最后只输出请求数大于 100 的 URL。输出的第一列是平均耗时(毫秒),第四列是超慢请求次数。
我这台小机器上跑出来的典型结果是:某个分类列表页平均 780ms、占全部请求的 6%,抓一下它的 SQL 发现是没走索引的 LIKE 查询。改完之后全站平均 TTFB 掉了 150ms 左右。这种收益是靠「看总平均值」永远发现不了的。
按小时切片:把「几点出事」变成常识
故障往往有强烈的时间规律。用 substr 从时间字段里切出小时:
awk '{print substr($4, 14, 2)}' access.log | sort | uniq -c
$4 形如 [19/Oct/2026:09:12:31,从左数第 14 个字符开始就是 09。这一条能立刻画出访问量的日内曲线,也能直接暴露「凌晨三点还有人刷你的站」这种异常。
更进一步,按小时统计 5xx:
awk '$9 ~ /^5/ {print substr($4,14,2)}' access.log | sort | uniq -c
我这边曾出现「每天凌晨 3:00-3:10 有 502」的怪现象,最后查到是 backup 脚本在那个时间点用 mysqldump 全量导出,把 MySQL 的连接数吃满了。这种规律只有时间维度聚合才能看出来,单看 502 的 URL 完全找不到线索。
把带宽也算出来:找出真正的流量黑洞
$10 是响应体字节数,按 URL 求和即可找出最耗带宽的资源:
awk '{b[$7]+=$10; c[$7]++} END {
for (u in b) printf "%10.1f MB %6d %s\n", b[u]/1048576, c[u], u;
}' access.log | sort -rn | head -20
输出的结果里,如果某个 .jpg 单独占了 3GB,那就是它了。我遇到过最离谱的情况是一张首页轮播图原始尺寸 4000×2600、2.8MB,被访问了 12 万次——一个 URL 吃掉全天带宽的四成。压缩到 300KB 之后,那个月 CDN 流量直接降了三分之一。
顺便提醒:$body_bytes_sent 是未压缩的字节数还是压缩后取决于配置,默认记录的是实际发出的字节数(即 gzip 后)。如果你用 gzip 的场景下重新算「原始大小」,可以用 $gzip_ratio 反推,但意义不大,参考趋势即可。
把常用的几条固化成脚本
每次都手打 awk 太累,我把最常用的几条封装成了一个 nginx-top 小脚本,扔到 /usr/local/bin/:
#!/bin/bash
# nginx-top: 快速日志报表 usage: nginx-top [ip|url|code|slow|hour|bytes]
LOG=${LOG:-/var/log/nginx/access.log}
case "$1" in
ip) awk '{print $1}' $LOG | sort | uniq -c | sort -rn | head -20 ;;
url) awk '{print $7}' $LOG | sort | uniq -c | sort -rn | head -20 ;;
code) awk '{print $9}' $LOG | sort | uniq -c | sort -rn ;;
slow) awk '{rt=$13; sub("rt=","",rt); if (rt+0>2) print rt" "$7}' $LOG | sort -rn | head -30 ;;
hour) awk '{print substr($4,14,2)}' $LOG | sort | uniq -c ;;
bytes) awk '{b[$7]+=$10} END {for(u in b) printf "%8.1fMB %s\n", b[u]/1048576, u}' $LOG | sort -rn | head -20 ;;
*) echo "usage: nginx-top {ip|url|code|slow|hour|bytes}" ;;
esac
加权限:chmod +x /usr/local/bin/nginx-top。以后排查就 nginx-top slow、nginx-top code 两条命令,比打开 Grafana 等面板加载快得多,尤其适合 SSH 上去紧急处理问题的时候。
大日志文件怎么处理:别把自己拖死
日积月累的 access.log 动不动几个 G,直接 awk 全量扫会吃满 IO,把线上服务拖慢。三个实用技巧:
技巧一:只扫尾部。用 tail -n 200000 喂给 awk,只看最近二十万行,通常覆盖最近几小时到一天,排查当下问题足够:
tail -n 200000 access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -20
技巧二:压缩归档的可读处理。rotate 之后的 .gz 用 zcat(或 zgrep)直接管道给 awk,不要先解压:
zcat access.log.1.gz | awk '$9 ~ /^5/ {print $7}' | sort | uniq -c | sort -rn | head
技巧三:跨天对比。排查「昨天还好好的」这类问题时,一定要拿同小时的两天数据对比,否则你把白天的流量高峰当成异常了:
for d in access.log.1.gz access.log; do
echo "== $d =="
zcat -f "$d" | awk '{print substr($4,14,2)}' | sort | uniq -c | head -24
done
另外提醒一个运维红线:不要在磁盘快满的时候跑全量 awk。awk 本身不写文件,但它读 IO 会和 MySQL 的写操作抢磁盘,小机器上很容易把数据库的查询延迟抬上去。养成加 ionice -c3 的习惯:
ionice -c3 nice -n 19 tail -n 500000 access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head
-c3 是 idle 级别,只有磁盘空闲时才给它 IO 时间片。nice -n 19 保证 CPU 也让着点。这两个前缀加上以后,跑日志统计基本感知不到对线上的影响。
从「看日志」到「用日志」的习惯转变
写到这里想说一句体会:日志的价值不在于出事时翻,而在于没出事的时候也定期翻一翻。我现在的做法是每天早上花五分钟跑三条命令——nginx-top code 看状态码有没有异动、nginx-top slow 看有没有新的慢 URL、nginx-top ip 看有没有陌生的大流量来源。三分钟就能发现昨天一整天的变化。
这个习惯抓出过好多次本来会拖成大事故的问题:某个被遗忘的 RSS 接口被爬虫疯狂请求、某个插件在特定 UA 下返回 500、某张图被外部站盗链。这些问题在「页面还能正常打开」的阶段是完全感知不到的,但只要看日志就一目了然。
最后留一个实用提醒:做任何日志分析之前,先确认 logrotate 是正常工作的。/var/log/nginx/ 撑爆根分区这件事我遇到过两次,两次都是因为 logrotate 的配置里写了 postrotate 却忘了 nginx -s reopen,nginx 一直往已经改名的旧文件句柄里写,新的空文件永远不增长,磁盘却一路涨到满。这类问题在日志报表里看不出来,只能靠 df -h 和 lsof +L1 发现,值得单独作为一项日常巡检加进你的清单。