跳转至

Direct & Sync I/O

是什么 / 解决什么问题

  • O_DIRECT:绕过 page cache,直接在用户缓冲区和磁盘间传输数据
  • O_SYNC / fsync:保证数据持久化到物理存储

这两者服务于不同目的,可以独立或组合使用。

使用模式

O_SYNC — 每次写入都等落盘

int fd = open("data", O_WRONLY | O_SYNC);
write(fd, data, len);
// 返回时数据已经在磁盘上(含文件元数据)
// 等价于每次 write 后都调 fsync

变体:

标志 保证
O_SYNC 数据 + 元数据都落盘
O_DSYNC 只保证数据落盘(不含不影响读取的元数据如 mtime)

fsync / fdatasync — 按需刷盘

int fd = open("data", O_WRONLY);
write(fd, data, len);   // 快,只到 page cache
write(fd, data2, len2); // 快
fsync(fd);              // 慢,等待上面所有写入落盘

// fdatasync:只刷数据,不刷文件大小未变的元数据
fdatasync(fd);

典型使用:WAL (Write-Ahead Log)

// 数据库写入模式
write(wal_fd, log_entry, entry_len);
fdatasync(wal_fd);  // 保证日志持久化
// 然后才更新数据文件

O_DIRECT — 绕过 page cache

int fd = open("data", O_RDWR | O_DIRECT);

// 关键约束:缓冲区必须对齐!
void *buf;
posix_memalign(&buf, 4096, buf_size);  // 4KB 对齐
// 或 aligned_alloc(4096, buf_size);

// 读写的大小和偏移也必须是扇区大小(通常 512 或 4096)的整数倍
pread(fd, buf, 4096, 0);       // OK
pread(fd, buf, 1000, 0);       // 可能失败 (EINVAL)
pread(fd, buf, 4096, 100);     // 可能失败 (EINVAL)

free(buf);

O_DIRECT + O_SYNC 组合

int fd = open("data", O_RDWR | O_DIRECT | O_SYNC);
// 绕过 cache + 保证落盘
// 最"诚实"的 I/O:返回时数据确实在磁盘上

行为与语义

普通 write 的路径

write(fd, buf, 4096)
  → 用户 buf 拷贝到 page cache      (CPU 拷贝)
  → 标记页为 dirty
  → 立即返回
  → ... 后台 writeback 线程异步写磁盘 ...

O_DIRECT write 的路径

write(fd, aligned_buf, 4096)
  → DMA 直接从用户 buf 写入磁盘    (零内核拷贝)
  → 等待磁盘完成
  → 返回

fsync 的语义细节

fsync(fd) 保证:
  1. fd 关联文件的所有 dirty 页写入磁盘
  2. 文件元数据(大小、权限等)写入磁盘
  3. 上述操作完成后才返回

但 fsync 不保证目录项(dentry)的持久化!
新创建文件后需要 fsync 父目录:
// 安全地创建新文件
int fd = open("dir/newfile", O_WRONLY | O_CREAT, 0644);
write(fd, data, len);
fsync(fd);
close(fd);

// 还需要 fsync 目录,确保 "newfile" 这个目录项持久化
int dirfd = open("dir", O_RDONLY);
fsync(dirfd);
close(dirfd);

rename + fsync 的安全写入模式

// 原子安全写入文件(数据库、配置文件常用)
int fd = open("file.tmp", O_WRONLY | O_CREAT | O_TRUNC, 0644);
write(fd, data, len);
fsync(fd);
close(fd);
rename("file.tmp", "file");  // 原子替换
int dirfd = open(".", O_RDONLY);
fsync(dirfd);  // 持久化 rename 操作
close(dirfd);

性能考量

O_DIRECT 的使用场景

适合: - 应用层自己管理缓存(数据库:MySQL InnoDB、RocksDB) - 避免 page cache 被大量一次性数据污染 - 超大文件顺序写(cache 反而是负担)

不适合: - 小文件随机读(page cache 的预读和缓存能力被浪费) - 需要 mmap 的场景 - 读取量远大于物理内存的场景(O_DIRECT 没有缓存命中的可能)

fsync 的性能影响

一次 fsync 的耗时:
  SSD:  0.1ms ~ 几ms
  HDD:  5ms ~ 15ms(机械寻道 + 旋转延迟)

频繁 fsync 是性能杀手!

优化策略:

// 1. 批量 fsync:攒一批写入后一次 fsync
for (int i = 0; i < batch_size; i++) {
    write(fd, entries[i], entry_len);
}
fsync(fd);  // 一次 fsync 覆盖整批

// 2. group commit:多个线程的写入合并一次 fsync
// 数据库常用:收集多个事务,一次 fsync 提交

// 3. fdatasync 代替 fsync(如果不关心元数据)
fdatasync(fd);  // 通常比 fsync 快(少刷一次 inode)

sync_file_range(精细控制)

// Linux 特有:只刷文件的指定范围
sync_file_range(fd, offset, length,
    SYNC_FILE_RANGE_WRITE);            // 开始回写但不等待
sync_file_range(fd, offset, length,
    SYNC_FILE_RANGE_WAIT_BEFORE |
    SYNC_FILE_RANGE_WRITE |
    SYNC_FILE_RANGE_WAIT_AFTER);       // 等价于 fdatasync 该范围

注意:sync_file_range 不保证元数据持久化,不能替代 fsync 做崩溃一致性。

常见陷阱与 FAQ

1. O_DIRECT 对齐要求

// 必须满足三个对齐:
// 1. 用户缓冲区地址对齐(通常 512 或 4096)
// 2. 读写偏移对齐
// 3. 读写长度对齐

// 不对齐 → EINVAL

// 查询设备的对齐要求
struct statfs sfs;
fstatfs(fd, &sfs);
// sfs.f_bsize 即文件系统块大小

2. fsync 可能失败(且状态不可恢复)

if (fsync(fd) == -1) {
    // EIO: 写回失败
    // 此时脏页可能已被丢弃!重试 fsync 不会重新写入
    // PostgreSQL 因此问题修改了崩溃恢复策略
    // 正确做法:视为数据损坏,从 WAL 恢复
}

3. O_DIRECT 和 page cache 不一致

// 进程 A: O_DIRECT 写入
// 进程 B: 普通 read(从 page cache 读)
// B 可能读到过期数据!

// O_DIRECT 写绕过 page cache,不会更新 cache 中的旧页
// 同一文件不要混用 O_DIRECT 和 buffered I/O

4. O_SYNC 不等于 O_DIRECT

O_SYNC O_DIRECT
绕过 cache 否(写入 page cache 后再刷盘)
保证落盘 否(只保证绕过 cache,磁盘可能有写缓存)

两者解决不同问题,需要根据需求组合。

观测与调试

# 区分 buffered 和 direct I/O
strace -e trace=openat ./app 2>&1 | grep O_DIRECT

# 查看 page cache 写回活动
cat /proc/vmstat | grep -E "nr_dirty|nr_writeback"

# 查看 fsync 延迟
bpftrace -e '
tracepoint:syscalls:sys_enter_fsync { @start[tid] = nsecs; }
tracepoint:syscalls:sys_exit_fsync /@start[tid]/ {
    @fsync_us = hist((nsecs - @start[tid]) / 1000);
    delete(@start[tid]);
}'

# 查看磁盘实际写入量(区分 cache 写和真实磁盘写)
iostat -x 1
# wkB/s: 实际写入磁盘的 KB/s

延伸阅读