操作系统内功 · 第 22 篇|O_DIRECT:绕过 Page Cache 的得与失——数据库为什么自管缓存

双缓存问题:Page Cache 的效率反噬

buffered IO(默认路径)里,一份数据存在两份:应用的缓冲池 + 内核的 Page Cache。对通用文件负载这是优化(第 21 篇的合并与预读),对数据库却是纯浪费:数据库自己有精心设计的缓冲池(InnoDB buffer pool、PostgreSQL shared buffers),Page Cache 里那份副本几乎永远不会被复用——数据库刚把页读进自己池子,内核也把它留在了 Page Cache;数据库 eviction 淘汰自己池子的页,Page Cache 的副本还占着内存。16GB 内存跑 InnoDB,buffer pool 配 12GB 的经典建议背后,就是给 Page Cache 留活口的无奈。

双缓存还有第二重代价:同一份写要经历两条刷盘路径。数据库 checkpoint 刷自己池子的脏页,内核 writeback 又要刷 Page Cache 的脏页(第 21 篇),两套节奏互相干扰,IO 调度器看到的是无序的写流。

O_DIRECT 是 Linux 给出的出路:打开文件时加 O_DIRECT 标志,读写完全绕过 Page Cache——数据直接在应用缓冲与设备之间搬运,内核不缓存、不预读、不回写。缓存管理的职责连同控制权一起交还应用。

Direct IO 的语义边界:四个必须遵守的规矩

绕过内核缓存的自由是用约束换的,四条规矩缺一就会得到 EINVAL 或未定义行为:

  1. 对齐要求:应用缓冲区的地址、读写长度、文件偏移三者都必须对齐到设备的逻辑块大小(通常 512 字节,现代 NVMe 常见 4KB)。posix_memalign 分配对齐缓冲是标准姿势。
  2. 无预读:内核的 readahead 完全失效,顺序读的吞吐要靠应用自己的异步预读(用 io_uring 或 aio 提前下发读请求)。
  3. 无缓存合并:相邻写不再被 Page Cache 合并,小 IO 直接打到设备——应用必须自己做写合并(日志组提交就是干这个的)。
  4. 无回写管理:数据落盘时机完全由应用决定(fsync 依然有效且必要),不再有内核 writeback 兜底。
// Direct IO 的最小形态:三个对齐缺一不可
int fd = open(path, O_RDWR | O_DIRECT);
void *buf;
posix_memalign(&buf, 4096, 4096);        // 缓冲对齐 4K
pread(fd, buf, 4096, offset_4096_aligned); // 长度与偏移也对齐

这四条规矩解释了 Direct IO 的历史定位:它从来不是「更快的 IO」,而是「更可控的 IO」——通用应用用它只会更慢(失去预读与合并),只有自己实现了等价能力的程序(数据库、存储引擎)才用得值。

路线分歧:InnoDB 的 O_DIRECT 与 PostgreSQL 的 buffered

MySQL InnoDB 支持 innodb_flush_method = O_DIRECT:数据文件与 redo 日志的读写绕过 Page Cache,只留 doublewrite 与日志的必要路径在内核侧。收益立竿见影:内存账本清晰(buffer pool 就是全部数据缓存,不再与 Page Cache 争抢)、避免双刷、SELECT 密集负载的 P99 更稳。这是 InnoDB 在高并发 OLTP 上的主流配置。

PostgreSQL 长期坚持 buffered IO,理由是历史包袱与功能依赖:它的 full_page_writes、检查点节奏、预读策略都是围绕 Page Cache 设计的;PG 14 引入 debug_io_direct 只是试验通道,正式的 Direct IO 支持仍在推进(PG 18 起逐步落地 WAL 的 O_DIRECT)。两派分歧的本质不是对错,而是「谁来管理缓存」的架构选择——InnoDB 自带全套缓存设施所以敢直通设备,PG 的设施还在补课。

