3

文本处理(1)

·23 分钟

1.2 文本处理

写在前面

让我先回忆一下我用过的文本处理工具:

编辑器

  • vim:只会 :q!(不保存退出)和 :wq(保存退出),其他记不住
  • nano:这个好用!Ctrl+K 删除行,Ctrl+O 保存,Ctrl+X 退出

常用操作(必须记住)

编辑器操作快捷键
nano删除整行Ctrl+K
nano保存Ctrl+O
nano退出Ctrl+X
vim进入编辑模式i
vim退出编辑模式Esc
vim保存并退出:wq
vim不保存退出:q!

补充知识(了解即可)

编辑器操作快捷键
nano搜索Ctrl+W
nano粘贴已删除的行Ctrl+U
vim删除整行dd
vim搜索/关键词

命令行工具

  • grep:会用 grep "关键词" 搜索
  • awksed:完全不会,每次都问 AI

我有两个 Shell 脚本:check_security.sh 检查服务器安全状态,check_nginx.sh 分析网站访问日志。说实话,这两个脚本是 AI 帮我写的,里面有很多 awkgrepsed 命令,我看不太懂。

这次的目标:通过分析这些脚本,学会文本处理的常用操作。

文本处理的对象

对象例子场景
日志文件nginx access.log、auth.log分析访问、排查问题
命令输出ps auxdf -hnetstat提取进程信息、磁盘使用等
配置文件nginx.conf、.env批量修改配置(sed 替换,下一节学)

本节主要以日志分析为例,命令输出会在后续章节学习相关命令时用到。

什么时候用什么工具?(我的习惯)

场景选择原因
服务器上分析日志grep/awk/sed日志就在 Linux 上,管道组合自然
处理 CSV、JSON 数据Pythonpandas 更熟悉,功能更强
复杂正则表达式问 AI没必要死记硬背

日志是 Linux 生态的产物(nginx、sshd 等服务生成),在终端直接处理最方便,不用下载到本地再用其他工具。


场景1:认识 Nginx 和它的日志

Nginx 是什么?

用我的博客举例:

用户访问 yustia.com 时发生了什么?

┌──────────┐     ┌─────────────────┐     ┌──────────────────┐
│  用户    │────▶│     Nginx       │────▶│  博客后端         │
│ 浏览器   │◀────│   (反向代理)     │◀────│  (FastAPI)       │
└──────────┘     └─────────────────┘     └──────────────────┘
                        │
                        ▼
                 /var/log/nginx/
                 access.log  ← 记录每个请求

Nginx 做了什么:

  1. 接收请求 - 用户访问网站,Nginx 先收到
  2. 转发请求 - 把请求转给后端 FastAPI
  3. 返回响应 - 把后端的响应返回给用户
  4. 记录日志 - 每个请求都记一笔

为什么需要 Nginx,不直接访问 FastAPI?

  • FastAPI 跑在 8000 端口,但用户访问的是 80/443 端口
  • Nginx 处理 HTTPS 证书
  • Nginx 可以同时代理多个服务
  • Nginx 能做负载均衡、缓存、限流...

关于"网关":网关是个通用概念,指"入口"。路由器网关是家庭网络的出口,Nginx 是 Web 服务的入口,说它是"Web 网关"也没错。

日志在哪里?

ls -la /var/log/nginx/
access.log        ← 当前日志(今天的)
access.log.1      ← 昨天的
access.log.2.gz   ← 前天的(压缩了)
...
access.log.14.gz  ← 14天前的

这是 logrotate(日志轮转) 在工作:每天把旧日志重命名并压缩,防止日志文件撑爆磁盘。

查看压缩的旧日志

# 直接查看 .gz 文件内容(不用解压)
zcat /var/log/nginx/access.log.2.gz | head -10

# 在 .gz 文件中搜索
zgrep "404" /var/log/nginx/access.log.2.gz

一条日志长什么样?

tail -1 /var/log/nginx/access.log
20.64.104.27 - - [14/Jan/2026:11:26:56 +0800] "GET /developmentserver/metadatauploader HTTP/1.1" 404 134 "-" "Mozilla/5.0 zgrab/0.x"

