目录

Linux-30 文件系统与 inode 抽象的必然性

目录

上一篇讲信号如何把异步事件插进进程控制流。这一篇把视线移到数据:文件系统为什么不能只是一张「文件名 → 磁盘位置」的表?

你每天都在写文件:日志追加一行、数据库提交事务、部署时替换配置。但 write() 返回成功时,数据可能只在页缓存、文件系统日志或磁盘控制器缓存中的某一层。真正困难的不是写入字节,而是定义:机器突然断电后,哪些字节和目录关系必须仍然成立?

先看五个问题:

  1. 为什么路径名、dentry、inode、打开文件描述符必须拆成多个对象?
  2. 删除文件后,为什么进程仍能继续读?硬链接为什么说明“文件名”不等于“文件”?
  3. 日志文件系统记录什么?data=journalorderedwriteback 差在哪里?
  4. write()fsync(file)fsync(directory)rename() 各自承诺什么?
  5. O_DIRECT 真的绕过缓存、保证持久化吗?为什么数据库仍然使用它?

1. 名字、对象与打开实例

1.1 路径不是文件

用户看到路径:

/var/log/app/access.log

内核管理的却是两类关系:

名字空间                          文件对象
目录层级、文件名、父子关系  ->  inode、权限、所有者、大小、数据块映射

/var/log/app/access.log       ->  inode 184729

同一个对象可以有多个名字(硬链接);名字消失后,已打开它的进程仍可访问对象。如果把名称和对象强行合成一条记录,rename、link、unlink、并发打开和崩溃恢复都会极其复杂。

Unix 因而把问题拆成:

  • dentry / directory entry:父目录与名字到 inode 的映射
  • inode:文件对象的元数据与数据块映射
  • file / open file description:一次 open() 的状态,包括偏移和 flags
  • file descriptor:进程 fd 表中的整数索引,指向 file

1.2 一个 fd 指向什么

进程 fd table
fd 3  ----------------------+
                            v
                      open file description
                      f_pos = 4096
                      flags = O_APPEND
                            |
                            v
                      inode 184729
                            |
                            v
                      page cache / data blocks

fd 只是表下标。dup(fd) 让两个 fd 指向同一个 open file description,因此共享偏移:

int b = dup(a);
read(a, buf1, 10); // f_pos 前进 10
read(b, buf2, 10); // 从新位置继续读

两次 open() 则产生两个 file 对象,即使指向同一 inode,偏移也独立。fork() 后父子 fd 仍引用同一个 open file description,所以也共享偏移与 file status flags。

1.3 VFS 为什么存在

Linux 不希望 open/read/write/stat 分别理解 ext4、XFS、Btrfs、tmpfs,于是用 VFS(Virtual File System)提供统一对象和操作:

用户态系统调用
      |
      v
     VFS
   /  |   \
ext4 XFS tmpfs
VFS 对象 职责
super_block 一个已挂载文件系统的整体信息
inode 一个文件对象的统一视图
dentry 路径组件与 inode 关系的缓存
file 一次打开实例
address_space 文件与页缓存/回写的关联

磁盘上的 ext4 inode 是持久化格式;内存中的 struct inode 是 VFS 统一对象,两者相关但不等同。

1.4 dentry cache 加速路径解析

每次 open("/a/b/c") 都从磁盘扫描目录会慢得不可接受。dcache 缓存每一层路径关系:

root -> "a" dentry -> "b" dentry -> "c" dentry
          hit             hit             hit

负 dentry 还能缓存“名字不存在”,避免重复查盘。dcache 是内存加速层,掉电后会丢失;重启后依据持久化目录数据重建。


2. inode 把名字与对象分开

2.1 inode 保存什么

典型 inode 保存文件类型、权限、uid/gid、大小、链接数、时间戳、extent/块映射、ACL、xattr 和 flags,通常不保存文件名。名字保存在父目录的数据中:

父目录 inode 42:
"report.txt" -> inode 9001
"latest"     -> inode 9001

多个目录项可以指向同一个 inode,rename 也只需改变目录关系,不复制文件内容。

2.2 硬链接揭示真实对象模型

echo hello > original
ln original alias
ls -li original alias
# inode number 相同,link count 为 2

两个名字背后是一份数据。unlink() 的实际过程是:

  1. 从父目录删除目录项
  2. inode 的 nlink 减一
  3. nlink==0 且没有打开引用时,才回收 inode 和数据块

所以:

int fd = open("app.log", O_RDWR);
unlink("app.log");
write(fd, "still alive\n", 12); // 仍然成功

进程持有 file/inode 引用,不是路径字符串。最后一个 fd 关闭后才真正释放空间。

2.3 为什么不能跨文件系统创建硬链接

硬链接只是同一文件系统目录项对 inode 的新引用。跨文件系统时 inode 命名空间、分配器和生命周期不同,无法共享对象,只能复制文件或使用符号链接。

符号链接则拥有自己的 inode,内容是目标路径字符串,因此能跨文件系统,也能暂时指向不存在的路径。

普通用户不能给目录创建硬链接,否则目录树可能形成循环,..、遍历和回收都失去清晰语义。

2.4 日志删了,空间为什么没回来

日志轮转可能 rename/unlink 旧文件,但服务仍持有旧 fd 并继续写:

目录项 --X--> inode <---- file <---- fd
              nlink=0       仍有引用

目录中已经看不到文件,数据块却不能回收。定位命令:

lsof +L1
ls -l /proc/"$PID"/fd | grep deleted

应用必须 reopen 新路径或关闭旧 fd,单纯删除名字不够。


3. 目录与 rename

3.1 目录是名字映射

目录也是 inode 和数据,但其内容由文件系统解释为“名字 → inode”的目录记录,不能让用户随意 write()

目录权限控制名字空间:读权限允许列举,写权限允许修改目录项,执行权限允许穿过目录并查找已知名字。因此删除文件通常取决于父目录的写+执行权限,而不是文件本身是否可写。

3.2 rename 原子不等于持久化

同一文件系统内 rename(old,new) 通常提供原子名字切换:并发观察者看到旧对象或新对象,不会看到一半。但这不保证掉电后新名字和内容一定存在。

可靠替换的经典协议:

int fd = open("config.tmp", O_WRONLY|O_CREAT|O_TRUNC, 0644);
write_all(fd, content, len);
fsync(fd);                         // 文件内容先稳定
close(fd);
rename("config.tmp", "config");  // 原子切换名字
int dfd = open(".", O_RDONLY|O_DIRECTORY);
fsync(dfd);                        // 目录项再稳定
close(dfd);

rename 解决并发可见性,fsync 解决崩溃后的持久化顺序。跨挂载点 rename 返回 EXDEV,所以临时文件必须放在目标所在文件系统。

3.3 不要用 access + rename 实现排他创建

“先检查目标不存在,再操作”存在 TOCTOU:另一进程可在两个系统调用间创建目标。需要“不覆盖已有目标”时,应使用 renameat2(..., RENAME_NOREPLACE)open(..., O_EXCL) 等原子接口,而不是用户态检查后再做。


4. 页缓存:write 返回不等于落盘

4.1 普通写入的数据旅程

用户 buffer
   | copy_from_user
   v
page cache(dirty page)
   | writeback
   v
文件系统映射 / journal
   |
   v
block layer / device queue
   |
   v
设备易失写缓存
   | flush / FUA
   v
稳定介质

write() 成功通常只表示内核接受数据。页缓存提供读缓存、写合并、预读和异步回写,避免应用等待每一次物理 IO;代价是“已写”与“抗断电”之间存在异步窗口。

4.2 脏页何时回写

内核会根据脏页比例、存在时间、内存压力和周期回写线程把 dirty pages 写出。sync 请求系统回写大量脏数据,但它不是单个业务事务的精确提交接口。

