【前情提要】 grep 练熟了,小林能从 10 万行日志里快速筛出想要的行。老周说:“grep 是筛子,但你要统计,还得靠 awk。”小林打开 awk 文档,准备学字段处理。但他不知道,$1、$NF、-F、NR、END,每一个都有用。

一、老周的第二把刀

早上到工位,老周问小林:

“昨天你用 grep 统计了 404 数量,怎么统计的?”

“grep -c " 404 "。”

“那如果我想统计每个 IP 的请求数呢?”

小林想了想:“grep 做不到吧?它只能筛行,不能按 IP 分组。”

“对。”老周说,“grep 是筛子,负责‘找出你想要的行’。但你要‘把行拆开,按字段统计’,就得用第二把刀——awk。”

“awk 是啥?”

“你可以把它理解成‘按列处理文本的语言’。”老周说,“grep 按行匹配,awk 按列处理。日志是一行一行的,每一行有多个字段,awk 就是用来拆这些字段的。”

小林打开 awk 手册,准备逐条查。

老周拦住他:“别查。跟 grep 一样,直接上手。你先告诉我,access.log 的一行,你能看到几列?”

小林敲:

sudo tail -1 /var/log/nginx/access.log

输出:

192.168.1.100 - - [14/Sep/2026:10:00:01 +0800] "GET / HTTP/1.1" 200 1234 "-" "Mozilla/5.0"

“空格分隔,大概十几列。”小林说。

“那你想取第一列,怎么取?”

“awk '{print $1}'?”

“对。”老周说,“这就是 awk 最基本的用法。”

二、awk 的基本逻辑:按列处理

“awk 的逻辑,比 grep 复杂一点,但核心就一句话。”老周说。

awk '模式 {动作}' 文件

“模式”决定哪些行要处理,“动作”决定对这些行做什么。“如果模式省略,就是所有行。”

“你刚才写的 awk '{print $1}',就是‘对所有行,打印第一列’。”

小林试:

sudo awk '{print $1}' /var/log/nginx/access.log | head -5

输出:

192.168.1.100
192.168.1.101
192.168.1.100
192.168.1.102
192.168.1.100

“这就是所有 IP。”小林说。

“对。但 awk 默认按空格和 Tab 分隔。你的日志是空格分隔,所以 $1 是 IP,$2 是 -,$3 是 -,$4 是时间……”

“那我怎么知道 $9 是状态码?”

“你打印出来看。”

sudo awk '{for(i=1;i<=NF;i++) print i": "$i}' /var/log/nginx/access.log | head -20

输出:

1: 192.168.1.100
2: -
3: -
4: [14/Sep/2026:10:00:01
5: +0800]
6: "GET
7: /
8: HTTP/1.1"
9: 200
10: 1234
11: "-"
12: "Mozilla/5.0"

“看到了吗?”老周说,“NF 是字段总数,for 循环从 1 到 NF,打印每个字段。你以后不确定字段位置,先用这个命令打印出来。”

小林记下:

  • awk '{print $N}':打印第 N 列
  • NF:字段总数
  • $NF:最后一列
  • $(NF-1):倒数第二列
  • for(i=1;i<=NF;i++):遍历所有字段

三、awk 常用参数和变量

“现在过一遍常用的。”老周说。

-F:指定分隔符

“日志默认空格分隔,但有些日志是逗号、冒号、竖线分隔。用 -F 指定。”

echo "a:b:c" | awk -F: '{print $2}'

输出:

b

“-F: 表示用冒号分隔。”老周说,“你以后处理 /etc/passwd,就是 awk -F: '{print $1}'。”

NR:行号

“你想在输出里带行号。”

sudo awk '{print NR": "$1}' /var/log/nginx/access.log | head -5

输出:

1: 192.168.1.100
2: 192.168.1.101
3: 192.168.1.100
4: 192.168.1.102
5: 192.168.1.100

“NR 是当前行号。”老周说,“NR==1 可以匹配第一行,NR<=10 匹配前 10 行。”

END:处理完所有行之后执行

“你想统计总数。”

sudo awk 'END {print NR}' /var/log/nginx/access.log

输出:

102347

