目录

Linux-11 文件系统与磁盘 IO

前置阅读:Linux-02 文件、链接与归档压缩Linux-10 内存管理

第 2 篇讲了 inode 和文件操作,这一篇往下走一层:文件系统怎么组织磁盘、IO 请求怎么到达硬件、以及怎么判断磁盘是不是瓶颈

iostat 的指标判读是本篇的核心——「磁盘慢」是个笼统的说法,await 高、%util 高、aqu-sz 高分别对应完全不同的原因和处理方式。

1. VFS:一切皆文件的实现层

一句话:VFS(Virtual File System)是内核提供的统一抽象层,让 read()/write() 能作用于任何东西——磁盘文件、管道、socket、设备、甚至 /proc 里的虚拟内容。

        应用程序
     read() / write()
            v
    +---------------+
    |      VFS      |   统一接口:file_operations 结构体
    +-------+-------+
            v
  +-----+-----+-----+------+-----+
  |ext4 | xfs |tmpfs|procfs| NFS |   各文件系统实现同一套接口
  +--+--+--+--+-----+------+-----+
     v     v
  +-------------+
  |  page cache |
  +------+------+
         v
  +-------------+
  |  块设备层   |   IO 调度、合并、排序
  +------+------+
         v
  +-------------+
  | 设备驱动    |
  +------+------+
         v
      物理磁盘

VFS 的核心是四个对象

对象 含义
superblock 一个已挂载的文件系统的整体信息
inode 一个文件的元数据(第 2 篇讲过)
dentry 目录项,缓存「路径名 → inode」的映射
file 一个已打开的文件,含读写偏移量

dentry 缓存的作用容易被忽略——解析 /var/log/nginx/access.log 要逐级查找 4 层目录,每层都要读 inode。dentry 缓存把结果记下来,避免重复解析:

# 查看 dentry 和 inode 缓存占用
cat /proc/meminfo | grep -i slab
# Slab:            1048576 kB
# SReclaimable:     892340 kB      <- 可回收的(dentry/inode 缓存主要在这)

slabtop -o | head -8
#  OBJS ACTIVE  USE OBJ SIZE  SLABS CACHE SIZE NAME
# 823410 812340  98%    0.19K  39210      21    156840K dentry
# 412034 401234  97%    0.58K  15242      27    243872K ext4_inode_cache

坑:海量小文件的目录遍历会让 dentry 缓存暴涨。有个几千万文件的目录被 find 扫过之后,slab 可能占掉几 GB。它是可回收的(SReclaimable),但在容器里会算进 cgroup 内存限制,可能直接导致 OOM。这是「容器里跑 find 或备份任务突然 OOM」的一个隐蔽原因。

1.1 文件描述符的三层结构

进程 A                        内核
+--------------+
| fd 表        |
|  0 -> -------+--> +----------------+
|  1 -> -------+--> | file 对象      |   含读写偏移量、打开模式
|  3 -> -------+--> | +- f_pos       |
+--------------+    | +- f_inode ----+--> +----------+
                    +----------------+    |  inode   |  文件元数据
进程 B                                    | (元数据) |
+--------------+                          +----------+
|  4 -> -------+--> +----------------+          ^
+--------------+    | 另一个 file    |----------+
                    | (独立的偏移量) |
                    +----------------+

三层结构解释了几个现象

# ① 两个进程各自 open 同一个文件 -> 各有独立的偏移量,互不影响
# ② fork 后父子共享 file 对象 -> 共享偏移量!一方读了另一方也会前进
# ③ dup2 复制 fd -> 指向同一个 file 对象,共享偏移量
#    这就是 shell 重定向 2>&1 的原理(第 5 篇)
# 实际查看 fd 的指向
ls -l /proc/1234/fd/
# lrwx------ 1 app app 64 Aug 6 22:00 0 -> /dev/null
# lrwx------ 1 app app 64 Aug 6 22:00 1 -> pipe:[89234]
# lrwx------ 1 app app 64 Aug 6 22:00 3 -> /var/log/app.log
# lrwx------ 1 app app 64 Aug 6 22:00 4 -> socket:[92341]
# lrwx------ 1 app app 64 Aug 6 22:00 5 -> anon_inode:[eventpoll]  <- epoll 实例

