Linux 文件系统与磁盘 I/O:从 VFS、块层到性能分析与优化

Linux 性能问题里,I/O 往往是最容易“看起来像别的问题”的一类:接口慢,可能表现成 CPU iowait 高;数据库慢,表面是 SQL,根因可能是全表扫描把磁盘打满;Redis 明明是内存数据库,查询时却可能因为 AOF 持久化和错误的客户端用法产生大量同步写;系统内存看起来几乎用完了,真正占用者却可能只是可回收的 Page Cache、dentry 或 inode 缓存。

真正有效的 I/O 排查,不是记住几十个命令,而是理解一条完整链路:

应用程序 → 系统调用 → VFS → 文件系统与缓存 → 通用块层 → I/O 调度 → 设备驱动 → 物理存储。

只要知道请求经过了哪些层、每层负责什么、每个指标在哪一层产生,topiostatpidstatstracelsoffio、BCC/eBPF 等工具就不再是零散命令,而会组成一套可以反复复用的分析方法。

Linux 存储 I/O 的整体知识地图

先把整个存储系统放到一张图里:

flowchart TB
    A[应用程序] --> B[标准库 / Runtime]
    A --> C[系统调用 open/read/write/mmap]
    B --> C

    C --> D[VFS 虚拟文件系统]
    D --> E[Page Cache]
    D --> F[dentry Cache]
    D --> G[inode Cache]

    D --> H[具体文件系统<br/>Ext4 / XFS / OverlayFS / NFS ...]
    H --> I[块 I/O / Buffer]
    I --> J[通用块层]
    J --> K[I/O 队列与调度]
    K --> L[设备驱动]
    L --> M[HDD / SSD / RAID / 网络块存储]

这张图可以解释后面几乎所有现象:

  • find 扫描大量路径时,dentry 和 inode 缓存会增长;
  • 普通文件读写通常先命中 Page Cache,不一定立即产生磁盘 I/O;
  • O_DIRECT 可以绕过 Page Cache;
  • iostat 观察的是块设备层面的结果,和应用实际 read() / write() 的次数并不一一对应;
  • pidstat 显示某个进程大量写,不意味着磁盘最终也按相同大小写入,因为中间还可能存在缓存、合并和异步回写;
  • 数据库、Redis、日志系统虽然业务形态完全不同,但到了 Linux 内核里,最终仍然会落到文件、socket、块设备和系统调用上。

文件系统到底管理什么

磁盘提供持久化存储能力,文件系统负责把一片块设备组织成可以按“文件名、目录、权限、元数据”访问的树状结构。

Linux 中常说“一切皆文件”。更准确地说,普通文件、目录、设备、管道、socket 等对象都可以通过统一的文件描述符和 VFS 接口访问。对应用来说,它们最终都表现成 open/read/write 一类统一接口。

inode、dentry、数据块和 superblock

理解 Linux 文件系统,最关键的是四个概念:

结构 主要作用 是否持久化
inode(索引节点) 保存文件元数据和数据位置,如权限、大小、时间、数据块指针等
dentry(目录项) 把文件名与 inode 联系起来,并维护目录树关系 主要是内核中的缓存对象
data block(数据块) 保存真正的文件内容,目录文件也会在数据块中保存“文件名与 inode”的映射
superblock(超级块) 保存文件系统整体状态,如 inode、数据块等资源信息

这里最容易混淆的是 dentry 和目录文件

dentry 是内核为了加速路径解析而维护的内存对象;但文件名当然不可能只存在内存里。目录本身也是一种文件,它有自己的 inode 和数据块,目录文件的数据中保存了子文件名与 inode 的映射。系统需要访问某个路径时,会根据磁盘上的目录内容按需构建 dentry 缓存。

因此:

  • dentry 不是文件名唯一的持久化存储;
  • 重启后即使 dentry 缓存消失,目录结构仍然可以从磁盘重新构建;
  • dentry 不需要开机时一次性构建完整目录树,而是按需创建;
  • 一个 inode 可以被多个 dentry 引用,这正是硬链接的基础。

inode 为什么也会把磁盘“用满”

inode 本身也占用磁盘空间,而且数量通常在创建文件系统时就已经确定。

所以会出现一种经典现象:

1
df -h /

看起来还有大量磁盘容量,但程序仍然报 No space left on device。这时要继续检查 inode:

1
df -i /

如果 inode 已经耗尽,而数据块仍然充足,通常意味着系统存在大量小文件。

扇区、页和逻辑块

传统机械磁盘常见扇区大小为 512B。若文件系统每次只处理一个扇区,管理成本会很高,因此会把连续扇区组织成更大的逻辑块,常见文件系统块大小为 4KB。

可以把几个常见粒度区分开:

  • HDD 的物理访问以扇区为基本单位;
  • SSD 内部通常以页为读写单位,同时还有更大的擦除块;
  • 文件系统以逻辑块组织数据,常见是 4KB;
  • CPU 和内核内存管理中还有 Page(页),常见也是 4KB,但它与文件系统逻辑块属于不同概念,只是大小经常恰好相同。

VFS:为什么不同文件系统能使用同一套 API

Linux 需要同时支持 Ext4、XFS、NFS、OverlayFS、procfs、sysfs 等大量文件系统。如果每种文件系统都向应用暴露不同 API,应用程序几乎无法维护。

因此内核引入了 VFS(Virtual File System,虚拟文件系统)作为统一抽象层。

应用看到的是统一接口:

1
2
3
int open(const char *pathname, int flags, mode_t mode);
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

具体由 Ext4、XFS 还是 NFS 完成,应用通常不需要关心。

不同文件系统最终都要挂载到 VFS 的目录树中:

1
mount

根文件系统先挂到 /,其他分区、/proc/sys、网络文件系统再分别挂到对应目录。

