面试不考参数背诵,而是给你一个故障现场让你现场拼管道;本章按场景组织,重点在管道的中间结果与坑。

学习目标

读完本章,你应当能够:

  1. 面对"从这份日志里求出 X"的现场题,先说出管道每一段的中间结果形态,再落笔写命令;
  2. 辨析 grep -c、uniq -c、wc -l 三者统计口径的差异,判断一条命令到底在数"行"还是数"次";
  3. 写出 find 与 xargs 的安全组合,说明 -exec \; 与 -exec + 的进程数差异、-print0 为什么必须配 -0;
  4. 解释 cmd > f 2>&1 与 cmd 2>&1 > f 结果不同的原因,并说明 set -euo pipefail 三项各治什么病、-e 在哪些语法位置静默失效;
  5. 从一份 Nginx 访问日志出发,完整推导 TOP10 IP 与每 IP 的 TOP1 URL(含内存受限时的替代写法)。

前置知识检查

你需要知道在哪一节补齐
文件描述符表、dup2 与 open 的共享语义见第 7 章 7.1 节;本章 14.6 只讲 Shell 层重定向的求值顺序
进程状态(S/D/Z)与 fork/exec 的开销量级见第 4 章 4.1 节、第 10 章 10.1 节;本章 14.3 只讲怎么观测
一次系统调用约 100 ns ~ 1 μs、一次上下文切换约 1 ~ 5 μs见第 9 章 9.2 节;本章用它解释 -exec + 为什么快
TCP 状态名(LISTEN/ESTABLISHED/TIME_WAIT/CLOSE_WAIT)的含义见第 16 章 16.4 节;本章只讲怎么把它们筛出来
page cache 与临时文件写入见第 13 章 13.2 节;本章 14.4 只讲 sort 的归并落盘

14.1 命令行面试的真实考法

“说出 grep 的十个参数"这类题已经很少见,因为它可以用手册回答,无法区分候选人。主流形态是给一个现场,让你现场拼命令,考的是:口径是否清楚(问"有多少次访问失败"要先确认失败按状态码 ≥ 400 还是按 5xx,按行数还是按请求数);能不能说出中间结果(面试官常在你写到一半时打断"这一步之后每行长什么样”,答不上来说明是背过的命令而非推出来的);知不知道边界(空输入、超大文件、文件名带空格、管道前段失败而后段成功);能不能收窄(先给能跑的粗版本,再按"内存受限、不许写临时文件、只统计今天"等新约束逐步改)。

14.2 文本三剑客的高频用法

14.2.1 grep:过滤与计数,以及 -c 的口径

grep 是逐行匹配器:按行读入,行内只要有一处匹配,该行即命中。这个模型直接决定了 -c 的行为——它数的是命中行数而非匹配次数:一行里出现 3 次 ERROR,-c 只记 1。

1
2
3
4
# 同一份文件,三种口径给出三个数,选错口径答案就是错的
grep -c 'ERROR' app.log          # 命中行数:一行里有 10 个 ERROR 也只加 1
grep -o 'ERROR' app.log | wc -l  # 匹配次数:-o 把每个匹配单独打成一行再数行
grep -v -c 'ERROR' app.log       # -v 先反选,这里数的是"不含 ERROR 的行数"

-o(only-matching)是唯一能把"次数"变回"行"的手段;反过来说,任何"数次数"的需求都要先经过 -o 或 awk 的关联数组。

参数作用现场常用场景容易答错的地方
-v反向选择,输出不匹配的行排掉探活:grep -v '/healthz' access.log说成"取非匹配部分",实际整行输出
-c输出命中行数快速估算错误规模当成匹配次数;与 -o 混用(此时数的是 -o 的输出行数)
-i忽略大小写日志里 error/Error/ERROR 混排以为它影响 -o 的计数口径(不影响)
-A / -B / -C附带后 n 行 / 前 n 行 / 前后各 n 行看异常附近的上下文-A 与 -B 记反;-C 双向附带,扫描量更大
-E / -o / -r扩展正则 ERE / 只输出匹配部分 / 递归目录grep -E '5[0-9]{2}';-o 配 uniq -c 做词频-o 与 -c 同用时期望值不对;-r 不跟随符号链接

grep 的退出码是脚本里的坑:0 命中、1 未命中、2 出错。所以 grep ... || true 与 if grep -q ... 是两套语义——前者把"找不到"当成功,后者能区分"找不到"与"文件读不了"。

警告

grep -v -c 常被误读:它等于"总行数 − 命中行数"(先反选再数行),不是匹配次数。面试问"多少行没问题"写 grep -vc 'ERROR' 才对,问"多少条错误日志"则要写 grep -c 'ERROR',互换就是口径错误。另一个高频陷阱是把 -c 当成"匹配次数"——要次数必须走 grep -o ... | wc -l 或 awk。

14.2.2 sed:流式编辑器,重点是 -i 与地址范围

sed 的模型是逐行读入、按地址选择、执行命令、输出,默认打到标准输出(所以 sed 's/a/b/' f 不改文件)。它最适合逐行变换:替换、按行号或正则区间增删、流式清洗。

1
2
3
4
5
sed -n '10,20p' file                  # -n 关掉默认输出,p 只打印选中行,等价于 head/tail 组合
sed -n '/BEGIN/,/END/p' file          # 正则区间:从匹配 BEGIN 的行到其后第一个匹配 END 的行
sed -i.bak 's/old/new/g' f            # GNU:-i 可紧跟备份后缀,先生成 f.bak 再原地改 f
sed -i '' 's/old/new/g' f             # BSD/macOS:备份后缀是独立参数,空串表示不备份
sed -E 's/([0-9]+)ms/\1 毫秒/' file   # -E 打开 ERE,反向引用写作 \1、\2;& 代表整个匹配

-i 的 GNU 与 BSD 差异是参数解析差异:GNU 把后缀写成紧邻 -i 的可选参数,BSD 把它当下一个独立参数。于是 sed -i 's/a/b/' f 在 macOS 上把 s/a/b/ 误当备份后缀、再因缺少脚本报错,跨平台写法是 sed -i.bak -e 's/a/b/' f 或用 perl -i -pe。

