# 第 13 章 文件系统、磁盘与 I/O 排查


> 第 7 章给出 fd 到 inode 的结构，第 9 章给出块层路径；本章回答现场真正要紧的三个问题：盘满在哪、I/O 慢在哪、谁在读写。

## 学习目标

读完本章，你应当能够：

1. 说出 ext4 三种日志模式（`journal`/`ordered`/`writeback`）各自保证什么、崩溃后会看到什么形态的坏数据，并在持久性与写吞吐之间取舍。
2. 解释 xfs 的延迟分配为什么让大文件更连续、inode 为什么按需分配，以及 ext4 的 inode 数量为什么在 `mkfs` 那一刻定死。
3. 在 `df -h` 有余量而 `df -i` 满时，用可直接执行的命令序列定位到堆积小文件的具体目录。
4. 给出 `du` 与 `df` 不一致的三类根因与排查命令链，并说清稀疏文件属于哪一组"数字对不上"。
5. 计算 RAID 0/1/5/6/10 的容量利用率与随机写惩罚，解释数据库为什么几乎只用 RAID 10。
6. 说清报 IOPS 时必须同时给出哪几个口径，用 `fio` 复现可比的数字，并判断该先看 `await` 还是 `%util`。
7. 按三层结构排查 `too many open files`，并在磁盘写满时按正确顺序处置而不是乱删。

### 前置知识检查

| 你需要知道 | 在哪一节补齐 |
|---|---|
| fd 表 → `struct file` → inode 三层结构、硬链接与软链接、page cache 与 `O_DIRECT` | 见第 7 章 7.1~7.3 节、7.6 节 |
| 块层 I/O 栈、bio 与请求、I/O 调度器、`fsync` 保证了什么 | 见第 9 章 9.9 节 |
| `iostat` 的 `%util`/`await`/`aqu-sz` 字段读音，以及 `%util` 为什么不可靠 | 见第 11 章 11.3 节 |
| page cache 回收、脏页回写与 `dirty_ratio` | 见第 12 章 12.2 节 |

## 13.1 ext4 与 xfs：三处分歧决定了选型

"xfs 更适合大文件"只是现象。两者的真正分歧只有三处：日志模式的设计、空间分配时机、inode 分配方式。

### 13.1.1 ext4 的三种日志模式：保证什么、放弃什么

ext4 的日志由 jbd2 实现，是环形日志。元数据（inode、目录项、块映射）永远先写日志再落最终位置——这是所有模式的共同点，区别只在**数据**怎么办。

| 模式 | 数据是否进日志 | 数据与元数据的先后 | 崩溃后可能的状态 | 写吞吐 |
|---|---|---|---|---|
| `journal`（`data=journal`） | 是，数据与元数据都先进日志 | 不涉及先后，两者都在日志里 | 内容与元数据自洽，要么全新要么全旧 | 最低：数据写两次，日志回收受 checkpoint 约束 |
| `ordered`（默认） | 否，数据直接写最终位置 | 数据块落盘先于引用它的元数据事务提交 | 不会"新元数据指向旧块内容"；可能丢最后一次未提交事务 | 折中：多一层屏障（barrier）开销 |
| `writeback` | 否，写入顺序不受约束 | 无约束 | 可能出现**新元数据 + 旧块内容** | 最高：少一层顺序约束与屏障 |

`writeback` 的坏数据形态必须能描述清楚，这是分水岭：`/etc/passwd` 刚被缩短，尾部块被释放并分给新建的 `secret.txt`；若元数据事务先提交而数据块后写，此时掉电，重启后 `secret.txt` 的长度与块映射指向该块，内容却还是旧 `/etc/passwd` 的尾部——结构完整，`fsck` 也查不出，**数据却是别的文件的残留**。这就是 `ordered` 存在的唯一理由：它保证的不是数据进日志，而是**顺序**。

```bash
mount | grep ' / '                                  # 当前生效的模式在这里（如 data=ordered）
tune2fs -l /dev/sda1 | grep 'Default mount options' # 这是默认值，不是当前生效值
mount -o remount,data=writeback /                   # 在线切换，不需 umount
```

> [!NOTE]
> 面试官追问的下一层通常是"那 `ordered` 会不会丢数据"。会——它不保证已返回的 `write` 落盘（那是 `fsync` 的职责，见第 9 章 9.9.3 节），只保证"不会读到不该看到的内容"。把**一致性**与**持久性**分开说，是这道题的加分点。

### 13.1.2 xfs 的延迟分配、预分配与在线扩容

延迟分配（delayed allocation, delalloc）把"分配哪些块"推迟到回写时刻。收益来自信息量：`write` 时只知道这一页，回写时能看到整个脏区间，于是一次分配出巨大的连续 extent，元数据条目数、碎片率与后续顺序读效率同时改善。ext4 默认也开 delalloc（`nodelalloc` 可关），xfs 的差别在更激进的**投机预分配（speculative preallocation）**：写入时预留 EOF 之外一段空间，避免多个写入者交错插进同一文件造成碎片。代价有两条：`ENOSPC` 会推迟到回写阶段才暴露，应用必须处理"之前写成功、之后报错"；预分配会让 `df` 的已用空间暂时大于 `du` 数出来的数据量，配额统计尤其容易被它误导。

```bash
growpart /dev/sda 3          # 先扩底层设备（分区 / LVM LV / 云盘），再让文件系统吃满
xfs_growfs -d /data          # xfs 只增不减：缩容只能备份重建
resize2fs /dev/sda3          # ext4 扩容可在线；缩小必须先 umount 并 fsck
```

大文件、高并发场景常选 xfs 的**结构原因**不是延迟分配，而是**分配组（allocation group, AG）**：空闲空间、inode、extent 都按 B+ 树组织并按 AG 分区，多个写入者落在不同 AG 上可并行修改各自的元数据；ext4 的块分配与组描述符在写者多、目录大时成为争用点。所以选型语境里的"高并发"指的是**元数据操作的并行度**，不是单个文件的大小。

### 13.1.3 inode 的两种分配哲学

| 维度 | ext4 | xfs |
|---|---|---|
| 分配时机 | `mkfs` 时按 `bytes-per-inode` 一次性建好整张 inode 表，位置固定 | 按需分配：以 64 个 inode 为一组（inode chunk）从空闲空间里取 |
| inode 数量 | 创建时确定，默认每 16 KiB 一个；`mke2fs -i`/`-N` 可改 | 动态增长，上限受 `imaxpct`（默认 25%，即 inode 最多占文件系统空间的 25%）约束 |
| inode 号 | 32 位，是该文件系统 inode 表的索引 | 64 位，跨 AG 分布，天然支持海量 inode |
| 典型故障 | 小文件多时 `df -i` 先满，而 `df -h` 还有大量空间 | 极少因 inode 耗尽失败，更常见的是真没空间或触到 `imaxpct` |
| 空间代价 | inode 表固定占空间：`inode 数 × inode 大小`（默认 256 B） | 无固定预留，但预分配与 AG 元数据也占空间 |