几类文件系统不要混淆

可以粗略按数据来源理解:

  • 本地磁盘文件系统:Ext4、XFS 等;
  • 伪文件系统 / 内核导出接口/proc/sys 等,不对应普通磁盘文件;
  • 网络文件系统:NFS、SMB 等,由远端服务器提供文件语义;
  • 堆叠文件系统:如 OverlayFS,在容器场景中很常见。

有一个需要特别澄清的点:iSCSI 不是 NFS/SMB 这种“网络文件系统”。iSCSI 通过网络暴露的是块设备,Linux 收到后通常仍会在它之上创建文件系统,它位于存储栈更底层的位置。


Linux 为什么要大量使用缓存

磁盘比内存慢几个数量级。如果每次 read() 都真的落到物理磁盘,系统性能会非常差。

Linux 在文件系统层使用多类缓存:

  • Page Cache:缓存文件内容;
  • dentry Cache:缓存路径和目录项;
  • inode Cache:缓存文件元数据;
  • 文件系统自己的 Slab Cache;
  • 块设备相关 Buffer。

Page Cache

普通 Buffered I/O 读取文件时,如果对应数据已经在 Page Cache 中,就可以直接从内存返回;写文件时,数据也可能先变成脏页,之后由内核异步回写磁盘。

因此,“应用写了 100MB”不意味着此刻磁盘就立即写了 100MB。

dentry 和 inode Cache

dentry 和 inode 都属于大量、小型、结构固定的内核对象,Linux 使用 Slab 机制管理它们。

可以查看:

1
cat /proc/slabinfo | grep -E '^#|dentry|inode'

更直观的是:

1
slabtop

常见条目包括:

1
2
3
4
5
dentry
inode_cache
ext4_inode_cache
xfs_inode
proc_inode_cache

其中 inode_cache 更偏 VFS 层,ext4_inode_cachexfs_inode 则属于具体文件系统实现。

find / 为什么会让缓存上涨

执行:

1
find / -name file-name

需要遍历大量目录并解析文件名。

原本没有被访问过的目录,需要从文件系统读取目录数据并构建 dentry,同时 inode 元数据也会进入缓存。因此你通常会看到:

  • dentry 增长;
  • inode 相关 Slab 增长;
  • Cache 增长;
  • 某些环境里 Buffer 也可能增长,因为构造元数据缓存需要读取底层文件系统数据。

第二次执行相同 find 往往明显更快,就是因为大量目录结构和元数据已经缓存。

可以用下面的方式观察:

1
vmstat 1

以及:

1
slabtop

如果为了实验清缓存,内核接口的语义是:

1
2
3
4
5
6
7
8
# 释放 Page Cache
echo 1 > /proc/sys/vm/drop_caches

# 释放 dentry 和 inode Cache
echo 2 > /proc/sys/vm/drop_caches

# 两者都释放
echo 3 > /proc/sys/vm/drop_caches

这类操作会直接破坏系统缓存命中率,不应该被当作日常“优化命令”。在生产环境中随意执行,可能把本来正常的数据库或文件服务瞬间变成磁盘 I/O 密集型应用。


四组最容易混淆的 I/O 概念

Linux I/O 经常出现四组词:

  • Buffered / Unbuffered
  • Direct / Non-direct
  • Blocking / Non-blocking
  • Synchronous / Asynchronous

它们描述的不是同一个维度。

Buffered I/O 与 Unbuffered I/O

这里的“Buffer”指的是用户态标准库自己的缓冲

例如 C 标准库的:

1
2
3
fopen();
fread();
fwrite();

通常会先经过标准库缓冲。

而直接使用:

1
2
3
open();
read();
write();

不会经过标准库缓冲。

但要注意:即便是所谓“Unbuffered I/O”,默认仍然可能经过操作系统 Page Cache。

Direct I/O 与普通缓存 I/O

Direct I/O 关注的是 是否绕过操作系统 Page Cache

典型做法是在 open() 时指定:

1
O_DIRECT

普通文件 I/O 默认通常会利用 Page Cache;Direct I/O 则尽量让用户缓冲区直接与文件系统/块设备交互。

数据库经常使用 Direct I/O,是因为数据库可能已经有自己的 Buffer Pool,再叠一层操作系统缓存会导致双重缓存和不可控的回写行为。

阻塞与非阻塞

这个维度关注调用线程会不会被卡住

阻塞 I/O:

发起 I/O 后,如果暂时没有结果,当前线程停止执行,等待条件满足。

非阻塞 I/O:

如果当前无法完成,系统调用立即返回,线程可以继续做其他事情,之后再轮询或等待事件通知。

例如 socket 可以设置:

1
O_NONBLOCK

然后配合 selectpollepoll 进行事件驱动。

普通磁盘文件通常总被认为“可读/可写”,因此对普通文件使用 select 并不能解决磁盘实际完成时间的问题。

同步与异步

这个维度关注I/O 完成结果由谁等待、以什么方式通知

同步 I/O:

  • 应用发起请求;
  • 系统最终通过当前调用路径返回结果;
  • 调用方需要参与等待完成。

异步 I/O:

  • 应用提交请求后先返回;
  • 内核后台完成 I/O;
  • 完成后再通过事件、回调等方式通知。

两者是正交维度,不要简单把“非阻塞”直接等同于“异步”。

flowchart LR
    A[阻塞 / 非阻塞] --> A1[关注调用线程是否等待]
    B[同步 / 异步] --> B1[关注 I/O 完成与结果通知机制]

资料中的经典例子是:

  • read():同步读;
  • AIO 接口:请求提交后返回,完成后再通知;
  • socket 未设置 O_NONBLOCK 时,send() 可能阻塞;
  • epoll 常与非阻塞 socket 搭配使用。