# 看某个 fd 的详细信息,包括偏移量
cat /proc/1234/fdinfo/3
# pos:	1048576          <- 当前读写偏移
# flags:	02100002
# mnt_id:	29

2. ext4 与 xfs:怎么选

现代 Linux 上的实际选择基本就这两个。

维度 ext4 xfs
inode 分配 mkfs 时固定,事后无法增加 动态分配,不会耗尽
能否缩小 能(resize2fs 不能,只能扩大
大文件性能 更好(extent + 延迟分配更成熟)
海量小文件 略差
并发写入 一般 更好(AG 分组,多线程写不争锁)
单文件上限 16 TB 8 EB
默认于 Debian/Ubuntu RHEL 7+ / Rocky
修复工具 fsck.ext4 xfs_repair(需先卸载)

决策建议

数据库、大文件、高并发写   -> xfs
需要能缩容、海量小文件      -> ext4
不确定、跟随发行版默认      -> 都行,差异在大多数场景下不明显

ext4 最需要警惕的是 inode 耗尽(第 2 篇讲过)——mkfs 时确定的 inode 总数无法事后增加:

df -i /data
# Filesystem      Inodes  IUsed IFree IUse% Mounted on
# /dev/vdb1     32768000 32768000    0  100% /data      <- 空间还有,但建不了文件

# 格式化时提高 inode 密度(每 8KB 一个 inode,默认是 16KB)
mkfs.ext4 -i 8192 /dev/vdb1
# 或用预设的 profile
mkfs.ext4 -T small /dev/vdb1     # small = 适合小文件多的场景

xfs 没有这个问题(动态分配 inode),这是选它的一个实际理由。

3. 挂载与 fstab

# 查看当前挂载
findmnt
# TARGET       SOURCE    FSTYPE OPTIONS
# /            /dev/vda1 ext4   rw,relatime
# /data        /dev/vdb1 xfs    rw,noatime,nodiratime
# /dev/shm     tmpfs     tmpfs  rw,nosuid,nodev

# 比 mount 命令的输出好读
mount | column -t | head -5

3.1 值得关注的挂载选项

选项 作用 建议
noatime 不更新访问时间 数据盘推荐,减少无意义的写
relatime 折中:仅当 atime < mtime 或超 24h 才更新 现代默认值
nodiratime 目录不更新 atime 配合 noatime
nosuid 忽略 SUID 位 用户可写的分区必加(第 3 篇)
nodev 不识别设备文件 同上
noexec 禁止执行文件 /tmp、上传目录
discard 实时 TRIM 不推荐,用定时 fstrim 代替
data=writeback ext4 日志只记元数据 性能好但崩溃可能丢数据

noatime 是最实用的一项。默认的 relatime 仍然会在某些情况下产生写操作——对于读多写少的场景(静态资源服务、日志读取),这些写是纯粹的浪费:

# /etc/fstab
/dev/vdb1  /data  xfs  defaults,noatime,nodiratime  0 0

坑:discard(实时 TRIM)会让删除操作变慢,因为每次删文件都要给 SSD 发 TRIM 命令。推荐改用定时批量 TRIM:

# 用 systemd timer 每周做一次批量 TRIM(现代发行版默认已启用)
systemctl enable --now fstrim.timer
systemctl status fstrim.timer

3.2 fstab 用 UUID 而不是设备名

# ❌ 设备名不稳定:加了新盘或改了顺序,/dev/vdb 可能变成 /dev/vdc
/dev/vdb1  /data  xfs  defaults  0 0

# ✅ 用 UUID
UUID=a1b2c3d4-...  /data  xfs  defaults,noatime  0 0

# 查 UUID
blkid
lsblk -o NAME,SIZE,FSTYPE,UUID,MOUNTPOINT

「加了一块盘之后系统起不来」的经典原因就是设备名漂移——/etc/fstab 里写的 /dev/vdb1 变成了别的盘,挂载失败导致系统进入紧急模式。

改完 fstab 一定要在重启前验证

# 测试所有 fstab 条目能否正常挂载
mount -a
# 无输出 = 全部成功;有报错就赶紧修,别急着重启

# 加 nofail 让挂载失败不阻塞启动(数据盘推荐)
UUID=xxx  /data  xfs  defaults,noatime,nofail  0 2
#                                              ^^^^^^ 关键

nofail 值得默认加上——它让「数据盘挂载失败」变成「系统正常启动但少个目录」,而不是「系统起不来只能上控制台救援」。

4. LVM:在线扩容

云上加盘扩容是常见运维操作,LVM 让它可以在不停机的情况下完成。

物理卷 PV (Physical Volume)     <- 实际的磁盘或分区 /dev/vdb1
       v 组成
卷组   VG (Volume Group)        <- 一个"资源池"
       v 划分
逻辑卷 LV (Logical Volume)      <- 给文件系统用 /dev/vg_data/lv_data
# 查看三层结构
pvs; vgs; lvs
#   PV         VG      Fmt  Attr PSize   PFree
#   /dev/vdb1  vg_data lvm2 a--  500.00g    0
#   VG      #PV #LV #SN Attr   VSize   VFree
#   vg_data   1   1   0 wz--n- 500.00g    0
#   LV      VG      Attr       LSize
#   lv_data vg_data -wi-ao---- 500.00g

扩容的完整流程(云上加了一块 500GB 新盘 /dev/vdc):

# ① 把新盘做成 PV
pvcreate /dev/vdc

# ② 加进现有的 VG
vgextend vg_data /dev/vdc
vgs
#   VG      #PV #LV VSize VFree
#   vg_data   2   1 1000g 500g      <- 池子变成 1TB

# ③ 扩展 LV,吃掉所有空闲空间
lvextend -l +100%FREE /dev/vg_data/lv_data
# ④ 扩展文件系统 —— 注意两种文件系统命令不同
resize2fs /dev/vg_data/lv_data        # ext4
xfs_growfs /data                      # xfs,参数是【挂载点】不是设备

# ⑤ 验证
df -h /data
# /dev/mapper/vg_data-lv_data  1000G  423G  577G  43% /data

**全程不需要卸载,业务无感知。**这是 LVM 最大的价值。

注意:xfs 只能扩大不能缩小。如果需要缩容,只能备份数据、重建文件系统、恢复数据。ext4 可以缩小但必须先卸载(umounte2fsck -fresize2fs 缩小 → lvreduce),且顺序错了会丢数据。规划分区时宁可先给小一点,因为扩容容易缩容难。

5. IO 调度器

块设备层会对 IO 请求做合并排序再下发给硬件。调度器决定这个策略。

# 查看当前调度器
cat /sys/block/vda/queue/scheduler
# [none] mq-deadline kyber bfq
#  ^^^^ 方括号里是当前生效的

# 临时切换
echo mq-deadline > /sys/block/vda/queue/scheduler
调度器 适用 说明
none NVMe SSD 不排序不合并,直接下发。SSD 没有寻道,排序无意义
mq-deadline SATA SSD、云盘 保证请求不会饿死,有读优先倾向
kyber 高速 SSD 按延迟目标自适应节流
bfq 桌面、机械盘 保证交互进程的公平性,服务器上开销偏大

现代选型很简单

NVMe SSD          -> none        (内核默认就是这个)
SATA SSD / 云盘   -> mq-deadline  或 none
机械盘            -> mq-deadline  或 bfq
虚拟机 / 容器      -> none        (宿主机已经调度过了,再调度是浪费)

关键认知:cfqdeadline 这些单队列调度器在 5.0+ 内核已被移除,全部换成了 multi-queue 版本。网上很多「把调度器改成 deadline」的老文章已经过时——现在应该看有没有 mq- 前缀。

# 持久化配置(用 udev 规则,按设备类型自动设置)
cat > /etc/udev/rules.d/60-scheduler.rules <<'EOF'
# NVMe 用 none
ACTION=="add|change", KERNEL=="nvme[0-9]*n[0-9]*", ATTR{queue/scheduler}="none"
# 旋转磁盘用 mq-deadline
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/rotational}=="1", ATTR{queue/scheduler}="mq-deadline"
EOF

