Storforge
编码预计 50 分钟

文件 IO 的三条通路

buffered、O_DIRECT、mmap 三种写法各自经过哪些内核层?fsync 到底保证了什么?运维时背过的结论,这次用 Rust 代码逐一验证。

学完这节你能做到

  • 写出三种 IO 通路的 Rust 代码并测出性能差异
  • 解释 fsync / fdatasync / O_DSYNC 的语义区别
  • 说清为什么存储引擎几乎都绕开 page cache

一次 write() 的旅行

运维时你一定遇到过这个现象:dd 报出 1.8 GB/s 的写速度,可这块 SATA SSD 明明只有 500 MB/s。 你也知道答案 —— 数据进的是 page cache,不是盘。这节课把这个「知道」变成「能写出来、能测出来」。

一次普通的 write() 系统调用,数据要走完这条路才算真正安全:

  1. 用户态 buffer —— 你程序里的那个 Vec<u8>
  2. page cache —— 内核把数据拷进来、把页标脏,write() 到这里就返回了
  3. 回写线程 —— 脏页积累到 vm.dirty_ratio 或超时后,内核后台刷盘
  4. 盘上易失缓存 —— 盘自己还有一层 DRAM 写缓存
  5. 持久介质 —— NAND / 磁碟,到这里才叫落盘

你调过的 vm.dirty_background_ratio、你在 iostat 里看到的「写延迟突然抖一下」 (回写风暴),全发生在第 2 到第 3 步之间。关键结论先立起来: write() 返回成功,只证明数据到了第 2 步。断电,它就没了。

i为什么存储引擎几乎都绕开 page cache

page cache 对通用负载是好东西,但对存储引擎是三重麻烦:拷贝(用户态到内核多一次 memcpy)、 不可控(回写时机由内核决定,延迟毛刺没法解释)、双份缓存(引擎自己有缓存, 内核再缓存一份纯属浪费内存)。RocksDB、Ceph BlueStore、Weka 全都选择自己管缓存、直通盘。

std::fs 与 buffered IO:默认路径的代价

先写最朴素的版本。注意一个容易误会的点:Rust 的 File::write 是直接的 write(2) 系统调用, 没有用户态缓冲;想要用户态缓冲要自己包 BufWriter。所以这条路上其实有两层缓冲:

use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;

/// 通路一:BufWriter(用户态缓冲)+ page cache(内核缓冲)
fn write_buffered(path: &Path, block: &[u8], count: usize) -> std::io::Result<()> {
    let file = File::create(path)?;
    let mut writer = BufWriter::with_capacity(1 << 20, file); // 1 MiB 用户态缓冲
    for _ in 0..count {
        writer.write_all(block)?; // 多数调用连系统调用都不发生
    }
    writer.flush()?;                  // 用户态缓冲 → page cache
    writer.get_ref().sync_all()?;     // page cache → 盘,这一步才是 fsync
    Ok(())
}

这个版本的顺序写吞吐会非常好看 —— 4k 的 write_all 大部分只是 memcpy, 每 256 次才发一次真正的系统调用。代价是你完全不知道数据什么时候在盘上, 以及大块脏页回写时,别人的 IO 延迟会被你打出毛刺(你在共享存储上被人这么坑过)。

O_DIRECT:对齐这道坎

通路二:打开文件时加 O_DIRECT,IO 绕过 page cache 直达块层。代价是内核把对齐的责任甩给你, 三样东西必须同时对齐到逻辑块大小(通常 512 B 或 4 KiB):buffer 的内存地址、文件内偏移、IO 长度。 任何一样不满足,write 直接返回 EINVAL —— 这是所有人第一次用 O_DIRECT 必踩的坑, 报错信息还完全不提「对齐」两个字。

use std::alloc::{alloc_zeroed, dealloc, Layout};
use std::fs::OpenOptions;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;

const ALIGN: usize = 4096;

/// 4 KiB 对齐的 buffer。Vec 不保证地址对齐,只能走底层分配器。
/// 这是三条红线里 unsafe 的例子:每一行都要能说出为什么存在。
struct AlignedBuf {
    ptr: *mut u8,
    layout: Layout,
}

impl AlignedBuf {
    fn new(len: usize) -> Self {
        assert_eq!(len % ALIGN, 0, "长度必须是对齐粒度的整数倍");
        let layout = Layout::from_size_align(len, ALIGN).expect("非法 layout");
        // SAFETY: layout 非零且合法;alloc 失败返回空指针,下一行会 panic 而不是继续
        let ptr = unsafe { alloc_zeroed(layout) };
        assert!(!ptr.is_null(), "内存分配失败");
        Self { ptr, layout }
    }

    fn as_slice(&self) -> &[u8] {
        // SAFETY: ptr 由 alloc_zeroed 得到,长度即 layout.size()
        unsafe { std::slice::from_raw_parts(self.ptr, self.layout.size()) }
    }
}

impl Drop for AlignedBuf {
    fn drop(&mut self) {
        // SAFETY: ptr/layout 与分配时一致 —— 所有权保证只释放一次
        unsafe { dealloc(self.ptr, self.layout) }
    }
}

/// 通路二:O_DIRECT 顺序写
fn write_direct(path: &Path, block_size: usize, count: usize) -> std::io::Result<()> {
    let mut file = OpenOptions::new()
        .write(true)
        .create(true)
        .custom_flags(libc::O_DIRECT)
        .open(path)?;
    let buf = AlignedBuf::new(block_size);
    for _ in 0..count {
        file.write_all(buf.as_slice())?; // 每次都是真实 IO,直达块层
    }
    file.sync_all()?; // O_DIRECT 不等于持久:盘内缓存还在,fsync 仍然要做
    Ok(())
}
×O_DIRECT 不等于落盘