-i 是"写新文件再改名",不是"就地改写字节",因此 inode 会变(硬链接断开、正在 tail -f 的读者可能跟丢),且改写期间磁盘空间短暂翻倍。-n 与 p 是绑定出现的一对:sed '5p' f 会把第 5 行打印两次。另外地址范围是粘性的:/BEGIN/,/END/p 命中 BEGIN 后一直选到下一个 END,中间再出现 BEGIN 也不会重开区间。

14.2.3 awk:唯一能"跨行聚合"的现场工具

awk 的模型是记录-字段:默认按换行切记录、按空白切字段,对每条记录执行 {...};BEGIN 在读取前执行一次,END 在读完所有输入后执行一次。“读完才执行 END“正是它能做聚合的根本原因——不像 sed 那样输出完一行就丢掉状态。

1
2
3
4
5
awk -F'|' '{print $2}' f            # -F 指定分隔符,多字符或正则均可(-F'[|,]')
awk '{print $(NF-1), $NF}' f        # NF 是本行字段数,$NF 是最后一列;$0 是整条记录
awk 'NR>1 {print}' f                # NR 是全局已读记录数,用来跳表头
awk '{s+=$3} END{print s, s/NR}' f  # 按列求和与平均值:累加在字段上,除法放 END
awk '{a[$1]++} END{for(k in a) print a[k], k}' f   # 关联数组是哈希表,不是线性表

关联数组是 awk 与 grep/sed 的分水岭:a[$1]++ 的语义是"用字段值当键做一次哈希查找并自增”,于是它在输入只扫一遍的前提下完成去重、分组、计数、求和;sort 系工具则必须先排好序。这个差别在内存上会反转:关联数组把所有不同的键驻留在内存里,键多则内存暴涨——这正是 14.8 节的内存判据。

维度grepsedawk
处理单位行行记录(行)+ 字段
状态与聚合能力无状态只有模式空间/保持空间,聚合别扭有变量与关联数组,聚合是主业
最适合的场景过滤、存在性判断、-A/-B/-C 看上下文逐行替换、按行号或正则区间增删按列取值、分组计数、求和平均
现场判据只想知道"有没有、有哪几行”想知道"每一行变成什么"想知道"这些行合起来是多少"
性能特征单模式(-F 固定串)最快简单替换与 awk 同量级列解析与聚合最省事;复杂正则比 grep -F 慢

重要

三者的现场判据只有一句话:需要跨行状态就用 awk,只需逐行变形就用 sed,只需逐行筛选就用 grep。 面试官给出"统计每个接口的 P99"这类题时,起手就是一堆 grep -c 基本会被判定为没有工程经验。现场写命令的通用节奏是先把数据流画出来(每行是什么、有多少列、算完还剩几行)再选工具;题目卡住几乎都是因为跳过了"每行长什么样"这一步。

14.3 进程与端口排查

14.3.1 ss 取代 netstat 的真实原因

ss 与 netstat 的差别不是参数风格,而是数据来源。netstat 从 /proc/net/tcp 之类的文本文件读全表并自行解析,还要把每个 socket 的 inode 去 /proc/<pid>/fd 反查进程名——这一步是 O(套接字 × 进程 × fd) 的扫描。ss 走 NETLINK_SOCK_DIAG(man 7 sock_diag)直接向内核请求套接字诊断信息,由内核侧完成过滤。于是在套接字到十万量级时,netstat 需要数秒到数十秒并明显吃 CPU,而 ss 仍在百毫秒以内。

1
2
3
4
5
ss -lntp                  # -l 只看监听,-n 不做服务名与 DNS 解析,-t TCP,-p 显示进程
ss -tn state established  # 显式按状态过滤:内核侧只返回该状态的套接字
ss -s                     # 汇总视图:各协议的套接字总数与状态分布
lsof -i:8080              # 涉及 8080 的所有 fd;本质是遍历 /proc/*/fd 反查 socket inode
fuser -n tcp 8080         # 只输出占用该端口的 PID,适合直接喂给 kill

-p 需要权限才看得到别人的进程:它依赖 /proc/<pid>/fd 的可读性,无权限时该套接字的进程信息为空。ss -s 的价值是一眼看出总量结构——estab/closed/orphaned/timewait 的分布,是判断"连接泄漏还是端口耗尽"的第一手材料;若 timewait 数量级远超 estab,方向应转向谁在频繁短连接(见第 16 章 16.4 节)。

lsof -i 慢的原因值得知道:内核没有"按端口查进程"的接口,只能遍历每个进程的 fd 目录。这也解释了容器场景下的经典困惑——容器里 ss -lntp 常显示 pid=1,而该 PID 在宿主机上并不对应同一个进程;因为进程反查依赖 /proc,而 /proc 只含本 PID 命名空间的进程。跨命名空间排查要么进容器,要么用 nsenter -t <pid> -n。

14.3.2 ps 的两种方言与进程信号

ps -ef 是 System V 风格,ps aux 是 BSD 风格,两者都能列出全部进程,但列名与语义不同:-ef 给 UID PID PPID C STIME TTY TIME CMD,aux 给 USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND。关键差别是 %CPU/%MEM/RSS 只有 BSD 方言直接给,PPID 只有 System V 方言直接给(BSD 侧要 ps -o ppid)。要锁死列序与列名,用 -o 显式指定更可靠:

1
2
3
4
ps -eo pid,ppid,stat,rss,etime,cmd --sort=-rss | head -20   # 比 top 更适合写进脚本
pgrep -f 'java.*-Xmx'   # 按完整命令行匹配(-f)取 PID,可做存在性判断
pkill -f 'java.*-Xmx'   # 语义是发默认信号 SIGTERM,不是强制杀
kill -0 "$pid"          # 发"空信号":只做权限与存在性检查,不投递任何信号

kill -0 的判定要精确:返回成功说明进程存在且当前用户有权给它发信号;返回 EPERM 说明进程存在但权限不足;只有 ESRCH 才说明不存在。所以失败不等于"进程没了"——这是脚本里最常见的误判。它常被用作"等待进程退出"的轮询条件,但僵尸进程仍然"存在",kill -0 对它成功(回收语义见第 4 章 4.4 节)。

