想象你是餐厅后厨的管事。厨房中央有一张备菜台,大厨提前把常用食材洗净、切丝、分碟摆好。客人点宫保鸡丁,伸手一拿就能下锅;如果每道菜都要跑去仓库现取,后厨早就乱成一锅粥。这张备菜台,就是操作系统的页缓存。但有一类客人——比如讲究“从渔港直达餐桌”的日料店——要求食材不经任何中转,现捞现做。这就是 Direct IO。本文带你用备菜台的眼睛,重新理解两种 IO 方式:Buffered IO 如何借缓存加速,Direct IO 如何绕开缓存直达磁盘,以及页缓存背后的预读、写回、同步与对齐规则,最后看看 Redis 和 MySQL 这些“大牌餐厅”是怎么选型的。理解了这些,你在排查数据库慢查询、磁盘 IO 飙高这类问题时,就不再是瞎猜,而是能一眼看出是哪条“上菜路径”出了问题。

岗位关联

理解 Direct IO 与 Buffered IO 的页缓存策略,是诊断数据库与存储系统性能问题的基本功——面试官常考,线上事故排查更是离不开它。

目录
  1. 可视化演示
  2. 基本概念
  3. IO类型对比
  4. 页缓存机制
  5. 对齐与复制
  6. 同步策略
  7. 性能特性
  8. 实践应用
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

通过Buffered IO页缓存路径、Direct IO DMA直传路径、脏页写回管理三个模式,展示Linux内核IO数据流与页缓存策略的核心原理。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Buffered IO页缓存路径 缓存命中:数据从Page Cache直接复制到用户缓冲区 缓存未命中:数据从磁盘经Page Cache再到用户缓冲区 read() → check page cache → hit: copy / miss: disk I/O + copy 两次数据拷贝,命中延迟百纳秒级。

基本概念

一家餐厅的后厨,最忙的不是灶台,而是中央那张备菜台。大厨从仓库抱回猪肉,在备菜台上切丝、腌好、分碟。客人点菜时,厨师伸手就能拿到食材;若每次做菜都要跑去仓库现取,后厨早就乱套。这张备菜台,就是内核的页缓存,而仓库就是那块慢吞吞的磁盘。

计算机世界里,磁盘比内存慢几个数量级。如果每次读写都直接敲磁盘的门,再快的程序也会被拖垮。于是内核在内存里开辟一片区域,按 4KB 的“页”为单位缓存磁盘文件的数据。程序员调用的 read()、write() 并不是直接访问硬件,而是先陷入内核态,让内核这位“大厨”替我们与磁盘博弈。

这里有两个基础概念要分清。第一,页缓存(page cache)位于内核空间,以页为单位组织,缓存的是磁盘块的数据副本。第二,read()/write() 是系统调用(system call),它让 CPU 从用户态切换到内核态,由内核完成查缓存、读磁盘、复制数据等操作。用户程序永远不能直接碰磁盘,只能通过这道“点菜窗口”来请求服务。

备菜台的价值在于:大多数时候客人的口味有规律可循,提前备好的菜能瞬间出餐。页缓存的意义完全一样——它把“慢”的磁盘访问,变成了“快”的内存复制,只要命中率够高,程序就能跑得像飞一样。