ext4 的数字必须会心算，`mke2fs` 默认 `bytes-per-inode = 16384`：

```text
2 TiB 的 ext4（mkfs 默认参数）
  inode 总数 = 2 TiB / 16 KiB = 2⁴¹ B / 2¹⁴ B = 2²⁷ = 134,217,728 ≈ 1.34 亿
  inode 表占用 = 1.34 亿 × 256 B = 2³⁵ B = 32 GiB   ← 固定吃掉，与存了多少文件无关
```

> [!WARNING]
> "ext4 的 inode 用完了加几个就行"是错的。ext4 **不能**在线增加 inode，唯一途径是给文件系统扩容后由 `resize2fs` 按比例补建；在既有文件系统上 `mke2fs -i 4096` 等于重新格式化。预判为小文件密集（缓存、邮件池、Git 仓库）时必须在建盘时调小 `-i` 或调大 `-N`，事后无法补救。

## 13.2 inode 耗尽：`df -h` 有空间而 `df -i` 满

2 TiB 的 ext4 上若文件平均只有 1 KB：inode 总量 1.34 亿，每个文件至少占一个 4 KiB 块，1.34 亿个文件需要约 512 GiB 数据——**inode 先于空间耗尽，而此时 `df -h` 只显示约 27%**。这就是"监控显示磁盘很空、写入却报 `No space left on device`"最常见的形态。

| 现象 | 根因 | 典型场景 |
|---|---|---|
| `df -i` 的 `IUse%` 100%，`df -h` 的 `Use%` 不高 | ext4 inode 数量在建盘时固定 | 邮件 spool、PHP session 文件、缩略图缓存、`/tmp` 临时文件、Git 松散对象 |
| 总数不多但增长极快且从不下降 | 临时文件只创建不清理（inode 泄漏） | 中间结果文件、失败任务残留、无 TTL 的本地缓存 |
| 只有某个挂载点异常 | inode 表是 per 文件系统的 | 只有 `/var` 满而 `/` 正常 |

定位序列固定为"先确认维度，再逐层聚合"：

```bash
df -h; df -i                          # 第 1 步：必须同时看，否则会朝完全错误的方向排查
df -i / /var /data                    # 第 2 步：把范围从整机缩到某个挂载点
# 第 3 步：按目录聚合文件数。关键在 -xdev，不加就会跨挂载点、把别的文件系统算进来
find /var -xdev -type f -printf '%h\n' | sort | uniq -c | sort -rn | head -20
# 按 %h（目录）而不是 %p（完整路径）聚合：完整路径会把一个目录下的百万文件散成百万行
du --inodes -d1 /var/spool 2>/dev/null | sort -n | tail   # 第 4 步：下钻，只看数量不看大小
find /var/spool/clientmqueue -xdev -type f -mtime +7 | head  # 第 5 步：删之前确认能删
```

> [!TIP]
> 面试话术：把"inode 满了"讲成因果链——`inode 数与字节数解耦` ⟹ `小文件用很少字节消耗一个 inode` ⟹ `ext4 的 inode 总量建盘时固定` ⟹ `空间还剩 70% 时写入失败`。只说"inode 满了删小文件"拿不到分，面试官接着就会问"ext4 的 inode 数量能改吗"。

## 13.3 `du` 与 `df` 不一致：三类根因

两者的数据来源完全不同，这是所有不一致的总根源：`df` 读超级块里的全局计数器（`statfs`），统计**已分配出去的块**，不管这些块有没有名字；`du` 按名字遍历目录树，累加每个文件的 `st_blocks`。凡"有分配、无名字"或"有名字、看不见"的块，都只出现在 `df` 里。

| 根因 | 机制 | 现象特征 | 定位手段 |
|---|---|---|---|
| 文件已删除但仍被持有 | 目录项被 `unlink`，但 `struct file` 与 inode 仍被 fd 引用，`i_nlink` 为 0 而数据块不释放 | `df` 很大、`du` 很小，删了日志空间也不回落 | `lsof -nP +L1`；`/proc/[pid]/fd` 中带 `(deleted)` 的链接 |
| 挂载点覆盖 | 另一个文件系统挂在一个有内容的目录上，原目录被遮住，`du -x` 不跨挂载点自然看不见 | 某个目录"数据凭空消失"，或整盘 `du` 与 `df` 差出一份 | `findmnt -R <dir>`；用 bind mount 读取被遮住的原目录 |
| 稀疏文件 | 表观大小远大于实占块；被计错的是 `ls -l` 与 `du` 这一对，**不是** `du` 与 `df` | `ls -l` 显示 10 GiB、`du` 只有几十 MB | `du -h f` 与 `du -h --apparent-size f` 对比；`stat f` 看 `Size` 与 `Blocks` |
| ext4 保留块 | 默认 5% 空间只留给 root，非 root 进程能用的比 root 看到的少 5% | root 手动写没问题，业务进程写不进去 | `tune2fs -l` 看 `Reserved block count`；`tune2fs -m 1` 下调 |

> [!CAUTION]
> 把稀疏文件说成"`du` 和 `df` 不一致的原因"是高频错误。稀疏文件造成的差异在 `ls -l` 表观大小与 `du` 实占块之间；`df` 统计的也是实占块，它与 `du` 在这件事上是**一致**的。答错会立刻被追问"那 `du --apparent-size` 报的是什么"。

完整命令链按"先量化差值、再定向、最后验证"推进：

```bash
df -h /data; du -xsh /data      # -x 不跨挂载点；du 明显小于 df 就先怀疑"有分配、无名字"
lsof -nP +L1 | sort -k7 -rn | head -20   # +L1 = 链接数小于 1；-n 避免 DNS 反解卡住
# lsof 不可用时的兜底：直接读内核暴露的 fd 符号链接
find /proc/[0-9]*/fd -lname '* (deleted)' -printf '%l\n' 2>/dev/null | sort | uniq -c | sort -rn | head
findmnt -o TARGET,SOURCE,FSTYPE -R /data  # 排除挂载点覆盖
# 想看被遮住的原目录里有什么，用 bind mount 换一个视角看同一个文件系统
mkdir -p /mnt/root && mount --bind / /mnt/root && du -sh /mnt/root/data; umount /mnt/root
tune2fs -l /dev/sda3 | grep -E 'Block count|Reserved block count'   # 最后才看保留块
```