另外两个值得调的队列参数

# 队列深度:允许多少请求同时在飞
cat /sys/block/nvme0n1/queue/nr_requests
# 1023

# 预读大小(KB)—— 顺序读大文件时加大有收益
cat /sys/block/vda/queue/read_ahead_kb
# 128
echo 512 > /sys/block/vda/queue/read_ahead_kb    # 顺序扫描场景

# 随机 IO 为主的场景(数据库)反而应该减小预读,避免读入无用数据
echo 16 > /sys/block/vda/queue/read_ahead_kb

**规律:顺序读加大 read_ahead_kb,随机读减小它。**默认 128KB 是个折中值。

6. iostat:判断磁盘是不是瓶颈

这是本篇最实用的一节。

iostat -x 1 3
# 注意:第一组数据是【开机至今的平均值】,要看第二组之后的
#
# Device  r/s   w/s  rkB/s   wkB/s  rrqm/s wrqm/s %rrqm %wrqm r_await w_await aqu-sz rareq-sz wareq-sz  svctm  %util
# nvme0n1 120.0 890.0 4800.0 89000.0   2.0   45.0   1.6   4.8    0.42    2.31   2.14     40.0    100.0   0.00  45.20

6.1 各指标的含义与判读标准

指标 含义 判读标准
r/s w/s 每秒读/写次数(IOPS 与设备能力对比
rkB/s wkB/s 每秒读/写数据量(吞吐 与设备带宽对比
rrqm/s wrqm/s 每秒被合并的请求数 高说明有顺序 IO 被合并了,是好事
r_await w_await 平均等待时间(含排队+服务,ms) 最重要的指标,见下
aqu-sz 平均队列长度 > 1 说明在排队
rareq-sz wareq-sz 平均单次请求大小(KB) 判断是随机小 IO 还是顺序大 IO
%util 设备繁忙时间占比 对 SSD 已失去意义,见下

await 的参考基准

NVMe SSD      < 1 ms       正常;> 5 ms 需要关注
SATA SSD      < 5 ms       正常;> 20 ms 有问题
云盘(普通)   < 10 ms      正常;> 50 ms 有问题
机械盘        < 20 ms      正常;> 100 ms 严重

6.2 %util 是个陷阱

%util = 100% 在 SSD 上不代表磁盘满载。

%util 的定义是「设备至少有一个请求在处理的时间占比」。这个定义源自机械盘时代——机械盘只有一个磁头,同一时刻只能处理一个请求,所以 %util=100% 确实意味着饱和。

SSD 和 NVMe 有几十甚至上千个并行队列,可以同时处理大量请求。%util=100% 只说明「一直有请求在处理」,可能只用了 5% 的实际能力。

# 一个真实的例子
iostat -x 1
# Device   r/s   w/s  r_await w_await aqu-sz  %util
# nvme0n1 50.0  30.0     0.08    0.12   0.01  99.80
#                        ^^^^^^^^^^^^ 等待时间极低       ^^^^^ 但 util 99.8%
#                                     aqu-sz 0.01 说明几乎不排队
# 结论:磁盘一点都不忙,%util 高只是因为请求间隔小于采样精度

判断 SSD 是否真的饱和,要看这三个组合

① r_await / w_await 显著升高(超过基准的 3~5 倍)
② aqu-sz > 1 且持续增长(请求在排队)
③ IOPS 或吞吐接近设备标称上限

三者同时出现 = 真饱和
只有 %util 高 = 大概率没问题

规律:SSD 时代看 awaitaqu-sz,不看 %util

6.3 区分随机 IO 和顺序 IO

rareq-sz/wareq-sz(平均请求大小)能告诉你负载类型:

# 场景 A:数据库的随机小 IO
# Device   r/s    rkB/s  rareq-sz  r_await
# nvme0n1 8000.0 32000.0     4.0     0.35
#                            ^^^ 平均 4KB = 典型的随机小 IO
# -> IOPS 是瓶颈,看 8000 IOPS 是否接近设备上限

# 场景 B:日志顺序写
# Device   w/s    wkB/s  wareq-sz  w_await
# nvme0n1  120.0 122880.0  1024.0    1.20
#                          ^^^^^^ 平均 1MB = 顺序大 IO
# -> 带宽是瓶颈,看 120MB/s 是否接近设备上限

设备能力的量级感

设备 随机 4K IOPS 顺序吞吐
机械盘 7200rpm 100 ~ 200 150 MB/s
SATA SSD 50k ~ 90k 500 MB/s
NVMe SSD 200k ~ 1M 3 ~ 7 GB/s
云盘(通用型) 3k ~ 20k(按容量给) 100 ~ 350 MB/s

云盘的 IOPS 通常与容量挂钩——这是个容易踩的坑:100GB 的云盘可能只有 3000 IOPS,把数据库放上去必然慢。扩容盘不只是为了空间,也是为了 IOPS。

6.4 定位到具体进程

iostat 只告诉你设备忙,pidstatiotop 告诉你是谁在忙:

# 按 IO 排序看进程
pidstat -d 1 3
# 时间     UID  PID   kB_rd/s   kB_wr/s kB_ccwr/s iodelay  Command
# 22:15:01   0  8821      0.00  45230.00      0.00     234  mysqld
#                                ^^^^^^^^ 每秒写 45MB
#                                                     ^^^ IO 延迟(时钟周期)

# 或者用 iotop(更直观,需要 root)
iotop -oPa
#  -o 只显示真正在做 IO 的
#  -P 只显示进程不显示线程
#  -a 显示累计值而不是速率

看单个进程的 IO 总量

cat /proc/1234/io
# rchar: 892340123          <- 读的字节数(含从 cache 读的)
# wchar: 1234567890         <- 写的字节数
# read_bytes: 234560000     <- 【真正从块设备读】的字节数
# write_bytes: 1089234000   <- 真正写到块设备的
# cancelled_write_bytes: 0

rcharread_bytes 的差值就是 page cache 的贡献

rchar = 892MB, read_bytes = 234MB
-> 缓存命中率 ≈ (892-234)/892 = 74%

这是评估 page cache 效果最直接的方法。

7. df 与 du 对不上的三种原因

第 2 篇讲了最常见的一种,这里补全:

df -h /data
# /dev/vdb1  500G  480G  0G  100% /data

du -sh /data
# 210G                                  <- 差了 270G
原因 判断方法 处理
被删但仍被进程持有 lsof +L1 reload/重启持有它的进程
被挂载点遮盖的文件 见下 卸载后清理
预留块(reserved blocks) tune2fs -l 正常现象,不用处理

「被挂载点遮盖」这种情况很隐蔽

① 原本 /data 下有 270GB 文件(在根分区上)
② 后来挂载了一块新盘到 /data
③ 那 270GB 文件还在根分区的 /data 目录里,但被新挂载点【遮住】了
④ du /data 看到的是新盘的内容,df / 却仍算着那 270GB
# 排查:把上层目录重新挂载到别处,看被遮盖的内容
mkdir /mnt/tmp
mount --bind / /mnt/tmp
du -sh /mnt/tmp/data
# 270G                    <- 找到了!这些文件被 /data 的挂载点遮住了

rm -rf /mnt/tmp/data/*    # 清理
umount /mnt/tmp

ext4 的预留块是设计如此,不是问题:

tune2fs -l /dev/vdb1 | grep -i reserved
# Reserved block count:     6553600
# Reserved GDT blocks:      1024
# 默认预留 5% 给 root,防止磁盘完全满时 root 也无法登录修复

# 数据盘可以调低到 1%(大盘上 5% 是很多空间)
tune2fs -m 1 /dev/vdb1

500GB 盘的 5% 是 25GB——对纯数据盘来说这是浪费,调到 1% 能省出 20GB。

8. 文件描述符与 ulimit

「Too many open files」是后端最常见的错误之一,而它的排查有一条明确的路径。

8.1 三层限制

① 系统级:整个系统能打开的 fd 总数
   /proc/sys/fs/file-max
   
② 用户级:单个用户的限制(PAM 机制,只对【登录会话】生效)
   /etc/security/limits.conf
   
③ 进程级:进程实际继承到的限制  <- 【只有这个才是真相】
   /proc/<pid>/limits
# 系统级
cat /proc/sys/fs/file-max
# 9223372036854775807      <- 现代内核基本无上限

# 当前系统打开了多少
cat /proc/sys/fs/file-nr
# 12480	0	9223372036854775807
#  ^^^^^ 已分配   ^ 空闲   ^^^ 上限

8.2 为什么改了 limits.conf 没用

这是最经典的坑,第 1 篇提过,这里讲透。

# 改了这个文件
cat /etc/security/limits.conf
# app  soft  nofile  65535
# app  hard  nofile  65535

# 登录后确认生效了
ulimit -n
# 65535                   <- 看起来对了

# 但服务还是报 Too many open files
cat /proc/$(pgrep myapp)/limits | grep -i "open files"
# Max open files            1024      1024      files
#                           ^^^^ 真相:还是 1024

**原因:limits.conf 是 PAM(pam_limits.so)的机制,只在「用户登录」时被应用。**systemd 启动服务时不经过 PAM,所以完全不读这个文件。

正确做法是在 systemd unit 里配置

# /etc/systemd/system/myapp.service
[Service]
LimitNOFILE=65535
LimitNPROC=65535
LimitCORE=infinity
systemctl daemon-reload
systemctl restart myapp

# 验证 —— 唯一可信的方式
cat /proc/$(pgrep myapp)/limits | grep -i "open files"
# Max open files            65535     65535     files      ✅

也可以设全局默认

# /etc/systemd/system.conf
DefaultLimitNOFILE=65535:65535
# 改完需要重启机器或 systemctl daemon-reexec

规律:ulimit -n 只反映当前 shell;进程的真实限制永远看 /proc/<pid>/limits

8.3 容器里的 fd 限制

# Docker
docker run --ulimit nofile=65535:65535 myapp

# K8s 没有直接的字段,要靠以下方式之一:
# ① 容器镜像的 entrypoint 里 ulimit -n 65535(需要 CAP_SYS_RESOURCE)
# ② 改 containerd/dockerd 的默认值
# ③ 用 initContainer 配合 privileged 调整

containerd 的默认值配置

# /etc/containerd/config.toml
[plugins."io.containerd.grpc.v1.cri"]
  # 容器的默认 fd 限制
  ...

现代 containerd/Docker 的默认 nofile 通常已经是 1048576,所以容器里反而很少遇到这个问题——遇到的话优先怀疑是应用自己泄漏 fd。

8.4 用 lsof 排查 fd 泄漏

# ① 确认是哪个进程 fd 多
for p in /proc/[0-9]*; do
    n=$(ls "$p/fd" 2>/dev/null | wc -l)
    [ "$n" -gt 500 ] && echo "$n $(cat $p/comm 2>/dev/null) ${p#/proc/}"
done | sort -rn | head
# 24312 myapp 1234        <- 2 万多个 fd,明显异常
# ② 看 fd 的类型分布 —— 这一步直接指向问题
ls -l /proc/1234/fd/ | awk '{print $NF}' | sed 's/:\[.*//' | sort | uniq -c | sort -rn
#  23890 socket
#    412 /var/log/app.log
#      8 pipe
#      2 /dev/null
#        ^^^^^^^^^^ 2 万多个 socket -> socket 泄漏,不是文件句柄泄漏

这两步就能把方向分成两类

socket 占大头     -> 连接泄漏,继续用 ss 看状态分布(见下)
普通文件占大头     -> 文件句柄泄漏,看是哪些路径,通常是日志或临时文件没关
# ③ socket 泄漏时,看连接状态
ss -tanp | grep "pid=1234" | awk '{print $1}' | sort | uniq -c
#  23880 CLOSE-WAIT          <- 应用漏了 close()
#     10 ESTAB

CLOSE-WAIT 堆积是纯粹的应用 bug——对端已关闭但本地没调用 close(),而且没有任何超时机制,调内核参数完全无效。它与 TIME-WAIT 的完整区分见 Linux-13 §3.2,一个真实的排查案例见 Linux-20 §5

9. 实战:确认磁盘是不是瓶颈

把本篇的判读方法串成一个最小流程。这里只演示「怎么确认是磁盘问题、是谁造成的」——完整的案例复盘(含根因分析、止血、长期改进)见 Linux-20 §3

现象:订单服务 P99 从 80ms 涨到 4s,同机的其他服务也变慢。

① 先确认瓶颈在 IO 而不是 CPU

vmstat 1 3
# procs -----------io---- -system-- ------cpu-----
#  r  b     bi     bo   in   cs us sy id wa st
#  1 22   4096 189234 3421 5623  3  4  6 87  0
#    ^^ 22 个进程阻塞在 D 状态
#                                          ^^ wa 87%,而 r 列只有 1

r 小、b 大、wa 高 → CPU 不排队,瓶颈在磁盘(判读规则见第 7 篇 §6)。

② 用三条件确认磁盘真饱和

iostat -xz 1 3
# Device   w/s    wkB/s  r_await w_await aqu-sz wareq-sz %util
# nvme0n1 3200.0 204800.0   1.20   28.45  92.31     64.0  99.90
#                                          ^^^^^ 28ms,是 NVMe 基准的 28 倍
#                                                  ^^^^^ 队列 92,远大于 1

await 超基准数倍 + aqu-sz > 1 + 吞吐接近上限,三者同时成立才是真饱和(§6.2)。只有 %util 高不算

③ 定位到进程

pidstat -d 1 3 | sort -k5 -rn | head -3
# UID  PID   kB_rd/s   kB_wr/s kB_ccwr/s iodelay  Command
#   0 8821      0.00 178234.00      0.00    8923  mysqld
#                      ^^^^^^^^^ 写了 178MB/s,占绝大部分

到这一步,排查就从「系统层」转入「应用层」了——接下来要查的是 MySQL 里在跑什么。

规律:wa 高 + b 列高 + await 超基准数倍 + aqu-sz > 1 = 确定的磁盘瓶颈。然后用 pidstat -d 定位到进程,再往应用层查根因。

10. 面试题

Q:VFS 是什么?为什么 read() 既能读文件又能读 socket?

VFS(Virtual File System)是内核的统一抽象层,它定义了一套标准接口(file_operations 结构体,含 read/write/open 等函数指针),各种具体实现——ext4、xfs、tmpfs、procfs、socket、管道、设备——都实现这套接口。应用调用 read(fd, ...) 时,VFS 根据 fd 找到对应的 file 对象,再调用它挂载的 f_op->read,从而分发到具体实现。这就是「一切皆文件」的落地方式。VFS 的四个核心对象是 superblock(文件系统整体)、inode(文件元数据)、dentry(路径名到 inode 的缓存)、file(已打开的文件,含读写偏移量)。

Q:iostat%util 达到 100%,磁盘是不是已经满载了?

在 SSD 上不是%util 的定义是「设备至少有一个请求在处理的时间占比」,这个定义源自机械盘时代——机械盘只有一个磁头,同一时刻只能处理一个请求,所以 100% 确实等于饱和。但 NVMe/SSD 有几十到上千个并行队列,%util=100% 只说明「一直有请求在处理」,可能只用了 5% 的实际能力。判断 SSD 真饱和要看三个条件同时成立:① r_await/w_await 超过设备基准的 3~5 倍(NVMe 基准 < 1ms);② aqu-sz > 1 且持续增长(请求在排队);③ IOPS 或吞吐接近设备标称上限。规律:SSD 时代看 awaitaqu-sz,不看 %util

Q:改了 /etc/security/limits.confulimit -n 也显示 65535,服务还是报 Too many open files,为什么?

因为 limits.confPAM 机制pam_limits.so),只在「用户登录」时被应用。systemd 启动服务时不经过 PAM,完全不读这个文件,所以服务进程继承的还是 systemd 的默认值(通常 1024)。而 ulimit -n 显示的是你当前登录 shell 的限制,与服务进程无关。正确做法是在 systemd unit 里写 LimitNOFILE=65535,或在 /etc/systemd/system.conf 里设 DefaultLimitNOFILE唯一可信的验证方式是 cat /proc/<pid>/limits——这是进程真实生效的限制,其他任何地方看到的都可能是假象。

Q:进程 fd 数量异常增长,怎么定位是哪一类泄漏?

先看 /proc/PID/limits 确认限制是否真的生效(不看 ulimit -n,那只是当前 shell 的值)。然后关键一步是看 fd 的类型分布ls -l /proc/PID/fd | awk '{print $NF}' | sed 's/:\[.*//' | sort | uniq -c | sort -rn。socket 占大头是连接泄漏,继续用 ss -tanp | grep pid= 看状态——CLOSE-WAIT 堆积说明应用漏了 close()(纯代码 bug,调内核参数无效,详见 第 13 篇 §3.2);普通文件占大头是文件句柄泄漏,看具体路径通常能直接认出是日志还是临时文件。注意lsof 在 fd 极多时会很慢,直接读 /proc/PID/fd 更快。

Q:ext4 和 xfs 怎么选?

关键差异有三点。① inode 分配:ext4 的 inode 总数在 mkfs 时固定,事后无法增加,海量小文件场景可能空间还有但 inode 耗尽(df -i 看 100%);xfs 动态分配 inode,没这个问题。② 能否缩容:ext4 可以缩小(需先卸载),xfs 只能扩大不能缩小。③ 并发写:xfs 用 AG(Allocation Group)分组,多线程写不争同一把锁,大文件和高并发写性能更好。所以:数据库、大文件、高并发写选 xfs;需要缩容能力或海量小文件选 ext4;不确定就跟随发行版默认(RHEL 系默认 xfs,Debian 系默认 ext4)。规划时宁可先给小一点,因为扩容容易缩容难。

Q:怎么在不停机的情况下给一个已经写满的分区扩容?

前提是用了 LVM。步骤:① 云上挂载新盘后 pvcreate /dev/vdc 做成物理卷;② vgextend vg_data /dev/vdc 加进现有卷组;③ lvextend -l +100%FREE /dev/vg_data/lv_data 扩展逻辑卷;④ 扩展文件系统——ext4 用 resize2fs /dev/vg_data/lv_data,xfs 用 xfs_growfs /data(参数是挂载点不是设备)。全程不需要卸载,业务无感知。如果没用 LVM,只能加新盘挂到别的目录、或者停机迁移数据。另外 /etc/fstab 必须用 UUID 而不是 /dev/vdb1——加盘后设备名可能漂移导致系统起不来,同时给数据盘加 nofail 让挂载失败不阻塞启动。

Q:df 显示磁盘 100%,du 加起来却对不上,有哪几种原因?

三种。① 被删但仍被进程持有(最常见):文件已 unlink 但进程还持有 fd,inode 未释放,du 数不到而 df 仍算。用 lsof +L1 定位(NLINK=0 的就是),让持有它的进程 reload 或重启。② 被挂载点遮盖的文件:先在 /data 目录下写了大量文件,后来挂载新盘到 /data,那些文件仍在原分区上但被遮住了。排查方法是 mount --bind / /mnt/tmp 然后看 du -sh /mnt/tmp/data。③ ext4 的预留块:默认给 root 预留 5%,500GB 盘就是 25GB,纯数据盘可以用 tune2fs -m 1 调到 1%。

Q:IO 调度器该怎么选?网上说的 deadline 还能用吗?

cfqdeadlinenoop 这些单队列调度器在 5.0+ 内核已被移除,全部换成了 multi-queue 版本,所以网上「改成 deadline」的老文章已经过时——现在应该看有没有 mq- 前缀。现代选型:NVMe SSD 用 none(SSD 没有寻道,排序和合并纯属浪费 CPU,内核默认就是 none);SATA SSD 和云盘用 nonemq-deadline;机械盘用 mq-deadlinebfq虚拟机和容器里用 none(宿主机已经调度过一遍了,再调度是重复劳动)。另外两个更值得调的参数是 read_ahead_kb——顺序读大文件时加大(512KB),随机读为主的数据库反而应该减小(16KB)避免读入无用数据。

Q:/proc/<pid>/io 里的 rcharread_bytes 有什么区别?

rchar 是应用层通过 read() 系统调用读取的总字节数,包含从 page cache 命中的部分read_bytes真正从块设备读上来的字节数。两者的差值就是 page cache 的贡献,所以 (rchar - read_bytes) / rchar 是一个直接可算的缓存命中率。这是评估 page cache 效果最方便的方法——如果命中率很低(比如低于 50%),说明工作集远大于可用内存,加内存会有明显收益。同理 wcharwrite_bytes 的差值反映了写合并和延迟写的效果。


上一篇:Linux-10 内存管理 | 下一篇:Linux-12 IO 模型与 epoll