☰
文件I/O与存储优化:从page cache到io_uring的实践指南
2026/10/17 3:34:52 网站建设 项目流程

文件I/O与存储优化,这话题我这些年反复碰到。早年间帮某在线交易系统查问题,数据库明明没几条数据,写磁盘却慢得像蜗牛,后来发现是日志文件写了几十个G,还是同步写。还有一次是某消息队列组件,吞吐死活上不去,最后排查发现它每处理一条消息就做一次fsync,直接把I/O路径打成了串行。这类问题太多了,今天专门把这摊事梳理清楚。

如果你是个做后端、中间件、数据库维护或者基础架构的开发者,这篇文章应该能帮你省掉不少线上踩坑的时间。我说的不只是"加个缓存、换个SSD"这种粗话,而是从用户态到内核态,从page cache到磁盘调度,把文件I/O这条路径上的每一个关键瓶颈讲透,再给出可以落地的优化套路。

1. 先把问题说清楚:文件I/O到底慢在哪

1.1 一次普通的文件写入要经过哪些关卡

很多人对文件I/O的理解停留在"write() 回车,数据就进磁盘了",这么想问题就大了。一次普通的write()系统调用,数据要走的路至少有这么几段:

  • 你的进程用户态缓冲区(比如BufferWriter里的内存)通过系统调用切到内核态,这本身就是一次上下文切换。
  • 数据拷到内核的页缓存(page cache)里,由内核缓存管理逻辑接手。
  • 内核把这条脏页链进回写队列,之后由后台线程根据策略刷到磁盘。
  • 磁盘驱动层把逻辑块号翻译成物理地址,经过I/O调度器排队,最后发给设备。
  • 设备固件还有一层写缓存/写合并,最终才算固化到介质。

这里每一环都有开销,但开销的放大程度完全不一样。你如果每次就写几个字节,那系统调用、上下文切换、锁竞争这几样分摊到每个字节头上的成本就极其恐怖。这也是为什么所有正经的I/O优化,第一件事永远是"减少调用次数",而不是去调什么磁盘参数。

1.2 糊涂账:你以为的慢,其实是系统调用太多

我先给个数据。单次write()裸开销,不涉及真实磁盘落盘的极端情况下,现代CPU上也要几十纳秒到微秒级别,而这还只是框架成本。一旦数据要落盘,按机械硬盘每秒几百次I/O、SSD 按队列深度打满才能到几万次I/O的规格算,单次物理写请求的耗时也在几十微秒到几毫秒区间。

可怕的是,如果你用循环逐行write(),一个文件写 100MB,可能触发上百万次系统调用。每次系统调用就算省到 1 微秒,这也有 1 秒的空转。再叠加用户态到内核态的数据拷贝、文件锁等等,你会看到 CPU 明明没跑满,I/O 延时却高得离谱。

注意:做任何优化之前,先去strace -c看看系统调用次数,再去perf top看看有没有热点在内核I/O路径上。很多时候问题根本不是存储介质,是你的调用方式有问题。

2. 缓存层优化:用好page cache,但别被脏页绑架

2.1 page cache是性能救星,也是隐患温床

Page cache(页缓存)是内核用来缓存文件数据的。你读一个文件时,如果对应的页在缓存里,直接内存返回;不在就去磁盘读并回填缓存。写一个文件时,只要不强制要求落盘,数据进缓存就算"写完"了,内核后台找机会再回写。

这套设计的收益很大:大部分读命中缓存,写操作被合并成异步批次,吞吐量能上来好几个数量级。问题也出在这——如果缓存策略被不当操作破坏,或者脏页积压太多,你看到的I/O表现会极其撕裂。

比如你写一个超大文件,如果不做任何限制,这部分脏页会把整个 page cache 冲掉,其他业务进程的热点数据全被挤出缓存,接下来所有读都变成冷读。这在我调过的案例里非常常见:频繁生成报表的定时任务,一到晚上就把系统I/O拖垮,原因就是它把缓存搞崩了。

2.2 该调的三个核心参数

Linux 下脏页回写主要受这几个参数影响,在/proc/sys/vm/下:

  • dirty_background_ratio(默认通常 10):脏页占内存比例达到这个值,后台回写线程开始刷盘。
  • dirty_ratio(默认通常 20):脏页比例达到这个值,应用程序自己写数据的时候会被强制参与刷盘,表现为 write 卡住。
  • dirty_writeback_centisecs(默认 500,单位是百分之一秒,即 5 秒):控制后台回写线程的唤醒间隔。
  • dirty_expire_centisecs(默认 3000,即 30 秒):脏页在缓存里待超过这个时间就必须被刷出。