设备还可能缓存写入。文件系统必须在正确时机发 flush/FUA,设备也必须诚实兑现。软件无法补救谎报持久化完成的硬件或虚拟存储层。

4.3 fsync 与 fdatasync

  • fsync(fd):请求文件数据及恢复它所需的元数据稳定
  • fdatasync(fd):重点保证数据和影响后续正确读取的必要元数据,可能不提交无关时间戳

数据库把 WAL 的同步作为 commit 边界。选择 fdatasync 还是 fsync 要依据具体文件系统和业务元数据需求,不能只按名字猜性能。


5. 日志文件系统为什么必要

5.1 一次写会改多个位置

文件增长可能同时修改:

① 分配数据块
② 更新 inode size / extent
③ 更新空闲块位图
④ 更新目录或校验信息

断电发生在中间可能造成块泄漏、重复分配、inode 指向未初始化块。没有 journal 时,fsck 需要扫描大量结构推断状态。

日志文件系统先把一组变更写入连续 journal:

transaction
  -> descriptor + changed blocks
  -> commit record
  -> checkpoint 到正式位置
  -> 回收 journal 空间

恢复时,完整提交的事务重放,未提交事务丢弃。它把全盘猜测变成有限日志重放,首先保证文件系统结构一致

5.2 三种 journal data mode

模式 journal 内容 取舍
data=journal 数据 + 元数据 最强,但数据可能写两次
data=ordered 元数据;相关数据先写正式位置 常见折中,避免指向旧垃圾块
data=writeback 主要是元数据,数据顺序约束弱 成本低,崩溃后可能见旧内容

data=ordered 不代表每次 write 都持久化;它主要保证已提交的元数据不会指向尚未写入的新块。data=writeback 可以让结构恢复一致,但文件内容不是应用最后写入的版本。

5.3 文件系统 journal 不是数据库 WAL

文件系统 journal:恢复 inode、目录、extent、位图等结构
数据库 WAL:恢复数据库逻辑事务与提交顺序

ext4 不理解“扣库存与创建订单必须一起提交”。数据库仍需自己的 WAL,并保证 WAL 先稳定后才返回事务提交。两层日志职责不同。

5.4 fsync 可能触发什么

一次 fsync(file) 可能需要:

  1. 回写 dirty data
  2. 将 inode/extent 变化加入 journal transaction
  3. 写 journal commit record
  4. 对设备发 flush

路径会随 ext4/XFS/Btrfs、挂载参数、内核和设备变化。不能从“启用了 journal”推导出“write 返回即防断电”。


6. ext3 到 ext4:delayed allocation 的教训

6.1 延迟分配为什么更快

write()
  -> 数据进入 page cache
  -> 暂不决定物理块
  -> 回写时看到更大范围 dirty pages
  -> 一次分配连续 extent

延迟到回写时分配物理块,文件系统能做出更好的连续布局,减少碎片。但逻辑文件已更新与物理块稳定之间的窗口也扩大了。

6.2 历史争议的真正结论

ext3/不同 data mode 与应用“写临时文件再 rename”的习惯曾让很多程序偶然获得比标准承诺更强的结果。ext4 delayed allocation 暴露了这些未调用 fsync 的应用假设:崩溃后可能留下零长度文件、旧版本或缺失的新名字。

教训不是“ext4 不可靠”,而是三层语义必须分开:

  • write 成功:内核接受了数据
  • journal 恢复:文件系统结构一致
  • 业务 commit:应用要求某个版本在崩溃后存在

第三层必须由 fsync、目录同步、WAL 或版本化协议明确表达,不能依赖旧文件系统的偶然写入顺序。

6.3 安全更新完整协议

write(temp)
  -> fsync(temp)
  -> rename(temp, target)
  -> fsync(parent directory)

