Linux-11 文件系统与磁盘 IO
第 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 可以缩小但必须先卸载(
umount→e2fsck -f→resize2fs缩小 →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 (宿主机已经调度过了,再调度是浪费)
关键认知:cfq 和 deadline 这些单队列调度器在 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 时代看 await 和 aqu-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 只告诉你设备忙,pidstat 和 iotop 告诉你是谁在忙:
# 按 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
rchar 和 read_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 时代看 await 和 aqu-sz,不看 %util。
Q:改了 /etc/security/limits.conf,ulimit -n 也显示 65535,服务还是报 Too many open files,为什么?
因为 limits.conf 是 PAM 机制(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 还能用吗?
cfq、deadline、noop 这些单队列调度器在 5.0+ 内核已被移除,全部换成了 multi-queue 版本,所以网上「改成 deadline」的老文章已经过时——现在应该看有没有 mq- 前缀。现代选型:NVMe SSD 用 none(SSD 没有寻道,排序和合并纯属浪费 CPU,内核默认就是 none);SATA SSD 和云盘用 none 或 mq-deadline;机械盘用 mq-deadline 或 bfq;虚拟机和容器里用 none(宿主机已经调度过一遍了,再调度是重复劳动)。另外两个更值得调的参数是 read_ahead_kb——顺序读大文件时加大(512KB),随机读为主的数据库反而应该减小(16KB)避免读入无用数据。
Q:/proc/<pid>/io 里的 rchar 和 read_bytes 有什么区别?
rchar 是应用层通过 read() 系统调用读取的总字节数,包含从 page cache 命中的部分;read_bytes 是真正从块设备读上来的字节数。两者的差值就是 page cache 的贡献,所以 (rchar - read_bytes) / rchar 是一个直接可算的缓存命中率。这是评估 page cache 效果最方便的方法——如果命中率很低(比如低于 50%),说明工作集远大于可用内存,加内存会有明显收益。同理 wchar 和 write_bytes 的差值反映了写合并和延迟写的效果。
上一篇:Linux-10 内存管理 | 下一篇:Linux-12 IO 模型与 epoll
xingliuhua