命令数据来源何时用已知限制
ss -lntp / ss -s / ss -tn state XNETLINK_SOCK_DIAG(内核侧过滤)看监听端口、套接字总量分布、精确筛 TCP 状态-p 需权限;跨 PID 命名空间显示为空;状态名拼错会静默返回空
netstat -lntp / lsof -i:PORT / fuser -n tcp/proc/net/tcp + 遍历 /proc/*/fd老系统兜底;按端口反查进程、排查 fd 泄漏十万级套接字或进程多时为秒级且吃 CPU;无权限时结果不完整
ps -eo ... --sort/proc/<pid>/stat 等脚本里稳定取列列名拼错会报错或静默空列

注意

面试官会追问的下一层:ss 与 lsof 为什么一个快一个慢? 因为内核只提供"按套接字查诊断信息",没提供"按端口反查 PID"。lsof 只能反过来遍历 /proc/<pid>/fd,再取每个 socket 的 inode 与 /proc/net/tcp 对照,开销与进程数 × 每进程 fd 数成正比,而不是与连接数成正比。

14.4 日志分析:排序、去重与计数

14.4.1 sort 的现场选项

1
2
3
4
5
6
7
sort -c file              # 只检查是否已排序(可校验上游是否真按时间写过)
sort -n -k2,2 -k1,1r f    # -n 数值序;-k2,2 限定只比较第 2 字段,避免把后文也当键
sort -u -k1,1 f           # 按第 1 字段去重,保留每组中排在最前的整行
sort -s -k1,1 f           # -s 稳定排序:键相等时保持原输入顺序
LC_ALL=C sort big.log     # 强制 C 区域按字节比较,通常比 UTF-8 区域快一个量级
awk '{print $1}' access.log | uniq -c                          # 错误:同一 IP 不相邻就各占一行,计数全是 1
awk '{print $1}' access.log | sort | uniq -c | sort -nr | head -10   # 正确:先聚拢再压缩再排序

三个必须记住的细节:-k 要写全 起点,终点,只写 -k2 会让键一直延续到行尾(等于"先比第 2 字段,再比后面所有字段"),这是"排序看起来没生效"的头号原因;-k2,2n 里的 n 修饰的是这个键,位置不能乱;LC_ALL=C 不仅更快,还会改变比较结果(区域感知比较会忽略标点权重),改口径要和业务确认。

14.4.2 uniq -c 为什么必须与 sort 连用

uniq 只比较相邻行:它不查表、不占内存,代价就是非相邻的重复行看不见(对比 14.4.1 代码块的最后两行)。

这不是缺陷而是设计:正因为只看相邻,uniq 才能用常量内存处理任意大小的输入,而"聚拢相同项"的责任交给 sort——两步分工换来整体的可扩展性。sort 自身是外部归并排序:内存放不下就落临时文件再归并,所以 sort | uniq 的瓶颈通常落在磁盘 I/O 而非内存。若看到 sort: cannot create temporary file,那是 TMPDIR 空间不足,不是内存不足。

14.4.3 内存受限时:sort -u 还是 awk 关联数组

正确答案取决于"不同的键有多少",而不是"文件有多大":

方案内存占用是否保序适用条件不适用条件
sort -u有界(-S 控制,超出落临时文件)输出有序数据远大于内存且允许磁盘 I/O磁盘比内存更紧张(写爆 TMPDIR)
awk '!a[$0]++'与不同键数成正比保持首次出现顺序数据极大但不同值很少(如只统计几十个接口名)键基数接近行数(如唯一请求 ID)必然 OOM

判断方法很实际:先估基数。awk '{print $7}' access.log | sort -u | wc -l 本身就是"用磁盘换内存"的探针——跑得动说明基数可控,跑不动就说明关联数组方案不能用。wc -l 的口径也要交代清楚:它数的是换行符个数,末行没有换行符会少算一行,而 awk 'END{print NR}' 数的是记录数,做行数校验时两者不一致是正常现象。

cut 与 awk 的分工按"输入规整程度"切:cut 用单字符分隔符或固定列位置、不处理连续空白,但轻量且不写脚本;日志用空格对齐就用 awk '{print $3}',逗号分隔就用 cut -d, -f2。tr 与 sed 的分工是字符级还是模式级:tr 只做单字符到单字符的替换与删除(tr -d '\r'、tr 'A-Z' 'a-z'),不支持正则与多字符替换;需要按模式替换就用 sed。tee 的价值是在管道中间留一份现场:cmd | tee raw.txt | awk ... 把上游原始输出落盘,事后换下游写法重算,不必重跑上游(对只能跑一次的抓包、压测尤其关键)。

14.5 find 与 xargs 的组合与陷阱

14.5.1 -exec \; 与 -exec + 的进程数差异

find -exec 的两种终止符决定命令被调用几次,这不是写法差异而是 fork/exec 次数的差异——每次调用都是一次量级约 100 μs ~ 1 ms 的进程创建(见第 4 章 4.1 节)。

1
2
find . -type f -name '*.log' -exec gzip {} \;   # 每个文件一次:10000 个文件 = 10000 次 fork+exec
find . -type f -name '*.log' -exec gzip {} +    # 每批一次(受 ARG_MAX 约 2 MB 限制)

用 + 要同时满足三条:被调用命令必须接受多个文件名参数(gzip a b c 可以,mv、basename 不行);{} 只能出现在命令末尾;需要管道或重定向的复合命令只能退回 \;。

14.5.2 文件名含空格:-print0 与 xargs -0

find 默认用换行分隔输出,而 xargs 默认按空白与引号分词。两条规则叠加的后果是:文件名里有空格就被拆成两个参数,有引号就被当成引用语法,有换行则直接错乱。

1
2
find . -type f -name '*.tmp' -print0 | xargs -0 rm -f   # -print0 用 NUL 分隔,-0 按 NUL 切分
find . -type f -name '*.tmp' -exec rm -f {} +           # 更省事:完全绕开 xargs 的分词

NUL 是唯一不会出现在文件名里的字节,所以它比"转义空格"可靠——转义方案在文件名同时含单引号与空格时仍会崩。另一个细节:GNU xargs 在输入为空时仍会执行一次命令,-r 可避免;xargs -P N 可并行,但输出会交错,聚合类任务不要直接用它。

维度-exec {} \;-exec {} +-print0 | xargs -0
命令调用次数每个文件一次每批(受 ARG_MAX 限制)一次每批一次
管道/重定向/复合命令可以(写在 -exec sh -c 里)不行({} 必须在末尾)可以(xargs 之后接任意管道)
文件名含空格或换行安全安全安全(-print0 配 -0),否则不安全

14.5.3 时间与大小条件,以及 -delete 的风险

-mtime/-size 的比较语义都是按单位向下取整后再比较:-mtime 0 指"不满 1 个 24 小时周期"(不是"今天"),-size 1M 指"向上取整到 1 MB 单位后等于 1",即实际大小落在 (0, 1 MB];要表达严格大于必须用 +:-size +100M 是"大于 100 MB"。

1
2
3
find /data -xdev -type f -name '*.log' -mtime +7 -size +10M -print   # -xdev 不跨文件系统
find /data -type f -newer /tmp/stamp -print                          # 比参考文件更新的文件
find /data -type f -name '*.tmp' -print0 | xargs -0 rm -f            # 危险操作的稳妥形态

-delete 的风险集中在三点:它是不可回滚且不二次确认的删除;-delete 隐含深度优先遍历(先处理子项),因此会连空目录一并删掉——路径写错一级就从"一个目录"变成"整棵子树",所以必须用 -type f 或明确的 -name 收窄;遍历同时修改目录树还会让某些目录项被跳过或触发告警。稳妥模板是:先把 -delete 换成 -print 跑一遍,确认输出集合,再执行删除。

小心

find . -name '*.log' -delete 这类命令一定要先以 -print 试跑。-delete 无回收站、无确认、隐含后序遍历会连空目录一起删;路径里多一级或少一级 ..,影响面会从"一个目录"变成"整棵子树"。换成 -print0 \| xargs -0 rm -f 还能顺带避免误删目录。

14.6 管道与重定向

14.6.1 > 与 >>,以及 2>&1 的顺序陷阱

Shell 处理重定向的规则是:从左到右依次求值,N>&M 的语义是"把 fd N 复制为 fd M 此刻指向的目标",不是"让 fd N 永远跟着 fd M 走"。这一条直接推出经典陷阱的两个结果:

1
2
3
4
5
6
7
8
9
cmd > f 2>&1 :先让 fd1 指向 f,再让 fd2 复制 fd1 ⟹ stdout 与 stderr 都进 f(顺序正确)
cmd 2>&1 > f :先让 fd2 复制"当时的 fd1"(终端),再让 fd1 指向 f
               ⟹ stderr 留在终端,只有 stdout 进 f(这就是"日志里没有报错"的原因)

求值过程(N>&M 是复制 fd 当前目标的快照,不是持续绑定):
初始:fd1 ──▶ 终端        fd2 ──▶ 终端
cmd 2>&1 > f  第 1 步 2>&1 → fd2 ──▶ 终端(fd1 此刻仍是终端);第 2 步 > f → fd1 ──▶ f
              结果 fd1 ──▶ f、fd2 ──▶ 终端 ⟹ 报错仍在屏幕上,日志文件里一行错误都没有
cmd > f 2>&1  第 1 步 > f → fd1 ──▶ f;第 2 步 2>&1 → fd2 ──▶ f ⟹ 两个流都进文件

Python 的 -u、Java 的 stdout 缓冲会给"为什么日志顺序不对"再叠一层:stdout 默认行缓冲或块缓冲,stderr 无缓冲,所以即便两个 fd 指向同一文件,写入顺序也不保证与代码执行顺序一致。要按时间对齐,必须在应用侧统一走同一个 logger,或在 Shell 侧把 stderr 也并进同一个已缓冲的 fd。

写法含义现场注意
> f / >> f截断写(O_TRUNC)/ 追加写(O_APPEND)> f 后已打开的 tail -f 会跟到末尾但内容被清空;多进程追加依赖 O_APPEND 的原子性
2> f只重定向 stderr排查启动失败时最常用
2>&1fd2 复制 fd1 的当前目标位置决定结果,必须写在 > f 之后
&> fBash 简写,等价 > f 2>&1非 POSIX,sh(dash)不支持
> /dev/null 2>&1全部丢弃空设备是字符设备,写进去直接丢弃,不产生 I/O
n>&- / > | f / set -o noclobber关闭 fd n / 允许覆盖 / 禁止 > 覆盖已有文件关闭 fd 可防子进程继承;noclobber 防误删,但要覆盖就得用 > |

14.6.2 进程替换与后台管道

进程替换 <(cmd) 让命令的输出表现为一个可读的文件路径(内核侧是 /dev/fd/N 或命名管道),从而把它塞进只接受文件名的位置,例如 diff <(sort a.txt) <(sort b.txt) 就不必生成两个临时文件。它更重要的用法是保留循环状态:tail -f app.log | while read ... 会把循环体放进子 shell,循环里设置的变量在结束后丢失;换成 while read -r line; do ...; done < <(tail -f app.log) 就把读取重定向到当前 shell——这正是"循环里累加的计数器最后总是 0"的根因。管道还有一处:cmd | while read 的退出码是 while 的(要 pipefail 才能暴露上游失败),且各段同时并发执行——上游先启动、下游边收边处理。cmd & 把命令放后台,$! 取它的 PID,wait 回收。

14.7 Shell 脚本的健壮性

14.7.1 set -euo pipefail 三项各治什么病

1
2
#!/usr/bin/env bash
set -euo pipefail   # -e 出错即退;-u 引用未定义变量即退;-o pipefail 管道任一环节失败整条管道就非零

-e 的失效场景必须逐条记住,否则"加了 -e 就安全"是错觉:命令处在被测试的位置时(if cmd; then、while cmd; do、cmd || true、! cmd)失败不会触发退出,因为它的状态正是要被读取的;没有 pipefail 时 cmd1 | cmd2 的退出码来自 cmd2,cmd1 失败会被完全吞掉;带命令替换的赋值 local x=$(cmd) 的退出码是 local 的(永远成功),命令替换失败被掩盖,这是除了 pipefail 之外还要拆分赋值的常见理由;函数被调用在条件位置时,函数返回值就是其最后一条命令的返回值,函数内部所有命令都落入"被测试"上下文。

IFS 与 -u 常一起出现:默认 IFS 含空格、制表、换行,for x in $var 依赖它分词;读文件要 while IFS= read -r line——IFS= 防止行首尾空白被吞,-r 防止反斜杠被当成转义。

14.7.2 五个特殊变量与 $@ / $* 的差异

变量含义现场要点
$? / $$上一条命令的退出码 / 当前 Shell 的 PID任何命令(包括 echo)都会覆盖 $?,必须立即保存;子 shell 里 $$ 仍是父 shell 的 PID(Bash 如此),要子 shell PID 用 $BASHPID
$! / $#最后一个后台命令的 PID / 位置参数个数$! 与 wait "$!" 配对;$# 用于参数校验 [ "$#" -lt 2 ] && exit 2
"$@"每个参数各自一个词唯一正确的转发方式:main "$@"
"$*"所有参数用 IFS 首字符拼成一个词用于拼日志或消息,不要用于转发

未加引号的 $@ 与 "$@" 差别很大:前者会再做一次分词与通配符展开,参数里带空格或 * 就会被破坏——这就是"脚本转发参数后行为变了"的根因。

14.7.3 trap、[ ] 与 [[ ]]

trap cleanup EXIT INT TERM 把清理挂到正常退出、错误退出与两个常见信号上;trap '' INT 表示忽略该信号(信号被吞掉、进程不退出),trap - INT 恢复默认处置。两个坑:EXIT 上的清理函数在退出码已被决定之后才执行,因此它读得到 $? 能做条件清理;trap 处理函数执行期间同一信号默认被屏蔽(不重入)。另外 SIGKILL/SIGSTOP 无法被捕获,kill -9 场景下 trap 不会执行(见第 10 章 10.2 节)。

[[ ]] 是 Bash 的关键字(语法级),[ ] 是 test 内建命令(命令级)。这个身份差异推导出全部行为差异:

维度[ ](test)[[ ]](Bash 关键字)
是否 POSIX是,dash/sh 都支持否,仅 Bash/Ksh/Zsh
变量是否需引号必须引号,否则分词与通配符展开不必,内部不做分词与展开
逻辑运算-a/-o(优先级反直觉)或外部 &&&&/||,短路求值
模式匹配右侧模式受通配符展开影响,易出错右侧不展开,== 右侧即模式
正则与字符串比较不支持正则;< > 需转义(否则被当重定向)=~ 原生 ERE(结果进 BASH_REMATCH);< > 直接可用

现场结论:脚本头部写 #!/usr/bin/env bash 就用 [[ ]],理由是引号遗漏导致的 bug 远比可移植性更重要;只有当脚本必须跑在 sh/dash(精简基础镜像、部分 RUN 指令的默认 shell)上时才退回 [ ] 并逐一加引号。

14.8 完整推导题:一条命令求出 TOP10 IP 与每 IP 的 TOP1 URL

题面:给定 Nginx 默认 combined 格式的 access.log,求出访问量 TOP10 的客户端 IP;并进一步求出每个 IP 访问量最高的那个 URL。内存有限,说明为什么你的写法能在有限内存下完成。

1
2
3
4
5
combined 格式:$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"
一行的字段切分($7 带前导引号,这是本题最易错的地方):
203.0.113.7 - - [10/Oct/2024:13:55:36 +0800] "GET /api/v1/orders?id=42 HTTP/1.1" 200 512 "-" "curl/8.4.0"
│             │                            │                                   │     │
$1            $4                           $7                                  $9    $10

关键判据:$1 是 IP,$7 是带查询串的完整请求目标,它由双引号包着但仍能被空白切出来("GET、/api/v1/orders?id=42、HTTP/1.1" 是三个字段,我们要的是中间那个)。正确切法是取 $7 后 sub(/^"/,"") 去掉前导引号;想按 URL 路径聚合而非完整 URL,再剥掉 ? 之后的内容。

第一步:TOP10 IP。 计数类需求走 sort 与 uniq -c 的经典组合:

1
2
3
4
5
6
7
8
9
awk '{print $1}' access.log | sort | uniq -c | sort -nr | head -10
# 中间结果逐步看:
#   awk '{print $1}'   ⟹ 每行一个 IP,可能重复
#   sort               ⟹ 相同 IP 聚成连续块
#   uniq -c            ⟹ 压缩成 "计数 IP"(计数列到这里才出现)
#   sort -nr           ⟹ 按计数从大到小(-n 数值序,-r 逆序)
#   head -10           ⟹ 只取前 10 行,并提前终止上游
grep -v -e '/healthz' -e '/metrics' access.log | awk '{print $1}' | sort | uniq -c | sort -nr | head -10
# 上一行排掉探活噪声,否则 TOP10 会被探活打乱

第二步:每个 IP 的 TOP1 URL。 这里必须用关联数组,因为 sort | uniq -c 只能给出 (IP, URL) 二元组的频次,无法在组内取最大值。做法是"先按二元组计数,再在 END 里按 IP 分组取最大":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
awk '
{
    ip = $1; url = $7
    sub(/^"/, "", url)       # 只去掉 $7 的前导引号;要完整 URL 就保留查询串
    sub(/\?.*$/, "", url)    # 只要路径就剥掉查询串;要完整 URL 则删掉这一行
    cnt[ip SUBSEP url]++     # SUBSEP 由 awk 注入,避免 "1.2"+"3" 与 "1"+"2.3" 撞键
}
END {
    for (k in cnt) { split(k, p, SUBSEP); if (cnt[k] > best[p[1]]) { best[p[1]] = cnt[k]; top[p[1]] = p[2] } }
    for (ip in best) printf "%8d  %-15s  %s\n", best[ip], ip, top[ip]   # 输出:计数、IP、TOP1 URL
}' access.log | sort -nr | head -10
#      812  203.0.113.7      /api/v1/orders
#      407  198.51.100.23    /api/v1/users

内存占用是 O(不同的 (IP, URL) 二元组数) 加 O(不同 IP 数),与总行数无关——日志涨到 100 GB,只要二元组基数稳定在几十万,内存就是几十 MB 量级;但若 URL 带高基数参数(时间戳、UUID),二元组基数逼近行数,必须先规范化 URL 或只统计路径。

内存更紧时用两次外部排序把状态换成磁盘(sort 内存由 -S 控制、超出落 TMPDIR):

1
2
3
awk '{u=$7; sub(/^"/,"",u); sub(/\?.*$/,"",u); print $1, u}' access.log \
  | LC_ALL=C sort | uniq -c | LC_ALL=C sort -k2,2 -k1,1nr \
  | awk '!seen[$2]++'      # 同一 IP 内计数已降序,首次出现的即该 IP 的 TOP1

最后的 awk '!seen[$2]++' 值得单独记:它利用"同一 IP 的计数已按降序排列"这一前提,只在某 IP 第一次出现时输出,等价于按键取 TOP1,而不需要保存每个 IP 的最大值。这是"用排序的确定性替代额外状态"的典型手法,也是面试官最想看到的加分点。

提示

完整推导题的答题模板是四句话:① 每行有哪几列、我要的是哪一列(本题 $1 与 $7,且 $7 带引号);② 计数类需求先 sort 再 uniq -c,取最大类需求用 awk 关联数组;③ 内存占用与"不同键的基数"成正比,不与行数成正比;④ 内存不够就把状态换成排序,让 sort 用磁盘归并。 把第 ④ 句说出来,通常就与只背过命令的候选人区分开了。

14.9 其他高频小工具

工具一句话用法现场要点
jqcurl -s api/x | jq -r '.items[] | select(.status>500) | .id'-r 去掉 JSON 字符串的引号,否则 awk/cut 拿到的是 "123";它是流式解析器,但不替代流式聚合
nc / curl -wnc -zv host 443 探端口;-w '%{time_namelookup} %{time_connect} %{time_appconnect} %{time_starttransfer} %{time_total}\n'-z 只探测不发数据、比 telnet 更明确地给退出码;curl -w 的字段是累计时刻而非分段耗时,要自己相减,配 -s -o /dev/null 才只看时间
watch / parallelwatch -n1 -d 'ss -s';find . -name '*.gz' -print0 | parallel -0 -j8 zcat-d 高亮变化处;parallel 与 xargs -P 的差别是按任务收集并回显输出、失败可重试,但不保证顺序
screen / tmuxtmux new -s work、tmux attach -t work价值是进程与终端解耦,SSH 断开不影响会话内进程;tmux 还支持面板与脚本化
stracestrace -f -tt -T -e trace=network -p <pid>-f 跟线程、-tt 打墙钟、-T 打每次调用耗时;对高吞吐进程是重负担,生产优先用 /proc 与 perf
time / perf stattime cmd 给 real/user/sys;perf stat -e cycles,instructions,cache-misses cmdtime 是进程级计账,perf stat 是硬件计数器级:real ≫ user+sys 说明在等 I/O 或锁,IPC 低于 1 说明停顿严重(见第 1 章 1.4 节)

strace 与 perf 的取舍:想知道"程序在向内核要什么"用 strace,想知道"CPU 时间花在哪"用 perf;前者把每次系统调用变成额外的两次上下文切换,一次跟踪可能把 100 ns ~ 1 μs 的调用放大百倍,后者走采样、基本无侵入。

本章小结

关键结论一句话表述常见错误
grep -c 数的是命中行数一行内多个匹配只算一次;要次数得配合 -o 或 awk把 -c 当匹配次数;把 grep -v -c 当成"匹配次数"
sed -i 的 GNU/BSD 参数解析不同GNU 后缀紧邻 -i,BSD 是独立参数;跨平台写 -i.bak -e写 sed -i 's/a/b/' 后在 macOS 报错
awk 是唯一能做跨行聚合的现场工具靠关联数组在 END 里汇总,输入只扫一遍用一堆 grep -c 去解"分组统计"类题
ss 快是因为内核侧过滤走 NETLINK_SOCK_DIAG,只返回请求的状态说"ss 只是 netstat 的新名字"
lsof -i 慢是因为要遍历 /proc内核没有"按端口反查 PID"的接口以为它是内核提供的索引查询
uniq 只看相邻行必须先 sort 把相同项聚拢;代价换来常量内存直接 cut | uniq -c 得到一片计数 1
去重方案取决于键基数键少用 awk 关联数组,键多用 sort -u(可落盘归并)只看文件大小,认为"大数据就一定要用 awk"
-exec + 比 -exec \; 少大量 fork每批一次调用(受 ARG_MAX 限),但 {} 必须在末尾认为两者只是写法差异,性能相同
2>&1 是复制而非绑定> f 2>&1 与 2>&1 > f 结果不同写反顺序,导致日志里看不到报错
-e 在条件上下文静默失效if/while/&&/||/! 中的命令不触发退出认为加了 set -e 就等于错误必停
"$@" 才能安全转发参数$@ 会二次分词与通配符展开用 "$*" 转发,参数含空格时被拆开

自测题

Q1(辨析)一份日志每行可能包含多个 ERROR。分别写出"包含 ERROR 的行数"“ERROR 出现的总次数"“不含 ERROR 的行数"三条命令,并说明 grep -c 在这里为什么不够用。

答案与解析```bash grep -c 'ERROR' app.log # 包含 ERROR 的行数 grep -o 'ERROR' app.log | wc -l # ERROR 出现的总次数(-o 把每个匹配单独成行) grep -vc 'ERROR' app.log # 不含 ERROR 的行数(-v 先反选,-c 再数行) ``` `grep` 是逐行匹配器:**行内只要有一处命中,整行就算命中**,所以 `-c` 的计数上限是总行数。"次数"这个口径必须先把匹配拆成行(`-o`),或用 `awk` 的字段循环/`gsub` 来数。 **错在哪**:用 `grep -c` 回答"出现多少次";或把 `grep -v -c` 当成"匹配次数"(它是补集的行数)。**面试官会怎么追问**:①"`grep -o` 配 `-c` 是什么结果?"——`-o` 已逐匹配成行,`-c` 数的是这些行的行数,此时数值恰好等于匹配次数,但这是巧合而不是 `-c` 的语义。②"一行日志被截断成两行怎么办?"——`grep` 无法跨行,需要 `awk` 维持状态,或用 GNU `grep -z` 按 NUL 切分记录。

Q2(推导)把 cmd 2>&1 > f 与 cmd > f 2>&1 的 fd 走向逐步画出,并解释为什么第一种写法会让"日志文件里看不到报错”。

答案与解析规则:重定向从左到右求值,`N>&M` 表示"把 fd N 复制为 fd M **当前**的目标",是一次性快照而非持续绑定(逐步走向见 14.6.1 节的 ASCII 推演)。`cmd 2>&1 > f` 先把 fd2 复制到"当时的终端",再把 fd1 改到文件,于是 stdout 进文件、stderr 仍在屏幕;`cmd > f 2>&1` 顺序相反,两个流都进文件。所以第一种写法根本不是"把 stderr 也写进文件"。 **错在哪**:把 `2>&1` 理解为"让 stderr 跟着 stdout 走"或"事后重定向",从而以为两行等价。**面试官会怎么追问**:①"`&> f` 与 `> f 2>&1` 等价吗?"——在 Bash 里等价,但 `&>` 不是 POSIX。②"两个流都进同一文件,顺序为什么还可能乱?"——stdout 默认块缓冲、stderr 无缓冲,写入顺序由各自缓冲区的刷新时机决定。

Q3(计算)目录下有 20000 个 *.log,逐个 gzip。请估算 -exec gzip {} \; 与 -exec gzip {} + 的进程创建次数与数量级耗时差异,并说明 + 的三条使用限制。

答案与解析`\;`:每个文件调用一次 ⟹ **20000 次** `fork` + `exec`。每次进程创建是 `fork` 约 100 μs ~ 1 ms 加 `exec` 的装载开销,即便只算 1 ms,光进程创建就是约 20 s,压缩本身反而是小头。 `+`:把文件拼成尽量长的命令行,受 `ARG_MAX` 限制(Linux 上参数与环境总量约 2 MB),每批可塞进几千个文件名 ⟹ 调用次数降到**个位数到几十次**,耗时从"进程创建"转为"实际压缩",通常快一个数量级。 三条限制:① 被调用命令必须**接受多个文件名**(`gzip`、`rm` 可以;`mv`、`basename` 不行);② `{}` **必须紧邻命令末尾**,因为要就地展开成多个参数;③ 需要管道或重定向的复合命令不能用 `+`,只能退回 `\;` 或写成 `-exec sh -c '...' _ {} +`。 **错在哪**:答"两者等价,只是写法风格不同";或把 `+` 的优势说成"少读几次目录索引"(真正的收益是进程创建次数)。**面试官会怎么追问**:①"`ARG_MAX` 从哪来?"——内核为 `execve` 的参数与环境总量设的上限(`getconf ARG_MAX`),`xargs` 与 `find ... +` 都按它分批。②"`gzip` 单线程,多核下该用什么?"——`pigz` 这类并行压缩器,而不是 `xargs -P`(它只并行进程、不解决单任务 CPU 占用)。

Q4(设计)netstat -anp 在一台连接数很多的服务器上要跑十几秒且明显吃 CPU。请从数据来源层面解释原因,给出替代命令,并说明"容器里 ss -lntp 显示 pid=1“这一现象的成因。

答案与解析`netstat` 从 `/proc/net/tcp`(及 tcp6、udp 等)读全量套接字表,再用每个套接字的 inode 去遍历各个进程的 `/proc//fd` 反查进程,开销与**套接字数量 × 进程数 × 每进程 fd 数**相关;它还要做服务名解析(可明显变慢)。 替代:`ss -lntp` 看监听、`ss -s` 看总量与状态分布、`ss -tn state established` 精确筛状态——它们走 `NETLINK_SOCK_DIAG`,**过滤在内核侧完成**,只返回请求的子集,因此高连接数下仍是百毫秒量级。按端口找进程用 `lsof -i:PORT` 或 `fuser -n tcp PORT`。 `pid=1` 的成因是 **PID 命名空间**:`ss` 把套接字 inode 映射到进程要依赖 `/proc`,而容器内的 `/proc` 只包含本 PID 命名空间的进程,容器主进程在其中就是 1;宿主机上对应的又是另一个 PID(或该套接字属于宿主机上容器外看不到的进程)。跨命名空间排查要么进容器,要么在宿主机上用 `nsenter -t -n ss -lntp`。 **错在哪**:答"因为 `netstat` 是旧软件、没优化"而说不出数据来源差异;或认为容器里看到 `pid=1` 是内核 bug、需要重启进程。**面试官会怎么追问**:①"`ss -p` 为什么普通用户看不到别人的进程?"——走的是 `/proc//fd` 的可读性检查,无权限时该 socket 的进程信息为空。②"监听套接字上的 `Recv-Q`/`Send-Q` 是什么含义?"——在 `LISTEN` 状态下它们分别是已完成三次握手的连接数与 `backlog` 上限,这正是判断 accept 队列是否溢出的观测点(见第 16 章 16.2 节)。

Q5(设计)日志有 80 GB,可用内存 2 GB,要求输出访问量 TOP10 IP 与每个 IP 的 TOP1 URL。给出两种方案,说明各自的内存与磁盘代价,并指出哪一个前提被破坏时方案会失效。

答案与解析方案 A(关联数组,单遍扫描):`awk` 用 `SUBSEP` 拼键 `ip SUBSEP url` 计数,在 `END` 里按 IP 取最大。内存与**不同二元组数**成正比,与 80 GB 的行数无关;若不同 URL 只有几十万,内存只需几十 MB,无需临时文件。**失效前提**:URL 高基数(带 UUID、时间戳)时二元组数逼近行数,必然 OOM。 方案 B(外部排序,多遍落盘):`awk '{print $1,$7}'` 压成两列后 `LC_ALL=C sort | uniq -c | sort -k2,2 -k1,1nr | awk '!seen[$2]++'`;`sort` 是外部归并排序,内存由 `-S` 控制、超出部分落 `TMPDIR`,再借"同一 IP 计数已降序"让 `awk '!seen[$2]++'` 只保留每个已见 IP 的标记(状态是 `O(不同 IP 数)`)。**代价**:需要容纳中间数据的磁盘空间与额外 I/O,通常比方案 A 慢数倍;`TMPDIR` 不足会直接失败(报错是 "cannot create temporary file",不是 OOM)。只要求 TOP10 IP 时可直接 `awk '{print $1}' | LC_ALL=C sort | uniq -c | sort -nr | head -10`,`head` 会提前终止上游。 **错在哪**:只用"文件大于内存就必须外部排序"这一条规则而忽略**键基数**(方案 A 在低基数下远优);或声称 `sort` 也会 OOM(它是外部排序,缺的是磁盘不是内存)。**面试官会怎么追问**:"为什么键要写 `-k2,2 -k1,1nr` 而不是 `-k2 -k1nr`?"——`-k2` 不写终点会让键延伸到行尾,等于把 URL 也当成次级比较键,取 TOP1 的语义就被破坏。

面试高频问题与陷阱

面试问法考查角度陷阱 / 加分点对应小节
一行日志里出现多次 ERROR,grep -c 统计的是次数还是行数?是否清楚 grep 的逐行匹配模型陷阱:答"次数”,或以为加 -w 就能改成次数。加分:指出必须 grep -o ... | wc -l,并补一句 grep -v -c 数的是补集行数14.2.1
为什么 uniq -c 一定要写在 sort 后面?不排序会怎样?相邻去重与聚合的分工陷阱:答"不排序也行,只是顺序乱”——实际非相邻重复项计数全是 1。加分:说明 uniq 的常量内存正是"只看相邻"换来的14.4.2
80 GB 日志、2 GB 内存,怎么统计 TOP10 IP?内存与键基数的关系陷阱:答"把文件读进内存排序"或"awk 一定能处理大数据"。加分:区分 sort 的外部归并(吃磁盘)与关联数组(吃键基数),并给出提前 head 终止上游的写法14.4.3 / 14.8
find ... -exec cmd {} \; 和 {} + 有什么区别?大目录下哪个快?是否理解进程创建成本陷阱:答"只是语法差异",说不出 fork/exec 次数差几个数量级。加分:给出 ARG_MAX 的分批机制,并说出 {} 必须在末尾的限制14.5.1
文件名里有空格,find | xargs rm 会出什么问题?怎么修?分词模型与 NUL 分隔陷阱:答"加引号就行"或"用 -print0 但 xargs 照旧"。加分:说明 xargs 默认按空白与引号分词,必须 -print0 | xargs -0,或用 -exec ... + 彻底绕开14.5.2
cmd > f 2>&1 和 cmd 2>&1 > f 一样吗?重定向的求值顺序陷阱:答"一样,只是写法不同",或答"2>&1 是持续绑定"。加分:画出 fd 快照式的逐步走向,并指出这是"日志里没有报错"的根因14.6.1
脚本开头写 set -e 就能保证出错就停吗?-e 的失效场景陷阱:答"能,这是最佳实践"。加分:列举 if/while/&&/||/! 上下文不生效、local x=$(cmd) 掩盖失败、管道需要 pipefail 才暴露前段失败14.7.1
"$@" 和 "$*" 有什么区别?转发参数该用哪个?分词与引号语义陷阱:答"作用一样"。加分:说明 $* 用 IFS 首字符拼成一个词、未加引号的 $@ 会二次分词与通配符展开,转发必须 "$@"14.7.2
为什么 ss 比 netstat 快这么多?数据来源与开销模型陷阱:答"netstat 是旧工具"。加分:指出 ss 走 NETLINK_SOCK_DIAG 由内核侧过滤,netstat 读 /proc/net/tcp 再遍历 /proc/*/fd,开销与进程数 × fd 数相关14.3.1
shell 里判断进程是否存在,kill -0 $pid 可靠吗?信号与权限语义陷阱:答"返回非零就是进程不存在"——EPERM 说明进程在但没权限。加分:补充僵尸进程仍然"存在",kill -0 对它成功14.3.2
同一个循环里累加的变量,循环结束后为什么变成 0 了?管道子 shell 与进程替换陷阱:答"变量作用域问题"就停住。加分:指出 cmd | while 的循环体在子 shell 中执行,改用 while ... done < <(cmd) 或 lastpipe 可保留状态14.6.2
[[ ]] 和 [ ] 该用哪个?为什么推荐 [[ ]]?命令与关键字的身份差异陷阱:答"[[ ]] 只是更好看"。加分:说明 [[ ]] 是语法关键字、内部不做分词与通配符展开、支持 =~ 与 &&;[ ] 是 test 命令、必须逐处加引号14.7.3