O_SYNCO_DSYNC

同步写语义还涉及落盘要求:

  • O_DSYNC:要求数据本身到达稳定存储后再返回;
  • O_SYNC:除了数据,还要求与本次写相关的必要元数据同步完成。

这也是为什么大量小请求配合强制同步写,性能可能极差。


从文件系统继续向下:Linux 块设备 I/O 栈

文件系统最终仍要访问存储设备。

Linux 把磁盘作为块设备管理。块设备支持按块读写,也支持随机访问,并通过主设备号和次设备号进行标识。

完整 I/O 栈可以简化为三层:

flowchart TB
    A[文件系统层<br/>VFS + Ext4/XFS/NFS...] --> B[通用块层<br/>Block Layer]
    B --> C[I/O Queue]
    C --> D[I/O Scheduler]
    D --> E[设备驱动]
    E --> F[HDD / SSD / RAID / NVMe / 网络块设备]

HDD 与 SSD 的性能差异

HDD(Hard Disk Drive)需要机械寻道和盘片旋转,所以:

  • 连续 I/O 通常非常快;
  • 随机 I/O 需要频繁寻道,延迟高很多。

SSD 没有机械磁头,随机性能远高于 HDD,但随机写仍可能触发 Flash Translation Layer、垃圾回收、写放大等问题,所以连续 I/O 往往仍然更友好。

这也是为什么大量高性能存储引擎都在努力把随机写转成顺序/追加写。

RAID

多块磁盘可以通过 RAID 组合成逻辑设备。

常见特性可以概括为:

RAID 核心特点
RAID0 条带化,性能高,但没有冗余
RAID1 镜像,提高可靠性,读性能可提升
RAID5 校验冗余,容量利用率较高,但写入有校验开销
RAID10 镜像 + 条带,兼顾性能与冗余,但有效容量降低

分析 RAID 上的 I/O 时,文件系统看到的容量、吞吐和底层真实磁盘消耗可能不同,不能直接把单盘经验套上去。


通用块层和 I/O 调度

不同磁盘设备、控制器和驱动差异很大,因此 Linux 又在文件系统和驱动之间提供了通用块层。

它主要做两件事:

  1. 向上提供统一块设备接口;
  2. 对 I/O 请求排队、合并、排序,再交给设备驱动。

资料所处的内核时代重点介绍了:

  • NONE
  • NOOP
  • CFQ
  • Deadline

这些名称和适用经验具有明显的内核时代背景。现代系统不要机械照搬“SSD 就一定选 noop、数据库就一定选 deadline”这样的旧结论,而应该直接检查当前设备实际支持的调度器:

1
cat /sys/block/sda/queue/scheduler

再结合真实 workload 做基准测试。

调度器本质上的目标始终没有变:在吞吐、公平性、延迟之间做权衡。


磁盘性能到底看哪些指标

磁盘 I/O 最常用的几个指标是:

  • 使用率(utilization)
  • IOPS
  • 吞吐量
  • 响应时间 / 延迟
  • 队列长度
  • 饱和度

IOPS

IOPS(Input/Output Operations Per Second)是每秒完成或提交给设备的 I/O 请求数。

它不是“每秒读写多少字节”。

同样 10 万 IOPS:

  • 每次 4KB,大约是 400MB/s;
  • 每次 128KB,则可能是十几 GB/s 级别。

所以分析 IOPS 时必须同时看平均请求大小。

近似关系是:

1
吞吐量 ≈ IOPS × 平均 I/O 大小

吞吐量

吞吐量表示单位时间传输的数据量,常见单位:

1
2
3
KB/s
MB/s
GB/s

大文件顺序读写场景通常更关注吞吐量。

响应时间

响应时间关注一次 I/O 从发出到完成需要多久。

数据库 OLTP、随机小 I/O、在线请求等场景,延迟经常比最大吞吐量更重要。

使用率 %util

iostat 中的 %util 表示设备在采样周期内有 I/O 活动的时间比例。

一个重要误区是:

%util = 100% 不一定等于“设备已经彻底无法接受更多 I/O”。

现代设备、RAID、SSD、NVMe 都可能并行处理多个请求。单纯一个 %util 很难完整表达队列深度和并发能力。

因此要同时看:

  • await
  • aqu-sz
  • IOPS
  • 吞吐量
  • 请求大小
  • 基准测试极限

饱和度

饱和度表示设备是否已经忙到不能继续有效接收更多请求。

它通常没有一个简单、直接的单指标,因此更可靠的方法是:

  1. fio 获得设备在相同 I/O 模式下的基准能力;
  2. 线上观察 await、队列长度、吞吐、IOPS;
  3. 与基准值对比;
  4. 看增加并发后吞吐是否不再增长、延迟是否迅速恶化。

iowait 高,不等于磁盘一定饱和

这是 I/O 分析里最值得反复强调的一点。

top 中的 wa / iowait 表示 CPU 在某段时间内处于“没有其他可运行任务,同时存在未完成 I/O”的状态。

它不是“磁盘使用率”。

因此可能出现:

1
2
iowait = 80%
%util = 0%~很低

只要系统很空闲,即使只有少量慢同步 I/O,也可能让一个 CPU 的 iowait 比例很高。

反过来,一个高并发 I/O 系统即便磁盘很忙,如果 CPU 还有大量其他任务可运行,也不一定表现为很高的 iowait。

正确方法不是看到 wa 就宣布“磁盘瓶颈”,而是把它当成一个线索,再通过 iostatpidstat 等继续验证。


iostat 看块设备

最常用命令:

1
iostat -d -x 1

常见字段:

