Linux 文件系统与磁盘 I/O:从 VFS、块层到性能分析与优化
Linux 性能问题里,I/O 往往是最容易“看起来像别的问题”的一类:接口慢,可能表现成 CPU iowait 高;数据库慢,表面是 SQL,根因可能是全表扫描把磁盘打满;Redis 明明是内存数据库,查询时却可能因为 AOF 持久化和错误的客户端用法产生大量同步写;系统内存看起来几乎用完了,真正占用者却可能只是可回收的 Page Cache、dentry 或 inode 缓存。
真正有效的 I/O 排查,不是记住几十个命令,而是理解一条完整链路:
应用程序 → 系统调用 → VFS → 文件系统与缓存 → 通用块层 → I/O 调度 → 设备驱动 → 物理存储。
只要知道请求经过了哪些层、每层负责什么、每个指标在哪一层产生,top、iostat、pidstat、strace、lsof、fio、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 | |
看起来还有大量磁盘容量,但程序仍然报 No space left on device。这时要继续检查 inode:
1 | |
如果 inode 已经耗尽,而数据块仍然充足,通常意味着系统存在大量小文件。
扇区、页和逻辑块
传统机械磁盘常见扇区大小为 512B。若文件系统每次只处理一个扇区,管理成本会很高,因此会把连续扇区组织成更大的逻辑块,常见文件系统块大小为 4KB。
可以把几个常见粒度区分开:
- HDD 的物理访问以扇区为基本单位;
- SSD 内部通常以页为读写单位,同时还有更大的擦除块;
- 文件系统以逻辑块组织数据,常见是 4KB;
- CPU 和内核内存管理中还有 Page(页),常见也是 4KB,但它与文件系统逻辑块属于不同概念,只是大小经常恰好相同。
VFS:为什么不同文件系统能使用同一套 API
Linux 需要同时支持 Ext4、XFS、NFS、OverlayFS、procfs、sysfs 等大量文件系统。如果每种文件系统都向应用暴露不同 API,应用程序几乎无法维护。
因此内核引入了 VFS(Virtual File System,虚拟文件系统)作为统一抽象层。
应用看到的是统一接口:
1 | |
具体由 Ext4、XFS 还是 NFS 完成,应用通常不需要关心。
不同文件系统最终都要挂载到 VFS 的目录树中:
1 | |
根文件系统先挂到 /,其他分区、/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 | |
更直观的是:
1 | |
常见条目包括:
1 | |
其中 inode_cache 更偏 VFS 层,ext4_inode_cache、xfs_inode 则属于具体文件系统实现。
find / 为什么会让缓存上涨
执行:
1 | |
需要遍历大量目录并解析文件名。
原本没有被访问过的目录,需要从文件系统读取目录数据并构建 dentry,同时 inode 元数据也会进入缓存。因此你通常会看到:
- dentry 增长;
- inode 相关 Slab 增长;
- Cache 增长;
- 某些环境里 Buffer 也可能增长,因为构造元数据缓存需要读取底层文件系统数据。
第二次执行相同 find 往往明显更快,就是因为大量目录结构和元数据已经缓存。
可以用下面的方式观察:
1 | |
以及:
1 | |
如果为了实验清缓存,内核接口的语义是:
1 | |
这类操作会直接破坏系统缓存命中率,不应该被当作日常“优化命令”。在生产环境中随意执行,可能把本来正常的数据库或文件服务瞬间变成磁盘 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 | |
通常会先经过标准库缓冲。
而直接使用:
1 | |
不会经过标准库缓冲。
但要注意:即便是所谓“Unbuffered I/O”,默认仍然可能经过操作系统 Page Cache。
Direct I/O 与普通缓存 I/O
Direct I/O 关注的是 是否绕过操作系统 Page Cache。
典型做法是在 open() 时指定:
1 | |
普通文件 I/O 默认通常会利用 Page Cache;Direct I/O 则尽量让用户缓冲区直接与文件系统/块设备交互。
数据库经常使用 Direct I/O,是因为数据库可能已经有自己的 Buffer Pool,再叠一层操作系统缓存会导致双重缓存和不可控的回写行为。
阻塞与非阻塞
这个维度关注调用线程会不会被卡住。
阻塞 I/O:
发起 I/O 后,如果暂时没有结果,当前线程停止执行,等待条件满足。
非阻塞 I/O:
如果当前无法完成,系统调用立即返回,线程可以继续做其他事情,之后再轮询或等待事件通知。
例如 socket 可以设置:
1 | |
然后配合 select、poll、epoll 进行事件驱动。
普通磁盘文件通常总被认为“可读/可写”,因此对普通文件使用 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_SYNC 与 O_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 又在文件系统和驱动之间提供了通用块层。
它主要做两件事:
- 向上提供统一块设备接口;
- 对 I/O 请求排队、合并、排序,再交给设备驱动。
资料所处的内核时代重点介绍了:
- NONE
- NOOP
- CFQ
- Deadline
这些名称和适用经验具有明显的内核时代背景。现代系统不要机械照搬“SSD 就一定选 noop、数据库就一定选 deadline”这样的旧结论,而应该直接检查当前设备实际支持的调度器:
1 | |
再结合真实 workload 做基准测试。
调度器本质上的目标始终没有变:在吞吐、公平性、延迟之间做权衡。
磁盘性能到底看哪些指标
磁盘 I/O 最常用的几个指标是:
- 使用率(utilization)
- IOPS
- 吞吐量
- 响应时间 / 延迟
- 队列长度
- 饱和度
IOPS
IOPS(Input/Output Operations Per Second)是每秒完成或提交给设备的 I/O 请求数。
它不是“每秒读写多少字节”。
同样 10 万 IOPS:
- 每次 4KB,大约是 400MB/s;
- 每次 128KB,则可能是十几 GB/s 级别。
所以分析 IOPS 时必须同时看平均请求大小。
近似关系是:
1 | |
吞吐量
吞吐量表示单位时间传输的数据量,常见单位:
1 | |
大文件顺序读写场景通常更关注吞吐量。
响应时间
响应时间关注一次 I/O 从发出到完成需要多久。
数据库 OLTP、随机小 I/O、在线请求等场景,延迟经常比最大吞吐量更重要。
使用率 %util
iostat 中的 %util 表示设备在采样周期内有 I/O 活动的时间比例。
一个重要误区是:
%util = 100%不一定等于“设备已经彻底无法接受更多 I/O”。
现代设备、RAID、SSD、NVMe 都可能并行处理多个请求。单纯一个 %util 很难完整表达队列深度和并发能力。
因此要同时看:
awaitaqu-sz- IOPS
- 吞吐量
- 请求大小
- 基准测试极限
饱和度
饱和度表示设备是否已经忙到不能继续有效接收更多请求。
它通常没有一个简单、直接的单指标,因此更可靠的方法是:
- 用
fio获得设备在相同 I/O 模式下的基准能力; - 线上观察
await、队列长度、吞吐、IOPS; - 与基准值对比;
- 看增加并发后吞吐是否不再增长、延迟是否迅速恶化。
iowait 高,不等于磁盘一定饱和
这是 I/O 分析里最值得反复强调的一点。
top 中的 wa / iowait 表示 CPU 在某段时间内处于“没有其他可运行任务,同时存在未完成 I/O”的状态。
它不是“磁盘使用率”。
因此可能出现:
1 | |
只要系统很空闲,即使只有少量慢同步 I/O,也可能让一个 CPU 的 iowait 比例很高。
反过来,一个高并发 I/O 系统即便磁盘很忙,如果 CPU 还有大量其他任务可运行,也不一定表现为很高的 iowait。
正确方法不是看到 wa 就宣布“磁盘瓶颈”,而是把它当成一个线索,再通过 iostat、pidstat 等继续验证。
用 iostat 看块设备
最常用命令:
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。它本身是推导指标,并不能保证准确,也不是现代分析中最值得依赖的字段。
用 pidstat 和 iotop 找到“谁在做 I/O”
iostat 只能告诉你“哪块盘有问题”,不能直接告诉你“哪个进程造成的”。
pidstat
1 | |
重点字段:
1 | |
它适合把块设备压力继续定位到进程。
多线程应用还要关注线程:
1 | |
iotop
1 | |
适合按 I/O 大小排序,快速找到大户。
进程层统计与磁盘真实读写大小可能不同,因为中间还有:
- Page Cache;
- Buffer;
- I/O 合并;
- 延迟回写;
- 文件系统日志;
- 设备缓存。
因此不要因为 pidstat 和 iostat 数值不完全一致,就认为工具出错。
文件系统容量与缓存的常用检查
空间
1 | |
inode
1 | |
目录实际文件大小
1 | |
如果 df 明显比 du 大,一个经典原因是:
文件已经从目录中删除,但仍被某个进程打开。
可以检查:
1 | |
这种文件只有在进程关闭文件描述符或退出后,磁盘空间才真正释放。
Page Cache / Slab
1 | |
或者:
1 | |
1 | |
dentry / inode
1 | |
1 | |
一套可复用的 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[修改后重新测量]
把它进一步压缩,可以记成四句话:
- 系统有没有 I/O 问题?
- 是谁造成的?
- 它到底在读写什么?
- 为什么业务会产生这些 I/O?
工具只是回答这些问题的手段。
案例一:狂打日志把磁盘写满
这是最典型的从系统到应用逐层收敛案例。
第一步:top 发现 iowait
系统中某个 CPU 的 iowait 超过 90%,同时内存里 Buffer/Cache 很高。
这说明值得怀疑 I/O,但还不能直接下结论。
第二步:iostat 确认磁盘
1 | |
案例中出现:
- 磁盘
%util接近 99%; - 每秒写入约 32MB;
- 写请求响应时间达到秒级;
- 请求队列长度超过 1000。
这已经不只是“有 I/O”,而是非常明显的磁盘瓶颈。
第三步:pidstat 找进程
1 | |
Python 进程每秒写入超过 45MB,是最可疑对象。
第四步:strace 找系统调用
1 | |
可以看到类似:
1 | |
一次就写大约 300MB。
第五步:lsof 把 fd 还原成文件
1 | |
发现:
1 | |
于是链路完整了:
1 | |
案例程序支持通过信号动态调日志级别:
1 | |
把 INFO 调高到 WARNING 后,iowait 和磁盘使用率都逐步恢复。
工程结论
日志不是“免费”的。
生产环境应该至少考虑:
- 默认日志级别;
- 单条日志大小;
- 日志频率;
- 异步日志;
- 日志轮转;
- 限速;
- 磁盘隔离;
- 动态调整日志级别后自动恢复。
案例二:大量临时文件让 API 从秒级拖到分钟级
另一个 Web 应用每次请求都会:
- 创建临时目录;
- 生成 1000 个文本文件;
- 把数据写入磁盘;
- 再逐个读回;
- 处理完成后删除整个目录。
结果一个接口响应超过两分钟。
系统层现象
top:
- CPU
iowait接近 94%。
iostat:
- 磁盘
%util接近 98%; - 大量写;
await达到十几秒。
pidstat:
- Python 进程每秒产生数百 MB I/O。
为什么 strace -p PID 却找不到 write
一开始只跟踪主线程:
1 | |
看不到写文件系统调用。
这不是 Python 的问题,也不是 Docker 把系统调用“藏起来”了,而是文件 I/O 发生在其他线程。
正确做法是:
1 | |
-f 会跟踪子进程和线程。
这个案例给出了一个非常重要的经验:
工具输出和已有证据矛盾时,先检查工具自己的观察范围、默认参数和限制,而不是立刻怀疑操作系统“不符合理论”。
用 BCC/eBPF 从内核侧观察
filetop 可以直接看到哪些线程在读写哪些文件:
1 | |
opensnoop 可以跟踪文件打开路径:
1 | |
最终发现应用动态生成类似:
1 | |
然后再全部读回。
优化
如果内存充足,这批短生命周期中间结果根本没有必要落盘,可以直接放内存。
案例优化后,接口从约 2m43s 缩短到不到 9s。
这里真正的思路不是“把 HDD 换 SSD”,而是:
最快的磁盘 I/O,是根本不发生磁盘 I/O。
案例三:MySQL 查询 15 秒,根因既有索引也有系统缓存
商品查询接口:
1 | |
一次请求需要大约 15 秒。
从系统先定位到 MySQL
top:
- iowait 很高;
- CPU 使用率并不高。
iostat:
- 磁盘读约 32MB/s;
%util接近 97%。
pidstat:
1 | |
发现主要读盘的是 mysqld。
strace -f 找到 MySQL 在读哪个 fd
MySQL 是多线程程序,要带 -f:
1 | |
可以看到某个线程持续:
1 | |
lsof 必须传进程 PID,而不是线程 ID
如果把线程号直接交给:
1 | |
可能查不到。
应该对 MySQL 进程执行:
1 | |
然后根据 fd 38 找到:
1 | |
该案例使用的是 MySQL 5.6 + MyISAM:
products.MYD:表数据;products.MYI:索引;.frm:表结构元信息。
这套文件布局具有明显时代背景。今天分析现代 MySQL/InnoDB 时,不应该机械寻找 .MYD/.MYI/.frm,但“从系统调用 → fd → 数据文件 → 数据库对象”的分析方法仍然完全成立。
继续进入数据库层
1 | |
发现长时间执行:
1 | |
再执行:
1 | |
关键结果:
1 | |
说明是全表扫描。
为 TEXT 字段建立前缀索引
直接建:
1 | |
因为 productName 是 TEXT,案例会报需要指定长度。
于是使用前缀索引:
1 | |
优化后接口从约 15 秒降到几毫秒。
为什么停掉另一个 DataService 后,不建索引也快了
案例中还有一个干扰程序,每隔几秒执行:
1 | |
也就是不断清 Page Cache。
MyISAM 本身主要依赖操作系统缓存表数据。全表扫描每次都需要把数据从磁盘读出来,而另一个进程又持续把 Page Cache 清掉,于是同一批数据永远无法形成热缓存。
DataService 停止后:
- Page Cache 逐渐增长;
vmstat的bi最终降到 0;iowait降到 0;- 即使没有索引,查询也从十几秒降到约 0.1 秒。
这暴露出两个不同层次的问题:
- 没有索引导致无意义的大量数据扫描;
- 把性能完全建立在不可控的全局系统缓存上,容易被其他应用干扰。
真正的工程优化应该优先解决第一个问题,而不是依赖“数据刚好在 Cache 里”。
案例四:Redis 查询慢,但磁盘根本没打满
Redis 案例更有意思,因为它证明:
没有磁盘饱和,不代表 I/O 行为就是合理的。
表面现象
top:
- 一个 CPU 的 iowait 超过 80%;
- Redis、Python CPU 都不高;
- 内存也充足。
iostat:
- 只有几 MB/s 写入;
%util接近 0。
所以不能说“磁盘性能不够”。
真正异常的是:
这是一个“查询缓存”的接口,为什么磁盘一直在写?
pidstat 找到 Redis 在写
1 | |
发现 redis-server 持续有写 I/O。
strace 看到了 fdatasync
1 | |
可以观察到:
1 | |
而且 fdatasync 高频出现。
lsof 找到 AOF
1 | |
fd 7:
1 | |
查询配置:
1 | |
案例得到:
1 | |
appendfsync always 表示几乎每次写操作都要求同步落盘。
对很多业务来说这是非常昂贵的持久化策略。
案例把它调整为:
1 | |
接口延迟从约 10 秒降低到约 0.9 秒。
可查询接口为什么会产生写命令
继续看 Redis socket 流量,可以发现:
1 | |
应用在扫描缓存时,每命中一个目标值,又把结果写进 Redis 的 Set,最后读取这个临时 Set,再把它删除。
也就是说,本来可以放在当前进程内存中的短生命周期中间结果,被当作 Redis 临时空间使用。
简化后的逻辑类似:
1 | |
优化后直接在应用内存维护临时列表,接口继续从约 0.9 秒降低到约 0.16 秒。
容器场景下别忘了 network namespace
Redis 和应用运行在容器里时,宿主机上的 socket 视图可能不完整。
可以进入对应网络命名空间:
1 | |
这可以把 Redis server 端 socket 和 Python 客户端连接对应起来。
从四个案例抽象出的性能分析“心法”
几个案例看起来分别是:
- 日志;
- 临时文件;
- MySQL;
- Redis。
但底层排查逻辑高度一致。
1. 从“资源异常”开始,不从源码猜
先看:
1 | |
目的是确定:
- CPU?
- 内存?
- Swap?
- Page Cache?
- 磁盘?
- 哪一块磁盘?
2. 从设备缩小到进程
1 | |
回答:
谁在读?谁在写?
3. 从进程缩小到行为
1 | |
重点关注:
1 | |
4. 把 fd 还原成资源
1 | |
Linux 中 fd 可能对应:
- 普通文件;
- 目录;
- socket;
- pipe;
- 设备;
- 动态库。
5. strace 看不清时向内核侧移动
可以考虑:
1 | |
这些工具可以从更低层看到文件、块 I/O 或内核事件。
6. 最终一定要回到应用语义
系统工具只能回答:
“它做了什么。”
不能自动回答:
“为什么业务要这么做。”
最后必须结合:
- 数据库执行计划;
- Redis 持久化;
- 日志配置;
- 文件生命周期;
- 缓存策略;
- 代码算法。
这一步才是真正产生优化方案的地方。
指标和工具对应表
| 目标 | 常用工具 |
|---|---|
| 文件系统空间 | df -h |
| inode 空间 | df -i |
| 目录大小 | du |
| Page Cache / Buffer / Slab | /proc/meminfo、free、vmstat |
| dentry / inode Cache | /proc/slabinfo、slabtop |
| 磁盘 IOPS / 吞吐 / await / util / 队列 | iostat、sar -d |
| 进程 I/O | pidstat -d、iotop |
| 进程系统调用 | strace |
| 进程打开文件 / socket | lsof |
| 文件读写动态追踪 | filetop |
| open 路径动态追踪 | opensnoop |
| 块 I/O 事件 | blktrace |
| 块 I/O 延迟与进程 | biosnoop、biotop |
| 内核 I/O 事件统计 | perf |
| I/O 基准测试 | fio |
生产环境不一定允许临时安装所有工具,所以重点不是“命令收集”,而是知道已有工具分别能回答什么问题。
fio:优化前先知道设备的真实能力
优化之前必须知道目标。
磁盘到底“慢”不慢,不能只看厂商宣传参数,也不能拿另一台机器的测试结果直接比较。
最常用的基准测试工具是 fio。
安装
Ubuntu:
1 | |
CentOS/RHEL 系:
1 | |
4KB 随机读
1 | |
4KB 随机写
1 | |
顺序读
1 | |
顺序写
1 | |
警告:不要在系统盘或包含重要数据的裸设备上做写测试。直接以块设备作为
filename进行写测试可能破坏原文件系统。
关键参数
direct=1
跳过 Page Cache,尽量直接测试底层存储。
iodepth=64
异步 I/O 同时允许在途的请求数量。
rw
I/O 模式:
1 | |
ioengine
I/O 引擎,例如:
1 | |
bs
单次 I/O 大小。
filename
既可以是文件路径,也可以是裸块设备路径。
如何读 fio 报告
重点看:
1 | |
slat
Submission Latency,I/O 提交阶段延迟。
clat
Completion Latency,从提交到完成的延迟。
lat
fio 视角下整个 I/O 的总延迟。
异步 I/O 下通常近似:
1 | |
不要只看平均值,还要看延迟分位数。
一个系统平均延迟 2ms,但 P99 是 200ms,对在线服务可能仍然完全不可接受。
用真实业务 I/O 做 Replay
固定 4KB 随机读写只能模拟某类 workload,而真实业务往往混合:
- 不同请求大小;
- 读写并发;
- 顺序和随机;
- 不同队列深度。
可以先用 blktrace 记录真实设备访问,再用 fio 回放。
记录
1 | |
转换
1 | |
回放
1 | |
这样做比“随便跑一个随机写 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 | |
热点数据反复读取时,缓存能够显著减少真实磁盘 I/O。
但也不要把系统稳定性建立在“缓存永远不会被别人挤掉”这个假设上。
3. 应用自己管理缓存
应用内部缓存或 Redis/Memcached 的优势是:
- 数据由应用决定;
- 生命周期更可控;
- 不容易被其他进程随意清理;
- 可以设计淘汰策略。
MySQL 案例已经说明,完全依赖全局 Page Cache 的性能非常容易受其他进程干扰。
4. 频繁访问同一区域时考虑 mmap
mmap 把文件映射到虚拟地址空间,可以减少显式 read/write 带来的用户态与内核态数据拷贝。
它并不是“永远更快”,但对频繁随机访问同一文件区域的场景很有价值。
5. 合并同步写
如果每一个小请求都:
1 | |
性能会非常差。
更合理的策略通常是先聚合:
1 | |
前提是业务能够接受相应的持久性窗口。
Redis 的 appendfsync always 与 everysec 本质上就是“可靠性和性能”的典型权衡。
6. 限制 I/O 大户
多个应用共享磁盘时,可以使用 cgroup 的 I/O 控制能力,限制某个服务的 IOPS 或吞吐,避免它把磁盘完全占满。
7. I/O 优先级
在支持相应调度策略的系统中,可以用:
1 | |
调整进程 I/O 优先级。
但是否生效、效果如何取决于当前块层和调度器,不应脱离系统实际配置机械套用。
文件系统层优化
1. 选择合适的文件系统
不同文件系统在:
- 大文件;
- 小文件;
- 并发写;
- 元数据;
- 扩容;
- 日志模式;
方面特性不同。
Ext4 和 XFS 都是常见选择,选型应依据 workload,而不是简单说“谁绝对更快”。
2. 挂载参数
一些场景可以考虑减少不必要的元数据更新,例如:
1 | |
但每一个挂载选项都可能改变语义,应先确认业务是否依赖对应元数据。
3. 日志模式
日志型文件系统为了崩溃一致性会产生额外写入。
不同日志模式本质上都在:
1 | |
之间做权衡。
4. 脏页回写
Linux 允许先把写入放入 Page Cache,再异步刷盘。
常见相关参数包括:
1 | |
这类参数影响:
- 回写频率;
- 允许积累多少脏页;
- 后台回写何时开始;
- 应用何时被迫参与回写。
调得太激进可能降低吞吐,积累太多又可能出现周期性的延迟尖峰。
5. vfs_cache_pressure
1 | |
控制内核回收 dentry 和 inode Cache 的倾向。
数值越高,一般意味着越积极回收这些元数据缓存。
6. 不需要持久化就使用 tmpfs
例如:
1 | |
属于典型内存文件系统。
短生命周期、中间计算结果如果不需要跨重启保存,tmpfs 往往比反复写物理磁盘更合理。
磁盘和块层优化
1. 换更快的设备
最直接:
1 | |
但硬件升级不能替代错误算法。
“每个请求生成 1000 个临时文件”即使换 NVMe,也只是把一个错误设计跑得更快。
2. RAID
RAID 可以:
- 提升吞吐;
- 提升读并行;
- 提供冗余。
代价包括:
- 写放大;
- 容量损失;
- 控制器缓存影响;
- 故障重建时性能下降。
3. I/O 调度器
查看当前设备:
1 | |
是否切换调度器必须基于真实测试。
资料中 NOOP、CFQ、Deadline 等经验来自较早内核环境,现代系统的调度器名称和块层实现已经发生演进,所以应以当前内核暴露的选项为准。
4. 数据隔离
数据库、日志、搜索引擎、备份如果共享一块盘,很容易互相影响。
可以把高 I/O 组件拆到独立设备:
1 | |
这既能提升性能,也让故障定位更容易。
5. 预读
顺序读明显时,可以增加块设备预读。
查看或调整:
1 | |
资料中的示例默认值为 128KB。
也可以使用:
1 | |
blockdev --setra 的单位是 512B 扇区,因此数值与 read_ahead_kb 的单位不同。
随机 I/O 场景过度预读反而可能浪费带宽和内存。
6. 队列长度
1 | |
增大队列可能提高吞吐,但通常会增加排队延迟。
这就是典型的吞吐和 latency trade-off。
硬件错误也会伪装成性能问题
如果磁盘性能突然异常下降,不要只调参数。
可以先检查:
1 | |
看是否存在 I/O error、reset、timeout 等日志。
物理盘还可以使用:
1 | |
或者:
1 | |
文件系统层可以根据类型使用相应检查工具。
需要注意,虚拟机或硬件 RAID 控制器后面的磁盘,smartctl 不一定能直接获得真实物理盘信息,排查时还需要结合虚拟化平台或 RAID 控制器工具。
三个极其常见的误区
误区一:iowait 高,所以磁盘坏了
错误。
iowait 只是 CPU 时间分类,不是磁盘饱和度。
正确链路:
1 | |
误区二:%util = 100% 就一定不能再提升
也不一定。
对具备并行能力的设备,要同时看队列、延迟、吞吐、IOPS 和基准上限。
误区三:工具没看到,所以事情没发生
磁盘已经在写,strace 没看到 write,不代表内核凭空产生 I/O。
先检查:
- 是不是多线程?
- 是否忘了
-f? - 是否采样窗口太短?
- I/O 是否由内核线程回写?
- 是否应该从文件层转到块层/eBPF?
性能工具也有自己的观察边界。
版本与时代背景
这些案例形成于较早的 Linux、MySQL 和工具链环境,学习时应区分“原理”与“当时的具体实现”。
I/O Scheduler
不要死记:
1 | |
当前系统实际支持什么,直接查:
1 | |
MySQL MyISAM 文件
案例里用:
1 | |
这是特定 MySQL 版本和存储引擎背景。
现代生产环境更常见的是 InnoDB,具体文件布局不同,但 OS 侧分析路径不变:
1 | |
BCC 安装命令
案例中的 BCC 软件源安装方式具有时代背景。实际环境应优先使用当前发行版维护的软件包或对应版本文档。
svctm
老版本 iostat 常见这个字段,但它属于推导指标,不应把它当作精确的物理设备服务时间。
最终可以记住的一张排查清单
遇到“接口突然很慢、系统卡顿、数据库延迟升高”时,可以按这个顺序走:
系统层
1 | |
回答:
1 | |
磁盘层
1 | |
回答:
1 | |
进程层
1 | |
回答:
1 | |
系统调用层
1 | |
回答:
1 | |
文件和 socket
1 | |
容器网络场景:
1 | |
内核动态追踪
1 | |
应用层
最后才问:
1 | |
然后修改,并重新跑最初同一组观测命令验证。
总结
Linux I/O 性能优化最重要的不是某一个参数,而是把整个链路串起来。
从文件系统角度,要理解:
1 | |
从磁盘角度,要理解:
1 | |
从分析方法看,核心链路是:
1 | |
从优化顺序看,最值得优先考虑的是:
1 | |
一个成熟的性能分析过程,不应该是“看到 iowait 高就开始调内核参数”,而应该是通过指标提出假设,用工具验证假设,再回到应用原理找到真正原因,最后用同样的指标证明优化确实有效。
这套方法的价值也不只在磁盘。CPU、内存、网络性能分析本质上也是同一件事:从现象出发,沿调用链逐层缩小范围,用原理解释指标,用数据验证结论。