# 第 14 章 命令行与 Shell 速查


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

## 学习目标

读完本章，你应当能够：

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。

```bash
# 同一份文件，三种口径给出三个数，选错口径答案就是错的
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 ...` 是两套语义——前者把"找不到"当成功，后者能区分"找不到"与"文件读不了"。

> [!WARNING]
> `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` 不改文件）。它最适合**逐行变换**：替换、按行号或正则区间增删、流式清洗。

```bash
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` 那样输出完一行就丢掉状态。

```bash
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 节的内存判据。

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

> [!IMPORTANT]
> 三者的现场判据只有一句话：**需要跨行状态就用 `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` 仍在百毫秒以内。

```bash
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` 显式指定更可靠：

```bash
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 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` 等 | 脚本里稳定取列 | 列名拼错会报错或静默空列 |

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

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

### 14.4.1 `sort` 的现场选项

```bash
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 节）。

```bash
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` 默认按**空白与引号**分词。两条规则叠加的后果是：文件名里有空格就被拆成两个参数，有引号就被当成引用语法，有换行则直接错乱。

```bash
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"。

```bash
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` 跑一遍，确认输出集合，再执行删除**。

> [!CAUTION]
> `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 走"。这一条直接推出经典陷阱的两个结果：

```text
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>&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` 三项各治什么病

```bash
#!/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**。内存有限，说明为什么你的写法能在有限内存下完成。

```text
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` 的经典组合：

```bash
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 分组取最大"：

```bash
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`）：

```bash
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 的最大值。这是"用排序的确定性替代额外状态"的典型手法，也是面试官最想看到的加分点。

> [!TIP]
> 完整推导题的答题模板是四句话：**① 每行有哪几列、我要的是哪一列（本题 `$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` 在这里为什么不够用。

<details><summary>答案与解析</summary>
```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 切分记录。
</details>

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

<details><summary>答案与解析</summary>
规则：重定向从左到右求值，`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 无缓冲，写入顺序由各自缓冲区的刷新时机决定。
</details>

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

<details><summary>答案与解析</summary>
`\;`：每个文件调用一次 ⟹ **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 占用）。
</details>

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

<details><summary>答案与解析</summary>
`netstat` 从 `/proc/net/tcp`（及 tcp6、udp 等）读全量套接字表，再用每个套接字的 inode 去遍历各个进程的 `/proc/<pid>/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 <pid> -n ss -lntp`。
**错在哪**：答"因为 `netstat` 是旧软件、没优化"而说不出数据来源差异；或认为容器里看到 `pid=1` 是内核 bug、需要重启进程。**面试官会怎么追问**：①"`ss -p` 为什么普通用户看不到别人的进程？"——走的是 `/proc/<pid>/fd` 的可读性检查，无权限时该 socket 的进程信息为空。②"监听套接字上的 `Recv-Q`/`Send-Q` 是什么含义？"——在 `LISTEN` 状态下它们分别是已完成三次握手的连接数与 `backlog` 上限，这正是判断 accept 队列是否溢出的观测点（见第 16 章 16.2 节）。
</details>

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

<details><summary>答案与解析</summary>
方案 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 的语义就被破坏。
</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应小节 |
|---|---|---|---|
| 一行日志里出现多次 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` 的排查口径。


---

> 作者: [破戒僧](https://onlyblacktea.cn)  
> URL: https://onlyblacktea.cn/book/backend/ch14/  