最常见的误解:「都 direct 了还要什么 fsync」。O_DIRECT 只是绕过 page cache, 数据仍然可能停在盘上的易失写缓存里。让盘把缓存刷进介质,靠的是 fsync 触发的 FLUSH 命令(或打开时用 O_DSYNC 让每次写都带上)。两件事,别混。

顺带把通路三说了:mmap 把文件映射进地址空间,读写就是访存,由缺页和回写驱动 IO。 它读小对象很舒服,但作为写路径对存储引擎是陷阱:脏页何时回写不可控、 msync 语义各平台微妙不一、IO 错误以 SIGBUS 的形式炸给你而不是返回 Err。 forge 的读路径以后可能用它,写路径不碰。

fsync 到底保证了什么

把几个容易混的原语一次对齐 —— 这张表建议记住,后面每一课都用:

原语Rust 对应保证大致代价
fsync(fd)file.sync_all()数据 + 元数据(长度、mtime)持久NVMe 约 20~100 µs,SATA SSD 约 1 ms
fdatasync(fd)file.sync_data()只保证数据 + 影响读数据的元数据(如长度)略低于 fsync
O_DSYNC 打开custom_flags(libc::O_DSYNC)每次 write 自带 fdatasync 语义每写都付一次刷盘钱

还有两个运维直觉照不到的坑:

  • 新文件要 fsync 目录create + write + fsync 之后断电,文件内容在, 但目录项可能没在 —— 文件「消失」。必须再对父目录的 fd 做一次 fsync。 WAL 课里的原子替换套路(写临时文件 → fsync → rename → fsync 目录)全靠它。
  • fsync 报错不能重试当没事。fsync 返回 EIO 时,内核可能已经把那批脏页标干净丢掉了, 重试一次返回成功不代表数据在盘上(PostgreSQL 的 fsyncgate 事故就是这个)。 正确姿势:fsync 失败即视为该文件状态未知,进崩溃恢复流程。这就是我们的代码里 fsync 的错误永远用 ? 上抛、绝不 retry 的原因。
Checkpoint单选

程序 write() 返回成功后立刻断电,重启后数据还在吗?

动手:三个版本的顺序写,和 fio 对账

现在验证。预期的量级差(一块普通 NVMe,4 KiB 顺序写):

  • buffered 不带 fsync:GB/s 级 —— 测的是内存,不是盘
  • O_DIRECT,QD1:几十到一二百 MB/s —— 测的是盘的真实单深度延迟
  • 每写一次 fsync 一次:吞吐直接跌到每秒几百到几千次 —— 测的是刷盘命令的代价

用你最熟的 fio 交叉验证,口径要对齐(direct、块大小、深度):

# 对照通路一:buffered,不 sync
fio --name=buf --rw=write --bs=4k --size=1g --ioengine=psync

# 对照通路二:O_DIRECT
fio --name=direct --rw=write --bs=4k --size=1g --ioengine=psync --direct=1

# 对照"每写必刷":fsync=1
fio --name=sync --rw=write --bs=4k --size=1g --ioengine=psync --fsync=1

如果你的 Rust 程序和同口径的 fio 差距超过 20%,说明有一层缓冲没对上 —— 找出来,这就是本课的练习价值。

AI 结对:三条 IO 通路的实测程序pair with ai

这是本阶段第一个动手任务,严格走四步循环。review 重点: 三条红线里的两条在这全占了 —— 落盘(sync_all 的调用位置对不对?direct 模式漏没漏?) 和 unsafe(对齐分配的 Layout、dealloc 是否配对?)。 跑完拿 fio 同口径对账,差距超过 20% 就回头找没对上的那层缓冲。

在 forge workspace 的 forge-store crate 下新建 examples/io_paths.rs,实现一个顺序写测试程序:

1. 命令行参数:--path 目标文件、--mode buffered|direct|sync、--bs 块大小(默认 4096)、--total 总量(用 forge-util 的 parse_capacity 解析,如 "1GiB");
2. buffered 模式:BufWriter + 结束时一次 sync_all;
3. direct 模式:O_DIRECT 打开,buffer 用 4096 对齐分配(std::alloc 实现,unsafe 块必须逐行注释安全性理由),结束时 sync_all;
4. sync 模式:普通打开,但每次 write_all 后立刻 sync_data;
5. 结束打印:耗时、吞吐 MB/s、平均每次写延迟 µs;
6. 所有错误用 Result 和问号运算符上抛,不许 unwrap;direct 模式下参数没对齐要报出人话错误,而不是让内核甩 EINVAL。

完成后在一块真实盘上跑三种模式各一次,把数字贴给我,并解释三者差距。

小结

  • 一次 write() 要过五站:用户 buffer → page cache → 回写 → 盘缓存 → 介质;write 返回只保证到第二站
  • buffered 快是因为在测内存;O_DIRECT 绕过 page cache 但要供奉三重对齐,且仍然需要 fsync
  • fsync/fdatasync/O_DSYNC 语义各不同;新文件要 fsync 父目录;fsync 报错不可重试当没事
  • mmap 作为写路径不可控(回写时机、SIGBUS),forge 写路径不用
  • 存储引擎绕开 page cache 的三个理由:多一次拷贝、回写不可控、双份缓存
  • 下一课把这些通路用起来:设计 forge-store 的盘上格式,写第一个 blob store