怎么调?关键看你的业务是不是"突然大批量写"型。

如果是,希望突发写尽量走内存、不要立刻落盘导致延迟尖刺,且写放大可接受,可以适当调高dirty_background_ratio;如果系统内存很紧张,或者业务里读热点也很重要,那脏页比例就得压低,否则缓存会被写脏页占满。

我在一台 256GB 内存的数据库宿主机上的习惯配置是:

vm.dirty_background_ratio = 5 vm.dirty_ratio = 10 vm.dirty_writeback_centisecs = 1000 vm.dirty_expire_centisecs = 3000

这套配置的思路是:脏页比例别失控,回写周期拉长一点,让回写更集中、减少磁盘碎片的产生。数据库一般有自己的缓冲池和日志机制,并不需要依赖 page cache 积攒太多脏页,所以压低比例对它们更友好。

2.3 缓存活性:保住热点数据才是读优化

调参数之外,想保住热点读数据,还有个工具是posix_fadvise。

  • POSIX_FADV_DONTNEED可以告诉内核"这批文件页你用完了,可以尽快回收",适合流式读过一次的日志扫描任务。
  • POSIX_FADV_WILLNEED可以提前把即将要读的区间预取到page cache,适合确定的顺序读场景。

用不用、怎么用,取决于你程序里对访问序列的预判能力。我见过一个比较极端的例子:某图像处理 Demo,每轮要读一批图片文件,顺序是固定的,但加载前不做任何预取,导致CPU等待I/O的时间占了一大半。加了WILLNEED预取后,那部分等待时间砍掉了七成左右。

实操小技巧:想查看某个文件的缓存命中情况,可以用mincore()或者直接看/proc/pid/io里的读字节数对比实际读设备量。别再靠感觉判断缓存有没有生效了。

3. 零拷贝与读写放大:从系统调用到最终落盘

3.1 mmap、sendfile如何减少拷贝和上下文切换

传统读文件再经Socket发送要经历至少四次数据拷贝(磁盘→page cache→用户缓冲区→socket缓冲→网卡),和至少两三次上下文切换。零拷贝的思路就是尽量在内核里直接搬数据。

两个最常用手段:

  • mmap:把文件映射到进程地址空间,读写文件像读写内存。访问不存在的页时由内核缺页异常去磁盘加载。省掉了read()的用户态拷贝这步。优点很明显,但坑也很多,比如映射区读写触发 SIGBUS 的情况、脏页回写时序不可控等。它更适合结构已知、访问模式随机的场景,不适合当通用日志写入方案。
  • sendfile:直接把文件内容从 page cache 送到 socket,不需要经过用户空间。做静态文件服务、大文件下载时,效果立竿见影。它的前提也是数据最好在page cache里,否则还是得等磁盘读。

零拷贝优化的本质是什么?把CPU密集的拷贝活挪给内核去批量处理,同时减少用户态/内核态切换的次数。CPU变闲了,吞吐自然能上去。但注意,它不能解决"磁盘本身慢"的问题,只能减少路径上的损耗。

3.2 O_DIRECT:别迷信,多数时候不是你的菜

O_DIRECT绕过 page cache,把数据直接从用户态缓冲区送到设备层,看起来"最直接",是不是最优?实际使用里我用它踩过好几次坑。

它的适用场景高度集中在数据库这类自己管理缓存、且追求落盘语义清晰的程序上。普通应用上O_DIRECT,往往碰到几个问题:写入必须对齐(通常是 512 或 4096 字节),否则write()报错;写小文件时,每次都要真正下盘,延迟不降反升;预读机制失效,顺序读的吞吐反而更低。

我自己的一般原则是:没有明确的性能瓶颈证据表明"page cache 是元凶",就不碰O_DIRECT。很多人的性能问题明明是业务层的调用模式不对,结果甩锅给缓存机制,这是方向性错误。

3.3 写放大:日志型场景的真正敌人

写放大问题在存储领域一直存在。文件系统日志(journal)要写、块设备映射层要写、闪存盘FTL映射表也要写,一层套一层。

真正的优化思路是两条:

  • 减少各层的无效写入。比如批量提交、合并小写请求;
  • 把多次元数据更新合并成一次提交。某些文件系统支持延迟日志(如 ext4 的data=writeback模式),先写数据页,元数据晚点再落盘,可以显著减少小写次数,但牺牲的是崩溃一致性,需要上层程序能容忍短窗口的不一致。

数据库里那些"先写 WAL,数据批量刷"的设计,本质上都是在跟写放大做斗争。你在做自己的存储层时,请先把这条思路学走:高频小写先进日志,低频文件再隔段时间合并落盘。