若还要保证 mode、owner、xattr,应在 rename 前设置并按文件系统语义同步。跨文件系统不能原子 rename,需要改用同挂载点临时文件或高层事务。


7. fsync 的边界

7.1 四种成功

write()       内核接受数据,通常进入 page cache
close()       fd 引用释放,不等于持久化
fsync(file)   文件数据及必要 inode 元数据稳定
fsync(dir)    创建、删除、rename 的目录关系稳定

close() 可能报告延迟 IO 错误,严肃程序不应忽略返回值;但它仍不能代替明确的提交同步。

7.2 为什么文件与目录都要同步

fsync(file),崩溃后新目录项可能丢失;只 fsync(dir),名字可能指向内容尚未稳定的新 inode。安全创建和替换需要分别处理文件内容链和名字空间链。

7.3 存储栈越长,契约越重要

应用 -> VFS -> 文件系统 -> block layer -> hypervisor -> 云存储 -> SSD

fsync 只能要求下层兑现持久化契约。RAID 控制器有无电池、虚拟磁盘是否透传 flush、云盘如何定义完成,都会影响结果。关键系统要通过故障注入与厂商契约验证,而不只靠正常关机测试。


8. O_DIRECT:缓存策略不是持久化策略

8.1 它解决双重缓存

数据库已有自己的 buffer pool,再让页缓存保留同一批页面,会造成双份内存和两套预读/淘汰策略冲突。O_DIRECT 尝试让数据在用户 buffer 与存储间传输,绕过普通页缓存,让数据库自行控制缓存和 IO 顺序。

8.2 复杂性转移给应用

int fd = open("data.db", O_RDWR | O_DIRECT);
void *buf;
posix_memalign(&buf, 4096, 4096);
pread(fd, buf, 4096, 0);

buffer 地址、长度和 offset 往往有对齐要求,具体粒度依内核、文件系统和设备。应用还要自己处理预读、缓存淘汰、短 IO、内存管理和并发。

Linus 对 O_DIRECT 的批评核心是:普通应用不应重复实现内核缓存,并把不可移植复杂性带进程序。数据库仍使用它,是因为数据库掌握页面、WAL、checkpoint、事务和查询模式的全局信息,确实有能力做更合适的策略。

8.3 绕页缓存不等于落盘

数据仍可能停在文件系统、块层或设备缓存。持久化仍需 fsync/fdatasync 与正确 flush 语义:

O_DIRECT:选择缓存路径
fsync:建立持久化完成点
O_SYNC:给写操作更强同步语义

普通 buffered IO 配合 fsync 一样可以持久化;O_DIRECT 不是 durability 开关,也不保证更快。


9. 线上文件系统排查

9.1 文件删除但空间不降

lsof +L1
ls -l /proc/"$PID"/fd | grep deleted
df -h /var/log
df -i /var/log

deleted-open 文件仍占空间。让应用 reopen/关闭旧 fd 后才释放。

9.2 df 有空间却写不进去

可能是 inode 用尽、quota、只读挂载、ACL、ext4 保留块、远程存储故障、文件大小限制或容器层容量:

df -h /path
df -i /path
findmnt -T /path/to/file
stat -f /path/to/file
getfacl /path/to/file
ulimit -a

不要只看块容量。

9.3 fsync 为什么慢

pidstat -d -p "$PID" 1
iostat -xz 1
strace -ff -ttT -e trace=write,fsync,fdatasync,openat,rename -p "$PID"

可能是脏页积累、journal commit、设备队列、云盘确认或多个线程争同一个 WAL。不要直接删 fsync;先明确 durability,再通过 group commit、批量、合理 WAL 和更合适的存储降成本。

9.4 df 与 du 不一致

du -xsh /var/lib/app
df -h /var/lib/app
lsof +L1

df 统计已分配块,du 统计目录中可遍历文件。差额常来自 deleted-open、快照、保留块、隐藏挂载或文件系统元数据。


10. 常用命令知识点扩展

