写这个主题的念头来自前两天帮人看一起线上故障某个数据加工服务每到凌晨跑批磁盘IO就被顶到接近饱和正常业务查询也跟着遭殃延迟从几十毫秒飙到几秒。查了半天发现不是磁盘坏了也不是某个SQL锁竞争而是这批任务在短时间内写入了大量小文件把内存里的page cache灌满随后内核的回写机制集中开始清算脏页后台写压力叠加上前台业务读写直接把设备IO队列压死了。这类问题根子其实都在page cache的“回写机制”上。只要接触Linux存储、数据库、文件落地类的应用就绕不开这几个问题脏页什么时候开始落盘为什么有时候写文件很快但有延迟卡顿为什么设备写着写着IO就飚了这篇文章我打算把.page cache回写机制的完整链路拆一遍包括触发逻辑、水位计算、flusher线程的执行骨架、参数调优以及应用侧怎么配合这套机制写作更稳的代码。1. page cache 为什么必须“回写”内存和磁盘的度量衡差异在深入机制之前先把最基础的问题想清楚内核为什么不把写入的数据立刻交给磁盘而要攒在内存里根本原因是内存和磁盘的访问速度不在一个量级。内存随机访问是几十到上百纳秒级别而一块普通NVMe SSD的随机读写也要几十微秒到上百微秒机械盘更是到了毫秒甚至十毫秒级别。如果每次应用层写入都立刻固化到磁盘那吞吐量上限直接被设备IOPS锁死。举个直观的对比一个进程在内存里连续写一页4KB数据几乎是瞬间完成但要把这一页刷到机械盘上光电机的寻道和旋转延迟就可能花去几十毫秒。一个跑批任务动辄写几十万个小文件如果每个文件都触发落盘整体时间会长得让人无法接受。page cache的缓冲说白了是在拿“掉电丢数据风险”和“写放大代价”换“写入速度”。应用把数据写进页缓存后这页数据就被标记为脏页dirty page内核记录它跟磁盘上的原始数据不一样。后续刷盘时机由内核统一调度尽量把相邻的、逻辑上相近的脏页合并成更大的写请求再提交给块设备层。这里有一个很关键的工程取舍写回不是越快越好也不是越慢越好。想理解一会儿要聊的水位阈值就得先接受两个结论回写太激进每个写请求被立刻固化的效果跟开同步写差不多内存缓冲的价值就没了磁盘IOPS被无谓消耗写放大严重。回写太拖延脏页不断累积虽然应用看起来写得飞快但一旦系统掉电全部未落盘的数据都会丢失另外脏页过多还会在某个瞬间触发强制回写造成前台业务的突发卡顿。所以内核的调度策略不是“攒够多少刷多少”这么简单而是综合了周期扫描、脏页占比水位、旧页年龄三个维度尽量把落盘做得平缓又及时。这些维度背后的实现细节下面几节挨个展开。2. 回写机制的执行骨架flusher线程、工作队列与inode状态机最早我学这块的时候也有个误区以为脏页回写是几个“定时器线程”遍历所有内存页去刷。实际上现代内核早就不是这个思路了整个回写的执行单位是per-BDI的flush线程配合一套工作队列和inode状态机。2.1 backing_dev_info 与 per-BDI flusher每个块设备或者支持回写的文件系统后端在内核里都对应一个backing_dev_info结构简称BDI。bdi下挂一个bdi_writeback缩写wbwb再绑定一个工作队列执行实体就是你经常在top或ps里看到的形如flush-8:0这类内核线程。8是主设备号0是次设备号代表某个具体设备/分区的flush线程。这套设计比老的pdflush改进在哪儿我放到后面历史演进那一节细讲。先把现在的链路记清楚脏页产生后所属文件系统的inode会被挂到对应wb的b_dirty链表上。当满足某种触发条件内核通过wb_start_background_writeback()或wb_start_writeback()往wb的工作队列里塞一个wb_writeback_work任务。工作队列线程执行wb_workfn()从链表上取inode把脏页生成bio提交给通用块层。也就是说所谓“回写”本质上是一个由工作队列驱动的异步任务后台慢慢消化脏页。2.2 inode状态机与链表迁移了解inode状态机之前先记住几个位标志位I_DIRTYinode本身有变更或者有数据页脏。I_DIRTY_PAGESinode的页缓存里有脏页。I_DIRTY_SYNC/I_DIRTY_DATASYNC元数据相关分别对应fsync和fdatasync需要关心的部分。I_SYNC这个inode正在被回写防止并发重复刷同一份数据。inode在wb里不是固定待在一个链表上的。默认情况下脏inode先挂在wb-b_dirty之后会被移到b_io开始真正的数据提交。如果发现这个inode还有新产生的脏页或者上一次刷写超时它会被摘下来挂回b_more_io下一轮优先继续处理。还有一个b_wb_error链表专门挂那些回写过程中写入出错的inode后续会统一上报错误。为什么要搞出这么多链表核心目标就是“公平顺带合并”。回写一个inode时会尽量把它所有的脏页都写完同时尽量顺序扫描避免随机跳文件否则一会儿刷A文件的前几页一会儿刷B文件中间几页底层IO全是碎片。状态机就是为了在一次扫描里尽可能连续处理同一个文件同时不饿死其他inode。2.3 一次性刷多少MAX_WRITEBACK_PAGES回写不是无限连续刷的。内核定义了MAX_WRITEBACK_PAGES默认值是1024页也就是通常一次循环最多尝试写4MB左右的数据假设基础页4KB。刷完这批就回到工作队列重新调度看看还有没有其他pending的work避免让某一个大文件的回写霸占flush线程太久。这个4MB的“批大小”是个很务实的折中太小设备利用率低太大长时间霸占写IO会影响前台应用。所以真实场景里你往往会看到flush线程在写IO上有节奏地波动而不是一口气把几GB脏页全部砸给磁盘。2.4 回写结束时发生了什么当一批写请求提交给块层后flush线程会等待bio完成然后逐页清除对应的脏标记回收或保留page cache承载页通常保留缓存供后续读命中。如果写过程中遇到设备错误相应inode会被丢到错误链并通过writeback_sb_inodes_requeue之类的路径重新排队同时内核也会把IO错误上报给文件系统层处理。这也是为什么回写慢的锅不能全甩给“磁盘坏了”得先看是不是脏页洪水把块设备队列打满。3. 三类触发回写的路径以及脏页水位的完整计算链路脏页不会平白无故被刷内核给回写设计了多种触发源。从触发方式上可以分成三大类主动同步、后台周期性、内存水位触发。3.1 主动同步类sync / fsync / fdatasync这类回写是应用自己发起的。sync系统调用会要求所有文件系统把所有脏数据和元数据都刷盘fsync针对某个文件描述符确保该文件的数据和必要的元数据都落盘后才返回fdatasync则聚焦数据本身不会强制刷新仅影响“大小/时间戳”之外的无害元数据性能通常比fsync稍好。这类调用走的是WB_SYNC_ALL语义提交所有脏页给块层并且等待落盘完成才给用户空间返回成功。代价是调用线程会被阻塞直到真正完成持久化。对于数据库、消息队列这类强一致场景fsync无可替代。但很多业务代码滥用这个调用比如一个小文件写完就立即fsync一次这类高频同步写对回写机制的压力很大后面调优部分会详细聊。3.2 后台周期性回写dirty_writeback_centisecs 与 dirty_expire_centisecs内核里的wb_check_old_data_flush()负责周期扫描。默认情况下每5000毫秒通过/proc/sys/vm/dirty_writeback_centisecs控制醒来一次检查是否有“老”脏页——脏页存在超过dirty_expire_centisecs默认值3000也就是30秒的页面就会被主动放到回写队列里刷掉。这个设计的目的是保证脏页不会“永远脏下去”。哪怕系统写入量很小长期不触发水位线30秒后那批老页也会被定时清理把掉电损失窗口控制在一个相对稳定的范围内。3.3 水位触发类dirty_background_ratio 与 dirty_ratio这是回写机制里最核心、最容易引起故障的部分。当脏页数量超过某一阈值内核会触发大规模回写。阈值有两个dirty_background_ratio默认10%。超过这个水位后内核唤醒后台flush线程开始异步回写。这个阶段应用进程仍然可以继续往page cache里写入不阻塞。dirty_ratio默认20%。超过这个水位就严重了内核会强制触发同步回写表现为哪些进程在继续产生脏页哪些进程就会在写系统调用里被卡住直到脏页水位降回dirty_ratio以下才放行。这个机制叫balance_dirty_pages()里的throttle逻辑。这两个比例相对于MemAvailable自由内存或者总内存的占用来计算更精确的计算函数是global_dirty_limits()。3.4 全局限额怎么算global_dirty_limits 与 bdi_dirty_limit水位计算不能简单用“总内存×10%”来理解。global_dirty_limits()内部会考虑多个限制因子硬上限基于总内存的一个百分比上限避免脏页完全吞掉可用内存。软上限基于可用自由内存的比例限制脏页与可回收页的总体占用。实际比例dirty_background_ratio/dirty_ratio配置的百分比。计算出的两个核心值一个是dirty阈值严格水位超了就阻塞进程一个是background阈值温和水位只唤醒后台回写。这两个值虽然都叫“ratio”但它们是基于不同口径算出来的不能简单认为就是“内存的10%”。在内核实现里还有对各种极端场景的夹逼处理既要保证至少留下固定量的自由内存给紧急分配也要防止阈值低到一写就撞线。拿到全局限额之后还要把额度分配给每个BDI。bdi_dirty_limit()会根据这个设备的bdi权重按比例分配它可占用的脏页额度。设计逻辑很简单一个设备如果长期只有少量I/O活动就不该被全局其他设备的脏页洪水一起拖入throttle状态。3.5 进程被卡住的幕后balance_dirty_pages 和 position ratio当脏页总量超过严格水位时还在持续写page cache的进程会在balance_dirty_pages()函数中被“按比例控制写入速度”而不是直接一刀切全部阻塞。内核引入了dirty_ratelimit动态调节机制每次进程尝试写入页缓存前先检查全局脏页总量假如超限就计算一个paused时间让进程睡一会儿等后台回写将水位降下来再继续。这个“休眠时间”不是固定的会随着距离水位的远近动态调整距离越远sleep越久。这套机制比单纯“超过就卡死”平滑很多。好处是回写高峰时进程只是被降低写入速度不会把所有线程瞬间冻住——所以你会看到高水位时应用延迟上升、但系统整体还能缓慢推进而不是完全Hang住。4. 从 pdflush 到 per-BDI flusher这二十年里回写机制经历了什么回写框架不是天生现在这样早期内核的实现比较简单粗暴。了解这段历史对理解为什么现在代码长成这样非常有帮助。4.1 pdflush全局共享的“救火队”在2.6内核相当长一段时间里脏页回写由pdflush机制负责。系统初始会创建若干pdflush内核线程数量可动态调整它们不绑定具体设备谁有空谁就处理当前最紧急的回写任务。这个方案的隐患是明显的全局几个线程要服务所有块设备。如果其中一台设备特别慢或者某个文件系统卡在IO上pdflush线程就可能全被占用其他设备的脏页只能干等着。用现在的话说就是“爆单”之后连调度平台本身都被拖死。此外全局共享有一定的锁竞争和状态耦合在大量并发写多设备场景下扩展性非常差。4.2 per-BDI flusher按设备隔离的关键改造2.6.32内核引入了per-BDI flusher框架也就是现在你看到的flush-8:0这类线程。每个备份设备BDI拥有自己独立的flush线程和工作队列设备之间互不拖累。慢设备只拖累自己快设备该刷照刷。内核还顺手做了一件事把原本基于内核线程的实现迁移到了通用工作队列框架上跟kthread说再见。迁移的意义在于工作队列允许更优雅的并发调度、错误处理也为后续cgroup的支持铺平了路。4.3 concurrency-managed workqueue把flush线程还给框架现在的flush线程并不是简单的循环线程而是一个运行在工作队列里的wb_workfn()任务。工作队列框架自己管理线程的创建、销毁和并发控制。没有回写任务时这个线程根本不在运行需要时才会被唤醒。这种设计最大的红利是省电和资源占用。服务器上几十块磁盘如果每块都由常驻内核线程守着哪怕完全空闲也在消耗调度和栈空间改成workqueue后空闲状态下几乎没有成本有任务了再唤醒。4.4 cgroup writeback把公平带到容器时代容器和cgroup普及之后回写机制又面临一个新问题同一个根文件系统、同一个BDI上可能跑了几百个容器。如果一个容器疯狂写脏页占满了整个BDI的脏页额度其他正常容器写入时就会被balance_dirty_pages()一起拖慢形成典型的“噪声邻居”问题。后来内核引入了cgroup writeback给每个cgroup都维护独立的bdi_writeback和回写状态。每个cgroup有自己独立的脏页限额、独立的水位调节与回写队列。这样即使某个容器把自家水位打爆也不会危及其他容器。但注意cgroup writeback只在块设备IO直接由cgroup页缓存驱动的场景下生效如果文件系统通过叠加层类似的方式间接提交IO情况会复杂很多。这段演进主线概括起来就是四个词隔离性、公平性、低功耗、可扩展。回头看当初那些设计决策会发现每一版改动都是被真实的生产环境问题逼出来的。5. 调回写参数之前先学会观测脏页状态回写参数在/proc/sys/vm/下改起来很容易但最怕的就是“不看状态就乱改”。所以先列几个动不动就上手的观测手段。5.1 最轻量的状态入口/proc/meminfo 与 /proc/vmstat这两个文件几乎每个系统运维都看过但“Dirty”“Writeback”两行经常被忽略。grep -E Dirty|Writeback /proc/meminfo输出里的Dirty是当前未回写的脏页总量Writeback是正在向磁盘提交中的页量。如果Dirty长期维持在高位说明后台回写速度跟不上写入速度如果Writeback高说明块设备队列已经饱和回写请求排队严重。/proc/vmstat里的nr_dirty、nr_writeback同样对应这两类页但数值单位是页4KB/页可以直接换算字节。观测时建议每秒采集一次看是否有周期性脉冲如果脏页量有节奏地爬升然后瞬间下降说明后台回写被水位周期触发是正常现象如果脏页永远降不下来那就是问题了。5.2 块设备层的真实压力/sys/block/ /stat回写最终都要落到块设备上所以块设备统计是绕不开的。/sys/block/sda/stat早期是/proc/diskstats里里有很多字段最常用的是sectors完成的扇区数。ios完成的IO次数。io_ticks设备忙碌的jiffies数。计算io_util设备利用率可以简单地用iostat -x输出里的%util如果持续在90%以上说明设备请求队列接近饱和。此时配合iotop看是不是flush-8:0线程吃掉了一大块写带宽基本就能判断回写压力是否是主导因素。5.3 更细粒度tracepoint 事件如果想知道内核某个时刻在刷哪些inode、刷了多少页最快的办法是打开writeback的tracepoint。echo 1 /sys/kernel/debug/tracing/events/writeback/enable cat /sys/kernel/debug/tracing/trace_pipe这组tracepoint里有writeback_start、writeback_end、writeback_queue、writeback_sb_inodes_requeue、balance_dirty_pages等事件。看到balance_dirty_pages频繁出现说明前台任务正在被限制看到writeback_queue里大量任务堆积说明后台回写严重积压。对生产环境不想长期开着全量trace的话用perf临时挂一下或者bpftrace写个小脚本都行。核心思路是先确认高水位阶段是否存在阻塞再确认阻塞是前台throttle还是块设备队列排队。5.4 一个典型的排查链路还原分享一个通用排查手法。假设你发现服务高峰期IO飙满先看iostat -x 1记录哪个设备%util高、读写带宽多少。再iotop -o看是哪个进程在写。如果发现flush-8:0的IO占用远超业务进程基本就可以把注意力放到回写机制上。看cat /proc/meminfo | grep -E Dirty|Writeback如果Dirty接近dirty_ratio对应水位那基本断定是水位触发回写。看vmstat 1里的bo列确认回写导致的块输出是否在持续。最后通过tracepoint确认是否存在频繁balance_dirty_pages。如果存在则问题就在内核强制throttle业务进程。到这一步修复方向就清楚了要么降低dirty_background_ratio让后台更早刷要么让业务降低写入频率要么用O_DIRECT绕过page cache要么增加写带宽比如换盘、raid条带化。5.5 参数怎么调是对的先给一张默认值与效果对照表避免一会儿查文档参数默认值单位/语义典型影响dirty_writeback_centisecs500百分之一秒周期唤醒间隔越小唤醒越频繁越及时dirty_expire_centisecs3000百分之一秒脏页最大年龄越大老页在内存停留越久dirty_background_ratio10百分比后台回写水位越小越早开始后台刷dirty_background_bytes0字节式后台水位与ratio互斥大内存机器更好用dirty_ratio20百分比同步阻塞回写水位越小越容易卡前台dirty_bytes0字节式严格水位与ratio互斥大内存机器更好用调优原则可以总结成三句话dirty_background_ratio一定要保持在dirty_ratio之下留出缓冲空间让后台回写能平稳接住洪峰。如果两个值相等后台刚干前台立马被卡尖峰波动区域会让系统很难受。大内存机器上优先考虑dirty_background_bytes和dirty_bytes。比例针对的是整机内存8GB和512GB机器同样10%对应的绝对脏页量差异巨大。比如512GB内存机器10%是51GB脏页20%就是102GB显然太高了要么卡出天际要么掉电丢一堆数据。这种场景通常会把dirty_background_ratio调低到1%-3%dirty_ratio调到5%-10%或者在应用层用O_DIRECT绕开。机械盘还是SSD策略也别一样。机械盘的随机IO成本高脏页累积过大导致一次性刷大量散页IOPS直接爆炸SSD对随机写更宽容可以稍微容忍更多的后台缓冲。但SSD也有写入放大的问题频繁小批量回写反而更伤寿命所以要让内核尽量把IO合并得更大一些。6. 这套机制对应用层程序设计的三点启示最后想聊点应用侧的心得。搞懂page cache回写不光是运维调内核参数对写代码同样影响很大。这里是我在几个模拟项目和实际排障里沉淀下来的几点经验。6.1 高频fsync是最容易踩的坑很多应用为了保证数据不丢会在每次写操作后立刻fsync。这个习惯在机械盘时代杀伤力极大每调一次fsync就可能意味着一次磁盘的完整提交周期秒级吞吐掉到个位数。换到SSD稍微好点但依然有写放大和FTL固件开销。我的建议是能合并就合并。比如攒一批记录每1000条或者每100ms做一次fsync而不是逐条调。对需要严格持久化顺序的场景比如WALfdatasync已经够用不要无脑用fsync。实测中把某个样例写流程从每条fsync改成批量fdatasync同等数据量的磁盘IO次数能下降一个数量级整体耗时缩短一半以上。6.2 利用sync_file_range做预写回如果应用知道自己接下来会有一大段数据要落地可以使用sync_file_range()系统调用提前把某段文件范围标记为“脏页需要尽快写出”。这有点像给内核打招呼我后面要大批量写你先把这个范围内的旧脏页刷出去让我后续写入时有更多空闲page cache可用避免某一瞬间把水位冲破。典型用法sync_file_range(fd, offset, len, SYNC_FILE_RANGE_WRITE);注意这个调用不等待落盘完成它是异步提交写出的真正等落盘要用SYNC_FILE_RANGE_WAIT_BEFORE/WAIT_AFTER配合。设计得当的话能把脏页压力从峰值打平到整个写入过程大大降低振荡。6.3 O_DIRECT不是万能解药绕开page cache的办法是O_DIRECT。它让数据直接从用户空间缓冲区交到块层不走页缓存因此也不参与dirty水位计算。代价是读写对齐要求高缓存复用的好处也没了。对小随机IO场景O_DIRECT的收益往往并不明显因为每次访问都要穿透存取几乎没有典型通过page cache聚合的机会。但对大文件顺序读写、或者对落盘时延有硬性要求的场景比如某些数据库的redo日志O_DIRECT反而更可控。我的经验是先度量再选择。用perf stat观测这page cache命中率和回写阻塞次数比凭感觉选IO路径靠谱得多。写在最后的体会回写机制的调优和程序侧改造本质上都是在“数据安全”“写入性能”“设备寿命”三者之间找平衡点。没有放之四海而皆准的参数每台机器、每个业务的特征都不一样。这些年我接触到的典型问题一半以上是“脏页水位设置太激进”加上“应用层写模式不合理”叠加出来的。先把观测手段准备好再动参数才能让这套机制真正为你所用。调参本身不复杂复杂的是理解你的业务有多能扛延迟、你的设备到底有多少余量。
阅读完成 · 觉得有帮助?