拆解

内容含义
20.64.104.27IP 地址
-用户标识(通常是 -)
-认证用户(通常是 -)
[14/Jan/2026:11:26:56 +0800]时间戳
"GET /developmentserver/metadatauploader HTTP/1.1"请求内容
404状态码(没找到)
134响应大小(字节)
"-"来源页面(Referer)
"Mozilla/5.0 zgrab/0.x"客户端标识(User-Agent)

这条日志在说什么?

IP 为 20.64.104.27 的人,在 11:26:56 访问了 /developmentserver/metadatauploader,返回了 404,用的客户端是 zgrab

zgrab 是一个扫描工具!有人在扫描我的服务器,看看有没有漏洞。

常见 HTTP 状态码

状态码含义说明
200OK成功
301Moved Permanently永久重定向(HTTP → HTTPS)
302Found临时重定向
304Not Modified缓存命中,内容没变
400Bad Request请求格式错误
403Forbidden被禁止访问(被拦截)
404Not Found页面不存在
405Method Not Allowed请求方法不允许
500Internal Server Error服务器内部错误
502Bad Gateway网关错误(后端挂了)
503Service Unavailable服务不可用

实时监控日志

# tail -f 实时跟踪日志新增内容
tail -f /var/log/nginx/access.log

# 实时监控并过滤特定内容
tail -f /var/log/nginx/access.log | grep "404"

Ctrl+C 退出。


场景2:awk 取列

基本用法

awk 默认按空格分割每一行,$1 是第一列,$2 是第二列...

# 取第一列(IP)
awk '{print $1}' /var/log/nginx/access.log

# 取第一列和第九列(IP 和状态码)
tail -10 /var/log/nginx/access.log | awk '{print $1, $9}'

输出:

114.80.36.40 200
102.22.20.125 200
93.174.93.12 200
102.22.20.125 404
147.182.151.27 403
...

💡 Tipcat file | awk '{...}' 可以简写成 awk '{...}' file,省掉 cat

查看每一列是什么

不确定哪一列是什么?用这个命令:

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

输出:

1: 20.64.104.27
2: -
3: -
4: [14/Jan/2026:11:26:56
5: +0800]
6: "GET
7: /developmentserver/metadatauploader
8: HTTP/1.1"
9: 404
10: 134
11: "-"
12: "Mozilla/5.0
13: zgrab/0.x"

我的疑问:awk 语法怎么理解?

awk '{command}' 是固定格式吗?NF 是什么?

awk 基本语法

awk '{这里写操作}' 文件名
# 或者
cat 文件 | awk '{操作}'

单引号里的 {...} 是对每一行执行的操作。

⭐ awk 符号(必须记住)

符号含义例子
$1第 1 列IP 地址
$9第 9 列状态码
$NF最后一列不管有几列都能取到
-F','指定分隔符处理 CSV

补充知识(了解即可)

符号含义用途
$0整行打印完整的一行
NF列数(不带 $)这一行有几列
$i第 i 列(i 是变量)循环时用

循环语法

for(i=1; i<=NF; i++)
#   ↑      ↑     ↑
#  从1开始 到列数 每次+1

$i 表示"第 i 列",i 是变量。所以循环可以遍历所有列。


基础命令补充

在学习统计套路之前,先补充几个基础命令。

管道 |

把上一个命令的输出,作为下一个命令的输入:

cat file.txt | grep "error" | wc -l
#     ↓              ↓           ↓
#  读取文件    过滤包含error的行  统计行数

可以串联很多命令,数据像水一样流过每个"管道"。

head 和 tail

head -10 file.txt    # 取前 10 行
tail -10 file.txt    # 取后 10 行
tail -1 file.txt     # 取最后 1 行
tail -f file.txt     # 实时跟踪新增内容(Ctrl+C 退出)

⭐ wc 统计(必须记住)

wc = word count(统计)

wc -l file.txt    # 统计行数(lines)

最常用| wc -l 统计管道输出有多少行。