短参 长参 英文全称 作用
ls -i ls --inode inode 显示 inode 编号
ls -l ls --format=long long listing 显示链接数、权限、大小
find -inum N 无统一长参 inode number 按 inode 找硬链接
df -h df --human-readable disk free 显示块空间
df -i df --inodes inode statistics 显示 inode 使用量
du -x du --one-file-system disk usage 只统计一个文件系统
lsof +L1 无统一长参 list open files 找 nlink 小于 1 的打开文件
findmnt -T findmnt --target find mount 查路径所属挂载点

场景配方:

# 找一个 inode 的所有名字
find /mount -xdev -inum 9001 -print

# 对比块与 inode 容量
df -h /mount
df -i /mount

# 查路径具体落在哪个文件系统及挂载参数
findmnt -T /mount/path -o TARGET,SOURCE,FSTYPE,OPTIONS

11. 面试题

Q:为什么要拆分 dentry、inode、file 和 fd?

dentry 管名字关系,inode 管无名字的文件对象,file 管一次 open 的偏移与 flags,fd 只是进程表索引。拆开后,同一 inode 可有多个硬链接,unlink 后已打开对象仍存活,dup/fork 可共享 open file description,而两次 open 可有独立偏移。

Q:inode 为什么通常不保存文件名?

文件名属于父目录,一个 inode 可被多个目录项引用。这样 rename 修改目录关系即可,硬链接也无需复制数据。unlink 先删除名字,只有链接数和打开引用都归零才回收对象。

Q:删除文件后为什么还能读?

fd 经 file 持有 inode 引用;unlink 只删除目录项并减少 nlink。打开引用仍在时数据块不能回收,最后一个 fd close 后才释放。lsof +L1 能找到这类文件。

Q:rename 是事务吗?

它通常只提供同文件系统内名字切换的原子可见性,不保证掉电后数据和目录项已持久化。安全替换一般要 write temp → fsync(temp) → rename → fsync(parent)。跨文件系统 rename 不原子并返回 EXDEV。

Q:write 成功为什么仍可能丢数据?

buffered write 通常只进入页缓存;后面还有异步回写、journal、块层和设备易失缓存。write 成功表示内核接受,不是稳定存储提交。明确持久化需要 fsync/fdatasync 和可信 flush 链路。

Q:三种 journal data mode 如何区别?

data=journal 把数据和元数据都写 journal,最强但写放大大;ordered 只 journal 元数据并要求相关数据先写正式位置;writeback 的数据顺序约束较弱,恢复后可能见旧块内容。它们首先保证文件系统恢复,不等于应用事务提交。

Q:delayed allocation 的教训是什么?

延迟物理块分配可优化 extent 和碎片,却扩大逻辑写入与物理稳定之间的窗口。ext4 暴露了很多应用依赖旧写入顺序、未调用 fsync 的错误假设。业务必须显式定义 commit,不应把 write 或 journal 一致性当业务 durability。

Q:为何 fsync(file) 后还要 fsync(directory)?

文件 fsync 保护内容与 inode 必要元数据;目录 fsync 保护创建、删除和 rename 的名字关系。只同步一边,崩溃后可能内容存在但名字丢失,或名字存在而新内容未稳定。

Q:O_DIRECT 是否保证落盘?

不保证。它主要绕过普通页缓存,仍经过文件系统、块层和设备缓存,并有对齐限制。持久化仍需 fsync/fdatasync。数据库因有自己的 buffer pool 和 WAL 才可能受益,普通应用可能反而更慢。

Q:数据库为何仍要 WAL?

文件系统 journal 只理解 inode、目录、extent 等结构,不理解数据库事务和提交顺序。数据库 WAL 定义 redo/undo 与 commit durability,职责不同,不能互相替代。

Q:df 与 du 为什么不同?