“END 块在所有行处理完后执行,NR 是总行数。”老周说,“END 是 awk 做统计的关键。”

BEGIN:处理之前执行

“你想在处理前做点事,比如打印表头。”

sudo awk 'BEGIN {print "IP"} {print $1}' /var/log/nginx/access.log | head -5

输出:

IP
192.168.1.100
192.168.1.101
...

“BEGIN 在处理前执行一次,END 在处理后执行一次。”老周说,“中间的主块,对每一行执行一次。”

小林记下:

  • -F:指定分隔符
  • NR:行号
  • NF:字段数
  • $NF:最后一列
  • BEGIN:处理前执行
  • END:处理后执行

四、实战一:统计 Top IP

“现在实战。”老周说,“统计访问量最高的 IP。”

小林想了想:“awk '{print $1}' | sort | uniq -c | sort -rn | head。”

“对。”老周说,“你敲一下。”

sudo awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10

输出:

   5000 192.168.1.100
   1200 192.168.1.101
    800 192.168.1.102
    300 192.168.1.103
    100 192.168.1.104

“192.168.1.100 访问 5000 次,最多。”小林说。

“那如果我不用管道,直接用 awk 统计呢?”

“可以。”老周说,“awk 支持数组。”

sudo awk '{count[$1]++} END {for (ip in count) print count[ip], ip}' /var/log/nginx/access.log | sort -rn | head -10

输出一样。

“count[$1]++ 表示用第一列做 key,计数加一。END 里遍历数组,打印计数和 IP。”老周说,“这是 awk 原生的统计方式,不用 sort | uniq -c。”

“那哪个更好?”

“都行。”老周说,“管道写法更直观,awk 数组写法更灵活。你入门阶段,先用管道写法,等熟练了再学数组。”

小林记下:

# 管道写法
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head

# awk 数组写法
awk '{count[$1]++} END {for (ip in count) print count[ip], ip}' access.log | sort -rn | head

五、实战二:统计 Top URL

“统计访问量最高的 URL。”

sudo awk '{print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10

输出:

   3000 /posts/my-first-post/
   1500 /
    800 /tags/hugo/
    500 /about/
    300 /images/hugo-logo.png

“你的文章访问量最高,首页第二。”老周说,“这是正常分布。”

“那如果我只要 200 的 URL 呢?”

“先筛状态码。”

sudo awk '$9 == 200 {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10

“awk 的模式部分,可以写条件。 $9 == 200 表示只处理状态码为 200 的行。”

六、实战三:统计状态码分布

“统计所有状态码的分布。”

sudo awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn

输出:

  95000 200
   5000 404
   1500 301
    500 500
    347 502

“200 占绝大多数,404 有 5000,500 有 500。”小林说。

“那如果我只看 4xx 和 5xx 呢?”

“用 awk 条件。”

sudo awk '$9 >= 400 {print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn

输出:

   5000 404
    500 500
    347 502

“$9 >= 400 表示状态码大于等于 400。”老周说,“awk 支持比较、逻辑运算。 $9 >= 400 && $9 < 500 就是所有 4xx。”

小林记下:

# 状态码分布
awk '{print $9}' access.log | sort | uniq -c | sort -rn

# 只看 4xx 和 5xx
awk '$9 >= 400 {print $9}' access.log | sort | uniq -c | sort -rn

# 只看 4xx
awk '$9 >= 400 && $9 < 500 {print $9}' access.log | sort | uniq -c | sort -rn

七、实战四:算平均响应大小

“$10 是响应大小。”老周说,“你算一下平均响应大小。”

“awk '{sum += $10} END {print sum/NR}'?”

“对。”

sudo awk '{sum += $10} END {print sum/NR}' /var/log/nginx/access.log

输出:

1234.56

“平均响应 1234 字节。”小林说。

“但你注意,$10 可能是 -,表示没有响应体。- 不是数字,awk 会当成 0。”老周说,“所以你要加条件。”

sudo awk '$10 != "-" {sum += $10; count++} END {print sum/count}' /var/log/nginx/access.log

“这样只算有响应体的请求。”老周说,“awk 做数值计算,先确认字段是数字。”