字段 含义
r/s 每秒读请求数
w/s 每秒写请求数
rkB/s 每秒读取数据量
wkB/s 每秒写入数据量
rrqm/s 每秒合并的读请求
wrqm/s 每秒合并的写请求
%rrqm / %wrqm 请求合并比例
r_await 读请求平均完成时间
w_await 写请求平均完成时间
aqu-sz 平均请求队列长度
rareq-sz 平均读请求大小
wareq-sz 平均写请求大小
%util 设备处于 I/O 活动的时间比例

不要把 r_await + w_await 简单当成“总响应时间”。它们分别是读、写请求的平均延迟;如果要计算整体平均,需要按读写请求数量做加权。

老版本 iostat 里还可能出现 svctm。它本身是推导指标,并不能保证准确,也不是现代分析中最值得依赖的字段。


pidstatiotop 找到“谁在做 I/O”

iostat 只能告诉你“哪块盘有问题”,不能直接告诉你“哪个进程造成的”。

pidstat

1
pidstat -d 1

重点字段:

1
2
3
4
kB_rd/s
kB_wr/s
kB_ccwr/s
iodelay

它适合把块设备压力继续定位到进程。

多线程应用还要关注线程:

1
pidstat -d -t 1

iotop

1
iotop

适合按 I/O 大小排序,快速找到大户。

进程层统计与磁盘真实读写大小可能不同,因为中间还有:

  • Page Cache;
  • Buffer;
  • I/O 合并;
  • 延迟回写;
  • 文件系统日志;
  • 设备缓存。

因此不要因为 pidstatiostat 数值不完全一致,就认为工具出错。


文件系统容量与缓存的常用检查

空间

1
df -h

inode

1
df -i

目录实际文件大小

1
du -sh /path

如果 df 明显比 du 大,一个经典原因是:

文件已经从目录中删除,但仍被某个进程打开。

可以检查:

1
lsof | grep deleted

这种文件只有在进程关闭文件描述符或退出后,磁盘空间才真正释放。

Page Cache / Slab

1
cat /proc/meminfo | grep -E 'Cached|SReclaimable|Buffers'

或者:

1
free -w
1
vmstat 1

dentry / inode

1
cat /proc/slabinfo | grep -E '^#|dentry|inode'
1
slabtop

一套可复用的 I/O 故障定位链路

多个案例背后,真正值得记住的是同一套分析框架:

flowchart LR
    A[接口慢 / 系统卡 / iowait 高] --> B[top / vmstat<br/>看整体资源]
    B --> C[iostat<br/>确认块设备是否有瓶颈]
    C --> D[pidstat / iotop<br/>定位进程]
    D --> E{普通进程还是内核线程?}

    E -->|普通进程| F[strace -f<br/>看系统调用]
    F --> G[lsof<br/>fd -> 文件 / socket]
    G --> H[filetop / opensnoop / perf / eBPF]
    H --> I[结合应用原理、配置和代码]

    E -->|内核线程| J[检查 Page Cache / Swap / 文件系统日志 / 回写]
    J --> I

    I --> K[修改后重新测量]

把它进一步压缩,可以记成四句话:

  1. 系统有没有 I/O 问题?
  2. 是谁造成的?
  3. 它到底在读写什么?
  4. 为什么业务会产生这些 I/O?

工具只是回答这些问题的手段。


案例一:狂打日志把磁盘写满

这是最典型的从系统到应用逐层收敛案例。

第一步:top 发现 iowait

系统中某个 CPU 的 iowait 超过 90%,同时内存里 Buffer/Cache 很高。

这说明值得怀疑 I/O,但还不能直接下结论。

第二步:iostat 确认磁盘

1
iostat -x -d 1

案例中出现:

  • 磁盘 %util 接近 99%;
  • 每秒写入约 32MB;
  • 写请求响应时间达到秒级;
  • 请求队列长度超过 1000。

这已经不只是“有 I/O”,而是非常明显的磁盘瓶颈。

第三步:pidstat 找进程

1
pidstat -d 1

Python 进程每秒写入超过 45MB,是最可疑对象。

第四步:strace 找系统调用

1
strace -p <PID>

可以看到类似:

1
write(3, "...", 314572844) = 314572844

一次就写大约 300MB。

第五步:lsof 把 fd 还原成文件

1
lsof -p <PID>

发现:

1
3w REG ... /tmp/logtest.txt

于是链路完整了:

1
2
3
4
5
6
高 iowait
→ 磁盘写满
→ Python 大量写
→ fd 3
→ /tmp/logtest.txt
→ 应用日志

案例程序支持通过信号动态调日志级别:

1
kill -SIGUSR2 <PID>

把 INFO 调高到 WARNING 后,iowait 和磁盘使用率都逐步恢复。

工程结论

日志不是“免费”的。

生产环境应该至少考虑:

  • 默认日志级别;
  • 单条日志大小;
  • 日志频率;
  • 异步日志;
  • 日志轮转;
  • 限速;
  • 磁盘隔离;
  • 动态调整日志级别后自动恢复。

案例二:大量临时文件让 API 从秒级拖到分钟级

另一个 Web 应用每次请求都会:

  1. 创建临时目录;
  2. 生成 1000 个文本文件;
  3. 把数据写入磁盘;
  4. 再逐个读回;
  5. 处理完成后删除整个目录。

结果一个接口响应超过两分钟。

系统层现象

top

  • CPU iowait 接近 94%。

iostat

  • 磁盘 %util 接近 98%;
  • 大量写;
  • await 达到十几秒。

pidstat

  • Python 进程每秒产生数百 MB I/O。

为什么 strace -p PID 却找不到 write

一开始只跟踪主线程:

1
strace -p <PID>

看不到写文件系统调用。

这不是 Python 的问题,也不是 Docker 把系统调用“藏起来”了,而是文件 I/O 发生在其他线程