df 统计文件系统分配块,du 只遍历可见目录项。deleted-open 文件、快照、保留块、隐藏挂载和元数据都会形成差额,应联合 lsof +L1findmnt 和文件系统工具排查。

Q:fsync 很慢时能直接删除吗?

不能。先明确哪些数据必须在返回前持久化,再定位是 journal、脏页、设备队列还是云盘确认。可通过 group commit、批量 WAL、减少提交频率或换存储优化;删 fsync 等于改变故障语义,必须由业务接受。


小结

  • 文件名不是文件:dentry 管名字,inode 管对象,file 管一次打开,fd 只是索引
  • 硬链接、unlink 后继续访问、dup/fork 共享偏移,都是对象拆分的自然结果
  • 页缓存提升读写性能,但 writeclose 不等于抗断电持久化
  • journal 主要保证文件系统结构可恢复,不理解数据库逻辑事务
  • data=journalorderedwriteback 在数据顺序、写放大和性能之间取舍
  • delayed allocation 提升布局质量,也揭示应用必须显式表达持久化边界
  • 安全替换通常是 write temp → fsync(temp) → rename → fsync(parent directory)
  • O_DIRECT 是缓存路径策略,不是持久化保证;数据库能用好它是因为掌握全局事务信息
  • dfdu、inode、deleted-open 文件和挂载边界要联合分析

下一篇讲 IO 模型演进:阻塞 → epoll —— select 的 O(n) 扫描才是关键、epoll 的红黑树与就绪链表如何分工、ET/LT 的真实区别、惊群与 EPOLLEXCLUSIVE,以及 Go netpoller 怎样把这些机制串起来。


12. 页缓存与回写的内部节奏

12.1 page cache 不是一个简单的 map

从应用视角看,页缓存像“文件偏移到内存页”的映射;从内核视角,它还要协调:

文件 inode
   |
address_space / xarray
   |
文件偏移 -> folio/page
   |
clean / dirty / writeback / locked

现代内核逐步用 folio 表示一组连续物理页,减少大页和批量回写的元数据开销。无论底层结构名称如何变化,关键状态仍是:

  • clean:缓存内容与稳定文件数据一致
  • dirty:应用修改过,尚未写回
  • writeback:正在提交到文件系统/块层
  • uptodate:缓存页内容完整可用

应用的 write() 可能只是在 dirty 状态上完成一次内存复制;真正的 IO 发生在后续 writeback 或显式同步时。

12.2 writeback 为什么会反过来阻塞应用

脏页不是无限积累的。达到 dirty limits 后,产生脏页的线程会被迫参与回写(direct reclaim / balance dirty pages):

低脏页:
write -> page cache -> 很快返回

脏页过多:
write -> 触发 balance_dirty_pages
     -> 等待回写速度追上产生速度
     -> write 延迟突然变高

这解释了一个常见现象:磁盘平均吞吐没有满,但应用 write() 偶尔卡住。瓶颈可能是回写节奏、journal 提交、单设备队列或 dirty limit,而不是单次 IO 的带宽。

# 观察系统脏页与回写页
grep -E '^(Dirty|Writeback|WritebackTmp):' /proc/meminfo

# 观察回写相关内核线程
ps -eo pid,stat,wchan:32,comm | grep -E 'flush-|jbd|kworker'

# 每秒看块设备队列和等待
vmstat 1
iostat -xz 1

12.3 顺序写也不一定顺序落盘

应用按偏移递增调用 write(),只说明逻辑提交顺序。页缓存可以合并写,文件系统可以延迟分配,块层可以重排请求,SSD 内部还有 FTL 和垃圾回收:

应用顺序
  -> page cache 合并
  -> 文件系统分配 extent
  -> block layer 调度
  -> SSD FTL 重映射

顺序写通常更容易获得高吞吐,但不能从“应用按顺序写”推导出“断电后按顺序看到所有字节”。需要顺序一致性时,使用明确的日志协议和同步边界。


13. 崩溃一致性:把承诺写成状态矩阵