处置已删除但仍被持有的文件有三种方式，代价不同：让持有进程重新打开（`reload`/`kill -USR1`，最安全）；重启进程（最彻底，有中断）；直接截断 `: > /proc/<pid>/fd/<n>`（立即生效，但**只对普通文件有效**，且若进程以固定偏移随机写而非追加写，截断会造成数据损坏——数据库数据文件绝不能这么干）。

## 13.4 挂载选项：每一次读都可能变成一次写

### 13.4.1 `atime` 的写放大链路

纯读变成写的链路是：`read` → `file_accessed()` 更新 `inode->i_atime` 并把 inode 标脏 → 回写时写入 inode，且 `ordered` 模式下这次元数据修改还要提交一次 jbd2 日志事务。放大倍率的准确口径是"每个 inode 每次回写一次"而非"每次读一次写"，因为同一 inode 的多次读会在内存里合并；但对**每次读的都是不同 inode** 的负载——静态资源服务、备份扫描、编译、`find` 遍历——两者等价：读一个文件就付一次随机元数据写加一次日志提交。

| 选项 | 语义 | 收益 | 代价/风险 |
|---|---|---|---|
| `atime`（`strictatime`） | 每次读取都更新访问时间 | 语义完整 | 读密集型负载的持续无谓元数据写 |
| `relatime`（2.6.30 起默认） | 仅当 atime 早于 mtime/ctime 或距上次更新超过 24 h 才更新 | 消除约 99% 的更新，几乎无风险 | 热文件每天仍有一次写 |
| `noatime` | 完全不更新 atime | 彻底消除这条写放大 | 依赖 atime 的清理工具与邮件客户端语义改变 |
| `nodiratime` | 只对目录关闭 atime | 保留文件的 atime 语义 | 与 `noatime` 同给时是冗余的：`noatime` 已涵盖目录 |

```bash
mount -o remount,noatime,nodiratime /data   # 在线改，不需 umount
# 第四列的最终形态：rw,noatime,nodiratime,errors=remount-ro,barrier=1
findmnt -o TARGET,OPTIONS /data             # 核对实际生效值，而不是 fstab 里写的值
```

### 13.4.2 `sync` 与 `async`、`barrier` 与 `nobarrier`

`async`（默认）让写只到 page cache 就返回，真实落盘由回写线程异步完成；`-o sync` 则让文件系统层面所有写同步完成，近似于给每个文件加 `O_SYNC`，吞吐直接退化为约 `1 / 设备写延迟`——对 `L = 100 μs` 的设备也只有约 10⁴ 次写/秒。需要单点同步语义时该用 per-file 的 `O_SYNC`/`O_DSYNC` 或 `fsync`，不要动整个挂载点，把整盘拖进同步写是典型的一处配置毁掉整个文件系统。

`barrier`（现代内核由块层 flush/FUA 实现）保证的是**顺序**：日志提交不能早于它描述的数据与元数据落介质。`nobarrier` 关掉这条保证后，设备易失性写缓存中的写可能乱序落盘，掉电后果不是"丢最后几笔"，而是**文件系统结构可能不一致**——这正是它与"丢数据"的区别。只有设备自带掉电保护（BBU、超级电容 + NAND，或本身不声明易失写缓存）时关屏障才安全；设备不支持 flush 时内核会打印 `Disabling barriers, not supported by the underlying device`，那属于设备能力不足而非配置选择。

`errors=remount-ro` 是 ext4 默认行为：检测到不一致就把文件系统**重新挂载为只读**，此后应用写失败并返回 `EROFS`。生产中"服务突然全变只读、进程没死、CPU 也不高"的第一嫌疑就是它，`dmesg` 里能找到 ext4 报错与 remount 记录；集群共享存储上更激进的取值是 `errors=panic`，用立即崩溃换取不一致状态不再扩散。

> [!IMPORTANT]
> 三个选项的取舍压成一句：`noatime` 换吞吐且几乎无风险；`barrier` 换一致性且只在设备有掉电保护时才该关；`sync` 几乎不该用，它的位置应该由 `fsync`/`O_SYNC` 按文件粒度替代。

## 13.5 存储层次与 RAID：写惩罚是怎么算出来的

### 13.5.1 五个级别的四个维度

设成员盘数为 `N`、单盘容量 `C`、单盘随机 IOPS 为 `I`：

| 级别 | 容量利用率 | 容错能力 | 正常读性能 | 小随机写惩罚 | 典型定位 |
|---|---|---|---|---|---|
| RAID 0 | `N × C`（100%） | 0 块；任一块坏全丢 | ≈ `N × I` | 1 | 临时数据、可重建的缓存 |
| RAID 1 | `N/2 × C`（50%） | 最多 `N-1` 块（每镜像组留一块） | ≈ `N × I` | 2 | 系统盘、小容量高可靠 |
| RAID 5 | `(N−1) × C` | 1 块 | ≈ `N × I` | 4 | 读多写少、顺序为主的归档 |
| RAID 6 | `(N−2) × C` | 2 块 | ≈ `N × I` | 6 | 大容量、重构时间长、要求双盘容错 |
| RAID 10 | `N/2 × C`（50%） | 每组镜像各 1 块 | ≈ `N × I` | 2 | 数据库、虚拟化、随机写为主 |

### 13.5.2 写惩罚的逐步推导

小随机写只改一个数据块，而校验必须基于**整条条带**重算，于是控制器被迫先读后写：

```text
RAID 5 修改一个 4 KiB 数据块 D0（同一条带内还有 D1、D2 与校验 P）

  P_new = P_old ⊕ D0_old ⊕ D0_new        ← 校验重算只需异或，CPU 不是瓶颈
  ① 读 D0_old  ② 读 P_old  ③ 写 D0_new  ④ 写 P_new
  为什么必须先读：校验按整条带算，不读旧值就无法只更新其中一个块
  ⟹ 4 次设备 I/O 换 1 次逻辑写，写惩罚 = 4
RAID 6：两个独立校验 P、Q（Reed-Solomon），读 D0_old/P_old/Q_old、写三者
  ⟹ 3 读 + 3 写，写惩罚 = 6
RAID 10：数据写两份镜像 ⟹ 2 次设备 I/O，写惩罚 = 2

整条带写（full-stripe write）是唯一例外：一次写覆盖条带内全部 N−1 个数据块时
无需读旧值，新校验可由新数据直接算出 ⟹ 写惩罚摊到每个数据块上接近 1。
这就是"RAID 5 顺序大块写不吃亏、只怕小随机写"的量化根因。
```

据此得到工程估算式 `可用随机写 IOPS = N × I / 写惩罚`。用 12 块 10K rpm SAS（`I ≈ 150`）算一遍：