正确做法是:

1
strace -f -p <PID>

-f 会跟踪子进程和线程。

这个案例给出了一个非常重要的经验:

工具输出和已有证据矛盾时,先检查工具自己的观察范围、默认参数和限制,而不是立刻怀疑操作系统“不符合理论”。

用 BCC/eBPF 从内核侧观察

filetop 可以直接看到哪些线程在读写哪些文件:

1
filetop

opensnoop 可以跟踪文件打开路径:

1
opensnoop

最终发现应用动态生成类似:

1
2
3
4
/tmp/<uuid>/0.txt
/tmp/<uuid>/1.txt
...
/tmp/<uuid>/999.txt

然后再全部读回。

优化

如果内存充足,这批短生命周期中间结果根本没有必要落盘,可以直接放内存。

案例优化后,接口从约 2m43s 缩短到不到 9s

这里真正的思路不是“把 HDD 换 SSD”,而是:

最快的磁盘 I/O,是根本不发生磁盘 I/O。


案例三:MySQL 查询 15 秒,根因既有索引也有系统缓存

商品查询接口:

1
/products/geektime

一次请求需要大约 15 秒。

从系统先定位到 MySQL

top

  • iowait 很高;
  • CPU 使用率并不高。

iostat

  • 磁盘读约 32MB/s;
  • %util 接近 97%。

pidstat

1
pidstat -d 1

发现主要读盘的是 mysqld

strace -f 找到 MySQL 在读哪个 fd

MySQL 是多线程程序,要带 -f

1
strace -f -p <mysqld-pid>

可以看到某个线程持续:

1
2
3
read(38, ..., 131072)
read(38, ..., 20480)
...

lsof 必须传进程 PID,而不是线程 ID

如果把线程号直接交给:

1
lsof -p <TID>

可能查不到。

应该对 MySQL 进程执行:

1
lsof -p <mysqld-pid>

然后根据 fd 38 找到:

1
/var/lib/mysql/test/products.MYD

该案例使用的是 MySQL 5.6 + MyISAM:

  • products.MYD:表数据;
  • products.MYI:索引;
  • .frm:表结构元信息。

这套文件布局具有明显时代背景。今天分析现代 MySQL/InnoDB 时,不应该机械寻找 .MYD/.MYI/.frm,但“从系统调用 → fd → 数据文件 → 数据库对象”的分析方法仍然完全成立。

继续进入数据库层

1
SHOW FULL PROCESSLIST;

发现长时间执行:

1
2
3
SELECT *
FROM products
WHERE productName = 'geektime';

再执行:

1
2
3
4
EXPLAIN
SELECT *
FROM products
WHERE productName = 'geektime';

关键结果:

1
2
3
4
type = ALL
possible_keys = NULL
key = NULL
rows = 10000

说明是全表扫描。

为 TEXT 字段建立前缀索引

直接建:

1
2
CREATE INDEX products_index
ON products(productName);

因为 productName 是 TEXT,案例会报需要指定长度。

于是使用前缀索引:

1
2
CREATE INDEX products_index
ON products(productName(64));

优化后接口从约 15 秒降到几毫秒。

为什么停掉另一个 DataService 后,不建索引也快了

案例中还有一个干扰程序,每隔几秒执行:

1
echo 1 > /proc/sys/vm/drop_caches

也就是不断清 Page Cache。

MyISAM 本身主要依赖操作系统缓存表数据。全表扫描每次都需要把数据从磁盘读出来,而另一个进程又持续把 Page Cache 清掉,于是同一批数据永远无法形成热缓存。

DataService 停止后:

  • Page Cache 逐渐增长;
  • vmstatbi 最终降到 0;
  • iowait 降到 0;
  • 即使没有索引,查询也从十几秒降到约 0.1 秒。

这暴露出两个不同层次的问题:

  1. 没有索引导致无意义的大量数据扫描;
  2. 把性能完全建立在不可控的全局系统缓存上,容易被其他应用干扰。

真正的工程优化应该优先解决第一个问题,而不是依赖“数据刚好在 Cache 里”。


案例四:Redis 查询慢,但磁盘根本没打满

Redis 案例更有意思,因为它证明:

没有磁盘饱和,不代表 I/O 行为就是合理的。

表面现象

top

  • 一个 CPU 的 iowait 超过 80%;
  • Redis、Python CPU 都不高;
  • 内存也充足。

iostat

  • 只有几 MB/s 写入;
  • %util 接近 0。

所以不能说“磁盘性能不够”。

真正异常的是:

这是一个“查询缓存”的接口,为什么磁盘一直在写?

pidstat 找到 Redis 在写

1
pidstat -d 1

发现 redis-server 持续有写 I/O。

strace 看到了 fdatasync

1
strace -f -T -tt -p <redis-pid>

可以观察到:

1
2
write(...)
fdatasync(7)

而且 fdatasync 高频出现。

lsof 找到 AOF

1
lsof -p <redis-pid>

fd 7:

1
/data/appendonly.aof

查询配置:

1
redis-cli CONFIG GET 'append*'

案例得到:

1
2
appendonly yes
appendfsync always

appendfsync always 表示几乎每次写操作都要求同步落盘。

对很多业务来说这是非常昂贵的持久化策略。

案例把它调整为:

1
redis-cli CONFIG SET appendfsync everysec

接口延迟从约 10 秒降低到约 0.9 秒。

可查询接口为什么会产生写命令

继续看 Redis socket 流量,可以发现:

1
2
GET uuid:...
SADD good ...

应用在扫描缓存时,每命中一个目标值,又把结果写进 Redis 的 Set,最后读取这个临时 Set,再把它删除。

也就是说,本来可以放在当前进程内存中的短生命周期中间结果,被当作 Redis 临时空间使用。