13.1 安全替换的四个断电点

假设目标文件是旧版本 A,临时文件写入新版本 B

断电位置 未调用 fsync(temp) 已 fsync(temp),未 rename 已 rename,未 fsync(dir) 已 fsync(dir)
可能的结果 temp/内容不完整 A 通常仍可见,B 可能残留 可能 A 或 B,依实现与时序 目标目录关系达到协议要求

这张表不是对所有文件系统的绝对承诺,而是帮助明确每一步解决哪个问题

  • fsync(temp):把 B 的文件内容和必要元数据推到稳定路径
  • rename:让并发读者看到 A/B 的原子名字切换
  • fsync(dir):把这个名字切换纳入目录持久化

如果临时文件创建在目标目录之外,rename 还可能失败 EXDEV;如果目标路径有多个硬链接,更新其中一个名字不会让所有名字一起变化。

13.2 创建新文件的协议

创建新文件比替换已有文件少了旧版本,但目录项仍是独立持久化对象:

fd = open(temp, O_CREAT|O_EXCL)
write all data
fsync(fd)
close(fd)
rename(temp, final)
fsync(parent directory)

如果使用 O_TMPFILE,Linux 可以先创建一个没有目录名的 inode,再用 linkat 把它发布到目录中:

int fd = open(".", O_TMPFILE | O_WRONLY, 0644);
write_all(fd, data, len);
fsync(fd);
linkat(fd, "", AT_FDCWD, "config", AT_EMPTY_PATH);
fsync(dirfd);

它减少了临时文件名暴露和清理问题,但依赖 Linux、文件系统支持以及正确处理 link 的错误路径。它同样不跳过最后的目录 fsync。

13.3 truncate 与 rename 的语义不同

直接 open(target, O_TRUNC)
  -> 先把旧文件截成空
  -> 写入新内容
  -> 崩溃可能只剩半个文件

写 temp + fsync + rename
  -> 旧文件一直完整保留到切换瞬间
  -> 崩溃更容易得到 A 或 B

配置、证书、路由表这类“必须是完整版本”的文件,不应直接 truncate 原文件再写。原子替换不是为了性能,而是把恢复状态压缩到完整旧版本/完整新版本两种可接受状态。


14. Go 视角:文件 API 没有自动提供事务

14.1 os.WriteFile 到底做了什么

Go 的高层 API 方便,但不会替你定义持久化协议:

err := os.WriteFile("config", data, 0o644)

它解决了打开、写入和关闭的工程细节,返回成功也不等于“断电后一定存在完整 data”。需要 durability 时,要显式控制 fd:

