浅谈shell,awk与sed

服务器上最高频的编程,往往不是写服务,而是把文本变成答案:日志、CSV、配置、进程列表。Shell、awk、sed 常被揉成「三种脚本语言」——更准的说法是三种职责。

一句话:Shell 管流程,sed 改行,awk 算列;角色分清,命令行才不会变成正则迷宫。

三者到底是什么

工具 核心角色 最适合
Shell(bash/zsh) 命令编排器 变量、条件、循环、启动程序、重定向
sed 流式文本编辑器 替换、删除、选取、简单逐行变换
awk 面向记录与字段的文本处理语言 分列、聚合、统计、格式化报表

Shell 像导演,负责“先做什么、失败怎么办”;sed 像校对,逐行修改;awk 像表格分析师,把每行当记录、每列当字段。

管道:把程序接成流水线

Unix 工具通常只做好一件事,并通过三条通道组合:

  • 标准输入 stdin:程序读取的数据
  • 标准输出 stdout:正常结果
  • 标准错误 stderr:诊断信息
1
grep 'ERROR' app.log | sed 's/password=[^ ]*/password=***/g' | head

数据从左向右流动:先筛错误行,再脱敏,最后只显示前十行。管道传递的是 stdout,默认不会传 stderr:

1
2
command 2>error.log | another
command 2>&1 | tee all.log

每一级都应有明确职责。若一条命令需要横向滚动三屏,最好拆成脚本、加中间变量和错误处理。

Shell:编排流程,而非硬算文本

Shell 最重要的能力是调用命令、展开变量、判断返回码:

1
2
3
4
5
6
7
8
9
10
11
#!/usr/bin/env bash
set -euo pipefail

log_file=${1:-app.log}

if [[ ! -r "$log_file" ]]; then
printf '无法读取文件:%s\n' "$log_file" >&2
exit 1
fi

grep -c 'ERROR' "$log_file"

这里有几个生产脚本习惯:

  • "$log_file" 必须加引号,避免空格和通配符展开;
  • ${1:-app.log} 表示参数为空时使用默认值;
  • set -u 让未定义变量尽早报错;
  • pipefail 让管道中间命令失败时,整条管道也失败;
  • 错误信息写到 >&2。

循环应当处理文件名,而不是解析 ls 输出:

1
2
3
4
find logs -type f -name '*.log' -print0 |
while IFS= read -r -d '' file; do
gzip -- "$file"
done

-print0 与 read -d '' 用 NUL 分隔,因此文件名含空格、换行也不怕。

sed:逐行做编辑

sed 的基本形式是:

1
sed [选项] '地址 + 命令' 文件

最常见的是替换:

1
2
3
sed 's/http:/https:/' config.txt       # 每行只替换第一次
sed 's/http:/https:/g' config.txt # 替换每行全部匹配
sed -E 's/[0-9]{3}-[0-9]{4}/****/g' contacts.txt

地址用来限定处理范围:

1
2
3
4
sed -n '10,20p' app.log               # 只打印第 10~20 行
sed '/DEBUG/d' app.log # 删除含 DEBUG 的行
sed '/BEGIN/,/END/p' app.log # 选取两个标记之间的区间
sed '/server {/a\ access_log off;' nginx.conf

默认情况下,sed 只把结果打印到 stdout,不修改原文件。这一点很安全:

1
sed 's/foo/bar/g' file >file.new

确认结果后再替换原文件。sed -i 会原地修改,但 GNU sed 与 macOS/BSD sed 的参数略有差异:

1
sed -i.bak 's/foo/bar/g' file   # 同时保留 file.bak,兼容性较好

sed 适合局部、线性的变换。需要跨多行状态、复杂分组或数值统计时,awk 通常更清晰。

awk:把文本当成表

awk 默认按空白切列:

1
awk '{print $1, $NF}' access.log

$1 是第一列,$NF 是最后一列,$0 是整行,NF 是字段数,NR 是累计行号。指定分隔符后,它很像一台轻量表格引擎:

1
awk -F, 'NR > 1 {sum += $3} END {print "总计:", sum}' sales.csv

awk 程序通常由三段组成:

1
2
3
BEGIN { 初始化 }
条件 { 对每条记录执行 }
END { 汇总输出 }

例如统计 nginx 访问日志里的状态码:

1
2
3
4
5
awk '{count[$9]++}
END {
for (code in count)
printf "%s %d\n", code, count[code]
}' access.log | sort -k2 -nr

又比如计算耗时字段的平均值与最大值:

1
2
3
4
5
6
7
8
9
awk '
{
sum += $NF
if ($NF > max) max = $NF
}
END {
if (NR > 0)
printf "count=%d avg=%.3f max=%.3f\n", NR, sum/NR, max
}' request.log

条件模式也很直观:

1
2
awk '$9 >= 500 {print $1, $7, $9}' access.log
awk -F= '$1 == "PORT" {print $2}' .env

注意:真正的 CSV 支持引号内逗号和换行,简单 -F, 无法正确解析所有 CSV。严肃数据处理应使用 Python csv、Miller 或专门工具。

经典单行命令

统计出现频率

1
awk '{print $1}' access.log | sort | uniq -c | sort -nr | head

为什么必须先 sort?因为 uniq 只合并相邻重复行。

替换配置但跳过注释

1
sed '/^[[:space:]]*#/! s/old.example.com/new.example.com/g' app.conf

/模式/! 表示“不匹配该模式的行”,因此注释保持原样。

找出 P99 之外的慢请求

若日志末列是毫秒耗时,可先做快速分位数观察:

1
2
awk '{print $NF}' access.log | sort -n |
awk '{a[NR]=$1} END {if (NR) print a[int(NR*0.99+0.999)]}'

这是离线近似写法;海量数据不宜全部排序,应使用监控系统的直方图或流式分位算法。

同时读取 Shell 变量

1
2
threshold=1000
awk -v limit="$threshold" '$NF > limit {print $0}' request.log

不要把 Shell 变量生硬拼进 awk 源码,-v 更安全、可读。

什么时候用哪个

先按问题形状选工具:

  1. 需要启动多个程序、判断成功失败:Shell。
  2. 每行做一次替换或删选:sed。
  3. 按列判断、累加、分组:awk。
  4. JSON:jq;YAML:yq。
  5. 复杂数据结构、网络请求、长期维护:Python/Go 等通用语言。

一个实用边界是:命令用于一次性探索很合适;一旦要进定时任务、会修改数据、需要测试和重试,就把它升级成有参数校验、日志和测试的脚本。

常见陷阱

引号层级

单引号阻止 Shell 展开,双引号允许 $variable、$(command) 展开:

1
2
3
name='world'
printf '%s\n' '$name' # 输出 $name
printf '%s\n' "$name" # 输出 world

awk 程序通常放单引号里,Shell 值通过 -v 传入。

正则不是通用解析器

HTML、JSON、CSV 都有结构与转义规则。sed/awk 可以处理受控的简单格式,但不应冒充完整解析器。

管道吞掉失败

未启用 pipefail 时,producer | consumer 的退出码通常只看最后一个命令。生产脚本建议:

1
set -o pipefail

不要边看边破坏

先输出预览,再写文件;先备份,再用 -i;处理批量文件时用 NUL 分隔;把破坏性动作设计成可重复、可回滚。

小结

Shell、sed、awk 的共同美感是组合,而不是炫技。Shell 管流程,sed 改行,awk 算列;选择合适的数据工具,保持每一级管道简单,再用 set -euo pipefail、正确引用和预览机制兜住边界。

真正高质量的一行命令,不是字符最少,而是读者能快速回答:输入是什么、做了哪些变换、失败会怎样。