从write到磁盘:Linux页缓存、脏页回写与IO调度全解 从write()到磁盘之间远比你想的复杂。很多人在学 Linux IO 的时候一开始接触的是标准 C 库的fwrite、fread后来又知道有write、read系统调用到第三篇往往有一个巨大的困惑系统调用把数据交给内核之后究竟是发生了什么事情数据是立刻就写到硬盘上了吗如果不是那内存里的数据什么时候才会落到磁盘这篇内容就是来解决这个问题的。适用对象是那些已经知道文件描述符、明白write()会触发系统调用、但对内核级缓冲区页缓存脏页回写磁盘 I/O 调度这些概念还处于一知半解状态的人。学完之后你至少能做到三件事第一能完整讲清楚一次write()返回之后数据到底经历了哪些环节第二能解释为什么程序突然断电不会丢数据、但服务器突然断电可能会丢数据第三在线上 IO 性能出现问题时知道从哪些内核参数和指标入手排查。1. 从用户态到内核态write()背后的一整条链路1.1 先搞清楚一个关键问题write()返回等于数据落盘吗直接说结论不等于。我们平常写代码用的write(fd, buf, count)是系统调用它做的事情是把用户空间的一块内存数据拷贝到内核空间的页缓存page cache里然后返回。返回值表示的是拷贝了多少字节到内核缓冲区而不是磁盘上已经写了多少字节。这个过程可以理解成你写文章时先在文档编辑器里打字编辑器帮你把内容保存在了本地草稿箱里。你看到已保存的提示其实内容还没被打印到纸上。真正由后台任务把草稿内容提交到最终介质的过程是内核的回写机制writeback负责的。所以write()返回得很快因为内存拷贝是纳秒到微秒级的操作而真正的磁盘写入是毫秒级的。内核把这两者解耦开了操作系统才能扛住高并发 IO 场景。1.2 页缓存Linux IO 性能的灵魂所在页缓存是内核管理文件数据的一块内存区域基本单位是页page通常是 4KB。当你第一次读取一个文件时内核把磁盘上的数据块读入页缓存之后读同样的文件就直接从内存命中不再碰磁盘这就是为什么连续读一个文件会越读越快。写路径则是反过来write()修改或者新增页缓存里的数据这些被修改的页面会被标记为脏页dirty page表示内存里的数据和磁盘上的数据不一致了。脏页并不马上刷盘而是等待合适的时机统一写入磁盘。这套机制背后是典型的局部性原理应用读写的热点数据都尽量留在内存里。没有页缓存的话每次write()都要直接寻道、写盘性能会下降两三个数量级。数据库这类应用往往使用O_DIRECT绕过页缓存直接落盘或者使用fsync主动刷盘正是因为在高一致性要求场景下页缓存这个中间层反而成了隐患——你根本没法确定数据到底什么时候真正进了磁盘。1.3 缓冲区 vs 页缓存你们说的缓冲区可能不是同一个东西写代码时很多人会把标准 C 库的缓冲区、内核页缓存混为一谈这是面试里最高频的混淆点之一。简单梳理一下三层关系标准 C 库缓冲区stdio层在用户空间受setvbuf控制默认全缓冲或行缓冲。fwrite写到这里可能还没进内核。页缓存page cache内核空间write()系统调用的目的地上一小节说的内容。磁盘控制器缓存硬件层面磁盘自己的 DRAM 缓存通常在 16MB 到 256MB 之间。数据哪怕到了磁盘的写缓存里也可能还没真正落到盘片上。所以一次fwrite(hello)的完整经过是这样的先进入标准 C 库缓冲区然后由fwrite内部调write()进入页缓存之后由回写机制把脏页传给磁盘控制器最后磁盘控制器决定什么时候真正写入盘片。你们可以看到中间是四层接力。每一层都是为了性能但也每一层都是丢数据的潜在风险点这就是为什么数据库必须要用fsync把数据从最外层一路推到最底层。2. 脏页与回写数据到底什么时候真正落盘2.1 内核参数脏页占比不是越多越好既然脏页不会立刻落盘那就必须有一套机制决定什么时候刷、刷多少。Linux 内核里由pdflush老内核叫这个名字或flush-x线程新内核负责这个任务。相关参数在/proc/sys/vm/下核心有四个参数默认值作用dirty_ratio20脏页占全部可用内存的百分比达到这个值时发出写请求的进程会被阻塞主动开始刷盘dirty_background_ratio10脏页占可用内存的百分比达到这个值时后台内核线程开始刷盘不阻塞业务进程dirty_writeback_centisecs500后台刷盘线程唤醒的频率单位是百分之一秒默认 5 秒唤醒一次dirty_expire_centisecs3000脏页在内存中的最大存活时间默认 30 秒超过时间就必须刷盘这里有一个很容易被忽略的要点dirty_ratio触发的是同步阻塞也就是写进程被卡住等脏页降到阈值以下才继续写。所以如果你的业务突然出现周期性写卡顿可以用vmstat观察bo块设备写入和等待进程数然后检查脏页占比是否频繁触及上限。我见过一次典型的案例一个日志采集服务配置了 16GB 内存的机器默认dirty_ratio是 20也就是说脏页可以累积到 3.2GB 才触发阻塞刷盘。当大量小文件写入时内存里积攒的脏页很容易冲到 3GB 以上然后一次性触发同步刷盘每次卡顿持续好几秒日志采集出现明显延迟尖刺。把dirty_background_ratio调低到 5、dirty_ratio调低到 10 之后后台刷盘线程会更勤快地干活写阻塞的尖刺基本消失了。2.2 回写触发时机不仅仅是满了才刷内核刷盘不是单纯看脏页额度实际有三类触发路径路径一后台周期性回写。dirty_writeback_centisecs指定的周期到了唤醒刷盘线程检查有没有超过dirty_expire_centisecs时间上限的脏页如果有就刷掉。这是兜底机制保证即使系统一直很空闲脏数据也不会无限期留在内存里。路径二比例触发回写。脏页比例超过dirty_background_ratio时启动后台回写超过dirty_ratio时启动同步回写。这是高负载场景下最主要的回写触发方式。路径三内存压力触发的回收。当系统内存不足需要回收页面时内核会优先回收干净页而不是脏页——因为干净页直接丢弃就行脏页还得先写回磁盘才能复用。如果内存压力太大内核会把脏页也回收到磁盘上这个过程往往伴随着明显的 IO 波动也就是为什么 swap 或内存紧张时磁盘反而更忙。2.3 同步落盘fsync、fdatasync、sync的语义差异如果你需要确保数据已经落到磁盘再继续必须调同步刷盘接口。sync()把所有修改过的文件系统缓冲区排入写队列。注意是排入不等候完成所以很多人误以为 sync 完就安全了其实未必。fsync(fd)把指定文件相关的所有脏数据和元数据文件大小、修改时间等刷到磁盘并且等待完成。代价是每次调用至少一次甚至多次磁盘 IO。fdatasync(fd)类似fsync但只刷数据不刷对读不重要的元数据比如 mtime。因为文件元数据通常和文件数据不在同一个磁盘位置跳过元数据刷写能省一次寻道在高频小文件场景下性能差距明显。这里补充一个细节fsync之后数据也不是百分百安全磁盘控制器缓存那一层还需要处理。一般的 SATA 盘你没办法直接控制它除非盘本身开启了掉电保护或者你用hdparm -W关闭写缓存不过这个操作在服务器环境下慎用性能损失很大。这也是为什么企业级 SSD 强调掉电保护特性它们在电容支持下的确能把缓存里的数据真正保住。提示数据库的 WAL 机制本质上就是利用fsync来保证日志先落盘、数据后落盘的顺序一致性。这部分内容面试的时候经常被拉出来跟页缓存一起问一定要能串起来。3. 脏页刷出去之后磁盘 I/O 调度与排队哲学3.1 刷盘不等于直接写盘I/O 调度器的存在脏页被内核从内存刷出形成一批块设备请求bio。这些请求会先进入 I/O 调度器的队列而不是直接发给磁盘。调度器的作用是把一堆杂乱无章的读写请求重新排列组合目标是减少磁盘寻道、提高整体吞吐。不同调度器的策略差异很大noop近似于先来先服务适合 SSD 这种没有寻道成本、或者底层硬件已经自带智能队列的场景比如 NVMe。deadline每一个请求都有一个截止时间读请求的截止时间通常比写请求短默认读 1500ms写 3000ms。目的是保证读请求不会被大量写请求饿死。传统机械硬盘场景下这是个非常稳的选择。mq-deadline多队列版本的 deadline现代内核默认之一。bfq按进程分配带宽适合桌面系统或多人共享 IO 的场景能保证每个进程都有一定的 IO 时间片代价是整体吞吐低一些。查看当前磁盘的调度器命令是cat /sys/block/sda/queue/scheduler改的话直接echo deadline /sys/block/sda/queue/scheduler但重启会失效要持久化需要写到 udev 规则或内核启动参数。3.2 为什么顺序写和随机写性能差距那么大这其实是最直观面试题之一同样一块硬盘为什么顺序写每秒能写几百 MB随机写每秒只有几 MB原因就是寻道。机械硬盘的盘片在高速旋转磁头要移动到指定磁道再等待盘片转到指定扇区这个操作叫寻道 旋转延迟。顺序写的场景下磁头基本不需要大幅移动每次写都接着上次的位置吞吐自然高。随机写每次都要重新定位绝大多数时间都浪费在找地方上真正的数据传输时间占比很低。SSD 没有寻道概念但随机写也有自己的问题——写放大和垃圾回收。SSD 必须先擦后写而擦除以块为单位所以随机小块写入会频繁触发读-改-写操作产生额外的写入量。高层应用对 SSD 的随机写优化通常是把小 IO 合并成大 IO或者用日志结构的方式全部变成顺序写比如 LSM Tree 这类数据结构就是专门为解决随机写问题设计的。3.3 合并不是万能的writeback 和调度器的协作边界有一个很常见的误解认为页缓存里攒了很多脏页刷盘时内核会做合并所以写大量小文件也没关系。实际上合并发生在两个层面。一层是块层block layer多个相邻地址的 bio 可以被合并成一个大的请求。另一层是 I/O 调度器内部的排序。但如果你的数据本身就分布在磁盘各处比如你并发写 10 个不同位置的小文件内核再努力也无法把分散的地址合并成连续大块结果是大量随机写。所以应用层的 IO 模式仍然是决定性能的根本。页缓存和调度器只能锦上添花不能雪中送炭。这就是为什么像 RocksDB、LevelDB 这类存储引擎要自研合并策略把写操作先在内存里排序再批量顺序落盘。4. 实战观察页缓存行为与排查 IO 性能问题4.1 掌握三件套vmstatiostat/proc/meminfo真正排查 IO 性能问题先别急着调参先用工具确认现象。vmstat 1里重点关注si、so、bo、wa这几列。bo是块设备每秒写入的块数如果长期居高不下说明回写在持续发生。wa是 CPU 等待 IO 完成的时间占比如果频繁飙高说明很多进程在等磁盘IO 已经成为了瓶颈。iostat -x 1更细%util表示设备利用率注意这个数值对 SSD 来说参考意义有限await表示请求平均处理时间含排队时间svctm是实际处理时间。你排障的时候重点看await是否明显大于svctm如果相差很大说明排队严重可能是瞬时请求量太大或者调度器队列太长。/proc/meminfo里重点看Dirty和Writeback两个指标grep -E ^(Dirty|Writeback): /proc/meminfoDirty是需要写但是还没开始写的脏页总量Writeback是正在写回磁盘的脏页量。如果你发现Writeback长时间不为 0 或者经常飙升说明磁盘的写吞吐已经追不上脏页产生的速度了这时候就需要从源头降低写负载或者调整回写参数让刷盘更均匀。4.2 一次线上事故排查实录IO 性能突然下降这里分享一个我处理过的真实案例。一台跑采集任务的服务器突然接到告警说 IO 延迟暴涨平均响应时间从 5ms 涨到了 800ms。第一轮排查vmstat 1看到wa高企bo还好si正常。这说明不是 swap 引起的而是确实有大量 IO 请求堵在设备层。第二轮排查iostat -x 1看到sda的await很高但%util并没有到 100%。这就是个很有意思的信号——%util未满但await高往往意味着请求没有被处理而是在队列里等。等什么等锁或者其他进程占用了设备。第三轮排查cat /proc/sda/相关统计结合ps看到底是哪些进程在发 IO。最后发现是两个问题叠加一是采集程序在写日志时突然加了大量同步刷盘操作导致每个小日志都要 fsync二是另一个定时任务在做全量文件扫描把页缓存挤占得很厉害脏页大量积压后触发了同步回写。处理方式把日志写入改成批量提交去掉高频 fsync让页缓存走常规的回写路径全量扫描调整到业务低峰期。效果立竿见影IO 延迟恢复到原有水平。这个案例想说明的是IO 性能图景是数层叠加的结果页缓存、回写参数、同步策略、调度器、应用 IO 模式任何一环出问题都会表现为磁盘变慢但实际根源可能根本不在磁盘。4.3 参数调优的正确姿势先测基准再动参数最后做对比页缓存相关的内核参数不是越多越好的盲目调大的教训我见过太多。比如服务器内存 64GB有人觉得dirty_ratio调大到 50 能让写入更快结果脏页攒到 30GB 的时候一次同步刷盘窗口极长所有写进程全部卡住业务抖动惨烈。我的建议是调优之前先做一次基准测试。工具可以用fio至少测出你当前工作负载下的随机写/顺序写吞吐基线。然后按以下步骤操作记录当前参数sysctl vm.dirty_ratio vm.dirty_background_ratio每次只改一个参数改完跑一遍相同负载的 fio用iostat和/proc/meminfo观察刷盘是否更均匀Writeback高峰是否降低、await是否更稳定如果改动没有明显收益就回滚不要恋战另外特别提醒在数据库服务器上通常需要配合O_DIRECT或fsync使用所以调整脏页参数对数据库类应用的影响远小于对文件类应用的影响。别把一套参数从日志服务直接搬到数据库服务器上会出问题的。5. 高频场景串联从内核级缓冲区到一个完整的 Linux IO 知识网络5.1 这张知识图谱怎么应对面试题到了这一步你可以试着回答几道常见问题write()之后数据在哪里——在页缓存是脏页。什么时候落到磁盘——达到脏页比例血统到期或者通过fsync等主动刷盘。为什么不立刻落盘——性能。磁盘 IO 慢内存几个数量级每次写都落盘会拖垮吞吐。断电会丢多少数据——默认最多可能丢 30 秒dirty_expire_centisecs的写数据实际要看你写负载和刷盘频率。数据库为什么要自己刷盘——因为几乎不能容忍丢失必须保证已提交事务的日志已落盘。这几个问题就是一道很经典的连环追问链路从应用层一路问到硬件层。有了这篇的内容基础你基本能把每一层的数据结构、触发机制、性能瓶颈说清楚而不是停留在有缓冲区这种模糊答案上。5.2 顺带说一下Linux 磁盘管理的常见误区热词里出现了linux磁盘管理、wd磁盘参数不可用、磁盘处于脱机状态这些搜索词说明很多人是从磁盘管理的角度进入这个领域的。但我想强调磁盘分区、格式化、挂载其实是文件系统层面的操作跟本文讨论的内核级缓冲区虽然是上下层关系但关注点完全不同。你在fdisk分区或者mkfs.ext4格式化时操作的是块设备层。格式化会在磁盘上建立文件系统结构而文件系统在上层负责把文件的逻辑地址映射到物理地址映射之后的读写请求才走进块层再到块设备调度和磁盘驱动。页缓存属于 VFS 和文件系统之上的通用层。所以遇到磁盘参数不可用磁盘脱机这类问题一定先确认是在块设备层出故障还是在文件系统/驱动层出故障排查思路完全不同。5.3 慢 IO 问题长期运行后的性能劣化怎么处理热搜里有io性能明显下降了这个词这确实是运维最头疼的问题之一。长期运行的服务器IO 性能下降的常见原因有磁盘碎片机械盘场景导致寻道距离增加SSD 磨损不均死块多了需要反复重试文件系统元数据膨胀w_meta写放大变严重页缓存长期被某个进程霸占其他进程每次写都触发回收排这种问题不要上来就调内核参数先用工具确定到底哪一层劣化了。测试裸盘fio --direct1确认物理层是否还是线速再测文件系统层去掉--direct确认页缓存路径是否正常最后测具体应用。一层层剥下来90% 的IO 变慢都能定位到具体环节。6. 踩坑记录关于内核缓冲区那些文档不会告诉你的教训先说一个最经典的坑你以为write()完就安全了结果程序崩了数据丢了。这个问题高发于使用自定义缓存的应用。很多人自己写了个内存队列攒一批数据调用一次write()结果进程中途崩溃整个队列全没了。正确做法是在每次逻辑事务完成后调fsync或者对数据可靠性要求没那么高时也要明确接受最近几秒的数据会丢这个事实再做设计。另外一个坑是盲目开启O_DIRECT以为能绕过页缓存一定更快。O_DIRECT直接让数据绕过页缓存写往设备它的真实价值和代价是剥离的很多应用开O_DIRECT后发现性能反而暴跌因为每次写入都要等磁盘真正完成没有了内存积攒批量刷盘的缓冲效果。O_DIRECT的正确使用场景是已经设计好批量写的应用比如数据库的 redo log 写入一次写固定大块数据且能忍受同步等待。说到刷盘的周期性还有个容易被忽略的细节如果系统经常睡眠或者被节流dirty_writeback_centisecs的定时器也会受到影响导致脏页刷盘延迟拉长。笔记本上如果你设置了节能模式很容易出现合上盖子再打开发现文件写入比预期慢很多的现象就是因为在休眠期间脏页积压了唤醒后触发了一次大规模回写。最后一个值得反复体会的点性能和一致性在这个架构里始终是一对矛盾。页缓存越大、刷盘越懒性能越好但数据窗口越大每次操作都调fsync数据最安全但性能直线下降。写业务代码的时候你要明确自己的系统属于哪一类日志系统可以容忍丢几秒数据交易系统一秒钟都不能丢。把这一点想清楚之后再看 Linux IO 内核设计很多决策逻辑就顺理成章了。从write()到磁盘这条链路里每一层的设计哲学几乎都是同一个问题如何在性能和数据安全之间做出取舍。页缓存解决的是慢速磁盘和快速 CPU 之间的速度不匹配脏页回写解决的是批量刷盘和及时落盘之间的频率矛盾I/O 调度解决的是多个请求之间的磁盘访问优化。理解了这个主线Linux 基础 IO 的知识就不再是零散概念的堆砌而是一个可以预测行为和定位问题的完整系统。