f, err := os.OpenFile("config.tmp", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil { log.Fatal(err) }
if _, err = f.Write(data); err != nil { log.Fatal(err) }
if err = f.Sync(); err != nil { log.Fatal(err) }
if err = f.Close(); err != nil { log.Fatal(err) }
if err = os.Rename("config.tmp", "config"); err != nil { log.Fatal(err) }

dir, err := os.Open(".")
if err != nil { log.Fatal(err) }
if err = dir.Sync(); err != nil { log.Fatal(err) }
if err = dir.Close(); err != nil { log.Fatal(err) }

这里的 Sync 是向操作系统请求同步,不应在没有业务需要时对每一行日志都调用;可以用批量提交、WAL group commit 或定期 checkpoint 平衡吞吐与 durability。

14.2 O_APPEND 保证什么

多个进程以 O_APPEND 打开同一文件时,内核会把“定位到文件尾并写入”作为一次写操作的原子定位语义,避免两个 writer 都拿到同一个旧 end offset。但它不保证:

  • 一次很大的 write 不会被拆分观察
  • 多个 write 调用的日志记录不会交错
  • 记录已经稳定落盘
  • NFS 等远程文件系统提供完全相同的并发语义

日志记录要避免交错,应让一条记录在一次合适大小的 write 中完成,或由单一 writer/日志代理串行化;要抗断电,还要设计同步策略。

14.3 mmap 修改也需要持久化协议

文件映射写入绕过了显式 write(),但仍可能修改页缓存:

p = mmap(..., MAP_SHARED, fd, 0);
p[0] = 'X';
msync(p, len, MS_SYNC);

msync 请求映射范围的更新同步;若需要文件大小、目录关系或多文件事务,仍要处理 inode/目录/WAL。MAP_PRIVATE 的写入是写时复制,不会把修改写回原文件。

不要认为 mmap 是“更直接的 fsync 替代品”:它改变的是访问 API,不会自动生成业务事务边界。


15. 常见错误与修复路径

15.1 用 close 当 commit

错误: 写完关闭 fd,就认为数据已安全。

问题: close 主要释放引用;写回可能尚未完成,目录项更可能未同步。

修复: 对需要 durability 的文件先 fsync/fdatasync,替换文件再同步父目录,并检查所有返回值。

15.2 只 fsync 文件,不 fsync 目录

错误: 新文件内容已经稳定,所以认为名字也稳定。

问题: 文件对象与父目录目录项是两条持久化链。

修复: 创建/rename 后打开父目录并 fsync;跨平台时用平台文档确认目录同步能力。

15.3 用 sleep 等待“磁盘已经写完”

错误: write(); sleep(1); 代替 fsync。

问题: 回写调度和设备队列没有固定时间上界,睡眠不是持久化协议。

修复: 使用明确的同步 API,并通过故障注入测试恢复状态。

15.4 把 O_DIRECT 当性能开关

错误: 发现 page cache 占内存,就全局加 O_DIRECT。

问题: 丢掉内核预读/回写优化,却未必拥有应用层缓存和 IO 调度能力。

修复: 只有基准证明双重缓存是瓶颈,并且应用能处理对齐、短 IO、预读、持久化和错误恢复时才考虑。

15.5 只看文件大小,不看 inode 与 fd

错误: du 看着很小,就认为日志空间没问题。

问题: deleted-open 文件、稀疏文件和隐藏挂载会让 dfdu 差异很大。

修复: 联合 df -hdf -idu -xlsof +L1findmnt,再定位具体进程。


16. 追加面试题

Q:页缓存为什么会让 write 偶尔突然变慢?

脏页达到阈值后,产生脏页的线程不能无限继续写,会被迫参与回写或等待 writeback;journal commit、块设备队列和云盘确认也可能把延迟传播到 write。平均磁盘带宽不满不代表回写没有造成阻塞,要结合 /proc/meminfo、vmstat、iostat 和 fsync/write 系统调用时间。

Q:为什么 write 按顺序调用,断电后不一定按顺序看到?

页缓存合并、文件系统延迟分配、块层调度和设备 FTL 都可能重排物理写入。逻辑调用顺序不是稳定介质顺序。需要恢复顺序时,用日志记录、commit marker 和 fsync 建立明确 happens-before。

Q:为什么配置更新不能直接 O_TRUNC 后写?

truncate 会先破坏旧版本;写入中途崩溃可能留下空文件或半个文件。临时文件写完整并 fsync,再 rename 切换名字,旧版本会一直保留到切换点,崩溃恢复状态更容易保证为旧/新完整版本。

Q:O_TMPFILE 有什么价值?

它创建没有目录名的临时 inode,应用可在不可见状态下写完、同步并校验,再通过 linkat 发布名字,减少临时文件暴露和清理问题。它仍需处理文件系统支持、link 错误和父目录 fsync,不能把它当自动事务。

Q:Go 的 os.WriteFile 是否保证断电后内容完整?

不保证。它提供方便的打开、写入和关闭,不自动为应用完成 fsync、rename 和目录 fsync。需要 durability 时,应显式使用 File.Sync、临时文件原子替换和父目录同步,或交给实现了 WAL 的数据库/存储库。