```text
RAID 5 : 12 × 150 / 4 = 450 IOPS   容量 11 × C
RAID 6 : 12 × 150 / 6 = 300 IOPS   容量 10 × C
RAID 10: 12 × 150 / 2 = 900 IOPS   容量  6 × C   ← 写 IOPS 最高，容量只有 RAID 5 的 55%
读不产生惩罚：数据块分布在所有成员盘上，正常读可让全部 N 块并行参与，
因此读吞吐近似随盘数线性增长（实际受条带大小与请求大小限制）。
```

### 13.5.3 JBOD 与 RAID 的取舍

JBOD（just a bunch of disks，直通模式）把每块盘独立交给上层，容量求和、无冗余。分布式存储（对象存储、HDFS、Ceph 一类）几乎一律选 JBOD：副本或纠删码已在软件层提供冗余，硬件层再做一次是双重浪费；RAID 组容量按最小盘计算，迫使盘规格统一；而且 RAID 控制器本身是单点，固件、缓存、电池任何一处出问题都是整组风险。RAID 的适用面反而是"单机内需要冗余、且对上层透明"的传统部署——数据库单机、老应用的裸机盘。

### 13.5.4 硬件 RAID 的写缓存与 BBU

带写回缓存（write-back cache）的阵列卡先写缓存就向主机确认，单次写延迟从"介质往返"降到"缓存写入"。前提是缓存内容掉电不丢，所以必须配电池备份单元（battery backup unit, BBU）或超级电容 + NAND 的闪存备份缓存。两个生产现象由此解释：电池失效时多数控制器自动切回写穿（write-through），性能骤降，这是"昨天还很快、今天突然慢"的一类原因；而在没有掉电保护的易失缓存上再配 `nobarrier`，是缓存丢失与乱序落盘的双重风险叠加。

### 13.5.5 为什么数据库几乎只用 RAID 10

四条理由按重要性排序。**其一，写惩罚**：随机写 IOPS 是 RAID 5 的 2 倍、RAID 6 的 3 倍，而 OLTP 的随机写占比极高，这笔差距直接决定能承载的 TPS。**其二，重构代价与风险**：RAID 5/6 重构要读整组所有盘的条带并重算校验，耗时与整组容量成正比，期间性能显著塌陷，且窗口内再坏一块就全丢；RAID 10 只需从镜像盘复制，耗时与单盘容量成正比，不影响其他盘组。**其三，不可恢复读错误（unrecoverable read error, URE）**：大容量盘上重构整组要读取天文数字的扇区，期间遇到一个无法纠正的读错误就会让 RAID 5 重构失败、整组不可用——这是"TB 级盘时代 RAID 5 不再被推荐"的标准论证。**其四，RAID 5 的写洞（write hole）**：掉电可能留下数据与校验不一致的条带，潜伏到下一次掉盘才暴露。唯一代价是容量利用率腰斩，这是可预算的成本，而前述四条都是不可控的风险。

## 13.6 IOPS 与吞吐量的口径

### 13.6.1 必须同时报的口径

| 口径 | 作用 | 缺失后的后果 |
|---|---|---|
| 顺序还是随机（`--rw`） | 机械盘上决定寻道次数，SSD 上决定 GC 与通道并行度 | "IOPS 十万"可能只是顺序读，随机读只有几千 |
| 块大小（`--bs`） | 与 IOPS 相乘才是带宽 | 4 KiB 与 1 MiB 下同一设备是两个完全不同的上限 |
| 队列深度（`--iodepth` × `--numjobs`） | 在途请求数，直接决定可达 IOPS | 少了它，测出的是应用下限而不是设备上限 |
| 读写比例（`--rwmixread`） | 混合负载下读被写拖慢（同盘争用、写惩罚） | 纯读数字无法外推到 7:3 混合负载 |
| 是否绕过 page cache（`--direct`） | 不绕过就是测内存 | 结果虚高一个数量级且与真实存储无关 |

量化关系来自 Little 定律的直接推论：`Q = IOPS × L`，即 `IOPS = Q / L`，而 `吞吐量 = IOPS × 块大小`。同一块盘，4 KiB 随机读 100 K IOPS 对应 400 MB/s；换成 1 MiB 顺序读，IOPS 可能只有 1 K 而带宽达到 1 GB/s。"能跑多少 IOPS"与"能跑多少 MB/s"是两个独立上限，不能互相换算。

### 13.6.2 fio 的关键参数

```bash
# 随机读 IOPS 口径。在途请求总数 = iodepth × numjobs = 256，报数字必须一起给出
fio --name=randread --ioengine=libaio --direct=1 --rw=randread \
    --bs=4k --iodepth=32 --numjobs=8 --size=4G --runtime=60 --time_based \
    --filename=/data/fio.test --group_reporting --output-format=json
# 顺序带宽口径：1 MiB、队列深度 8、单任务，与上面相差数个数量级
fio --name=seqread --ioengine=libaio --direct=1 --rw=read \
    --bs=1M --iodepth=8 --numjobs=1 --size=16G --runtime=60 --time_based \
    --filename=/data/fio.test --group_reporting
# 持续写要够长：SLC 缓存几十秒就写满，--runtime=600 起步才能看到"掉速后"的性能
fio --name=sustained --ioengine=libaio --direct=1 --rw=randwrite \
    --bs=4k --iodepth=32 --numjobs=4 --size=8G --runtime=600 --time_based \
    --filename=/data/fio.test --group_reporting
```

> [!WARNING]
> 把 `--filename` 指向裸设备（如 `/dev/sdb`）会**覆盖该设备数据且不可恢复**。测盘请用独立分区或专用测试机；也不要在承载业务的分区上做写入测试，它产生的 I/O 会污染被测对象的真实延迟。

### 13.6.3 `iodepth` 与 `%util` 的关系

`%util` 来自 `/proc/diskstats` 的 `io_ticks`，定义是"该设备至少有一个在途请求的时间占比"，它回答**忙不忙**，不回答**还能不能更快**。两者的关系由队列深度决定：

- 队列深度 1 的机械盘：一次只有一个在途请求，`%util` 到 100% 基本等于打满，此时 `await ≈ 单次 I/O 时间`。
- 队列深度 32 以上的 NVMe：控制器内部可并行处理上百个请求，`%util` 停在 100% 时设备可能仍有余量；真正的饱和信号是 `aqu-sz`（平均在途请求数）持续升到接近设备的最佳队列深度，并伴随 `await` 明显上升。

因此判断顺序是**先看 `await` 与 `aqu-sz`，再用 `%util` 做旁证**（`%util` 为什么不可靠见第 11 章 11.3 节）。`await` 高且 `aqu-sz` 高说明设备过载；`await` 高但 `aqu-sz` 低说明单次 I/O 本身就慢，与排队无关。

### 13.6.4 SSD 的写放大、GC 与 TRIM