4. 存储与文件系统选型:硬件决定下限,格式决定上限

4.1 随机与顺序:物理差距比想象中还大

存储介质的天条是:顺序访问和随机访问的性能差距巨大。

单块机械硬盘,顺序读可以到 150-200MB/s,随机 4K 读就只有几十到一两百 IOPS,换算下来每秒几MB都费劲。SSD 好一些,随机4K读也能到上万 IOPS,但随机写反复擦除会消耗寿命,且延迟抖动比顺序写高一个量级。

所以存储优化的大原则:把随机I/O流改造成顺序I/O流。比如消息队列的主题消费进度、日志文件的追加写,天然是顺序的,你只要保证不走随机寻道,性能就能稳住。

如果业务里大量出现多路随机小IO,优先考虑把它们重新组织成批量任务,而不是单独放一个"高性能盘"了事。硬件再快也架不住你拿它当随机计数器用。

4.2 文件系统选型:ext4、XFS乃至新秀

文件系统选型影响很直接:

  • ext4:老牌稳妥,日志机制和延迟分配(delayed allocation)对中小业务足够,很多云盘的默认系统盘还是它,但元数据操作并发一高,性能下跌明显。
  • XFS:对大规模并发和大文件场景更从容,支持动态分配inode,IOPS密集型场景通常比 ext4 表现好。大数据平台的中间数据路径用 XFS 非常多。缺点是删除大目录、大量小文件时容易卡顿(现在好一些,仍要注意)。
  • 追求极端元数据性能或快照能力的环境,还可以看 btrfs、ZFS 这类会牺牲一部分读写吞吐、换取高级功能的选择。它们不是坏的,只是适用的抓手不同,不建议普通业务一上来就上 ZFS,管理复杂度会反噬你。

我再给一条小规律:大量小文件、低并发写场景,ext4 好调也好懂;高并发流式读写、容量很大、数据不经常删除的场景,XFS 不容易失误。两者切换时注意先确认内核文件系统驱动版本,避免高版本特性导致的兼容沟。

4.3 内核I/O调度器:默认值不一定适合你

Linux 内核里按设备类型有不同I/O调度器。机械盘老系统常用cfq(现在不少改成mq-deadline或bfq),NVMe SSD 一般用none(即 noop)。

  • NVMe 设备:用none就好,因为设备自身队列已经几十层,内核再排队只是添乱。
  • 机械盘:mq-deadline对读写延迟的平衡更合理,bfq适合多用户共享盘场景,能防长尾延迟。
  • 云磁盘、分布式存储映射出来的块设备:通常选none或mq-deadline,和上层虚拟化栈配合得更好。

查看和修改调度器方式:查/sys/block/<盘符>/queue/scheduler,临时改直接echo进去,永久改要配合你的启动脚本。这个小调整有时候立竿见影——某次压测一台老机器,IOPS 没变,但 P99 延迟从 80ms 掉到了 30ms,就因为从 cfq 换到了 deadline。

5. 应用层套路与I/O规约:最有效的优化往往在代码里

5.1 缓冲、批量和聚合:I/O优化的三板斧

无论内核参数怎么调,应用层不良代码都能把I/O拖垮。最常见优化手段总结下来就是三招:

  • 缓冲:写入时,攒够一个合理大小(比如 4KB、64KB)再发出。读时,按块预读到内存,别一行一行read()。
  • 批量:能用writev()合并的多个小内存段,就合并成一次系统调用;能用一次pread()读多个不连续块的,就传一个 iovec 数组。
  • 聚合:把同类型的小文件周期性地合并成大文件,减少元数据操作。典型如日志按天归档、小图片合成雪碧图。

5.2 同步fsync策略:什么时候必须落盘

fsync()是最贵又最难避免的操作。它强制把文件相关的脏页全部刷到持久化介质,等返回值时才算真写完。开玩笑地讲,每次调用fsync,你都在摇晃磁盘说"给我一个确定性的承诺"。

业务上要区分两类操作:

  • 可丢失类(如缓存、临时加工结果、可重建数据):完全可以不fsync,顶多靠后台回写兜底。
  • 关键事务类(如订单记录、账务流水、数据库日志):丢一条都出事故,必须在持久化语义上给足确定性。

处理关键事务但又不希望每次操作都全量 fsync 的技巧是组提交(group commit)。把fsync的调用攒到一个周期里,一批完成多个事务的持久化要求。很多数据库的WAL刷盘就是这么干的。