# 例:统计有多少个 404 请求
cat access.log | grep "404" | wc -l

补充知识(了解即可)

wc -w file.txt    # 统计单词数(words)
wc -c file.txt    # 统计字节数(characters/bytes)

echo -e

-e 让 echo 识别转义字符:

echo "apple\nbanana"      # 输出:apple\nbanana(原样输出)
echo -e "apple\nbanana"   # 输出两行:apple 和 banana(\n 变成换行)

场景3:管道统计套路

手动看10条有什么意义?

我可以看10条日志,但我能回答这些问题吗:

  • 今天一共有多少个不同的 IP 访问过?
  • 哪个 IP 访问最多?是不是在攻击我?
  • 有多少请求返回了 404?

看10条日志回答不了这些问题,需要分析全部日志!

统计各状态码数量

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

输出:

    677 301
    594 200
    274 403
    117 404
     24 "-"
     21 400
     18 405
      6 166
      1 304
      1 206
      1 150

畸形请求分析

注意输出中有些奇怪的值:

 24 "-"      ← 状态码位置是 "-"
  6 166      ← 不是标准状态码
  1 150      ← 也不是标准状态码

这些是畸形请求,不是正常的 HTTP 请求。可能是:

  • 扫描器发的乱码
  • 协议探测(比如试试你的服务器是不是 SOCKS5 代理)
  • 连接中断导致日志不完整

我的疑问:应该在哪里过滤这些畸形请求?

存日志时过滤,还是统计时过滤?

答案:在统计时过滤,不要在存日志时过滤!

原因:

  1. 原始日志要保留完整信息,方便事后分析
  2. 畸形请求本身也有价值(比如想知道谁在扫描你)
  3. 统计时过滤很简单,用 grep 就行
# 只统计正常的 HTTP 状态码(1xx-5xx)
cat access.log | awk '{print $9}' | grep -E '^[1-5][0-9][0-9]$' | sort | uniq -c | sort -rn

拆解统计套路

# 第1步:提取状态码列
awk '{print $9}'
# 输出:200, 404, 200, 301, ...

# 第2步:排序(为 uniq 做准备)
| sort
# 输出:200, 200, 301, 404, ...

# 第3步:去重并计数
| uniq -c
# 输出:
#    2 200
#    1 301
#    1 404

# 第4步:按数量降序排列
| sort -rn

# 第5步(可选):只取前10个
| head -10

我的疑问:为什么 uniq 要先 sort?

相邻去重操作在程序实现上很方便,O(n),而完整去重似乎是 O(n²)?为什么会设计出这个命令?

历史原因:1970年代内存很贵!

相邻去重:只需要记住"上一行"      → 内存 O(1)
完整去重:需要记住"所有见过的行"  → 内存 O(n)

当时的解决方案:

  • sort(可以用磁盘做外部排序,内存要求低)
  • uniq(只比较相邻行,内存几乎为0)

验证

# 不排序直接 uniq
echo -e "apple\nbanana\napple" | uniq
# 输出:apple, banana, apple  ← 没去重!

# 先排序再 uniq
echo -e "apple\nbanana\napple" | sort | uniq
# 输出:apple, banana  ← 正确去重

⭐ sort 和 uniq 参数(必须记住)

sort        # 排序(为 uniq 做准备)
sort -rn    # 倒序 + 按数字 = 从大到小
uniq -c     # 去重并计数(count)

补充知识(了解即可)

sort 其他参数

sort -n     # 按数字排序(numeric)
sort -r     # 倒序(reverse)

为什么需要 -n

# 不加 -n(按字母排序)
echo -e "9\n80\n100" | sort
# 输出:100, 80, 9  ← 因为 "1" < "8" < "9"(比较首字符)

# 加 -n(按数字排序)
echo -e "9\n80\n100" | sort -n
# 输出:9, 80, 100  ← 正确的数字顺序

uniq 其他参数

uniq        # 只去重
uniq -d     # 只显示重复的行(duplicate)
uniq -u     # 只显示不重复的行(unique)