简化后的逻辑类似:

1
2
3
4
5
6
7
8
9
def get_cache(type_name):
for key in redis_client.scan_iter("uuid:*"):
value = redis_client.get(key)
if value == type_name:
redis_client.sadd(type_name, key[5:])

data = list(redis_client.smembers(type_name))
redis_client.delete(type_name)
return data

优化后直接在应用内存维护临时列表,接口继续从约 0.9 秒降低到约 0.16 秒。

容器场景下别忘了 network namespace

Redis 和应用运行在容器里时,宿主机上的 socket 视图可能不完整。

可以进入对应网络命名空间:

1
2
3
PID=$(docker inspect --format '{{.State.Pid}}' app)

nsenter --target "$PID" --net -- lsof -i

这可以把 Redis server 端 socket 和 Python 客户端连接对应起来。


从四个案例抽象出的性能分析“心法”

几个案例看起来分别是:

  • 日志;
  • 临时文件;
  • MySQL;
  • Redis。

但底层排查逻辑高度一致。

1. 从“资源异常”开始,不从源码猜

先看:

1
2
3
top
vmstat 1
iostat -d -x 1

目的是确定:

  • CPU?
  • 内存?
  • Swap?
  • Page Cache?
  • 磁盘?
  • 哪一块磁盘?

2. 从设备缩小到进程

1
2
pidstat -d 1
iotop

回答:

谁在读?谁在写?

3. 从进程缩小到行为

1
strace -f -T -tt -p <PID>

重点关注:

1
2
3
4
5
6
7
8
9
read
write
openat
fsync
fdatasync
mmap
stat
rename
unlink

4. 把 fd 还原成资源

1
lsof -p <PID>

Linux 中 fd 可能对应:

  • 普通文件;
  • 目录;
  • socket;
  • pipe;
  • 设备;
  • 动态库。

5. strace 看不清时向内核侧移动

可以考虑:

1
2
3
4
5
6
filetop
opensnoop
biosnoop
biotop
blktrace
perf

这些工具可以从更低层看到文件、块 I/O 或内核事件。

6. 最终一定要回到应用语义

系统工具只能回答:

“它做了什么。”

不能自动回答:

“为什么业务要这么做。”

最后必须结合:

  • 数据库执行计划;
  • Redis 持久化;
  • 日志配置;
  • 文件生命周期;
  • 缓存策略;
  • 代码算法。

这一步才是真正产生优化方案的地方。


指标和工具对应表

目标 常用工具
文件系统空间 df -h
inode 空间 df -i
目录大小 du
Page Cache / Buffer / Slab /proc/meminfofreevmstat
dentry / inode Cache /proc/slabinfoslabtop
磁盘 IOPS / 吞吐 / await / util / 队列 iostatsar -d
进程 I/O pidstat -diotop
进程系统调用 strace
进程打开文件 / socket lsof
文件读写动态追踪 filetop
open 路径动态追踪 opensnoop
块 I/O 事件 blktrace
块 I/O 延迟与进程 biosnoopbiotop
内核 I/O 事件统计 perf
I/O 基准测试 fio

生产环境不一定允许临时安装所有工具,所以重点不是“命令收集”,而是知道已有工具分别能回答什么问题。


fio:优化前先知道设备的真实能力

优化之前必须知道目标。

磁盘到底“慢”不慢,不能只看厂商宣传参数,也不能拿另一台机器的测试结果直接比较。

最常用的基准测试工具是 fio

安装

Ubuntu:

1
apt-get install -y fio

CentOS/RHEL 系:

1
yum install -y fio

4KB 随机读

1
2
3
4
5
6
7
8
9
fio \
--name=randread \
--filename=/dev/sdb \
--direct=1 \
--iodepth=64 \
--rw=randread \
--ioengine=libaio \
--bs=4k \
--size=1G

4KB 随机写

1
2
3
4
5
6
7
8
9
fio \
--name=randwrite \
--filename=/dev/sdb \
--direct=1 \
--iodepth=64 \
--rw=randwrite \
--ioengine=libaio \
--bs=4k \
--size=1G

顺序读

1
2
3
4
5
6
7
8
9
fio \
--name=read \
--filename=/dev/sdb \
--direct=1 \
--iodepth=64 \
--rw=read \
--ioengine=libaio \
--bs=4k \
--size=1G

顺序写

1
2
3
4
5
6
7
8
9
fio \
--name=write \
--filename=/dev/sdb \
--direct=1 \
--iodepth=64 \
--rw=write \
--ioengine=libaio \
--bs=4k \
--size=1G

警告:不要在系统盘或包含重要数据的裸设备上做写测试。直接以块设备作为 filename 进行写测试可能破坏原文件系统。

关键参数

direct=1

跳过 Page Cache,尽量直接测试底层存储。

iodepth=64

异步 I/O 同时允许在途的请求数量。

rw

I/O 模式:

1
2
3
4
read
write
randread
randwrite

ioengine

I/O 引擎,例如:

1
2
3
sync
libaio
mmap

bs

单次 I/O 大小。

filename

既可以是文件路径,也可以是裸块设备路径。


如何读 fio 报告

重点看:

1
2
3
4
5
IOPS
BW
slat
clat
lat

slat

Submission Latency,I/O 提交阶段延迟。

clat

Completion Latency,从提交到完成的延迟。

lat

fio 视角下整个 I/O 的总延迟。

异步 I/O 下通常近似:

1
lat ≈ slat + clat

不要只看平均值,还要看延迟分位数。

一个系统平均延迟 2ms,但 P99 是 200ms,对在线服务可能仍然完全不可接受。


用真实业务 I/O 做 Replay