八、awk 的常用内置变量

老周拿过小林的笔记本,写了一个表。

变量 含义
$0 整行
$1、$2…… 第 1、2……列
$NF 最后一列
NF 字段总数
NR 当前行号
FNR 当前文件的行号(多文件时区分)
FS 输入字段分隔符,默认空格
OFS 输出字段分隔符,默认空格
RS 输入行分隔符,默认换行
ORS 输出行分隔符,默认换行

“常用的就这几个。”老周说,“$0、$1、$NF、NF、NR、FS。你先把这六个记死。”

“那 FS 和 -F 有啥区别?”

“-F 是命令行参数,FS 是 awk 内置变量。效果一样,但 FS 可以在 BEGIN 里改。”

awk 'BEGIN {FS=":"} {print $1}' /etc/passwd

“等价于 awk -F: '{print $1}'。”老周说,“你以后在脚本里,用 FS 更灵活。”

小林记下六个核心变量:$0、$1、$NF、NF、NR、FS。

九、排错挑战:awk 统计结果全是 1

下午,小林想统计每个 URL 的状态码分布。他写:

sudo awk '{print $7, $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10

输出:

      1 / 200
      1 / 404
      1 /posts/my-first-post/ 200
      1 /posts/my-first-post/ 404
      1 /api/health 500
...

“全是 1?”小林懵了,“每个组合只出现一次?”

他检查日志:

sudo tail -5 /var/log/nginx/access.log

输出:

192.168.1.100 - - [14/Sep/2026:10:00:01 +0800] "GET / HTTP/1.1" 200 1234 "-" "Mozilla/5.0"
192.168.1.100 - - [14/Sep/2026:10:00:01 +0800] "GET / HTTP/1.1" 200 1234 "-" "Mozilla/5.0"
192.168.1.100 - - [14/Sep/2026:10:00:01 +0800] "GET / HTTP/1.1" 200 1234 "-" "Mozilla/5.0"

“日志里明明有重复的 / 200,为什么统计出来是 1?”

他喊来老周。

老周看了一眼命令:“你 print $7, $9,中间是逗号。awk 输出时,默认用空格分隔。但你的日志里,$7 和 $9 之间可能已经有空格了,所以输出里 $7 和 $9 之间是空格,但 uniq -c 是按整行去重。问题是——你 $7 和 $9 之间,是不是有别的字段?”

小林仔细看:

/posts/my-first-post/ 200

“看起来是 /posts/my-first-post/ 和 200,中间一个空格。”

“那你 print $7, $9 输出是什么?”

小林单独试:

sudo awk '{print $7, $9}' /var/log/nginx/access.log | head -5

输出:

/ 200
/ 200
/posts/my-first-post/ 200
/posts/my-first-post/ 200
/api/health 500

“看起来没问题啊。”

老周凑近看:“你 uniq -c 之前,有没有 sort?”

“有啊,sort | uniq -c。”

“那你 sort 的时候,/ 200 和 /posts/my-first-post/ 200 排序,/ 在 /posts 前面,没问题。但你看输出里,/ 200 和 /posts/my-first-post/ 200 是分开的,说明 sort 没把它们归到一起。”

“为啥?”

“因为空格数量可能不一样。”老周说,“awk 输出时,print $7, $9 用 OFS 分隔,默认是一个空格。但你的 $7 里可能包含空格?不对,$7 是 URL,没有空格。那问题在哪?”

老周仔细看:

1 / 200

“等等,这个 1 是什么?”

小林说:“uniq -c 输出的计数啊。”

“不对。”老周说,“uniq -c 的输出格式是‘计数 内容’。你这里 1 / 200,说明 uniq -c 认为这一行只出现了一次。但你日志里明明有多次 / 200。”

老周拿过键盘,敲:

sudo awk '{print $7, $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -5

输出:

      1 / 200
      1 / 404
      1 /posts/my-first-post/ 200

“你看,sort 之后,/ 200 和 / 200 应该挨在一起,uniq -c 应该统计成 3 / 200。但它统计成 1 / 200,说明 sort 没把它们排在一起。”

“为什么?”