NAND 只能按擦除块（erase block，通常数 MB）擦除、按页（通常 4~16 KiB）写入，覆盖写必须先擦后写，而擦除单位远大于写单位，于是控制器必须做垃圾回收（garbage collection, GC）：把某擦除块里仍有效的页搬到新块再整块擦除，搬移量就是写放大（write amplification）：`WA = 实际写入 NAND 的数据量 / 主机写入量`。TRIM 的作用正在这条链的第一环：文件系统删除文件时不通知设备，设备就不知道那些逻辑块已失效，GC 会把它们当有效数据继续搬移，`WA` 随盘内碎片化上升，持续写性能与寿命同时下降。落地方式有两种：挂载 `-o discard`（每次删除立刻发 TRIM，给删除操作增加一次设备往返）或周期性 `fstrim`（推荐）。第二种衰减机制是 SLC 缓存：消费级 SSD 把一部分 TLC/QLC 当 SLC 用吸收突发写，缓存写满后速度落到原生水平，可能差 5~10 倍——这就是持续写测试必须跑够时长的原因。

```bash
fstrim -av                                   # 定期执行并观察各挂载点回收量
smartctl -a /dev/nvme0 | grep -Ei 'Percentage Used|Data Units Written|Available Spare'
```

## 13.7 谁在读写：从聚合指标到单次 I/O

| 工具 | 粒度 | 回答什么问题 | 边界与代价 |
|---|---|---|---|
| `/proc/diskstats` | 每设备累计计数 | 原始数据源，一切聚合指标的起点 | 需要自己算差值 |
| `iostat -x 1` | 每设备每秒 | 设备整体忙不忙、延迟多少、队列多长 | 完全不知道是哪个进程 |
| `iotop -oP` | 每进程每秒（taskstats 差值） | 哪个进程在读写、读写量多少 | 采样窗口粗，看不到单次 I/O 延迟 |
| `biosnoop` / `biotop`（eBPF） | 每次 I/O / 每进程 | 谁、读写多少扇区、这一次延迟多少 | 需要 eBPF 支持；高频 I/O 下自身开销明显 |
| `blktrace` + `blkparse`/`btt` | 每个 I/O 的完整事件序列 | 队列等待（Q→D）与设备处理（D→C）各占多少，可出延迟分布 | 数据量巨大，必须限定设备与过滤条件 |

```bash
biosnoop -d nvme0n1     # 一句话看"谁在读写、延迟多少"：pid、进程名、扇区、字节、延迟一应俱全
biosnoop -Q             # 延迟口径改为"从进入块层排队算起"，含排队等待
blktrace -d /dev/nvme0n1 -o trace -w 10 && btt -i trace -o trace.btt   # 需要延迟分布时
```

日常顺序是"`iostat` 定位设备 → `iotop` 定位进程 → `biosnoop` 确认单次 I/O 与延迟 → 需要 P99 分布才上 `blktrace`"，判断流程可固化成下图：

```text
现象：应用变慢，iostat 显示某块盘 await 升高
  ├─① 先看 await 与 aqu-sz（不是 %util）
  │    await 高 + aqu-sz 高  → 设备真的过载 ─────────────────▶ ②
  │    await 高 + aqu-sz 低  → 单次 I/O 本身就慢（掉速、介质错误
  │                            重试、被限流、后端抖动）───────▶ ③
  │    await 低 + %util 100% → 浅队列设备正常打满，不构成瓶颈证据 → 停止
  ├─② 找"谁"：iotop -oP 定位进程 → biosnoop -Q 看单次 I/O 与延迟；
  │    再分是"量"（并发请求数超设备能力 → 限流/降并发/加盘）还是
  │    "质"（SSD 磨损与持续写掉速、RAID 降级或重构、写缓存退回
  │           write-through、网络存储抖动）
  └─③ 需要 P99/P999 时上 blktrace + btt；不要在秒均值上做长尾结论
```

## 13.8 文件描述符与句柄：三层排查

`too many open files` 必须按三层定位：

| 层次 | 观测点 | 触顶后的错误 | 判据 |
|---|---|---|---|
| 进程级 `RLIMIT_NOFILE` | `ulimit -n`（软）、`ulimit -Hn`（硬）、systemd `LimitNOFILE`、容器 `--ulimit` | `EMFILE` | 该进程 fd 数贴着软限制、稳态波动 |
| 系统级上限 | `/proc/sys/fs/file-max`、`fs.nr_open`（单进程硬上限）、`/proc/sys/fs/file-nr` | `ENFILE` | 全系统已分配 fd 接近 `file-max` |
| 泄漏 | 周期性采集 `ls /proc/[pid]/fd | wc -l` | 先是 `EMFILE`，之后连带 `ENFILE` | fd 数**单调上升、从不回落** |

区分"配置不足"与"泄漏"的唯一可靠判据是**时间维度**：配置不足在稳态水平上下波动、随时可复现；泄漏只涨不跌，或在某个操作后阶梯式上升。前者调大上限（注意 systemd 与容器运行时的配置会覆盖 shell 设置），后者必须修代码——调大上限只会让它以更难看的方式失败，fd 表占用大量内核内存，最终 OOM 或全系统 `ENFILE`。

```bash
lsof -p 12345 -n | awk '{print $5}' | sort | uniq -c | sort -rn   # 按 fd 类型看构成
lsof -p 12345 -n +L1        # 该进程持有的已删除文件
lsof -i :8080; lsof -i @10.0.0.5 -nP    # 谁占着端口、谁连着某台对端
lsof +d /data/logs          # 只看这一层目录（快）
lsof +D /data/logs          # 递归整棵子树（大目录上极慢，慎用）
fuser -v /data/app.log      # 谁占用了这个文件
fuser -vm /data             # 该挂载点上所有使用者，umount 报 busy 时必用
fuser -n tcp 8080           # 不加 -v 时输出的是 pid，容易被看错
```

Java 服务的 fd 泄漏按指向物分类：指向 `.jar` 查类加载器与未关闭的 `JarFile`；指向 socket 查客户端连接池与未关闭的响应体（见第 18 章 18.2 节）；指向管道查未回收的子进程；表现为 `anon_inode` 的则是 `epoll` 实例、`eventfd`、`timerfd`、`inotify` 这类对象泄漏（fd 三层结构见第 7 章 7.1 节）。

> [!NOTE]
> 一个看起来像 fd 问题、其实不是的情况：`inotify_add_watch` 失败返回 `ENOSPC`，原因是 `fs.inotify.max_user_watches` 或 `max_user_instances` 触顶，与文件描述符无关。被追问"还有哪些资源上限会报 No space left on device"时，这是最容易被忽略的一个。

## 13.9 磁盘满的紧急处置顺序