固定 4KB 随机读写只能模拟某类 workload,而真实业务往往混合:

  • 不同请求大小;
  • 读写并发;
  • 顺序和随机;
  • 不同队列深度。

可以先用 blktrace 记录真实设备访问,再用 fio 回放。

记录

1
blktrace /dev/sdb

转换

1
blkparse sdb -d sdb.bin

回放

1
2
3
4
5
fio \
--name=replay \
--filename=/dev/sdb \
--direct=1 \
--read_iolog=sdb.bin

这样做比“随便跑一个随机写 benchmark”更接近真实应用访问模式。


I/O 优化的总原则:先减少 I/O,再加速 I/O

优化优先级可以这样理解:

flowchart LR
    A[业务需要访问数据] --> B{能否不访问磁盘?}
    B -->|能| C[内存 / Cache / Redis / 算法重构]
    B -->|不能| D{能否减少请求次数?}
    D -->|能| E[批量 / 合并 / 预读 / 顺序化]
    D -->|不能| F{能否降低单次延迟?}
    F --> G[文件系统 / 调度 / 队列 / SSD / RAID]

越靠应用层,往往收益越大、可控性越强。


应用层优化

1. 把随机写改成追加写

随机写会增加寻址和设备内部管理成本。

日志、WAL、LSM Tree、AOF 等设计大量使用 Append-only,就是利用顺序写更友好的特性。

2. 充分利用缓存

普通文件读取可以利用 Page Cache:

1
磁盘 → Page Cache → 应用

热点数据反复读取时,缓存能够显著减少真实磁盘 I/O。

但也不要把系统稳定性建立在“缓存永远不会被别人挤掉”这个假设上。

3. 应用自己管理缓存

应用内部缓存或 Redis/Memcached 的优势是:

  • 数据由应用决定;
  • 生命周期更可控;
  • 不容易被其他进程随意清理;
  • 可以设计淘汰策略。

MySQL 案例已经说明,完全依赖全局 Page Cache 的性能非常容易受其他进程干扰。

4. 频繁访问同一区域时考虑 mmap

mmap 把文件映射到虚拟地址空间,可以减少显式 read/write 带来的用户态与内核态数据拷贝。

它并不是“永远更快”,但对频繁随机访问同一文件区域的场景很有价值。

5. 合并同步写

如果每一个小请求都:

1
2
3
4
5
6
write
fsync
write
fsync
write
fsync

性能会非常差。

更合理的策略通常是先聚合:

1
2
3
4
write
write
write
fsync

前提是业务能够接受相应的持久性窗口。

Redis 的 appendfsync alwayseverysec 本质上就是“可靠性和性能”的典型权衡。

6. 限制 I/O 大户

多个应用共享磁盘时,可以使用 cgroup 的 I/O 控制能力,限制某个服务的 IOPS 或吞吐,避免它把磁盘完全占满。

7. I/O 优先级

在支持相应调度策略的系统中,可以用:

1
ionice

调整进程 I/O 优先级。

但是否生效、效果如何取决于当前块层和调度器,不应脱离系统实际配置机械套用。


文件系统层优化

1. 选择合适的文件系统

不同文件系统在:

  • 大文件;
  • 小文件;
  • 并发写;
  • 元数据;
  • 扩容;
  • 日志模式;

方面特性不同。

Ext4 和 XFS 都是常见选择,选型应依据 workload,而不是简单说“谁绝对更快”。

2. 挂载参数

一些场景可以考虑减少不必要的元数据更新,例如:

1
noatime

但每一个挂载选项都可能改变语义,应先确认业务是否依赖对应元数据。

3. 日志模式

日志型文件系统为了崩溃一致性会产生额外写入。

不同日志模式本质上都在:

1
2
3
一致性
性能
恢复能力

之间做权衡。

4. 脏页回写

Linux 允许先把写入放入 Page Cache,再异步刷盘。

常见相关参数包括:

1
2
3
4
dirty_expire_centisecs
dirty_writeback_centisecs
dirty_background_ratio
dirty_ratio

这类参数影响:

  • 回写频率;
  • 允许积累多少脏页;
  • 后台回写何时开始;
  • 应用何时被迫参与回写。

调得太激进可能降低吞吐,积累太多又可能出现周期性的延迟尖峰。

5. vfs_cache_pressure

1
/proc/sys/vm/vfs_cache_pressure

控制内核回收 dentry 和 inode Cache 的倾向。

数值越高,一般意味着越积极回收这些元数据缓存。

6. 不需要持久化就使用 tmpfs

例如:

1
/dev/shm

属于典型内存文件系统。

短生命周期、中间计算结果如果不需要跨重启保存,tmpfs 往往比反复写物理磁盘更合理。


磁盘和块层优化

1. 换更快的设备

最直接:

1
HDD → SSD → 更高性能 SSD/NVMe

但硬件升级不能替代错误算法。

“每个请求生成 1000 个临时文件”即使换 NVMe,也只是把一个错误设计跑得更快。

2. RAID

RAID 可以:

  • 提升吞吐;
  • 提升读并行;
  • 提供冗余。

代价包括:

  • 写放大;
  • 容量损失;
  • 控制器缓存影响;
  • 故障重建时性能下降。

3. I/O 调度器

查看当前设备:

1
cat /sys/block/sdb/queue/scheduler

是否切换调度器必须基于真实测试。

资料中 NOOP、CFQ、Deadline 等经验来自较早内核环境,现代系统的调度器名称和块层实现已经发生演进,所以应以当前内核暴露的选项为准。

4. 数据隔离

数据库、日志、搜索引擎、备份如果共享一块盘,很容易互相影响。

可以把高 I/O 组件拆到独立设备:

1
2
3
4
数据库数据盘
数据库日志盘
应用日志盘
备份盘

