nginx 访问日志实战分析:用 awk 一条命令查慢请求、异常 IP 与状态码突增

为什么用 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 发现,值得单独作为一项日常巡检加进你的清单。

Last modification:October 19th, 2026 at 01:23 pm

Leave a Comment