用户进程→read() 系统调用内核→查询页缓存{命中: 直接复制未命中: 读磁盘入缓存\text{用户进程} \xrightarrow{\text{read() 系统调用}} \text{内核} \xrightarrow{\text{查询页缓存}} \begin{cases} \text{命中: 直接复制} \\ \text{未命中: 读磁盘入缓存} \end{cases}
一条读请求从发出到拿到数据,总共就两步:先查备菜台,没有再去仓库搬。
  1. 发起系统调用用户进程调用 read(),把文件描述符、缓冲区指针和读取长度交给内核,请求从指定文件读取数据。
    read(fd,  buffer,  count)\text{read}(fd,\; \text{buffer},\; \text{count})
  2. 陷入内核态CPU 从用户态切换到内核态,程序失去直接访问硬件的能力,由内核代为执行。
    用户态→系统调用内核态\text{用户态} \xrightarrow{\text{系统调用}} \text{内核态}
  3. 查询页缓存内核根据文件偏移计算目标页号,在页缓存中查找是否已缓存该页。
    页号=⌊offset4096⌋\text{页号} = \left\lfloor \dfrac{\text{offset}}{4096} \right\rfloor
  4. 命中或读盘命中则把缓存页复制到用户缓冲区;未命中则先发磁盘请求读入缓存再复制。
    磁盘→DMA页缓存→copy用户缓冲区\text{磁盘} \xrightarrow{\text{DMA}} \text{页缓存} \xrightarrow{\text{copy}} \text{用户缓冲区}

IO类型对比

同样是炒菜,不同餐厅哲学不同。中餐中央厨房提前备菜、统一分发,出餐快,这叫 Buffered IO;高端日料讲究“渔港直达、现切现上”,食材不经任何中转,这叫 Direct IO。两者服务的是不同的客人,也各有代价。

Buffered IO 是默认模式:读数据时先查页缓存,命中直接复制,未命中读盘后也先放进缓存;写数据时先把内容复制进页缓存并标记为脏页,然后由内核择机写回磁盘。Direct IO 则需要用 open() 的 O_DIRECT 标志打开文件,数据在用户缓冲区和磁盘之间直接搬运(DMA 传输),一步也不经过页缓存。

差异可以凝练成三句话:Buffered 借助页缓存获得局部性收益,Direct 放弃缓存换取对数据的完全掌控;Buffered 需要两次复制,Direct 只需一次;Buffered 对地址毫无要求,Direct 则强制对齐。适用范围上,通用程序用 Buffered,自带缓冲的应用(比如数据库)用 Direct 以避免双重缓存。

备菜台模式把“慢取货”变成“快取货”,直达模式省掉中转费却每次现取。没有哪家餐厅永远正确——理解了两种上菜路径,才能看懂后面所有策略的取舍。

Ncopy={2,Buffered IO: 用户↔页缓存↔磁盘1,Direct IO: 用户↔磁盘N_{\text{copy}} = \begin{cases} 2, & \text{Buffered IO: 用户} \leftrightarrow \text{页缓存} \leftrightarrow \text{磁盘} \\ 1, & \text{Direct IO: 用户} \leftrightarrow \text{磁盘} \end{cases}
复制次数是两种 IO 最直观的差异:绕台一次,经台两次。
  1. Buffered 写入write() 先把用户数据复制进页缓存,并标记为脏页。
    用户缓冲区→copy页缓存\text{用户缓冲区} \xrightarrow{\text{copy}} \text{页缓存}
  2. 延迟刷盘脏页由 flusher 线程在后台择机写入磁盘,write() 不等落盘就返回。
    页缓存→DMA磁盘\text{页缓存} \xrightarrow{\text{DMA}} \text{磁盘}
  3. Direct 写入O_DIRECT 下数据直接在用户缓冲区和磁盘之间传输,跳过页缓存。
    用户缓冲区→DMA磁盘\text{用户缓冲区} \xrightarrow{\text{DMA}} \text{磁盘}
  4. 复制次数对比Buffered 经过两次复制,Direct 只一次;次数少但要求对齐。
    NBuffered=2,NDirect=1N_{\text{Buffered}} = 2,\qquad N_{\text{Direct}} = 1

页缓存机制

备菜台要运营得好,光有台子不够,还得有人管备货节奏。客人点的菜台上有,是命中;没有,就得现去仓库搬,是未命中。经验丰富的大厨会预判客流:客人连续点了三份宫保鸡丁,他就顺势多切几份备着——这就是预读。

内核的预读(readahead)机制同样聪明。它监测到程序在顺序读文件时,会按窗口批量把后续页面提前读入页缓存,让顺序扫描快如流水。而写这一侧,备好的菜不会马上上桌,而是攒着:write() 只把数据复制进页缓存并标记“脏页”,真正落盘由内核的 flusher 线程在后台批量执行,这叫写回(writeback)。

写回的触发有明确条件。后台回写阈值 dirty_background_ratio 默认 10%:脏页比例超过它,flusher 线程开始异步刷盘;强制回写阈值 dirty_ratio 默认 20%:超过它,新写入会被阻塞,逼迫脏页尽快落地。两个阈值像餐厅的“备菜量警戒线”:超过十成就开始收拾,超过两成就暂停接单先理货。

备菜台不是越大越好,也不该永远不清理。理解了命中、预读与写回这三板斧,你就掌握了页缓存的脾气——它是内核用内存换速度的赌注,而赌注大小由参数替你兜底。

脏页比例=脏页总量页缓存上限→超过 10%后台回写→超过 20%强制回写\text{脏页比例} = \dfrac{\text{脏页总量}}{\text{页缓存上限}} \quad \xrightarrow{\text{超过 } 10\%} \text{后台回写} \quad \xrightarrow{\text{超过 } 20\%} \text{强制回写}
内核不会等磁盘完全空闲才写回,而是按脏页比例这位“管家”随时盯着备菜台。
  1. 计算脏页比例内核统计被修改但未写盘的页面占页缓存总容量的比例。
    脏页总量页缓存上限\dfrac{\text{脏页总量}}{\text{页缓存上限}}
  2. 后台回写比例超过 10% 时,flusher 线程开始异步刷盘,不阻塞业务写入。
    脏页总量页缓存上限>10100\dfrac{\text{脏页总量}}{\text{页缓存上限}} > \dfrac{10}{100}
  3. 强制回写比例超过 20% 时,新的写入被阻塞,迫使脏页尽快落盘。
    脏页总量页缓存上限>20100\dfrac{\text{脏页总量}}{\text{页缓存上限}} > \dfrac{20}{100}
  4. 预读窗口顺序读时内核按窗口预取后续页,窗口通常成倍增长并设上限。
    预取窗口=min⁡(当前窗口×2,  上限)\text{预取窗口} = \min(\text{当前窗口} \times 2,\; \text{上限})

对齐与复制

直达模式有一个隐藏规矩:传送带只认固定规格的托盘。Direct IO 把菜从用户餐桌直接端到磁盘仓库,但托盘(用户缓冲区)、取菜位置(文件偏移)、菜量(传输长度)都必须严格符合规格,否则传送带直接拒收,程序报错。

这个规格就是逻辑块大小,通常是 512 字节或 4096 字节。为什么这么严?因为绕过页缓存后,内核不再帮你“重新规整”数据,DMA 引擎按扇区搬运,地址和长度都必须落在扇区边界上。对齐要求覆盖三处:用户缓冲区地址、文件偏移、传输字节数,三者都必须是逻辑块大小的整数倍。

复制次数也因此一目了然。Buffered 读:磁盘 DMA 复制到页缓存,再从页缓存 CPU 复制到用户缓冲区,共两次;Direct 读:磁盘 DMA 直接复制到用户缓冲区,仅一次。写路径同理。一次复制的节省,换来的是对齐的繁琐——天下没有白省的力气。

端菜的人数越少,菜越新鲜,但每一手都得稳。Direct IO 的“一人直送”省了中转,却要求每个环节都精确对齐;Buffered IO 的“两人接力”多一道工序,却让程序员可以大大咧咧地写代码。规格和便利,总要选一头。

addr mod 512=0,offset mod 512=0,len mod 512=0\text{addr} \bmod 512 = 0, \qquad \text{offset} \bmod 512 = 0, \qquad \text{len} \bmod 512 = 0
对齐是 Direct IO 的“入场券”:地址、偏移、长度,三者缺一不可。
  1. 缓冲区地址对齐用户分配的内存起始地址必须是逻辑块大小的整数倍,DMA 才能直接搬运。
    addr mod 512=0\text{addr} \bmod 512 = 0
  2. 文件偏移对齐读写位置必须落在扇区边界上,否则数据会错位。
    offset mod 512=0\text{offset} \bmod 512 = 0
  3. 传输长度对齐一次读写的字节数也必须是块大小的整数倍。
    len mod 512=0\text{len} \bmod 512 = 0
  4. 失败表现任一条件不满足,系统调用返回 EINVAL,程序直接报错。
    不满足⇒EINVAL\text{不满足} \Rightarrow \text{EINVAL}

同步策略

有些客人对新鲜度有执念,要求每道菜“现点现做、不得提前备”。对应到磁盘,就是同步写:Buffered IO 默认写回是延迟的,如果断电,页缓存里的脏数据会消失——这对数据库、记账系统是灾难。于是有了各种“强制新鲜”的开关。

第一组工具是系统调用:fsync(fd) 把该文件的所有脏数据和元数据(大小、时间戳、权限)刷到磁盘;fdatasync(fd) 只刷数据和读取所必需的大小字段,跳过时间戳等次要元数据。第二组工具是打开标志:O_SYNC 让每次 write() 都等数据和元数据落盘才返回,O_DSYNC 只等数据落盘、元数据可延迟。

为什么区分这么细?因为磁盘上更新元数据往往要额外的写入与屏障(barrier),成本很高。时间戳这类信息对数据内容没有影响,丢了也不影响读取,fdatasync 和 O_DSYNC 就是为省这笔钱而生。它们的共同点是:都不绕过页缓存,只是把“延迟写回”变成“即写即刷”。

现做现吃最安全,但厨房节奏会被打乱。同步策略的本质,是用吞吐换安全:fsync/O_SYNC 把确定性放在第一位,fdatasync/O_DSYNC 则精打细算只保数据。选哪个,取决于这道菜端到客人面前时,能容忍多大的“变质”风险。

fsync:数据落盘+全部元数据(大小/时间戳/权限)fdatasync:数据落盘+必要元数据(读取所需的大小)\begin{aligned}\text{fsync:} &\quad \text{数据落盘} + \text{全部元数据(大小/时间戳/权限)} \\\text{fdatasync:} &\quad \text{数据落盘} + \text{必要元数据(读取所需的大小)}\end{aligned}
fsync 是全套上桌,fdatasync 只上主菜——差别全在那些次要元数据上。
  1. 数据落盘fsync 先把该文件所有脏数据页写入磁盘,保证内容不丢。
    fsync:  数据→磁盘\text{fsync}:\; \text{数据} \rightarrow \text{磁盘}
  2. 全量元数据fsync 再刷新大小、时间戳、权限等全部元数据。
    fsync:  元数据(全部)→磁盘\text{fsync}:\; \text{元数据(全部)} \rightarrow \text{磁盘}
  3. 必要元数据fdatasync 只刷新读取数据所必需的大小字段,跳过时间戳等。
    fdatasync:  元数据(必要)→磁盘\text{fdatasync}:\; \text{元数据(必要)} \rightarrow \text{磁盘}
  4. 性能差异跳过时间戳等可省去额外的元数据写事务,因此 fdatasync 通常更快。
    耗时(fsync)>耗时(fdatasync)\text{耗时(fsync)} > \text{耗时(fdatasync)}

性能特性

两种上菜模式的绩效,不能拍脑袋下结论。用备菜台的尺子量:Buffered 的优势全在“重复”二字——同一道菜被反复点,备菜台总能瞬间出餐;Direct 的优势在“省”字——省掉中转复制,也不占备菜台的空间。

顺序读写:Buffered 有预读护航,内核提前搬货,吞吐很漂亮;Direct 少一次复制,带宽上限更高,但失去预读,常见做法是应用自己做大块顺序 IO 来弥补。随机读写:页缓存命中时 Buffered 比磁盘快几个数量级,Direct 每次都要实打实访问磁盘,随机小 IO 是它的软肋。

内存开销也要算进来。页缓存会占用系统内存,缓存大量文件后可能挤压其他应用——Direct IO 完全不占页缓存,但代价是应用必须自己承担缓存责任。于是出现一个朴素公式:平均读延迟 = 命中率 × 命中延迟 + 未命中率 ×(未命中延迟 + 磁盘延迟)。命中率越高,Buffered 越占优。

没有绝对快的上菜方式,只有匹配客流的备菜策略。顺序大 IO、重复读、内存吃紧、随机小文件……把这些场景摆到备菜台上,答案自然浮现:缓存擅长过滤重复,直达擅长避免浪费。

T平均读=p⋅T命中+(1−p)⋅(T未命中+T磁盘)T_{\text{平均读}} = p \cdot T_{\text{命中}} + (1 - p) \cdot (T_{\text{未命中}} + T_{\text{磁盘}})
命中率 p 是两种 IO 胜负的天平:p 趋近 1,Buffered 完胜;p 趋近 0,Direct 才有机会。
  1. 命中延迟页缓存命中时只需一次内存复制,是微秒级操作。
    T命中≈10−6 sT_{\text{命中}} \approx 10^{-6}\,\text{s}
  2. 未命中延迟未命中要访问磁盘,是毫秒级操作,相差三个数量级。
    T未命中+T磁盘≈10−3 sT_{\text{未命中}} + T_{\text{磁盘}} \approx 10^{-3}\,\text{s}
  3. 期望公式平均读延迟是命中率加权的期望值,命中率决定整体表现。
    T平均读=p⋅T命中+(1−p)(T未命中+T磁盘)T_{\text{平均读}} = p \cdot T_{\text{命中}} + (1 - p)(T_{\text{未命中}} + T_{\text{磁盘}})
  4. 不等式直觉命中率越高,Buffered IO 优势越大;命中率趋近 0 时,Direct 的一次复制优势才显现。
    p→1⇒Buffered 占优;p→0⇒Direct 占优p \to 1 \Rightarrow \text{Buffered 占优};\quad p \to 0 \Rightarrow \text{Direct 占优}

实践应用

真正的大牌餐厅,后厨方案都是定制过的。Redis 和 MySQL 就是两家最典型的“餐厅”,它们的选型几乎成了行业模板——看懂它们,你就看懂了页缓存策略在工程上的全部想象力。

Redis 的 AOF 持久化走 Buffered IO 路线,但它用 appendfsync 参数控制“新菜品上台的节奏”:always 每次写命令后都调 fsync,最安全但吞吐最低;everysec 每秒攒一批再 fsync,最多丢一秒数据,是默认的折中;no 完全不调 fsync,交给内核脏页回写,最快但丢数据窗口最大。

MySQL InnoDB 的算盘更精:它自带缓冲池(buffer pool),数据文件若再用页缓存,等于双重缓存、内存翻倍浪费。于是 innodb_flush_method=O_DIRECT 让数据文件绕开页缓存直接读写,而重做日志(redo log)文件仍用 Buffered + fsync 保证顺序写和小步快刷。这是“备菜台与直达”的教科书式分工。

选型指南一句话:如果你的应用自带“备菜台”(缓冲池),就用直达模式避免浪费;如果依赖内核替你做缓存,就用 Buffered 并调好预读与写回参数。Redis 的 everysec 和 MySQL 的 O_DIRECT,都是在这张备菜台上找到的最优解。

appendfsync={always: 每次写后 fsynceverysec: 每秒 fsyncno: 交给内核回写\text{appendfsync} = \begin{cases} \text{always: 每次写后 fsync} \\ \text{everysec: 每秒 fsync} \\ \text{no: 交给内核回写} \end{cases}
Redis 用“每秒一刷”在安全和吞吐间找平衡,MySQL 用“直通数据文件”避免两套备菜台。
  1. always 模式每个写命令执行后立即调用 fsync,数据安全最好但吞吐最低。
    appendfsync=always⇒每写必刷\text{appendfsync} = \text{always} \Rightarrow \text{每写必刷}
  2. everysec 模式每秒累积一批命令后 fsync 一次,最多丢失一秒数据。
    appendfsync=everysec⇒每秒一刷\text{appendfsync} = \text{everysec} \Rightarrow \text{每秒一刷}
  3. no 模式不主动 fsync,完全依赖内核脏页回写,吞吐最高、风险最大。
    appendfsync=no⇒内核决定\text{appendfsync} = \text{no} \Rightarrow \text{内核决定}
  4. InnoDB 分工数据文件用 O_DIRECT 绕开页缓存,日志文件用 Buffered + fsync 保序。
    数据文件: O_DIRECT,日志文件: fsync\text{数据文件: O\_DIRECT},\qquad \text{日志文件: fsync}

AI 应用场景

训练数据管线的“备菜台避让”

阶段:数据准备与加载

解决的问题:大模型训练时,图像、TFRecord 等训练样本每个 epoch 只读一遍,若都走 Buffered IO,页缓存这块“备菜台”会被一次性数据塞满,挤占模型权重、验证集等热数据的缓存空间。用 Direct IO(O_DIRECT)或 posix_fadvise 标记“读完即弃”,可避免页缓存污染,让备菜台始终留给真正会被重复访问的数据。

具体案例:某 CV 训练平台用 DataLoader 读取 10TB 图片集,改用 O_DIRECT 读取一次性样本后,页缓存命中率恢复稳定,验证阶段随机读延迟下降约 40%。

向量检索服务的“备菜台加速”

阶段:推理服务与索引构建

解决的问题:向量索引文件(如 HNSW、IVF 结构)在线上查询时被反复访问,命中率极高。Buffered IO + mmap 让内核页缓存充当“第二份索引缓存”,把磁盘访问变成内存访问;但需要配合脏页回写参数调优,防止大量索引写入时内存积压。

具体案例:某推荐系统的 embedding 服务将 200GB 索引 mmap 进页缓存,查询 P99 延迟从 8ms 降到 1.2ms,且 CPU 占用基本不变。

大模型 checkpoint 的“现做现收”

阶段:训练容错与恢复

解决的问题:大模型 checkpoint 动辄几十 GB,若只 write 不 fsync,断电可能损坏;若每次都用 fsync 全量刷元数据,保存时间被拖长。采用 Buffered write + fdatasync 可跳过时间戳等次要元数据,或者用 O_DIRECT 写减少内存双缓存压力,兼顾速度与安全。

具体案例:某 LLM 训练任务保存 60GB checkpoint,从“write + fsync“改为”O_DIRECT 写 + fdatasync”,保存耗时从 20 秒降到 9 秒,内存峰值下降约 15GB。

Python 演示

python66 行
# -*- coding: utf-8 -*-"""备菜台实验: 对比 Buffered IO 与 Direct IO 的页缓存行为""" import mmapimport osimport tempfileimport time FILE_SIZE = 128 * 1024 * 1024   # 测试文件 128MBCHUNK = 4 * 1024 * 1024         # 每块 4MB, 天然是 4KB 的整数倍  def buffered_read(path):    total = 0    start = time.perf_counter()    with open(path, 'rb') as f:        while True:            chunk = f.read(CHUNK)            if not chunk:                break            total += len(chunk)    return time.perf_counter() - start, total  def direct_read(path):    if not hasattr(os, 'O_DIRECT'):        return None, 0    fd = os.open(path, os.O_RDONLY | os.O_DIRECT)    try:        buf = mmap.mmap(-1, CHUNK)  # 匿名映射: 页对齐的用户缓冲区        offset = 0        total = 0        start = time.perf_counter()        while True:            n = os.preadv(fd, [buf], offset)            if n == 0:                break            offset += n            total += n        buf.close()        return time.perf_counter() - start, total    finally:        os.close(fd)  def main():    with tempfile.TemporaryDirectory() as tmp:        path = os.path.join(tmp, 'kitchen.bin')        seed = os.urandom(CHUNK)        with open(path, 'wb') as f:            for _ in range(FILE_SIZE // CHUNK):                f.write(seed)         t1, n = buffered_read(path)        print('第一次 Buffered 读: %.3f 秒, %d 字节' % (t1, n))        t2, n = buffered_read(path)        print('第二次 Buffered 读: %.3f 秒 (命中备菜台!)' % (t2,))        t3, n = direct_read(path)        if t3 is not None:            print('Direct IO 读: %.3f 秒, %d 字节 (绕过备菜台)' % (t3, n))        else:            print('当前平台不支持 O_DIRECT, 跳过 Direct IO')  if __name__ == '__main__':    main()
目的:用同一个 128MB 文件,对比“两次 Buffered 读”和“一次 Direct 读”的耗时,直观验证页缓存命中带来的加速,以及 Direct IO 绕过缓存的特性。
  1. with open(path, 'rb') as f:用默认的 Buffered IO 打开文件,读写都会经过页缓存这个“备菜台”。
  2. chunk = f.read(CHUNK)每次读取 4MB 大块,第一次大概率未命中,第二次命中缓存会明显更快。
  3. fd = os.open(path, os.O_RDONLY | os.O_DIRECT)关键:O_DIRECT 让数据绕过页缓存直通磁盘。
  4. buf = mmap.mmap(-1, CHUNK)关键:匿名映射得到的缓冲区天然页对齐,才能满足 DMA 的对齐要求。
  5. n = os.preadv(fd, [buf], offset)把数据直接读入对齐缓冲区,不经过页缓存,offset 手动推进。
  6. if not hasattr(os, 'O_DIRECT'):兼容非 Linux 平台,保证代码在任意环境都能正常运行。

常见误区

误区我觉得 Direct IO 绕过了页缓存这道中间环节,少了一次复制,肯定比 Buffered IO 快,所以只要追求性能就无脑上 O_DIRECT。

正解备菜台虽然多一道手,但能大幅提升重复访问的速度。Direct IO 的优势只在特定场景成立:应用自带缓冲池(如数据库)、数据不会被重复读取、内存紧张。对随机小文件、重复读场景,页缓存命中的 Buffered IO 比 Direct 快几个数量级。

为何常见:人们容易把“绕过一个环节”等同于“更快”,忽略了缓存带来的复用收益——备菜台的意义恰恰在于重复访问时不跑仓库。

误区我以为调用了 fsync,数据就到了磁盘物理介质上,断电、重启都不会丢,妥妥的。

正解fsync 只把页缓存中的脏页交给磁盘控制器,但磁盘自身还有写缓存(硬盘缓存),控制器可能延迟落盘,掉电仍可能丢。严格保证需要关闭磁盘回写缓存或使用带掉电保护的存储(如数据库的 double write 机制)。

为何常见:把“交给内核”误认为“写进物理介质”,忽略了存储硬件还有一级自己的“备菜台”。

误区我以为 open 加个 O_DIRECT 标志就行了,结果程序直接报 Invalid argument,还以为是系统出故障了。

正解O_DIRECT 要求用户缓冲区地址、文件偏移、传输长度都对齐到逻辑块大小(常见 512B 或 4KB),三者缺一不可。Python 里用普通 bytes 做缓冲就可能踩中未对齐的 EINVAL,需要像演示代码那样用 mmap 或对齐分配的缓冲区。

为何常见:平时用 Buffered IO 从不关心对齐,内核兜底惯了,突然遇到“无人代劳”的规格要求就懵了。

误区我以为每次 write() 返回成功,数据马上就写进磁盘了,所以没必要调用 fsync。

正解write() 只把数据复制进页缓存并标记脏页,真正的落盘由 flusher 内核线程在脏页比例超过阈值(默认 10% 后台、20% 强制)或超时后触发。要保证数据不丢必须显式 fsync/fdatasync,或打开 O_SYNC/O_DSYNC。

为何常见:write 的语义太像“写文件”,让人默认它是同步落盘,忽略了写回缓存设计——备菜台上的菜不端出去,客人根本吃不到。

面试题

Q1. Buffered 读文件时,数据从磁盘到用户缓冲区的正确路径是?

单选题 · easy

  • A.磁盘→页缓存→用户缓冲区← 正确答案
  • B.磁盘→用户缓冲区直接到达
  • C.磁盘→寄存器→用户缓冲区
  • D.用户缓冲区→页缓存→磁盘

答案:A。Buffered 读必须经过页缓存这个“备菜台”:磁盘先 DMA 进页缓存,再复制给用户。B 是 Direct IO 的路径,C 没有寄存器中转环节,D 是写路径的方向。

Q2. Direct IO 最具标志性的特点是?

单选题 · easy

  • A.绕过页缓存直接在用户与磁盘间传输← 正确答案
  • B.数据总是先进入页缓存再写回磁盘
  • C.允许任意未对齐地址和长度访问
  • D.每次读写必然同步数据落盘

答案:A。O_DIRECT 的核心是绕过页缓存,让数据在用户缓冲区和磁盘间直接 DMA。B 是 Buffered 的写回路径;C 与事实相反,Direct 强制对齐;D 是把 Direct IO 与 O_SYNC 同步写混淆了。

Q3. 关于 Direct IO 对齐要求,下列说法正确的是?

单选题 · mid

  • A.只需长度对齐地址随意
  • B.地址与偏移和长度都要对齐← 正确答案
  • C.只需地址对齐长度随意
  • D.完全没有对齐方面的要求

答案:B。Direct IO 要求用户缓冲区地址、文件偏移、传输长度三者都对齐到逻辑块大小(512B/4KB),否则返回 EINVAL。A、C 只提一个维度,D 完全错误。

Q4. fsync 与 fdatasync 的主要区别是?

单选题 · mid

  • A.前者同步数据与全部元数据← 正确答案
  • B.后者同步数据与全部元数据
  • C.前者只同步数据不碰元数据
  • D.两者功能完全一致无区别

答案:A。fsync 刷数据加全部元数据(大小、时间戳、权限),fdatasync 只刷数据加读取所必需的大小字段。B、C 把两者说反了,D 忽略了元数据范围的关键差异。

Q5. 数据库(如 MySQL InnoDB)用 O_DIRECT 打开数据文件,最主要原因是?

单选题 · hard

  • A.避免自身缓冲池与页缓存双重缓存← 正确答案
  • B.Direct IO 读速度总是快于缓冲读
  • C.充分利用内核预读机制加速扫描
  • D.减少文件系统元数据的维护开销

答案:A。InnoDB 自带 buffer pool,若数据文件再走页缓存,等于两套“备菜台”缓存同一批数据,内存翻倍浪费且性能不稳。B 不是绝对结论;C 恰恰相反,Direct IO 没有预读;D 与对齐开销的现实不符。

Q6. 脏页后台回写与强制回写的触发条件分别为?

单选题 · hard

  • A.分别超过10%和20%← 正确答案
  • B.分别超过5%和10%
  • C.分别超过20%和30%
  • D.分别超过50%和80%

答案:A。dirty_background_ratio 默认 10% 启动后台 flusher 异步刷盘,dirty_ratio 默认 20% 时阻塞写者强制回写。其余选项的数值都不符合内核默认策略。

Q7. 第二次读取同一文件明显快于第一次,最可能的原因是?

单选题 · easy

  • A.数据已在页缓存中命中← 正确答案
  • B.磁盘转速突然提升了
  • C.CPU 主频临时变高了
  • D.文件被系统自动压缩了

答案:A。第一次读把文件带进页缓存,第二次读直接命中“备菜台”,无需访问磁盘。B、C 都不会在两次读之间自发变化,D 属于无中生有的机制。

Q8. 下列属于 Buffered IO 行为或特点的有?

多选题 · mid

  • A.写入数据先进入页缓存← 正确答案
  • B.脏页由内核延迟写回← 正确答案
  • C.每次写入都立即同步落盘
  • D.用户缓冲区必须页对齐

答案:AB。Buffered 写入先落页缓存标记脏页,由 flusher 延迟写回。C 是 O_SYNC 同步模式的特征;D 是 Direct IO 的对齐要求,Buffered IO 并不需要。

Q9. 关于 O_SYNC 与 O_DSYNC,下列说法正确的有?

多选题 · hard

  • A.系统调用O_SYNC同步数据与元数据← 正确答案
  • B.系统调用O_DSYNC只同步用户数据← 正确答案
  • C.两者都直接绕过页缓存访问磁盘
  • D.O_SYNC 的同步开销相对更大← 正确答案

答案:ABD。O_SYNC 要求数据和元数据都落盘,O_DSYNC 只要求用户数据落盘、元数据可延迟,所以 O_SYNC 开销更大。但两者都仍然走页缓存,只是把延迟写回变成即写即刷,C 把同步标志与 Direct IO 混淆了。

Q10. 下列属于 Direct IO 适用场景的有?

多选题 · mid

  • A.自带缓冲池的数据库系统← 正确答案
  • B.数据被大量地重复读取
  • C.避免一次性数据污染页缓存← 正确答案
  • D.高频小文件的随机读写

答案:AC。自带缓冲池的数据库用 Direct 避免双重缓存;一次性大数据用 Direct 避免污染备菜台。B 和 D 都依赖缓存复用,Buffered IO 的页缓存命中优势远大于 Direct 的省复制优势。