顺序本身就是考点。**先不要猜是"大文件"还是"已删除文件"**，用两条命令在几秒内定向：`df -h` 与 `df -i` 判断是块满还是 inode 满；`du -xsh` 与 `df` 对比判断空间是"有名字的"还是"无名字的"。这一步决定后面所有工作的方向。

```bash
df -h /data; df -i /data     # ① 判断维度：块 vs inode，两者必须同时看
du -xsh /data                # ② 判断有无名字：接近 df 去找大文件，远小于 df 去找已删除文件
# ③ 情况 A（du ≈ df）：逐层下钻，先删可再生的
du -x -d1 /data | sort -h | tail -10
du -x -d1 /data/logs | sort -h | tail -10    # 命中目录后继续下钻，直到定位到具体文件
# ④ 情况 B（du << df）：找持有者，让进程重开或重启后再删
lsof -nP +L1 | sort -k7 -rn | head -20
df -h /data                  # ⑤ 释放后必须回验，否则等于没做
```

处置的取舍原则：先删**可再生**的（日志、缓存、临时文件、core 转储、包管理器缓存），再考虑压缩或迁移；数据库数据文件、`/etc` 下的配置、正在写的 WAL **绝不能**擅自 `truncate`；不要在满盘上跑无过滤的 `find /` 全盘遍历，它会加剧元数据与 I/O 压力，让本来还能响应的服务雪崩。

> [!WARNING]
> `rm` 一个被进程持有的日志文件**不会释放空间**：目录项消失了，inode 与数据块仍被 fd 引用，而且日志被删后连排查线索也没了——这正是"删了日志盘还是满的"的原因。正确顺序是先让进程重新打开文件（`reload`、`kill -USR1`、重启任一），再删除旧文件，最后用 `df` 回验。

### 13.9.1 日志轮转失败导致的写满

`logrotate` 默认用 `create` 模式：把 `app.log` 改名为 `app.log.1`，再创建新的空 `app.log`，然后靠 `postrotate` 里的信号让应用重新打开日志。如果应用没有实现 reopen（或信号发错、进程名匹配不到），它会继续往已经改名的 `app.log.1` 里写——**空间永不释放，而新文件一直是 0 字节**，这是最典型的"轮转配置看起来正确、盘却一直满"的形态。

```bash
ls -l /var/log/app/app.log*     # 症状：.1 持续增长，而当前日志一直是 0
lsof -nP /var/log/app/app.log.1 # 确认是谁还在写旧 inode
grep -A5 -E 'postrotate|copytruncate|create' /etc/logrotate.d/app  # 检查信号与进程匹配
```

两条修法各有代价：让应用重新打开日志（首选，无丢失窗口，需要应用支持）；改用 `copytruncate`（应用无需配合，但**复制与截断之间的写入会丢失**，且对大流量文件会额外产生一次全量拷贝）。另外三类常见来源也要能一眼认出：`systemd-journald`（`journalctl --disk-usage` 查看，`SystemMaxUse` 限制，`journalctl --vacuum-size=1G` 应急）；容器日志（Docker 默认 `json-file` 驱动不限制大小，需在 daemon 配置里设 `max-size`/`max-file`，存量容器要重建才生效）；内核 core dump 与崩溃转储目录。

## 本章小结

| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| ext4 日志模式 | `ordered` 保证的是"数据先于元数据提交"这个顺序，不是数据进日志 | 把 `ordered` 说成"会丢数据"，或说不出 `writeback` 的坏数据是"新元数据 + 旧块内容" |
| xfs 选型理由 | 真正理由是分配组带来的元数据并行度，延迟分配只解释大文件连续性 | 只答"xfs 适合大文件" |
| 在线扩容 | xfs 只增不减；ext4 可在线扩、离线缩 | 以为 xfs 能缩小 |
| ext4 inode | 建盘时按每 16 KiB 一个固定分配，事后不能增加 | 答"inode 用完了加几个就行" |
| inode 耗尽 | 小文件场景 inode 先耗尽，此时空间还剩大半 | 只查 `df -h` 就下"磁盘没满"的结论 |
| `du` vs `df` | `df` 统计已分配块（含无名），`du` 按名字累加 | 把稀疏文件列为 `du`/`df` 不一致的原因；漏掉挂载点覆盖与保留块 |
| `noatime` | 读一次文件就让 inode 变脏，回写时是元数据写加日志提交 | 认为 atime 只影响"文件访问记录"这类无关紧要的语义 |
| `barrier` | 保证日志提交晚于数据落盘；关掉后掉电可能损坏结构而非仅丢数据 | 把 `nobarrier` 当成"只是快一点的开关" |
| `errors=remount-ro` | 检测到不一致即挂为只读，应用写失败报 `EROFS` | 服务变只读时先怀疑应用而不是 `dmesg` |
| RAID 写惩罚 | RAID 5 = 4、RAID 6 = 6、RAID 10 = 2；整条带写接近 1 | 只背数字，说不出 2 读 2 写的来源 |
| RAID 10 的取舍 | 用一半容量换 2~3 倍写 IOPS 与极短重构窗口 | 只答"RAID 10 快" |
| IOPS 口径 | 必须同时报顺序/随机、块大小、队列深度、读写比例、是否 `direct` | 报 IOPS 不提 `iodepth`，或用 `IOPS = 1/延迟` 忘了乘队列深度 |
| `%util` | 定义是"至少一个在途请求的时间占比"，不是饱和度 | 看到 100% 就宣布磁盘到瓶颈 |
| SSD 写放大 | GC 搬移有效页产生写放大；TRIM 让设备知道哪些块已失效 | 以为 TRIM 能加快删除操作 |
| fd 三层 | 进程级 `RLIMIT_NOFILE`、系统级 `file-max`、泄漏；用趋势区分 | 一上来就调大 `ulimit -n` |
| 磁盘满定序 | 先 `df -h`+`df -i` 定维度，再 `du` 对比 `df` 定有无名字 | 上来就 `du -sh /*` 或 `find /` 全盘扫 |

## 自测题

**Q1**（计算）一个 2 TiB 的 ext4 按 `mkfs` 默认参数（每 16 KiB 一个 inode，inode 大小 256 B）创建，inode 总数与 inode 表占用空间分别是多少？若其上存了 800 万个平均 1 KB 的小文件，`df -h` 与 `df -i` 各显示什么？要多少个这种文件才会把 inode 用尽，届时空间用了多少？
<details><summary>答案与解析</summary>