常见误区:我见过把fdatasync和fsync用混的。前者只刷数据内容(不含文件属性如大小、时间戳),后者连元数据一起刷。如果只关心文件内容,用fdatasync能省一部分元数据写开销,别每次都杀鸡用牛刀。

5.3 异步I/O:从线程池到io_uring的演进

传统"阻塞式"写文件会让线程卡在write()上。处理并发I/O的经典套路是线程池:开几十个线程各自阻塞在自己的I/O上。这套模型成熟,但线程太多时,上下文切换和内核栈占用会拖累吞吐。

Linux 的io_uring算是新一代异步接口:提交一组I/O请求,然后一次系统调用等待一批完成,极大减少了系统调用次数,且支持缓冲区和预注册的高级玩法。但它的问题是API稍微复杂,编程模型从"同步思维"改成"提交-收割",对已有代码改动大。

我在一个消息队列的存储模块里试过用 io_uring 重写文件读写路径,延迟数据没差多少,但相同QPS下机器CPU用量降了 20%。值不值得做,要看你是否真的被系统调用开销卡住。业务代码还顶着大数据量在复制内存的话,先别急着上 io_uring。

6. 排查与量化:说优化之前,先学会算账

6.1 工具链:iostat、strace、perf怎么配合

优化要有证据,证据靠数据。我的标准排查流程:

  1. iostat -x 1看设备层:%util不能只看它高不高——SSD 100% util 未必是瓶颈,可能队列深度才一点点。关键是看r_await、w_await和aqu-sz(平均队列深度)。等待上去了,才说明设备层拥堵。
  2. strace -c -p <pid>挂上进程,统计系统调用分布。如果write、fsync次数巨大,那说明应用层调用粒度太碎。
  3. perf top或perf record看热点。如果热点在内核filp_close、ext4_do_update_inode,说明元数据或文件开关开销过高;如果热点在你自己的内存拷贝函数,说明数据复制路径太长。
  4. vmstat 1看bo(块设备写)、cs(上下文切换)、wa(CPU等待I/O)指标,跟基线对比。

要特别注意:fio工具做基准测试时,要把rw、bs、iodepth设成贴近你真实负载的模式,别一上来就rw=randrw bs=4k iodepth=64,测出来一堆数字跟业务完全不搭,除了好看没用。

6.2 实战复盘:一次日志模块优化记录

举一个我最近带了两三个小朋友做的例子。某个内部服务,日志模块每天大几十GB文件,但经常卡,排查结果如下:

  1. 第一步iostat:设备w_await高达 300ms,但写入量只有每秒十几MB,显然不对。
  2. 用strace挂上发现,它处理每条业务请求要写一行日志并调用一次fsync——这是故意保留的功能,为的是确保日志不丢,但代价是完全不可接受。
  3. 然后我把日志分类:业务审计类日志必须落盘保证不丢;调试型日志允许丢。然后把fsync频率降到每 500ms 一次(组提交),并在fdatasync和fsync之间选了前者。
  4. 改完后再看w_await稳定到了 5ms 以下,整体吞吐反而涨了不少,因为不再互相卡。

这个案例里我什么都没改硬件,只动了调用模式和持久化语义,整体收益就是两个量级。很多时候,你以为在优化磁盘,其实是在优化代码逻辑。

6.3 长效监控:别等线上出事才看指标

I/O问题通常不是瞬间爆炸,而是积累出来的。建议长期记录以下指标,并做成简单的趋势图:

  • 系统级:设备I/O吞吐、IOPS、await、队列深度、脏页比例、内存回收速率。
  • 进程级:各主要进程的read_bytes、write_bytes、cancelled_write_bytes,这些在/proc/<pid>/io里都有。
  • 业务级:写延迟P99、fsync次数、系统调用次数。

争取做到每次发布前跑一轮压测,把上面参数基线存下来。后面线上I/O波动,直接对比基线,定位链条会快得多。

最后再分享一点我个人体会

文件I/O这个坑,永远不在单点技术。很多人上来就调内核参数、换文件系统,折腾一圈,最后发现是应用里逐行write造成的。我自己的步骤永远是:先量化系统调用和硬件指标,再做最小改动,再验证收益。任何一次I/O调整,最好都能落到"减少了多少次系统调用""P99延迟降了多少""吞吐涨了多少"这样的具体数字上。

再给一条这几年越用越觉得值得的原则:所有写操作提前想好"丢不丢得起"。"丢得起"的数据,走缓存、走批量、走异步都不慌;"丢不起"的数据,单独设计持久化策略,别让高频非关键数据把关键数据的I/O路径给堵了。这条想清楚,比你记住任何内核参数都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询