文件 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() 系统调用,数据要走完这条路才算真正安全:
- 用户态 buffer —— 你程序里的那个
Vec<u8> - page cache —— 内核把数据拷进来、把页标脏,
write()到这里就返回了 - 回写线程 —— 脏页积累到
vm.dirty_ratio或超时后,内核后台刷盘 - 盘上易失缓存 —— 盘自己还有一层 DRAM 写缓存
- 持久介质 —— NAND / 磁碟,到这里才叫落盘
你调过的 vm.dirty_background_ratio、你在 iostat 里看到的「写延迟突然抖一下」
(回写风暴),全发生在第 2 到第 3 步之间。关键结论先立起来:
write() 返回成功,只证明数据到了第 2 步。断电,它就没了。
page cache 对通用负载是好东西,但对存储引擎是三重麻烦:拷贝(用户态到内核多一次 memcpy)、 不可控(回写时机由内核决定,延迟毛刺没法解释)、双份缓存(引擎自己有缓存, 内核再缓存一份纯属浪费内存)。RocksDB、Ceph BlueStore、Weka 全都选择自己管缓存、直通盘。
std::fs 与 buffered IO:默认路径的代价
先看最朴素的版本 —— 代码交给 AI 写,你要读到能复述每一步在做什么。注意一个容易误会的点: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 的例子:这几行必须让 AI 讲清楚为什么非它不可、出错会怎样。
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(())
}
最常见的误解:「都 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 的原因。
程序 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%,说明有一层缓冲没对上 —— 找出来,这就是本课的练习价值。
这是本阶段第一个动手任务,严格走四步循环。review 重点是三条红线里的两条 —— 落盘和 unsafe。都不用你读代码:让 AI 画出三种模式下 「数据从我的 buffer 走到持久介质」各经过哪几站、刷盘那一步落在哪个位置, 再让它说清 direct 模式的对齐是谁在保证、这块内存什么时候被释放。 它讲的每句话你都要能用上面的验收动作或 fio 数字对上;对不上就是它在编。
目标:给 forge-store 做一个顺序写测试程序,让我能亲眼量出 buffered、O_DIRECT、每写必刷这三条 IO 通路的真实差距。 行为规格: 1. 命令行能指定:目标文件路径、通路模式(buffered / direct / sync 三选一)、单次写入块大小(默认 4096 字节)、总写入量(支持 1GiB、512MiB 这种写法); 2. buffered 模式:写入先攒在用户态缓冲里,攒满才真正下发系统调用;全部写完时刷一次盘,数据与文件长度都要持久; 3. direct 模式:绕过 page cache 直达块层。因此内存地址、文件内偏移、每次 IO 长度三者都必须按 4096 对齐,少一样内核就直接拒绝;全部写完仍然要刷一次盘 —— 绕过 page cache 不等于落盘,盘上还有一层易失写缓存; 4. sync 模式:普通打开,但每写完一块就立刻把这块数据刷到盘上,才继续下一块; 5. direct 模式下如果块大小不是 4096 的整数倍,程序必须在开写之前用人话告诉我哪个参数没对齐、应该是多少,不许把内核的 EINVAL 原样甩给我; 6. 任何预期内的失败(路径不存在、权限不足、盘满、参数非法)都要变成明确的错误信息返回给我,而不是让进程直接崩溃; 7. 结束时打印:总耗时、吞吐 MB/s、平均每次写入延迟微秒数。 交付:程序 + 在一块真实盘上三种模式各跑一次的数字,并解释三者为什么差这么多。(用什么库解析参数、怎么做对齐内存分配你定,最后告诉我为什么这么选,以及对齐分配那段有没有绕过语言的安全检查。)
- 三种模式各跑一次 4 KiB / 1 GiB,记下吞吐。预期量级:buffered 是 GB/s(测的是内存)、direct 是几十到一二百 MB/s、sync 是每秒几百到几千次。哪个对不上量级,就是有一层缓冲没对上
- 用同口径 fio 对账(--bs=4k --size=1g --ioengine=psync,分别不加参数 / 加 --direct=1 / 加 --fsync=1),差距超过 20% 就回头找那层没对上的缓冲
- 给 direct 模式故意传块大小 4095,确认它在开写之前就报出「没对齐」的人话错误,而不是内核的 EINVAL 或者一串栈回溯
- 用 strace -c -e trace=write,fsync,fdatasync 各跑一遍三种模式,数系统调用次数:buffered 的 write 次数应该远少于写入块数,sync 模式的刷盘次数应该等于块数 —— 这是缓冲层次的铁证
- buffered 模式跑的同时开一个 iostat -x 1,看盘上的真实写入是不是一波一波来的(回写风暴);再跑 sync 模式对比曲线形状
- buffered 模式跑出 GB/s,这个数字测的到底是内存还是盘?如果程序打印完成的下一秒我拔电源,文件里还剩多少数据?
- direct 模式已经绕过 page cache 了,为什么最后还要刷一次盘?不刷会丢什么?
- 如果我把块大小改成 512,direct 模式在我这块盘上还能不能跑?为什么有的盘行有的盘不行?
- 做对齐内存分配那段,是不是绕过了语言的安全检查?如果参数传错,最坏会发生什么?
小结
- 一次 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