延伸阅读

  • man 1 grep、man 1 sed、man 1 awk(POSIX 语义以 man 1p 为准)、man 1 sort、man 1 uniq、man 1 find、man 1 xargs:本章所有参数行为的权威依据,尤其是 -exec ... + 的 {} 位置限制与 xargs 的分词规则。
  • man 1 bash 的 REDIRECTION、Shell Grammar、SHELL BUILTIN COMMANDS(set、trap)三节:2>&1 的求值顺序、[[ ]] 条件表达式与 pipefail 的官方定义都在这里;man 1p sh 提供 POSIX 口径,用来判断哪些写法不可移植。
  • man 8 ss、man 7 sock_diag、man 8 lsof、man 5 proc(/proc/net/* 与 /proc/<pid>/fd):理解"内核侧过滤"与"用户态遍历 /proc“两条路线的性能差异。
  • man 1 strace、man 1 perf-stat(含 perf stat 事件列表)、man 1 time(区分 shell 内建与 /usr/bin/time)。
  • Nginx 官方文档 ngx_http_log_module(log_format 的 combined 默认格式与各变量定义):14.8 节列切分推导的依据;jq 1.7 manual 的 -r、select、.[] 语义。
  • 对照第 7 章 7.1 节(fd 表与 dup2)理解重定向的内核落点,对照第 16 章 16.2 / 16.4 节理解 ss 输出的 Recv-Q/Send-Q 与 TIME_WAIT/CLOSE_WAIT 的排查口径。