第 14 章 命令行与 Shell 速查
面试不考参数背诵,而是给你一个故障现场让你现场拼管道;本章按场景组织,重点在管道的中间结果与坑。
学习目标
读完本章,你应当能够:
- 面对"从这份日志里求出 X"的现场题,先说出管道每一段的中间结果形态,再落笔写命令;
- 辨析
grep -c、uniq -c、wc -l三者统计口径的差异,判断一条命令到底在数"行"还是数"次"; - 写出
find与xargs的安全组合,说明-exec \;与-exec +的进程数差异、-print0为什么必须配-0; - 解释
cmd > f 2>&1与cmd 2>&1 > f结果不同的原因,并说明set -euo pipefail三项各治什么病、-e在哪些语法位置静默失效; - 从一份 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。
| |
-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 不改文件)。它最适合逐行变换:替换、按行号或正则区间增删、流式清洗。
| |
-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 那样输出完一行就丢掉状态。
| |
关联数组是 awk 与 grep/sed 的分水岭:a[$1]++ 的语义是"用字段值当键做一次哈希查找并自增”,于是它在输入只扫一遍的前提下完成去重、分组、计数、求和;sort 系工具则必须先排好序。这个差别在内存上会反转:关联数组把所有不同的键驻留在内存里,键多则内存暴涨——这正是 14.8 节的内存判据。
| 维度 | grep | sed | awk |
|---|---|---|---|
| 处理单位 | 行 | 行 | 记录(行)+ 字段 |
| 状态与聚合能力 | 无状态 | 只有模式空间/保持空间,聚合别扭 | 有变量与关联数组,聚合是主业 |
| 最适合的场景 | 过滤、存在性判断、-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 仍在百毫秒以内。
| |
-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 显式指定更可靠:
| |
kill -0 的判定要精确:返回成功说明进程存在且当前用户有权给它发信号;返回 EPERM 说明进程存在但权限不足;只有 ESRCH 才说明不存在。所以失败不等于"进程没了"——这是脚本里最常见的误判。它常被用作"等待进程退出"的轮询条件,但僵尸进程仍然"存在",kill -0 对它成功(回收语义见第 4 章 4.4 节)。
| 命令 | 数据来源 | 何时用 | 已知限制 |
|---|---|---|---|
ss -lntp / ss -s / ss -tn state X | NETLINK_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 的现场选项
| |
三个必须记住的细节:-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 节)。
| |
用 + 要同时满足三条:被调用命令必须接受多个文件名参数(gzip a b c 可以,mv、basename 不行);{} 只能出现在命令末尾;需要管道或重定向的复合命令只能退回 \;。
14.5.2 文件名含空格:-print0 与 xargs -0
find 默认用换行分隔输出,而 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"。
| |
-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 走"。这一条直接推出经典陷阱的两个结果:
| |
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>&1 | fd2 复制 fd1 的当前目标 | 位置决定结果,必须写在 > f 之后 |
&> f | Bash 简写,等价 > 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 三项各治什么病
| |
-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 是 IP,$7 是带查询串的完整请求目标,它由双引号包着但仍能被空白切出来("GET、/api/v1/orders?id=42、HTTP/1.1" 是三个字段,我们要的是中间那个)。正确切法是取 $7 后 sub(/^"/,"") 去掉前导引号;想按 URL 路径聚合而非完整 URL,再剥掉 ? 之后的内容。
第一步:TOP10 IP。 计数类需求走 sort 与 uniq -c 的经典组合:
| |
第二步:每个 IP 的 TOP1 URL。 这里必须用关联数组,因为 sort | uniq -c 只能给出 (IP, URL) 二元组的频次,无法在组内取最大值。做法是"先按二元组计数,再在 END 里按 IP 分组取最大":
| |
内存占用是 O(不同的 (IP, URL) 二元组数) 加 O(不同 IP 数),与总行数无关——日志涨到 100 GB,只要二元组基数稳定在几十万,内存就是几十 MB 量级;但若 URL 带高基数参数(时间戳、UUID),二元组基数逼近行数,必须先规范化 URL 或只统计路径。
内存更紧时用两次外部排序把状态换成磁盘(sort 内存由 -S 控制、超出落 TMPDIR):
| |
最后的 awk '!seen[$2]++' 值得单独记:它利用"同一 IP 的计数已按降序排列"这一前提,只在某 IP 第一次出现时输出,等价于按键取 TOP1,而不需要保存每个 IP 的最大值。这是"用排序的确定性替代额外状态"的典型手法,也是面试官最想看到的加分点。
提示
完整推导题的答题模板是四句话:① 每行有哪几列、我要的是哪一列(本题 $1 与 $7,且 $7 带引号);② 计数类需求先 sort 再 uniq -c,取最大类需求用 awk 关联数组;③ 内存占用与"不同键的基数"成正比,不与行数成正比;④ 内存不够就把状态换成排序,让 sort 用磁盘归并。 把第 ④ 句说出来,通常就与只背过命令的候选人区分开了。
14.9 其他高频小工具
| 工具 | 一句话用法 | 现场要点 |
|---|---|---|
jq | curl -s api/x | jq -r '.items[] | select(.status>500) | .id' | -r 去掉 JSON 字符串的引号,否则 awk/cut 拿到的是 "123";它是流式解析器,但不替代流式聚合 |
nc / curl -w | nc -zv host 443 探端口;-w '%{time_namelookup} %{time_connect} %{time_appconnect} %{time_starttransfer} %{time_total}\n' | -z 只探测不发数据、比 telnet 更明确地给退出码;curl -w 的字段是累计时刻而非分段耗时,要自己相减,配 -s -o /dev/null 才只看时间 |
watch / parallel | watch -n1 -d 'ss -s';find . -name '*.gz' -print0 | parallel -0 -j8 zcat | -d 高亮变化处;parallel 与 xargs -P 的差别是按任务收集并回显输出、失败可重试,但不保证顺序 |
screen / tmux | tmux new -s work、tmux attach -t work | 价值是进程与终端解耦,SSH 断开不影响会话内进程;tmux 还支持面板与脚本化 |
strace | strace -f -tt -T -e trace=network -p <pid> | -f 跟线程、-tt 打墙钟、-T 打每次调用耗时;对高吞吐进程是重负担,生产优先用 /proc 与 perf |
time / perf stat | time cmd 给 real/user/sys;perf stat -e cycles,instructions,cache-misses cmd | time 是进程级计账,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/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的排查口径。