【存储】Linux 存储 I/O 操作完全图解:从 open 到 unmap,新手也能看懂的底层原理
Linux 存储 I/O 操作完全图解从 open 到 unmap新手也能看懂的底层原理一、前置概念用户态、内核态、VFS二、数据和元数据的关系三、open一切 I/O 的起点3.1 open() 到底做了什么3.2 fd 为什么是 3、4、53.3 关键的 flags3.4 vim 打开文件时涉及哪些步骤四、read从磁盘读到内存4.1 read() 的真实流程4.2 Page Cache 和预读4.3 cat file.txt 完整拆解五、write从内存写到磁盘5.1 write() 的真实流程5.2 fsync / fdatasync强制落地5.3 echo hello file.txt 完整拆解5.4 vim :w 保存文件的 I/O 链路5.5 echo hello file.txt追加写入六、truncate改变文件大小6.1 两种 truncate6.2 什么时候文件缩小不释放空间6.3 实际场景七、unmap / punch hole释放已分配的空间7.1 fallocate 的三副面孔7.2 punch hole vs truncate八、unlink / rename删除和重命名8.1 unlink——删除不等于消失8.2 rename——vim 保存的秘密武器8.3 rm -rf file.txt 拆解九、I/O 模式总览与对比9.1 四种主流 I/O 模式9.2 怎么选9.3 Direct I/O 的对齐陷阱十、存储测试工具推荐不同操作配什么工具测10.1 工具全景速览10.2 各 I/O 操作对应的测试工具10.3 快速上手的测试命令十一、新手做存储测试前必须记住的 5 件事1. 记得预热warmup2. 记得 fsync或者搞清楚你要测什么3. Direct I/O 和 Buffered I/O 是两种完全不同的测试场景4. IOPS / 带宽 / 延迟三个维度缺一个就等于没说5. 小文件看元数据大文件看数据吞吐附录常用观测工具速查摘要本文从系统调用层面系统介绍 Linux 存储 I/O 的核心操作——open、read、write、truncate、unmap、unlink 等并结合 vim 编辑、echo 写入、rm 删除等日常场景拆解背后的真实 I/O 链路。附带数据/元数据关系图解、各操作对应的压测工具推荐以及新手做存储测试前必须知道的 5 件事。一、前置概念用户态、内核态、VFS在聊具体操作之前先得搞清楚一个基本问题你敲下echo hello file.txt的时候到底发生了什么你的 shell 是一个用户态进程它没有权限直接操作磁盘。读写磁盘这件事必须由内核代劳。用户程序通过**系统调用syscall**告诉内核帮我做这件事内核做完再把结果返回来。而内核也不是直接跟 ext4 或 xfs 对话的。中间有一层叫VFSVirtual File System虚拟文件系统它就像一个万能转接头——不管你底层用的是 ext4、xfs、nfs 还是 tmpfs上层的 open/read/write 写的都是同一套接口。整条链路长这样用户程序 (vim / echo / cat) ↓ C 标准库 (glibc) 封装 系统调用 (syscall: open / read / write / fsync...) ↓ VFS 层 (统一接口路径解析 inode/dentry 缓存) ↓ 具体文件系统 (ext4 / xfs / btrfs / nfs...) ↓ 块层 (Block Layer, 请求合并/排序/I/O 调度) ↓ 设备驱动 磁盘 / SSD理解这条链路才能更好的理解后边的操作掌握这条链上某个环节的具体行为。二、数据和元数据的关系这是小白最需要区分的概念 。数据Data文件的实际内容。写进去的每一个字节存在磁盘的数据块data block里。元数据Metadata“关于数据的数据”描述文件是谁、在哪、有多大、什么时候创建的。主要包含元数据类型存储位置记录内容inode文件系统 inode 表文件大小、权限rwx、owner/group、时间戳atime/mtime/ctime、指向数据块的指针目录项dentry目录的数据块文件名 → inode 号的映射文件名存在目录里不在 inode 里超级块superblock文件系统固定位置整个文件系统的全局信息总块数、空闲块数、块大小、inode 数量等注意修改数据和修改元数据是两次不同的 I/O。举个例子我们用echo hello file.txt追加一行内容——找到 file.txt 的 inode →读元数据分配一个新数据块如果当前块满了→写元数据更新空闲块位图把 “hello\n” 写进数据块 →写数据更新 inode 中的文件大小size 字段 6 字节→写元数据更新 inode 的 mtime修改时间→写元数据一次追加一行至少触发了 1 次数据写 3 次元数据写。这就是为什么小文件大量写入的时候瓶颈往往在元数据操作而不是数据吞吐。为什么强制断电会丢数据数据和元数据不是原子写入的。可能数据块已经落盘了但 inode 里的新 size 还在内存里没刷下去。下次挂载文件系统时inode 记录的还是老 size你那些已写入的数据就凭空消失了。这就是fsync必须存在的理由——后面 write 那章会细说。三、open一切 I/O 的起点3.1 open() 到底做了什么open(/path/to/file, flags, mode)背后的步骤路径解析从路径字符串逐级找到目标文件。比如open(/home/user/file.txt)→ 先找到/的 inode → 在/目录里找home→ 在/home目录里找user→ 在/home/user目录里找file.txt。每一步都可能触发磁盘 I/O如果 dentry cache 没命中的话。权限检查当前进程的 uid/gid 是否匹配文件的 owner/group/other 权限位。创建 file 结构体内核分配一个struct file记录当前文件偏移量position 0、打开模式只读/只写/读写等状态信息。分配文件描述符fd在当前进程的 fd 表里找一个最小的空闲位置把这个 file 结构体的指针挂上去返回这个位置的下标。3.2 fd 为什么是 3、4、5每个进程启动时内核已经预分配了三个 fdfd名称含义0stdin标准输入键盘1stdout标准输出终端2stderr标准错误终端所以你打开第一个文件时open()返回 3第二个返回 4以此类推。3.3 关键的 flagsflag作用什么时候用O_RDONLY只读cat / less / vim 查看模式O_WRONLY只写echo 重定向O_RDWR读写vim 编辑模式O_CREAT文件不存在则创建echo 写入新文件O_TRUNC打开时把文件截为 0 长度重定向O_APPEND写入前自动跳到文件末尾追加O_DIRECT绕过 page cache直接 I/O数据库、压测O_SYNC每次 write 都等数据和元数据落盘对数据安全性要求极高的场景O_DSYNC每次 write 只等数据落盘元数据可异步稍微比 O_SYNC 快一点3.4 vim 打开文件时涉及哪些步骤vimfile.txtvim 实际调用open(file.txt, O_RDONLY) → fd3拿到 fd3 后vim 用read()把内容读进内存缓冲区显示在屏幕上。此时文件在磁盘上没有任何修改。你只是在看。四、read从磁盘读到内存4.1 read() 的真实流程ssize_tread(intfd,void*buf,size_tcount);内核做的事通过 fd 找到对应的struct file从file-f_pos当前偏移量开始尝试在Page Cache页缓存中找到对应数据命中直接从 Page Cache 拷贝数据到用户态 buf更新 f_pos返回未命中触发磁盘 I/O从磁盘读取对应数据块到 Page Cache再拷贝到 buf重复直到读够 count 字节或到达文件末尾4.2 Page Cache 和预读Page Cache 是 Linux 最重要的 I/O 优化机制。第一次读某个文件区域时数据从磁盘读到 Page Cache 后保留在内存中。下次读同一区域零磁盘 I/O纯内存拷贝。内核还会做预读readahead你只read()了 4KB内核可能偷偷把后面 128KB 都读进 Page Cache。理由是程序通常是顺序读的先帮你准备好下次read()秒回。这对存储测试意味着什么不预热直接跑测试第一轮的数据根本不能代表真实性能。4.3 cat file.txt 完整拆解catfile.txt用strace cat file.txt可以看到实际系统调用序列简化版① open(file.txt, O_RDONLY) → fd3 ② read(3, 这是文件内容..., 131072) → Page Cache 未命中 → 读磁盘 → 返回 n 字节 ③ write(1, 这是文件内容..., n) → 写到 stdout终端 ④ read(3, , 131072) → 读到 EOF返回 0 ⑤ close(3) → 释放 fd②③ 会循环多次直到读完整个文件。read()返回 0 表示到达文件末尾。五、write从内存写到磁盘5.1 write() 的真实流程ssize_twrite(intfd,constvoid*buf,size_tcount);关键认知write()返回成功 ≠ 数据已落盘。默认情况下不带O_SYNCwrite()只是把数据从用户态 buf 拷贝到 Page Cache 中的对应页面标记这个页面为脏页dirty page然后就返回了。真正的磁盘写入由内核的后台线程pdflush / bdflush异步完成。这样做性能好把多个小写合并成大的顺序写但风险也明显如果在脏页刷盘之前断电数据就丢了。5.2 fsync / fdatasync强制落地调用做了什么性能开销fsync(fd)文件的数据 所有元数据都刷到磁盘高fdatasync(fd)只刷数据和必要的元数据size不刷 atime/mtime比 fsync 低sync()全局刷所有文件系统的脏页很高数据库SQLite、MySQL InnoDB 等大量依赖fsync来保证事务持久性。这也是为什么数据库在机械盘上很慢而在 SSD 上好很多——fsync对机械盘的延迟影响巨大磁头寻道而 SSD 几乎没有寻道延迟。5.3 echo “hello” file.txt 完整拆解echohellofile.txt这是最常用的写入文件操作但拆开了里面有 4 个系统调用① open(file.txt, O_WRONLY|O_CREAT|O_TRUNC, 0644) → fd3 - O_CREAT: 如果文件不存在就创建 - O_TRUNC: 如果文件已存在直接把长度截为 0先清空 ② write(3, hello\n, 6) → 6 字节写入 Page Cache ③ close(3) → 释放 fd - close 本身不会触发 fsync - 数据此时还在 Page Cache 里稍后内核自行刷盘重点重定向的关键在于O_TRUNC。如果你中间写入失败比如磁盘满了旧数据已经被 truncate 销毁了文件变成空文件或半截文件。这就是为什么重要数据不要直接用覆盖——后面 vim 那节会讲 vim 怎么规避这个问题。5.4 vim :w 保存文件的 I/O 链路vim 保存文件的操作远比你想象的复杂。它是这样做的① open(file.txt~, O_WRONLY|O_CREAT|O_TRUNC, 0644) → 创建备份文件 把旧内容写进去备份 ② open(file.txt, O_RDONLY) → 读取当前文件内容 跟缓冲区比对生成差异 ③ open(file.txt.swp, O_WRONLY|O_CREAT) → swap 文件崩溃恢复用 write → fsync → close → 每次修改都刷 swap ④ 用户按下 :w ⑤ write(临时文件fd, 新内容, size) → 写入临时文件 ⑥ fsync(临时文件fd) → 强制临时文件落盘 ⑦ close(临时文件fd) ⑧ rename(临时文件路径, file.txt) → 原子替换 - rename 是原子操作要么执行完要么完全没执行 - 如果在写入临时文件时断电 → 原文件完整无损 - 如果在 fsync 之后、rename 之前断电 → 临时文件在原文件也在 ⑨ unlink(file.txt~) → 删除备份如果配置不保留 ⑩ unlink(file.txt.swp) → 删除 swap 文件vim 的这个流程本质上是一个迷你事务临时写入 fsync 原子 rename。哪怕你在保存的过程中拔电源线原文件也不会坏——这是成熟的文本编辑器该有的底线。相比之下echo hello file.txt的流程就粗暴多了open O_TRUNC 先销毁原内容然后 write没有 fsync没有原子替换。文件一大就存在写一半崩溃的风险窗口。5.5 echo “hello” file.txt追加写入① open(file.txt, O_WRONLY|O_CREAT|O_APPEND, 0644) → fd3 - O_APPEND 保证每次 write 前自动把 f_pos 跳到文件末尾 ② write(3, hello\n, 6) ③ close(3)跟的核心区别是O_APPEND替代了O_TRUNC。不做清空操作只在末尾追加。对于日志文件来说这是更安全的写入方式。六、truncate改变文件大小6.1 两种 truncateinttruncate(constchar*path,off_tlength);// 通过路径操作intftruncate(intfd,off_tlength);// 通过 fd 操作length 当前大小文件被截断多余的数据块被释放inode 的 size 字段更新为 lengthlength 当前大小文件被扩展新增的区域填充\0零字节但磁盘空间不一定立即分配稀疏文件6.2 什么时候文件缩小不释放空间truncate只会更新 inode 中的size 字段和释放对应的数据块。但如果有人在别的进程里还开着这个文件的 fd那个进程依然可以读旧 fd 指向的数据直到关闭 fd。这就是 Linux 的引用计数机制——磁盘空间在最后一个 fd 关闭时才会真正释放。6.3 实际场景场景对应操作日志轮转logrotatetruncate清空日志文件进程不用重启 file.txtshell 用open(O_TRUNC)→ 等价于 truncate 到 0 再写创建稀疏文件truncate -s 1G test.img→ 1GB 的空壳文件几乎不占磁盘文件系统镜像精简truncate把镜像缩小去掉冗余空间稀疏文件的妙用你可以在一个只有几 GB 空闲的磁盘上创建 100GB 的稀疏文件ls -l显示 100GB但du -h显示实际占用只有几 KB。只有当你真正往里写数据时磁盘空间才会被分配。# 创建一个 1GB 的稀疏文件只消耗几 KB 元数据空间truncate-s1G sparse.imgls-lhsparse.img# -rw-r--r-- 1 root root 1.0G → 显示 1GBdu-hsparse.img# 4.0K → 实际只占 4KB七、unmap / punch hole释放已分配的空间7.1 fallocate 的三副面孔intfallocate(intfd,intmode,off_toffset,off_tlen);mode行为文件 size 变不变默认 0预分配空间保证后续写入不会因磁盘满而失败不变空间预占FALLOC_FL_KEEP_SIZE预分配空间保持文件大小不变不变FALLOC_FL_PUNCH_HOLE在 offset ~ offsetlen 范围内打洞释放物理块不变7.2 punch hole vs truncate这是两种完全不同的释放空间truncate: [████████████░░░░] 从末尾切只能改总长度 保留部分 丢弃部分 punch hole:[████░░░░░░████] 中间打洞长度不变 保留 空洞 保留punch hole 的典型场景虚拟机镜像精简虚拟机删除了大文件但 qcow2 镜像没有自动收缩。用fallocate -d即 punch hole把已删除区域标记为空洞。数据库空间回收MySQL/PostgreSQL 删了大量数据后表空间文件大小不变但中间有大量空闲区域。通过 punch hole 把空闲区域还给文件系统。日志文件局部清理只清除文件中间的旧内容保留头部和尾部。# 对一个 100MB 的文件在 10MB~90MB 范围打洞fallocate-p-o10485760-l83886080file.dat# 打洞前后du-hfile.dat# 100MB → 20MB中间 80MB 还给了文件系统ls-lhfile.dat# 依然显示 100MB文件逻辑大小没变八、unlink / rename删除和重命名8.1 unlink——“删除不等于消失”intunlink(constchar*pathname);unlink做的事是把文件名从目录中移除减少一次硬链接计数。如果这个文件的硬链接计数降到 0且没有任何进程打开这个文件内核才会释放 inode 和所有数据块。这就是为什么你可以删掉一个正在被进程读写的文件进程还继续工作终端1: tail -f /var/log/app.log ← 进程持有 fd 终端2: rm /var/log/app.log ← unlink 删除目录项 终端1: tail -f 继续输出新日志 ← fd 还在inode 还在此时ls /var/log/app.log→ 文件不存在目录项已删进程的 fd 依然有效继续读写数据磁盘空间不会释放直到进程close(fd)这也是实现安全临时文件的标准手段fdopen(/tmp/myapp.XXXXXX,O_RDWR|O_CREAT,0600);unlink(/tmp/myapp.XXXXXX);// 立即删除目录项// 现在只有这个进程能用 fd 操作文件// 进程退出时内核自动回收 inode 和数据块// 不用担心临时文件泄漏8.2 rename——vim 保存的秘密武器intrename(constchar*oldpath,constchar*newpath);rename是原子操作它要么完全执行要么完全不执行。即使系统在 rename 中途崩溃文件系统也不会处于两个名字各指一半的中间状态。这就是 vim:w用rename的原因write → fsync 临时文件 → close → rename(临时文件, 目标文件) ↑ 这一步要么全部完成要么全部不完成 不存在半截文件状态8.3 rm -rf file.txt 拆解rmfile.txt如果你没用-f① stat(file.txt) → 检查文件是否存在 ② access(file.txt, W_OK) → 检查是否有写权限用来确认是否安全删除 ③ unlink(file.txt) → 移除目录项加了-frm -f file.txt如果文件不存在也不报错。九、I/O 模式总览与对比9.1 四种主流 I/O 模式模式实现方式数据路径典型应用特点Buffered I/O默认open 不加 O_DIRECT用户buf → Page Cache → 磁盘普通文件读写、日志利用缓存写回丢失风险Direct I/Oopen(O_DIRECT)用户buf → 直接到磁盘数据库MySQL/PostgreSQL自己管缓存对齐要求严格mmapmmap() 直接地址访问文件映射到进程地址空间LMDB、RocksDB、大文件随机访问少一次拷贝缺页开销AIOio_submit()/libaio异步提交回调通知高性能数据库、分布式存储不阻塞实现复杂9.2 怎么选看使用场景有没有自带缓存数据库 ├─ 有 → Direct I/OO_DIRECT │ 数据库有自己的 buffer pool不需要内核再缓存一遍 │ 双缓存不仅浪费内存还会导致双重写入 │ └─ 没有 → Buffered I/O默认 内核帮你做合并写、预读、缓存管理 看使用场景有大量随机小读写吗 ├─ 有 → 考虑 mmap │ 避免频繁的 read/write 系统调用开销 │ 但要处理好缺页中断和 SIGBUS │ └─ 没有 → 普通 write/read 就够 看使用场景需要 10 万 IOPS 以上 └─ → AIO 或 io_uring新一代异步 I/O比 AIO 更友好9.3 Direct I/O 的对齐陷阱O_DIRECT不是你想用就能用。它要求用户 buffer 的内存地址必须对齐到磁盘逻辑块大小通常是 512 字节读写偏移量必须是块大小的整数倍读写长度必须是块大小的整数倍不然read()/write()直接返回EINVAL。很多新手第一次用 Direct I/O 都会在这踩坑。十、存储测试工具推荐不同操作配什么工具测做存储测试最忌讳的就是一把 fio 走天下。不同 I/O 操作的瓶颈完全不同要用对的工具测对的东西。10.1 工具全景速览工具擅长测什么适用场景评价fio数据读写性能IOPS / 带宽 / 延迟块设备、文件、Direct I/O、Buffered I/O瑞士军刀什么都能测但配置项多得吓人vdbench企业级存储综合性能SAN / NAS / 分布式存储的多节点并发压测企业级标准工具报告漂亮适合正式出报告mdtest元数据操作性能open/create/stat/unlink大量小文件场景、并行文件系统Lustre/GPFS就测一件事元数据能撑多高iozone多模式文件 I/O 对比含 mmap不同读写模式下的性能对比分析老牌工具适合做 I/O 模式横向对比cosbench对象存储性能S3/Swift对象存储系统的读写、list、delete 性能对象存储压测的事实标准strace单个进程的系统调用追踪拆解任意程序的 I/O 行为不是压测工具但百试百灵配合 -c 还能出统计10.2 各 I/O 操作对应的测试工具根据本文前面聊到的几种核心 I/O 操作一一对应的测试方案你要测的 I/O 操作推荐工具关键配置要点纯数据读写read/writefiorwread/write/randread/randwrite,direct1避缓存iodepth32Buffered I/O 缓存效应fiodirect0, 对比预热前后loops2看第二次结果O_SYNC / fsync 延迟fiofsync1每次 write 后 fsync,sync1O_SYNCTruncate / 预分配空间fio fallocatefio 测带宽时配合fallocate或truncate -s观察分配策略对写入性能的影响Punch hole / 稀疏文件手写脚本 观测fallocate -p打洞后用du -h和ls -lh对比配合 fio 测打洞前后的读写性能元数据操作open/stat/unlink/createmdtest-n 10000文件数,-i 3迭代次数, 调整进程数看并发能力并发创建/删除目录mdtest-u唯一目录模式看目录级别的元数据瓶颈大文件顺序读写fio / vdbenchfio 的rwread/writebs1m, vdbench 的sdsd1 sequential多客户端并发读写vdbench多 Client 节点同时挂载同一个 NFS/CIFS/对象存储对象存储 GET/PUT/LIST/DELETEcosbenchXML 定义 workload可配 workers、containers、object size10.3 快速上手的测试命令用 fio 测随机 4K 读Direct I/OIOPS 导向fio--namerandread-4k\--filename/dev/sdb\--rwrandread\--bs4k\--direct1\--iodepth32\--numjobs4\--runtime60\--time_based\--group_reporting用 fio 测顺序写并每次 fsync模拟数据库写日志fio--nameseqwrite-fsync\--filename/mnt/test/testfile\--rwwrite\--bs4k\--fsync1\--size1G用 mdtest 测元数据性能创建 10000 个文件mdtest-d/mnt/test-n10000-i3-b1# -d: 测试目录# -n: 每个进程创建的文件数# -i: 迭代轮数# -b: 每个目录下的文件数1 表示平铺大值表示嵌套十一、新手做存储测试前必须记住的 5 件事1. 记得预热warmup第一次读和第二次读的性能完全不同。Page Cache 没预热的情况下跑出来的数据跟真实长期运行环境差了好几倍。fio 里用loops2跑两轮第二轮的结果才有参考意义。2. 记得 fsync或者搞清楚你要测什么测写入性能的时候如果没开fsync或direct1你测的其实是内存写入速度不是磁盘写入速度。两种场景都有意义但你要知道自己在测哪个。别把 “write to page cache” 的结果当成 “write to disk” 来汇报。3. Direct I/O 和 Buffered I/O 是两种完全不同的测试场景测 Direct I/O → 模拟数据库、虚拟机存储 测 Buffered I/O → 模拟日志文件、Web 服务器静态文件两者结果差异可能 10 倍以上对比时要显式标注用了哪种模式。4. IOPS / 带宽 / 延迟三个维度缺一个就等于没说只报顺序写 500MB/s没有意义——同时要报告IOPS4K 随机读写能跑到多少延迟分布P50 / P95 / P99fio 输出里有如果是 Direct I/O 还是 Buffered I/O5. 小文件看元数据大文件看数据吞吐小文件 64KB→ 瓶颈在 open/stat/unlink/rename → 用 mdtest 大文件 1MB → 瓶颈在 read/write 带宽 → 用 fio / vdbench同一个存储集群可能大文件读写飙到 10GB/s但在几百个小文件的创建场景下卡成 PPT——因为元数据服务器的能力跟不上。拿到存储系统的真实极限必须两种都测。附录常用观测工具速查工具一句话常用命令strace追踪一个进程的所有系统调用strace -e traceopen,read,write,close,fsync,rename -o trace.log your_programstrace -c统计各系统调用的耗时和调用次数strace -c cat file.txt看 read/write 各占多少时间iostat看磁盘级别的 IOPS、吞吐量、await 延迟iostat -x 1每秒刷新重点看 %util, await, r/s, w/sblktraceblkparse深入块层看每个 I/O 请求的全生命周期blktrace -d /dev/sdb -o - | blkparse -i -perf系统级性能分析看哪里的锁竞争、缺页、缓存未命中perf stat dd if/dev/zero of/tmp/test bs1M count1000lsof看谁打开了什么文件lsof /var/log/app.log找出持有 fd 的进程/proc/sys/vm/dirty_*控制脏页刷盘策略cat /proc/sys/vm/dirty_ratio脏页达到内存的百分之几就强制刷盘后记这篇文章定位是职场存储新手的 I/O 基础知识手册。不用背完做存储测试前翻出来对一下你这个场景的核心 I/O 操作是哪个、工具用对了没、数据/元数据的瓶颈分清楚没——就够了。剩下的在实践中慢慢长出来。