这既能提升性能,也让故障定位更容易。

5. 预读

顺序读明显时,可以增加块设备预读。

查看或调整:

1
cat /sys/block/sdb/queue/read_ahead_kb

资料中的示例默认值为 128KB。

也可以使用:

1
blockdev --setra 8192 /dev/sdb

blockdev --setra 的单位是 512B 扇区,因此数值与 read_ahead_kb 的单位不同。

随机 I/O 场景过度预读反而可能浪费带宽和内存。

6. 队列长度

1
/sys/block/sdb/queue/nr_requests

增大队列可能提高吞吐,但通常会增加排队延迟。

这就是典型的吞吐和 latency trade-off。


硬件错误也会伪装成性能问题

如果磁盘性能突然异常下降,不要只调参数。

可以先检查:

1
dmesg

看是否存在 I/O error、reset、timeout 等日志。

物理盘还可以使用:

1
smartctl

或者:

1
badblocks

文件系统层可以根据类型使用相应检查工具。

需要注意,虚拟机或硬件 RAID 控制器后面的磁盘,smartctl 不一定能直接获得真实物理盘信息,排查时还需要结合虚拟化平台或 RAID 控制器工具。


三个极其常见的误区

误区一:iowait 高,所以磁盘坏了

错误。

iowait 只是 CPU 时间分类,不是磁盘饱和度。

正确链路:

1
2
3
4
top / vmstat
→ iostat
→ pidstat
→ syscall / file

误区二:%util = 100% 就一定不能再提升

也不一定。

对具备并行能力的设备,要同时看队列、延迟、吞吐、IOPS 和基准上限。

误区三:工具没看到,所以事情没发生

磁盘已经在写,strace 没看到 write,不代表内核凭空产生 I/O。

先检查:

  • 是不是多线程?
  • 是否忘了 -f
  • 是否采样窗口太短?
  • I/O 是否由内核线程回写?
  • 是否应该从文件层转到块层/eBPF?

性能工具也有自己的观察边界。


版本与时代背景

这些案例形成于较早的 Linux、MySQL 和工具链环境,学习时应区分“原理”与“当时的具体实现”。

I/O Scheduler

不要死记:

1
2
3
NOOP
CFQ
Deadline

当前系统实际支持什么,直接查:

1
cat /sys/block/<device>/queue/scheduler

MySQL MyISAM 文件

案例里用:

1
2
3
.MYD
.MYI
.frm

这是特定 MySQL 版本和存储引擎背景。

现代生产环境更常见的是 InnoDB,具体文件布局不同,但 OS 侧分析路径不变:

1
2
3
4
5
mysqld I/O
→ fd
→ 文件
→ 表/索引/日志
→ SQL / 数据库内部机制

BCC 安装命令

案例中的 BCC 软件源安装方式具有时代背景。实际环境应优先使用当前发行版维护的软件包或对应版本文档。

svctm

老版本 iostat 常见这个字段,但它属于推导指标,不应把它当作精确的物理设备服务时间。


最终可以记住的一张排查清单

遇到“接口突然很慢、系统卡顿、数据库延迟升高”时,可以按这个顺序走:

系统层

1
2
3
top
vmstat 1
free -w

回答:

1
2
3
4
5
CPU?
iowait?
内存?
Swap?
Cache?

磁盘层

1
iostat -d -x 1

回答:

1
2
3
4
5
6
7
哪块盘?
读还是写?
IOPS?
吞吐?
await?
队列?
%util?

进程层

1
2
pidstat -d 1
iotop

回答:

1
谁在做 I/O?

系统调用层

1
strace -f -T -tt -p <PID>

回答:

1
2
3
4
5
read?
write?
fsync?
open?
mmap?

文件和 socket

1
lsof -p <PID>

容器网络场景:

1
nsenter --target <PID> --net -- lsof -i

内核动态追踪

1
2
3
4
5
6
filetop
opensnoop
biosnoop
biotop
blktrace
perf

应用层

最后才问:

1
2
3
4
5
6
为什么要读这个文件?
为什么要写这么多?
为什么每次都 fsync?
为什么没走索引?
为什么查询接口还在 SADD?
为什么中间结果非要落盘?

然后修改,并重新跑最初同一组观测命令验证。


总结

Linux I/O 性能优化最重要的不是某一个参数,而是把整个链路串起来。

从文件系统角度,要理解:

1
2
3
4
5
6
7
inode
dentry
superblock
data block
VFS
Page Cache
Slab

从磁盘角度,要理解:

1
2
3
4
5
6
7
8
9
随机 / 顺序
IOPS
吞吐量
延迟
队列
利用率
块层
调度
设备

从分析方法看,核心链路是:

1
2
3
4
5
6
7
整体资源
→ 磁盘
→ 进程
→ 线程
→ 系统调用
→ 文件/socket
→ 应用语义

从优化顺序看,最值得优先考虑的是:

1
2
3
4
5
6
减少 I/O
→ 合并 I/O
→ 顺序化 I/O
→ 利用缓存
→ 调整文件系统与块层
→ 升级存储硬件

一个成熟的性能分析过程,不应该是“看到 iowait 高就开始调内核参数”,而应该是通过指标提出假设,用工具验证假设,再回到应用原理找到真正原因,最后用同样的指标证明优化确实有效。

这套方法的价值也不只在磁盘。CPU、内存、网络性能分析本质上也是同一件事:从现象出发,沿调用链逐层缩小范围,用原理解释指标,用数据验证结论。


Linux 文件系统与磁盘 I/O:从 VFS、块层到性能分析与优化
https://allendericdalexander.github.io/2026/08/11/devops/linux/performanceOptimization/04linux-filesystem-disk-io-performance/
作者
AtLuoFu
发布于
2026年8月11日
许可协议