消息队列的对照同样有启发性:Kafka 的日志段坚持 buffered 写 + 内核 Page Cache 读(消费者大多读最近消息,Page Cache 命中率极高),再配合 sendfile 零拷贝(第 6 篇)直接从 Page Cache 推到网卡——Kafka 的性能恰恰建立在「利用」Page Cache 而非绕过它。同样的存储问题,InnoDB 绕、Kafka 用,分叉点是访问模式:随机读写 + 强持久化 → 自管缓存直通设备;顺序读写 + 多消费者共享热数据 → 喂饱 Page Cache 借力内核。

io_uring:Direct IO 从小众到主流的转折

Direct IO 长期小众的另一个原因是异步接口的残缺:Linux AIO(libaio)只支持 O_DIRECT 文件、接口粗糙、缓冲管理复杂——buffered 异步 IO(io_submit 后内核要处理 Page Cache 交互)一直缺位。io_uring(第 13 篇)改变了这个格局:它原生支持 buffered 文件 IO 的异步(IORING_OP_READ 对普通文件也真正异步),同时把 O_DIRECT 的批量下发能力发挥到极致——提交队列攒批、内核侧多队列并行、buffer 选区(provided buffer rings)省掉每请求的注册开销。

对存储引擎的意义:Direct IO 的两个历史短板(无预读、接口难用)都被 io_uring 补齐——应用用 io_uring 自己做预读(提前下发后续 offset 的读请求),接口复杂度由 io_uring 的批量语义摊薄。新一代存储引擎(RocksDB 的 MultiRead、TiKV 的 io_uring 接入、各家自研 KV)已经把「O_DIRECT + io_uring + 自管预读」当作默认路径。

flowchart LR
    subgraph Buffered["buffered 路径"]
        A1[应用缓冲] -->|copy| PC[Page Cache]
        PC -->|writeback| D1[设备]
    end
    subgraph Direct["O_DIRECT + io_uring 路径"]
        A2[应用自管缓存<br>+ 自建预读] -->|直通| D2[设备]
    end

什么时候用 O_DIRECT:一张决策表

负载形态 建议 原因
数据库/存储引擎,自带缓冲池 O_DIRECT 消灭双缓存与双刷
顺序日志、多消费者共享读 buffered + sendfile 借力 Page Cache 与零拷贝
通用文件服务(上传下载、静态内容) buffered 依赖内核预读与缓存
大文件单次流式读写(备份、导入导出) O_DIRECT 或 posix_fadvise(DONTNEED) 不污染缓存,用完即走
内存紧张、缓存命中率低的扫描负载 O_DIRECT 避免 Page Cache 挤兑匿名页

补充两个轻量级替代品:posix_fadvise(POSIX_FADV_DONTNEED) 用 buffered IO 但主动驱逐已读页——比 O_DIRECT 门槛低,适合「一次性大扫描」;O_SYNC 等价于每次写自动 fsync——比 O_DIRECT 简单但性能代价大,适合低频关键写。

posix_fadvise 与缓存回收:比 O_DIRECT 更轻的控制开关

不是所有「不想要 Page Cache」的场景都需要 O_DIRECT 的重约束。posix_fadvise 提供五个档位的软建议:POSIX_FADV_SEQUENTIAL(翻倍预读窗口,顺序读大文件的免费加速)、WILLNEED(异步预读指定范围,明知马上要读的预热)、DONTNEED(读完即驱逐,避免冷数据挤占缓存)。典型组合:全量扫描工具(备份、离线分析)「SEQUENTIAL 打开,分块读完 DONTNEED 逐段释放」——既能吃满预读带宽,又不留一页垃圾缓存。与 drop_caches 的区别要划清:echo 3 > /proc/sys/vm/drop_caches 是全局无差别清空,会瞬间打爆后续的缓存 miss(生产禁用,只用于基准测试前清场);fadvise 是按文件、按范围的精准驱逐,对其他负载无感。第三个相关开关是 syncfs:把整个文件系统的脏页一次性同步(MySQL 8.0 的克隆插件、容器检查点用它做全局落盘点),语义介于单文件 fsync 与全局 sync 之间。工程检查单补充一条:数据库机器上跑一次性大扫描前(备份校验、全表导出),先评估是否给扫描工具加 DONTNEED——否则扫一遍盘,Page Cache 里的热数据全被换掉,业务缓存命中率雪崩半小时,这类「备份拖垮生产」的事故每年都在发生。