inode 总数 `2 TiB / 16 KiB = 2⁴¹ B / 2¹⁴ B = 2²⁷ = 134,217,728`，约 1.34 亿个；inode 表占用 `2²⁷ × 256 B = 2³⁵ B = 32 GiB`，这是与存了多少文件无关的固定成本。存 800 万个 1 KB 文件时，`df -i` 显示 `IUse% = 8×10⁶ / 1.342×10⁸ ≈ 6%`；`df -h` 必须按**块**算——每个文件至少占一个 4 KiB 块，800 万 × 4 KiB ≈ 32 GiB，加上 inode 表 32 GiB 与其他元数据约 64 GiB 以上，占 2 TiB 的约 3%。两个维度都很宽松。把 inode 用尽需要 1.34 亿个文件，按每文件至少一个 4 KiB 块计约 512 GiB 数据，加 inode 表 32 GiB，此时 `df -h` 只显示约 27%——这就是"`df -h` 有空间但 `df -i` 满"的定量形态。

错在哪：①把 `df -h` 的占用按文件字节数（1 KB × 800 万 = 8 GB）算，忘了文件在块设备上按块分配；②答"inode 用尽了扩容就行"，而 ext4 必须靠文件系统扩容 + `resize2fs` 补建。面试官会追问："那你怎么在建盘时预判？"答按文件数与平均大小估算 `bytes-per-inode`，用 `mke2fs -i` 或 `-N` 在建盘时定死。
</details>

**Q2**（辨析）`df -h` 显示还有空闲空间，应用写日志却报 `No space left on device`。请给出至少三个不同根因，并说明各自的排除命令。
<details><summary>答案与解析</summary>

三个根因：**inode 耗尽**（空间有余量而 inode 维度已满，`df -i` 的 `IUse%` 到 100%，排除命令 `df -i`）；**ext4 保留块**（默认 5% 只留给 root，以 root 运行 `df` 时 `Avail` 包含这部分，所以 root 看到的可用空间多于业务进程能用的，排除命令 `tune2fs -l <dev> | grep 'Reserved block count'`，需要时 `tune2fs -m 1` 下调）；**配额触顶**（user/group/project quota 满了同样报 `ENOSPC`，部分路径报 `EDQUOT`，排除命令 `repquota -a` 或 `quota -s`）。加分项：xfs 的延迟分配与投机预分配会让 `ENOSPC` 推迟到回写阶段暴露，应用会看到"之前写成功、之后报错"；`inotify` 的 watch 上限触顶也报 `ENOSPC`，但那是内核对象限制、与磁盘无关；若文件系统因 `errors=remount-ro` 被挂成只读，报的是 `EROFS`——把这两个错误码分开，说明真的理解来源。

错在哪：只答"删点文件"或只答"inode 满了"。面试官会追问："如果是保留块，为什么业务进程报错而 root 手动写没问题？"——因为 `df` 对不同用户报告的 `Avail` 不同，答不出这一点就暴露了只是背结论。
</details>

**Q3**（计算）12 块 10K rpm SAS 盘，单盘随机 IOPS 约 150，单盘容量 1.8 TB。分别计算 RAID 5、RAID 6、RAID 10 的可用随机写 IOPS 与可用容量，并说明一个 OLTP 数据库该选哪个、代价是什么。
<details><summary>答案与解析</summary>

按 `可用随机写 IOPS = N × I / 写惩罚`、容量 `= 有效数据盘数 × 单盘容量`：RAID 5 得 `12 × 150 / 4 = 450` IOPS、容量 `11 × 1.8 = 19.8 TB`；RAID 6 得 `12 × 150 / 6 = 300` IOPS、容量 `10 × 1.8 = 18 TB`；RAID 10 得 `12 × 150 / 2 = 900` IOPS、容量 `6 × 1.8 = 10.8 TB`。OLTP 数据库选 RAID 10：随机写 IOPS 是 RAID 5 的 2 倍、RAID 6 的 3 倍；重构只需从镜像盘复制，耗时与单盘容量成正比且不影响其他盘组，而 RAID 5/6 重构要读整组条带重算校验，期间性能塌陷且存在第二次故障与 URE 导致整组不可用的风险。代价是容量利用率只有 50%——10.8 TB 相比 RAID 5 的 19.8 TB 少了 45%，这笔成本可预算，而重构失败的风险不可控。

错在哪：①写惩罚记成 3 或 5；②只答"RAID 10 快"而说不出重构窗口与 URE 风险，面试官下一步必定问"RAID 5 重构为什么危险"；③忽略"整条带写"这个例外，于是无法回答"RAID 5 是不是完全没有价值"——顺序大块写时它接近无惩罚，且容量利用率更高，适合归档与顺序日志。
</details>

**Q4**（设计）一台机器 `df -h /data` 显示已用 900 GB，而 `du -xsh /data` 只有 30 GB。给出完整排查步骤、每一步的预期输出形态，并指出哪些"常见原因"在这个场景里可以被排除。
<details><summary>答案与解析</summary>

第一步 `lsof -nP +L1 | sort -k7 -rn | head`，预期看到若干行 `NLINK` 为 0、`SIZE/OFF` 很大的记录，`NAME` 列形如 `/var/log/app.log (deleted)`，`/proc/[pid]/fd` 中是带 `(deleted)` 的符号链接。命中即根因是**文件已删除但仍被进程持有**：870 GB 的块有分配、无名字，`du` 按名字遍历自然看不见；处置是让持有进程重开或重启，或对**普通文件**执行 `: > /proc/<pid>/fd/<n>`。第二步若 `lsof` 无发现，检查**挂载点覆盖**：`findmnt -o TARGET,SOURCE,FSTYPE -R /data`，预期看到 `/data/xxx` 上挂着另一个文件系统；再用 `mount --bind / /mnt/root` 从根文件系统视角看被遮住的原目录，`du -sh /mnt/root/data` 就能把差额数出来。第三步两者都排除后看**保留块与稀疏文件**：保留块最多只占文件系统的 5%，差的 870 GB 远超这个量级，可直接排除；稀疏文件造成的是 `ls -l` 表观大小与 `du` 实占块的差异，`df` 与 `du` 在稀疏文件上一致，因此也**不是**本题原因。

错在哪：①一上手 `du -sh /*` 逐层找大文件——方向就错了，空间根本不在任何有名字的文件里；②把稀疏文件当答案，被追问"那 `df` 统计的是什么"就崩；③不知道 `lsof +L1` 与 `/proc/[pid]/fd`，只能靠重启服务碰运气。面试官会追问："为什么 `rm` 之后空间没释放？"答案是目录项与 inode/数据块的分离：`unlink` 只删名字，fd 仍持有引用时 `i_nlink` 为 0 但块不回收。
</details>

**Q5**（推导）为什么 `noatime` 值得开？请从"一次读如何变成一次写"的链路推导，说明 `relatime` 与 `noatime` 的差别，并解释为什么 `nodiratime` 与 `noatime` 同时给出时是冗余的。
<details><summary>答案与解析</summary>