“因为你 sort 的时候,/ 200 这一行的实际内容,可能不是你想的那样。”老周说,“你 awk '{print $7, $9}' 输出的是 / 200,但 sort 是按整行排序。如果两行看起来一样,但实际有不可见字符,sort 就会认为它们不同。”

“那怎么检查?”

“用 cat -A。”

sudo awk '{print $7, $9}' /var/log/nginx/access.log | head -3 | cat -A

输出:

/ 200$
/ 200$
/posts/my-first-post/ 200$

“$ 是行尾标记,看起来没有不可见字符。”小林说。

“那你再看这个。”

sudo awk '{print $7, $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10

老周指着输出:“你看,/ 200 的计数是 1,但 /posts/my-first-post/ 200 的计数也是 1。你的日志里,/ 和 /posts/ 应该都有很多次。为什么全是 1?”

小林突然想到:“我日志是不是被 logrotate 切了?access.log 现在是新的,只有几条?”

“你 wc -l 看看。”

sudo wc -l /var/log/nginx/access.log

输出:

5 /var/log/nginx/access.log

“5 行。”小林说,“原来日志被切了,新的 access.log 只有 5 行,所以每个组合只出现一次。”

“对。”老周说,“你分析日志之前,先 wc -l 确认数据量。 如果日志被切了,你要分析的是 access.log.1 或者 .gz 文件。”

小林打开旧日志:

sudo zcat /var/log/nginx/access.log.2.gz | awk '{print $7, $9}' | sort | uniq -c | sort -rn | head -10

输出:

   3000 /posts/my-first-post/ 200
   1500 / 200
    800 /tags/hugo/ 200
    500 /about/ 200
    300 /images/hugo-logo.png 200

“这才是正确的统计。”小林说。

排错思路总结:

  1. awk 统计结果全是 1,先 wc -l 确认日志行数。
  2. 如果行数很少,说明日志被 logrotate 切了。
  3. 分析旧日志要用 access.log.1 或 zcat access.log.2.gz。
  4. sort 之前确认数据量,别对空文件或极少行做统计。
  5. cat -A 检查不可见字符,但优先检查数据量。
  6. 分析日志前,先 wc -l,再 head,确认数据对不对。

“记住,”老周说,“日志分析的第一原则:先确认数据。 你 wc -l 看行数,head 看格式,tail 看时间。这三步做完,再开始统计。不然你统计出来的全是 1,还以为是命令写错了。”

十、练习

  1. 练习一:用 awk '{print $1}' | sort | uniq -c | sort -rn | head -10 统计 access.log 中访问量最高的前 10 个 IP。用 awk '{count[$1]++} END {for (ip in count) print count[ip], ip}' | sort -rn | head -10 做同样的统计,对比结果。
  2. 练习二:用 awk '$9 == 404 {print $7}' | sort | uniq -c | sort -rn | head -10 统计 404 最多的 URL。用 awk '$9 >= 400 {print $9}' | sort | uniq -c | sort -rn 统计所有 4xx 和 5xx 状态码分布。
  3. 练习三:用 awk '{sum += $10} END {print sum/NR}' 计算平均响应大小。再用 awk '$10 != "-" {sum += $10; count++} END {print sum/count}' 计算排除 - 后的平均响应大小。对比两个结果。

参考答案

练习一:

sudo awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10
sudo awk '{count[$1]++} END {for (ip in count) print count[ip], ip}' /var/log/nginx/access.log | sort -rn | head -10

练习二:

sudo awk '$9 == 404 {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10
sudo awk '$9 >= 400 {print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn

练习三:

sudo awk '{sum += $10} END {print sum/NR}' /var/log/nginx/access.log
sudo awk '$10 != "-" {sum += $10; count++} END {print sum/count}' /var/log/nginx/access.log

十一、下集预告

awk 练熟了,小林能从日志里按字段统计 Top IP、Top URL、状态码分布。老周说:“grep 筛,awk 算,但如果你想‘改’日志内容呢?比如脱敏 IP、提取时间段、删除空行。”小林打开 sed 文档,准备学第三把刀。但他不知道,s///、-i、-n、p、d,每一个都有讲究。

下一集:《sed 实战:批量“修改”日志和配置》