小测验

统计今天访问最多的 10 个 IP,写出命令?

我的回答

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

✅ 正确!(head -10head -n 10 效果一样)


场景4:排查可疑 IP

发现 → 排查 → 处理的流程

┌─────────────────────────────────────────────────────────────────┐
│  1. 发现可疑                                                     │
│     统计访问量 → 找出异常 IP                                      │
│                                                                 │
│  2. 排查行为                                                     │
│     看这个 IP 访问了什么 → 判断是扫描器还是正常用户                 │
│                                                                 │
│  3. 处理(本节不涉及)                                            │
│     封禁 IP → fail2ban / ufw / iptables                         │
└─────────────────────────────────────────────────────────────────┘

第一步:找出谁访问最多

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

第二步:看这个 IP 在干什么

# 看这个 IP 的所有请求
cat /var/log/nginx/access.log | grep '可疑IP' | head -20

# 只看这个 IP 访问了哪些路径
cat /var/log/nginx/access.log | grep '可疑IP' | awk '{print $7}'

# 统计这个 IP 访问最多的路径
cat /var/log/nginx/access.log | grep '可疑IP' | awk '{print $7}' | sort | uniq -c | sort -rn

如果发现它在访问 /wp-admin/.env/admin.php 这些路径,基本可以确定是扫描器。


场景5:awk 指定分隔符

问题:列数不对?

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

某些日志输出:

1: 185.12.59.118
...
12: "Mozilla/5.0
13: (Windows
14: NT
15: 10.0;
...
20: Firefox/132.0"

问题根源:awk 默认按空格分割,但 User-Agent 里有很多空格,被拆成了很多列!

解决方案:用双引号作分隔符

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

输出:

1: 185.12.59.118 - - [14/Jan/2026:17:06:18 +0800]
2: GET / HTTP/1.1
3:  400 248
4: -
5:
6: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0
7:

现在请求行($2)和 User-Agent($6)都是完整的了!

统计最热门的请求路径

cat /var/log/nginx/access.log | awk -F'"' '{print $2}' | sort | uniq -c | sort -rn | head -10

输出:

    122 GET / HTTP/1.1
     27 GET /favicon.ico HTTP/1.1
     18 GET /wp-includes/assets/autoload_classmap.php HTTP/1.1
     18 GET /ioxi-o.php HTTP/1.1
     18 GET /admin.php HTTP/1.1
     ...

大部分是扫描器在探测有没有漏洞!

排除扫描请求

# 用 grep -v 排除 .php 结尾的请求
cat /var/log/nginx/access.log | awk -F'"' '{print $2}' | grep -v '\.php' | sort | uniq -c | sort -rn | head -10

输出:

    122 GET / HTTP/1.1
     27 GET /favicon.ico HTTP/1.1
     17 GET / HTTP/1.0
     14 GET /robots.txt HTTP/1.1
     12 GET /api/collections/ops-learning HTTP/1.1
     ...

好多了!


场景6:awk 条件过滤

只看特定状态码的请求

# 只看 404 的请求
cat /var/log/nginx/access.log | awk '$9 == 404' | head -5

输出:

167.94.138.55 - - [14/Jan/2026:00:16:02 +0800] "GET /favicon.ico HTTP/1.1" 404 134 "-" "Mozilla/5.0 (compatible; CensysInspect/1.1; +https://about.censys.io/)"
167.94.138.55 - - [14/Jan/2026:00:16:15 +0800] "GET /wiki HTTP/1.1" 404 134 "-" "Mozilla/5.0 (compatible; CensysInspect/1.1; +https://about.censys.io/)"
...

小测验

找出所有 403(被拦截)的请求来自哪些 IP,并统计次数?

我的回答

cat /var/log/nginx/access.log | awk '$9 == 403 {print $1}' | sort | uniq -c | sort -rn

输出:

     38 20.187.120.186
     36 74.225.244.89
     36 52.141.44.40
     28 74.225.245.5
     ...

✅ 正确!

统计 404 数量

cat /var/log/nginx/access.log | awk '$9 == 404' | wc -l

输出:157


场景7:grep 过滤行

检查 UFW 防火墙状态

脚本里有这行:

UFW_ACTIVE=$(sudo ufw status | head -1 | grep -iE "active|激活" | wc -l)

先看看原始输出:

sudo ufw status | head -1

输出:Status: active

⭐ grep 常用参数(必须记住)

参数含义例子
-i忽略大小写grep -i "error" 匹配 Error、ERROR
-E扩展正则grep -E "error|warn" 匹配 error 或 warn
-c只输出匹配数量grep -c "404"

补充知识(了解即可)

参数含义例子
-v反向匹配(排除)grep -v "DEBUG" 排除 DEBUG 行

我发现了一个 bug!

# 测试 inactive
echo "Status: inactive" | grep -iE "active|激活" | wc -l

输出:1

问题:"inactive" 里包含 "active" 这个子串,也会被匹配到!

修复:使用单词边界 \b

# 测试修复
echo "Status: inactive" | grep -iE "\bactive\b|\b激活\b" | wc -l  # 输出 0
echo "Status: active" | grep -iE "\bactive\b|\b激活\b" | wc -l    # 输出 1

什么是 \b?

\b\n\t 不是一回事:

类型用在哪例子含义
转义字符字符串里\n \t \\换行、Tab、反斜杠
正则元字符正则表达式里\b \d \s特殊匹配规则

\b 是单词边界,匹配单词和非单词字符之间的位置

  i n a c t i v e
  ↑               ↑
  \b              \b
  
  "inactive" 中间没有边界,所以 \bactive\b 匹配不到

⭐ 正则表达式:只记这几个就够了

符号含义例子
.任意一个字符a.c → abc, aXc
*重复 0 次或多次ab*c → ac, abc, abbc
^行开头^hello
$行结尾world$
|cat|dog

补充知识(了解即可)

符号含义例子
\b单词边界\bactive\b 精确匹配单词

其他复杂的?问 AI!


⭐ 命令速查表(必须记住)

命令用法作用
awk '{print $1}'取列提取 IP、状态码等
awk -F'分隔符'指定分隔符处理复杂格式
awk '$9 == 404'条件过滤只看特定状态码
grep '关键词'过滤行找包含关键词的行
grep -i忽略大小写匹配 Error、ERROR
grep -E 'a|b'多条件匹配 a 或 b
grep -c统计匹配数快速计数
sort排序为 uniq 做准备
sort -rn倒序数字排序从大到小
uniq -c去重计数统计出现次数
head -10取前 N 行只看 Top 10
wc -l统计行数数有多少行

补充命令(了解即可)

命令用法作用
awk '$9 == 403 {print $1}'条件 + 取列过滤后提取数据
grep -v '关键词'排除行去掉包含关键词的行
tail -f实时跟踪监控日志新增
zcat / zgrep查看压缩文件处理 .gz 日志

⭐ 万能统计套路(必须记住)

# 提取 → 排序 → 去重计数 → 倒序 → 取前N
awk '{print $列号}' | sort | uniq -c | sort -rn | head -10

补充变体(了解即可)

# 带条件的版本
awk '条件 {print $列号}' | sort | uniq -c | sort -rn | head -10

踩过的坑

  1. uniq 只能去除相邻重复行,要先 sort
  2. awk 默认按空格分割,User-Agent 会被拆散,用 -F'"' 解决
  3. grep "active" 会匹配到 "inactive",要用 \b 单词边界
  4. 畸形请求会干扰统计,在统计时用 grep 过滤,不要在存日志时过滤

学到的概念

概念含义
日志轮转(logrotate)自动归档压缩旧日志,防止磁盘爆满
单词边界 \b正则表达式,匹配单词和非单词字符之间的位置
管道统计套路sort | uniq -c | sort -rn 组合
反向代理Nginx 接收请求后转发给后端服务

下一步学习

  • sed 文本替换(批量修改配置文件)
  • grep -oP 提取值(正则进阶)
  • 分析 auth.log(SSH 登录日志)