链路是 `read` → `file_accessed()` 更新 `inode->i_atime` 并把 inode 标脏 → 回写时写入 inode，且 ext4 默认 `ordered` 模式下这次元数据修改还要提交一次 jbd2 日志事务，于是一次纯读引出一笔随机元数据写加一次日志提交。注意放大倍率的准确口径：同一 inode 的多次读会在内存里合并成一次回写，因此是"每个 inode 每次回写一次"而非"每次读一次写"，但对**每次读的都是不同 inode** 的负载（静态资源、备份扫描、编译、目录遍历）两者等价。`relatime`（2.6.30 起默认）只在 atime 早于 mtime/ctime 或距上次更新超过 24 小时时更新，消除了约 99% 的更新并保留"这文件最近被读过吗"的粗粒度语义；`noatime` 完全不更新，收益彻底但有语义代价。`nodiratime` 只对目录关闭 atime，而 `noatime` 已涵盖包括目录在内的所有 inode，所以同给时不产生额外效果，它只在"想保留文件 atime、又想省掉目录那部分写"的折中场景下单独使用才有意义。

错在哪：答"atime 只是记录访问时间，开不开都行"等于放弃了整条因果链；或答"每次读都要写盘"，被追问"同一文件读一万次也写一万次吗"时无法解释内核的合并行为。面试官会追问："那 `relatime` 为什么是默认值而不是 `noatime`？"答案是兼容性：保留弱 atime 语义的代价只是热文件每天一次写，收益是避免破坏依赖 atime 的既有工具。
</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| ext4 的三种日志模式有什么区别？生产上一般用哪种，为什么？ | 文件系统一致性模型 | 陷阱：只背"journal 最安全、writeback 最快"，说不出 `writeback` 的坏数据形态是"新元数据 + 旧块内容"。加分：说清 `ordered` 保证的是顺序而非数据进日志，并区分一致性（`ordered` 保证）与持久性（`fsync` 保证） | 13.1.1 |
| `df` 和 `du` 的结果对不上，可能是什么原因？ | 空间计量的数据来源 | 陷阱：把稀疏文件当成 `du`/`df` 不一致的原因（它造成的是 `ls -l` 与 `du` 的差异），也想不到挂载点覆盖。加分：从 `statfs` 全局计数与按名字遍历的差异统一解释三类根因，并补上保留块 | 13.3 |
| 已经 `rm` 掉的日志文件为什么空间没释放？怎么找到它？ | 目录项与 inode 的分离 | 陷阱：答"没删干净""要 sync""文件系统缓存"。加分：说清 `unlink` 只删目录项、fd 持有引用使 `i_nlink` 为 0 而块不回收，并给出 `lsof -nP +L1` 与 `/proc/[pid]/fd` 两条路径 | 13.3 |
| RAID 5 的随机写惩罚为什么是 4？RAID 6 为什么是 6？ | 校验型 RAID 的写路径 | 陷阱：只背数字说不出"读旧数据 + 读旧校验 + 写新数据 + 写新校验"。加分：指出整条带写时无需读旧值、惩罚接近 1，因此 RAID 5 怕小随机写而不怕顺序大块写 | 13.5.2 |
| 数据库为什么一般推荐 RAID 10 而不是 RAID 5？ | 冗余方案的工程权衡 | 陷阱：只答"RAID 10 快"。加分：给出写 IOPS 差 2 倍、重构窗口与性能塌陷、重构期 URE 导致整组不可用、RAID 5 写洞，并承认代价是容量腰斩 | 13.5.5 |
| 为什么测硬盘 IOPS 必须同时说明队列深度和块大小？ | 性能口径 | 陷阱：答"因为影响性能"。加分：给出 `IOPS = Q / L` 与 `吞吐量 = IOPS × 块大小`，指出 4 KiB 与 1 MiB 下 IOPS 与 MB/s 是两套独立上限，并说明不 `direct` 就是测内存 | 13.6.1 |
| `iostat` 里 `%util` 到 100% 是不是说明磁盘到瓶颈了？ | 指标语义 | 陷阱：答"是"。加分：指出 `%util` 的定义是"至少有一个在途请求的时间占比"，深队列设备上它饱和不代表余量耗尽，应看 `aqu-sz` 与 `await` 的组合 | 13.6.3 |
| 服务报 `too many open files`，你怎么排查？ | 资源限制的层次 | 陷阱：一上来就答"调大 `ulimit -n`"，把泄漏掩盖成延迟爆发的 OOM。加分：分 `EMFILE`（进程级）与 `ENFILE`（系统级），用 fd 数的单调性区分配置不足与泄漏 | 13.8 |
| ext4 的 inode 用完了能扩吗？平时怎么预防？ | 文件系统的静态布局 | 陷阱：答"删小文件就行"或"能动态加"。加分：说清 inode 表在 `mkfs` 时按 `bytes-per-inode` 固定、只能靠扩容后 `resize2fs` 补建，预防手段是建盘时调 `-i`/`-N` | 13.1.3 |
| NVMe SSD 上为什么还要关心 TRIM？它会加快删除文件吗？ | SSD 内部机制 | 陷阱：答"TRIM 能加快删除操作"（删除本来就是元数据操作，与 TRIM 无关）。加分：从"设备不知道块已失效 → GC 搬运有效页 → 写放大升高 → 持续写性能与寿命双降"这条链解释，并区分 `-o discard` 与周期性 `fstrim` | 13.6.4 |

## 延伸阅读

- `man 5 ext4`：三种 `data=` 模式的权威描述，含 `writeback` 数据暴露风险的原话。
- `man 8 mkfs.xfs`、`man 8 xfs_growfs`、`man 8 xfs_info`：分配组、`imaxpct`、投机预分配与在线扩容参数。
- `man 8 mke2fs`、`man 8 tune2fs`、`man 8 resize2fs`：`bytes-per-inode`/`-N`、保留块比例、离线缩容的前提。
- `man 8 mount`：`noatime`/`relatime`/`nodiratime`、`sync`、`barrier`、`errors=`、`discard` 的逐条语义。
- `man 8 lsof`、`man 1 fuser`：`+L1`、`+d` 与 `+D`、`-v` 与 `-m` 的行为差异。
- `man 1 fio` 与 fio 官方 HOWTO：`--rw`/`--bs`/`--iodepth`/`--numjobs`/`--direct`/`--ramp_time` 的含义。
- Linux 内核文档 `Documentation/admin-guide/blockdev/` 与 `Documentation/filesystems/`：`io_ticks` 与 `%util` 的来源、文件系统挂载选项说明。
- 第 7 章 7.1~7.3 节（fd 三层结构与 dentry 缓存）、第 9 章 9.9 节（块层栈与 `fsync`）、第 11 章 11.3 节（`%util` 为什么不可靠）、第 12 章 12.2 节（脏页回写）构成本章的前置与后续。


---

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