Direct IO 的错误模型:对齐违规与 EINVAL 排查

O_DIRECT 的四个对齐约束(第 22 篇正文)落到工程里是一类特征鲜明的故障:EINVAL(参数非法)或性能骤降,且「同一份代码换台机器就复现」。排查路径三步。第一步确认三个对齐值的实际要求:逻辑块大小(logical block size,blockdev --getss,通常 512)约束偏移与长度;物理块大小(physical block size,blockdev --getpbsz,4K NVMe 常见)约束最优对齐;文件系统可能额外叠加(如 DAX 模式要求更大对齐)——用错了基准(按 512 分配但设备要求 4K)是最常见的错法。第二步确认缓冲区:malloc 的指针只保证最大基本类型对齐(16 字节),Direct IO 必须用 posix_memalign 或 memalign 按 pbsz 对齐分配——「偶尔 EINVAL」的随机性往往来自 allocator 的偶然对齐。第三步确认叠加层:加密层(dm-crypt)、LVM、网络文件系统(NFS 的 Direct IO 语义弱化)都会改写对齐要求,分层存储的每一层都要过一遍。性能侧的对齐问题更隐蔽:对齐正确但长度非 2 的幂时,设备内部拆分成读改写(read-modify-write),吞吐掉一半——「功能对、性能错」的 Direct IO 多半栽在这里。一个防御性实践:存储引擎的 Direct IO 路径加启动自检(对齐探测 + 小 IO 实测),把对齐违规从运行时的随机 EINVAL 提前到部署时的显式失败——错误越早暴露,修复越便宜。

io_uring 时代的缓冲策略:registered 与 provided buffers

O_DIRECT 与 io_uring 组合后,缓冲管理本身成为优化对象,两个 io_uring 专属机制值得认识。registered buffers(IORING_REGISTER_BUFFERS):应用启动时把一组缓冲区注册给内核,后续 IO 请求只带索引——省掉每次请求的缓冲校验与页表固定(pin)开销,海量小 IO 场景收益显著。provided buffer rings(IORING_OP_READ 与 buffer select):更进一步的「缓冲池反转」——应用预先挂起一个缓冲环,读请求不带缓冲地址,内核来数据时自己从环里挑一个空缓冲装填,完成后告诉应用用了哪个索引。这个反转解决了异步读的经典难题:传统 aio 必须预知「数据会到哪个请求」,而网络服务器的读到达是随机的——provided buffers 让内核按到达顺序动态分配,应用侧零竞态。对存储引擎的意义:RocksDB 类工作负载(大量并发随机读)用 provided buffers 可以把「每请求分配缓冲」的成本归零,配合 poll 机制(内核轮询模式,免中断)形成完整的高性能读路径。选型视角:这三个机制(registered files、registered buffers、provided rings)共同把 io_uring 从「更快的 aio」推向「应用与内核共享资源池」的新契约——理解了这层,才能理解为什么 io_uring 被视为 Linux IO 接口的终结者而不仅仅是替代品。

小结

O_DIRECT 绕过 Page Cache,把缓存、预读、刷盘三个职责连同控制权交还应用——它解决的是双缓存的效率反噬与刷盘节奏的失控,而不是单纯的「更快」。四个对齐与语义规矩是自由的价码,InnoDB 与 Kafka 在同一问题上的相反选择说明分叉点是访问模式:随机读写 + 自管缓存 → 直通设备;顺序 + 共享热数据 → 喂饱 Page Cache。io_uring 补齐了异步与预读的短板后,「O_DIRECT + io_uring」正在成为新一代存储引擎的默认路径。下一篇回到文件系统的可靠性根基:日志(journaling)如何让 ext4 在断电后不坏。

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?