第 03 章 文件系统:一切皆文件的野心与代价

Unix 有一个著名到被滥用的口号:“一切皆文件”(Everything is a file)。

它听起来优雅、简洁、哲学。但当你真的把磁盘、键盘、网卡、随机数发生器、甚至进程状态都塞进 read/write 这两个动词里时,会发生什么?

答案是:你会得到一个极其优雅的统一接口,和一堆藏在暗处的、要你命的语义差异。


引子:一个让你困惑 30 分钟的现象

先看一段 Go 代码,你猜它哪里会出问题:

f, _ := os.OpenFile("data.txt", os.O_CREATE|os.O_WRONLY, 0644)
f.Write([]byte("hello world"))
f.Close()
// 数据写进去了吗?写进去了。安全了吗?未必。
 
// 再看这个
os.Rename("data.tmp", "data.txt")
// 这个操作是原子的吗?是的。为什么?因为它没写数据,只改了个指针。

两个操作,一个”看起来写了数据但可能没持久化”,一个”看起来只改了个名字但保证原子”。这背后是文件系统最核心的设计智慧,也是最多人栽跟头的地方。

这一章,我们拆开”文件”这个我们天天用、却很少有人真正理解的东西。

给你一句总纲:

文件系统的本质,是在”持久化存储”这个又慢又容易坏的物理介质之上,用层层缓存和抽象,伪装出一个”又快又可靠”的假象。

它靠三样东西维持这个假象:VFS(统一抽象)、page cache(用内存换速度)、日志(用冗余换可靠)。

而它的账单,就是这三个东西各自的裂缝——抽象泄漏、缓存丢失、日志开销。


本章路线图

第一幕:一切皆文件的真相
  Q1  "一切皆文件"到底是什么意思?真的"一切"都是文件吗?
  Q2  文件描述符是什么?它为什么是"整数的指针"?

第二幕:VFS —— 抽象层的抽象层
  Q3  VFS 解决了什么问题?代价是什么?
  Q4  inode、dentry、page cache,这三层分别管什么?

第三幕:链接 —— 文件名的真相
  Q5  硬链接和软链接的本质区别是什么?
  Q6  为什么"删除文件"其实是"减引用计数"?

第四幕:持久化 —— 最深的坑
  Q7  fsync 到底保证什么?为什么不保证什么?
  Q8  为什么文件写完了,掉电后变成 0 字节?
  Q9  日志(journaling)的三种模式,差别在哪?
  Q10 rename 为什么是原子的?怎么用它做安全更新?

第五幕:性能与陷阱
  Q11 为什么 ls 一个大目录会卡住?
  Q12 ext4 的"延迟分配"是怎么让你丢数据的?

第六幕:抽象的反噬
  Q13 为什么网络设备不是文件?ioctl 这个"逃生舱"说明了什么?
  Q14 这套设计面临哪些结构性的问题?

第一幕:一切皆文件的真相

Q1 “一切皆文件”到底是什么意思?真的”一切”都是文件吗?

先把神话戳破一半。

“一切皆文件”的准确含义,不是”万物都是文件”,而是:

所有资源的访问,都被抽象成同一种接口:open / read / write / close。

也就是说,无论你面对的是磁盘、键盘、鼠标、网卡、打印机、随机数发生器,还是 /proc 里的进程信息、/dev 里的设备节点,你都用同一套系统调用来操作:

int fd = open("/dev/sda", O_RDONLY);   // 打开硬盘设备
char buf[512];
read(fd, buf, 512);                     // 读 512 字节
 
int kbd = open("/dev/input/event0", O_RDONLY);  // 打开键盘
read(kbd, &event, sizeof(event));                 // 读一次按键
 
int rnd = open("/dev/urandom", O_RDONLY);  // 打开随机数发生器
read(rnd, &seed, 8);                          // 读 8 字节随机数
 
FILE *f = fopen("/proc/self/status", "r");   // 打开进程自己的状态
// 读出来的是"名字:值"的文本

这个统一的接口,好处是巨大的:

  1. 工具通用:cat、grep、dd、hexdump 这些工具,不需要为每种设备写一个版本。因为它们只认 read/write。
  2. 组合能力强:管道把”一个程序的输出”接到”另一个程序的输入”,因为两边都是文件描述符,天然能接。
  3. 学习成本低:学会了 read/write,就学会了访问所有资源。

但代价同样巨大,而且藏得很深:

不同的”文件”,read 的语义完全不同:

read(磁盘文件)   → 返回你要求的字节数(或者 EOF),可以 seek,有位置概念
read(键盘)       → 返回"一次按键"的数据,没有"位置"概念,会阻塞
read(网络socket) → 返回"当前可读的字节数",可能比你要的少,可能阻塞
read(/dev/urandom) → 永远不阻塞,永远有数据
read(/proc/... )  → 每次读都是"实时生成的",没有持久化的"文件"存在

抽象给你的是”统一的调用方式”,但没给你”统一的语义”。

这就是”抽象泄漏”的又一例:你以为所有文件都一样,其实它们只是”长得一样”,行为天差地别。

停下来想:这跟你的日常工作有什么关系?

你调用一个内部 RPC 接口,接口签名都是 func Call(ctx, req) (resp, err),看起来统一。但有的接口是同步的,有的内部是异步的;有的幂等,有的不幂等;有的能重试,有的重试会重复扣款。

统一的签名 ≠ 统一的语义。 如果你假设”长得一样就行为一样”,就会踩坑。这正是”一切皆文件”给你上的课。

Q2 文件描述符是什么?它为什么是”整数的指针”?

open() 返回一个 int,比如 3。这个小小的整数,是 Unix 最精妙的设计之一。

它叫文件描述符(file descriptor,fd),本质是一个指向内核态结构的索引。

内核为每个进程维护一张表——文件描述符表。fd = 3 的意思就是”去这张表的第 3 项找”。

进程 A
┌─────────────────────────────┐
│ 文件描述符表(进程私有)        │
│  0 → 标准输入(终端)          │
│  1 → 标准输出(终端)          │
│  2 → 标准错误(终端)          │
│  3 → 指向"打开的文件"结构 ───┐ │
│  4 → 指向另一个文件 ────────┐│ │
└───────────────────────────┼┼┘
                            ││
        ┌───────────────────┘└──────────┐
        ↓                               ↓
   ┌──────────────┐               ┌──────────────┐
   │ 打开文件描述   │               │ 打开文件描述   │
   │ (file struct)│               │ (file struct)│
   │  当前偏移量    │               │  当前偏移量    │
   │  打开模式     │               │  打开模式     │
   └──────┬───────┘               └──────┬───────┘
          │                              │
          ↓                              ↓
   ┌──────────────────────────────────────────────┐
   │  inode(真正的文件,有引用计数,全系统共享)      │
   └──────────────────────────────────────────────┘

这个设计的精妙之处:

精妙一:fd 把”进程私有的状态”和”共享的文件实体”分开了。

注意中间那层 file struct——它存的是当前偏移量(你 read 到哪了)和打开模式(只读还是读写)。这层是每次 open 都新建一份的。

而最底层的 inode 是全系统共享一份的(不管多少人打开同一个文件,inode 只有一个,用引用计数管理)。

这意味着:

两个进程各自 open 同一个文件:
  - 各自有自己的"偏移量"(file struct 各自一份)
  - 但共享同一个 inode(真正的数据)

fork() 之后父子进程:
  - 共享同一个 file struct(所以共享偏移量!)
  - 这是父子进程能协作读写同一个文件的原因

精妙二:fd 是”能力(capability)“的雏形。

一个进程拿到 fd,就拥有了”对这个文件的操作能力”。你可以把 fd 通过 Unix socket 传给另一个进程(SCM_RIGHTS),这就是**“传递一个能力”**,而不是”传递一个名字”。

被传过去的进程,直接就有了操作这个文件的权限,不需要重新 open(也就绕过了路径权限检查)。

这个”能力”模型,后来在安全领域发扬光大:capability-based security。Docker 的权限管理、Kubernetes 的 ServiceAccount、甚至零信任网络里的”短时令牌”,都是这个思想的延伸。

“传递能力”比”传递凭证”更安全——因为能力是”对具体资源的操作权”,凭证是”证明你是谁”,后者的泄露面大得多。

精妙三:fd 让”重定向”变得无比简单。

# 把标准输出重定向到文件
./program > output.txt

这个 > 做了什么?就是:

1. open("output.txt", O_WRONLY|O_CREAT)
2. 把 fd 1(标准输出)替换成这个新文件的 fd
3. program 里所有 write(1, ...) 都写到了 output.txt

因为程序只认”fd 1”,不关心 fd 1 背后是终端还是文件,所以重定向是零改动的。

精妙四:fd 是最小的整数 = 最高效的引用。

进程内引用一个文件,只需要 4 个字节(一个 int)。比存路径字符串、存对象指针都轻。内核里所有”文件”相关的操作,都通过这个 int 索引,快。


第二幕:VFS —— 抽象层的抽象层

Q3 VFS 解决了什么问题?代价是什么?

VFS(Virtual File System,虚拟文件系统)是 Linux 内核里最成功的一次抽象。

它解决的问题:

内核要支持多种文件系统——ext4、XFS、btrfs、NFS(网络)、FAT32(U盘)、procfs(虚拟)、tmpfs(内存)……如果每个文件系统调用都写一套专门的代码,内核会爆炸。

VFS 的做法:定义一套通用的接口(inode 操作、文件操作、目录操作),每个具体文件系统实现这套接口。

用户态
  │  read() / write() / open() / close()
  ↓
┌─────────────────────────────────────────┐
│  VFS(通用层,不关心具体文件系统)          │
│  - 管理 dentry cache、inode cache         │
│  - 定义接口:read/write/readdir/lookup    │
└─────────────────────────────────────────┘
  │  调用具体实现的函数指针
  ↓
┌───────┐ ┌───────┐ ┌───────┐ ┌────────┐
│ ext4  │ │ XFS   │ │ btrfs │ │ NFS    │
└───────┘ └───────┘ └───────┘ └────────┘

这就是经典的”面向接口编程”——VFS 是接口,ext4/XFS 是实现。跟你在 Go 里写 type Fs interface { Read(...) } 然后各种实现,是一模一样的思想。

VFS 的代价:

代价一:接口是”最小公分母”,表达能力受限。

VFS 的接口要能同时描述 ext4 和 NFS,那它就只能提供两者都有的能力。任何”某个文件系统特有”的能力,都进不了 VFS 的接口。

这导致了两个问题:

1. 功能损失:btrfs 的快照、ZFS 的克隆、XFS 的某些特性,
   无法通过标准接口访问,只能通过 ioctl 或者专用工具

2. 语义被抹平:不同文件系统的 fsync 语义、原子性保证、性能特征
   完全不同,但 VFS 接口上它们"看起来一样"

代价二:抽象层的性能开销。

VFS 不是零成本的。每次文件操作,要经过 VFS 层的函数指针跳转、锁、引用计数。虽然优化得很好,但在极端性能场景(比如数据库直接操作裸盘),VFS 这层就是”多余的”。

这就是为什么数据库(MySQL、PostgreSQL、RocksDB)的存储引擎,有的会绕过 VFS 直接 I/O(O_DIRECT),甚至绕过文件系统直接操作块设备(裸设备 raw device)。

当通用抽象成为瓶颈时,高手会绕过它。 但要清醒:绕过 VFS 意味着你放弃了 page cache 的便利(缓存、预读、批量写回),这些你都要自己实现。

Q4 inode、dentry、page cache,这三层分别管什么?

文件系统在内存里的数据结构,最重要的三个:

inode —— 文件的”本体”。

inode 存的是一个文件的元数据(metadata):

inode 包含:
  - 文件类型(普通文件、目录、设备、软链接)
  - 权限(rwx + owner + group)
  - 大小、时间戳(atime/mtime/ctime)
  - 指向数据块的指针(数据在哪)
  - 引用计数(还有多少路径指向我)

关键:inode 里没有文件名。

文件名存在哪?存在目录里。目录本质上是一个”文件名 → inode 号”的映射表。

dentry —— 路径到 inode 的缓存。

dentry(directory entry)是路径查找的缓存。它把”路径字符串”和”inode”关联起来:

路径 /home/user/data.txt
  → dentry("/") → dentry("home") → dentry("user") → dentry("data.txt") → inode

dentry 是纯内存结构(不像 inode 会落盘),它存在的唯一目的就是加速路径解析——不用每次都从磁盘读目录。

dentry cache 就是路径解析的缓存,跟 TLB 是地址翻译的缓存,是同一个思想。

page cache —— 文件内容的缓存。

page cache 缓存的是文件的数据内容(不是元数据)。它是内核里最大、最重要的一块缓存。

读文件:
  第一次 read → 磁盘读到 page cache → 从 page cache 拷贝给用户
  第二次 read → 直接在 page cache 里,不碰磁盘

写文件:
  write → 写到 page cache(标记为脏页)→ 返回
  稍后 → 内核后台把脏页刷到磁盘(writeback)

这三层的关系,一句话:

inode 是”文件是谁”,dentry 是”文件叫什么(在哪个路径)“,page cache 是”文件的内容在哪(内存里)”。

为什么拆成三层?因为它们的生命周期和访问模式不同:

结构生命周期能回收吗落盘吗
inode有引用(被打开或有 dentry 指向)就活着引用清零后回收是(存在磁盘)
dentry有路径引用就活着,可被内存回收可以(重新解析路径即可)否(纯内存)
page cache文件被访问就活着,可被内存回收可以(脏页先写回)是(脏页写回)

这个分层设计,是理解文件系统一切行为的基础。后面讲 fsync、ls 为什么慢、为什么文件会”假删除”,都会回到这三层。


第三幕:链接 —— 文件名的真相

Q5 硬链接和软链接的本质区别是什么?

这是面试高频题,但大多数人的答案停留在”软链接存路径,硬链接存 inode”。这没错,但没说到”为什么”。

先说事实:

硬链接(hard link):ln target link
  - 多个名字指向同一个 inode
  - 删除任何一个名字,文件还在(只要还有一个名字指向它)
  - 不能跨文件系统(因为 inode 号是文件系统内部的)
  - 不能链接目录(防止成环)

软链接(symbolic link):ln -s target link
  - 一个特殊的"文件",内容存的是"目标路径"字符串
  - 删除目标文件,软链接就"断"了(悬空链接)
  - 可以跨文件系统
  - 可以链接目录

本质区别:

硬链接是”同一个 inode 的两个名字”,软链接是”一个指向名字的名字”。

用房屋比喻(接上一章):

硬链接:一个房子(inode)有两个门牌号。你从哪个门进去,都是同一个房子。
        拆掉一个门牌(删一个链接),房子还在,因为另一个门牌还在。

软链接:一个牌子写着"请去 XX 路 YY 号"。牌子本身不是房子,
        你顺着牌子走,才到真正的房子。
        房子拆了(删目标),牌子就成了"指向空地的牌子"(悬空链接)。

关键推论一:硬链接的”引用计数”。

inode 有个 link count 字段,记录”有多少个名字指向我”。

创建文件      → link count = 1
加硬链接      → link count = 2
删除一个名字   → link count = 1(文件还在)
删除最后一个名字 → link count = 0(文件真正被删除)

这就是”删除文件”的真正含义:不是”抹掉数据”,是”引用计数减一”。

关键推论二:软链接的悬空问题。

软链接不增加目标 inode 的引用计数。所以:

ln -s real.txt link.txt
rm real.txt
# link.txt 还在,但指向一个不存在的文件
# 读 link.txt 会报 ENOENT

关键推论三:软链接的”路径解析陷阱”。

软链接存的是路径字符串,这个路径是相对于”软链接所在目录”解析的,还是相对于”当前工作目录”解析的?这是个经典的坑。

ln -s ../data/config.json link_to_config
# 这个软链接存的字符串是 "../data/config.json"
# 解析时,是相对于软链接所在目录(而不是你的 cwd)解析的
# 所以:/app/conf/link_to_config → /app/data/config.json
# 而不是 相对于你 cwd 的 ../data/config.json

工程上,软链接的这些”微妙”是很多部署事故的来源。K8s 的 configmap 挂载、npm 的依赖链接、Python 的虚拟环境,都大量用软链接,也都踩过这些坑。

Q6 为什么”删除文件”其实是”减引用计数”?

这个问题引出一个非常深刻、也非常实用的知识点:“删除的文件”和”打开的文件”是两个状态。

// 这段代码演示了一个经典技巧:匿名临时文件
int fd = open("/tmp/tempfile", O_RDWR|O_CREAT);
unlink("/tmp/tempfile");   // 立刻删除文件名
// 但 fd 还开着,文件数据还在!直到 close(fd) 才真正删除

发生了什么?

1. open() → inode 引用计数 +1(fd 打开着)
2. unlink() → link count 减到 0,但 inode 引用计数(fd)还是 1
3. 文件从目录里"消失"了(ls 看不到),但数据还在
4. close(fd) → 引用计数减到 0 → 文件真正被删除,磁盘空间释放

这个技巧的用途:

  1. 临时文件自动清理:程序崩溃时,fd 自动关闭,文件自动删除,不用手动清理。

  2. 进程间安全共享临时数据:A 进程创建文件,立刻 unlink,然后通过 fd 传给 B 进程。这个文件在文件系统里”不可见”,只有拿到 fd 的进程能访问。这是一种”基于能力的文件共享”,比”共享一个名字”更安全。

为什么”ls 看不到但空间没释放”?

这是运维最常见的问题之一:

$ df -h
/dev/sda1  100G  98G  2G  100% /    ← 磁盘满了!

$ du -sh /*
... 加起来才 80G                    ← 但只找到 80G 的文件

# 少掉的 18G 去哪了?答案:被"删除了但仍被打开"的文件占着

诊断:

# 找出所有"已删除但仍被打开"的文件
lsof +L1
# 或者
lsof | grep deleted

解决:

# 找到那个进程,重启它(或让它 close 那个 fd)
# 经典案例:日志文件被 logrotate 改名了,但进程还在往旧 fd 写
#   1. logrotate 把 app.log 改名为 app.log.1(rename)
#   2. 进程的 fd 还指向旧 inode,继续写 app.log.1
#   3. 磁盘满了,但 ls 看不到"大文件"(因为它叫 app.log.1,被忽略了)
#   4. 或者进程没重开文件,一直往已删除的 inode 写

这就是”引用计数”这个设计带来的后果——它精确,但反直觉。

工程迁移:这个”引用计数 + 延迟释放”的模式,你在很多地方都会用到。

  • 内存管理:引用计数 GC、sync.WaitGroup
  • 连接管理:连接池里的”借出中”状态
  • 对象生命周期:Go 里 Close() 和 finalizer
  • 文件锁:多个进程共享一个锁文件

核心思想都是:“可见性”(名字还在吗)和”存在性”(资源还在吗)分离。 名字没了,不代表资源没了;资源没了,才是真的没了。


第四幕:持久化 —— 最深的坑

Q7 fsync 到底保证什么?为什么不保证什么?

这是全章最重要的一问,也是数据库开发者、存储开发者必须彻底搞清楚的问题。

先说结论:

fsync(fd) 保证:当它返回时,这个文件的所有脏数据(page cache 里的)都已经写到了物理磁盘的持久化介质上。

它保证什么(严格版):

  1. 数据确实离开了内存(page cache),写到了磁盘
  2. 返回后,即使立刻掉电,数据也在
  3. 数据的顺序:先 fsync 的,一定先落盘

它不保证什么(这才是坑):

1. 不保证"到了磁盘"就真的"持久了"
   - 现代磁盘/SSD 自己也有缓存(磁盘 write cache)
   - 数据可能还在磁盘的 volatile cache 里,没到盘片/闪存
   - 要彻底保证,需要"写穿"(write-through),或者用带电容保护的阵列卡

2. 不保证目录项
   - 你 fsync 了文件,但文件的"名字"(目录项)可能还没落盘
   - 新建文件:fsync(文件) 还要 fsync(所在目录),否则掉电后文件"消失"

3. 不保证父目录的完整
   - 嵌套创建:mkdir("a"); mkdir("a/b"); 
   - 要保证 a/b 存在,得依次 fsync a 和 a/b

4. 代价极其昂贵
   - fsync 会强制把该文件的脏页全部刷盘,可能阻塞几毫秒到几十毫秒
   - 对 HDD 尤其贵(要等待磁头归位、寻道)

为什么 fsync 这么贵?

因为 write() 只是把数据放进 page cache(内存),几十微秒就返回。而 fsync() 要等数据真正落到物理介质,这个过程受磁盘物理特性限制:

机械硬盘:一次 fsync 可能 10-100 毫秒(寻道 + 旋转延迟)
SATA SSD:一次 fsync 约 1-10 毫秒
NVMe SSD:一次 fsync 约 0.1-1 毫秒(取决于是否开了 write cache)

这就是为什么”write 很快,fsync 很慢”。

一个真实的工程教训:

很多”日志库”宣称自己”高性能”,压测时 QPS 很高。但你仔细看,它们默认不 fsync,只是 write 到 page cache 就返回。这意味着:

  • 正常运行时:快,数据在内存里
  • 掉电时:最近 30 秒的日志全丢(page cache 还没刷盘)

如果你的日志是”审计日志”(法律要求不能丢),或者”金融交易日志”(丢了就是事故),那必须 fsync,代价是 QPS 掉一个数量级。

这就是”性能”和”可靠性”的取舍,没有免费的午餐。

Q8 为什么文件写完了,掉电后变成 0 字节?

这是 fsync 问题的一个具体表现,非常经典。

场景:

// 场景一:覆盖写一个已存在的文件
fd = open("data.txt", O_WRONLY|O_TRUNC);  // O_TRUNC 清空
write(fd, "hello", 5);
// 掉电!此时 data.txt 可能是 0 字节,也可能是旧的完整内容,也可能是 "hello"

为什么会这样?涉及两个机制:

机制一:ext4 的”延迟分配”(delayed allocation)。

ext4(和 XFS)为了性能,用了”延迟分配”:

write("hello") 时:
  1. 数据先写进 page cache(内存)
  2. 但"数据块"还没分配!ext4 故意推迟分配磁盘块
     → 目的是:把多次小写合并成一次大的连续写,减少碎片
  3. 只有到了"回写"(writeback)时,才真正分配磁盘块、写数据

所以 write 返回后,“hello” 只存在于 page cache,磁盘上可能连数据块都还没分配。

机制二:元数据(文件大小)的落盘时机。

文件大小(inode 里的 size 字段)也是元数据,它也在内存里,要等 inode 落盘才更新。

如果掉电时:

  • 数据块分配了、写入了,但 inode 的 size 字段没更新 → 文件大小是旧的(可能是 0)
  • 或者数据块根本没分配 → 文件是 0 字节

所以”write 完掉电变 0 字节”的根本原因:

write() 只保证”数据到了内存”,不保证”数据到了磁盘”,更不保证”元数据(大小、块分配)到了磁盘”。

怎么避免?

// 正确做法
fd = open("data.txt", O_WRONLY|O_TRUNC);
write(fd, "hello", 5);
fsync(fd);        // 1. 刷数据 + 刷 inode 的 size
fsync(dir_fd);    // 2. 如果是新建文件,还要刷目录项
close(fd);

注意第 2 步容易漏:如果是新建文件,光 fsync 文件不够,还要 fsync 父目录,否则目录项(“data.txt → inode X”这条映射)没落盘,掉电后目录里根本没有这个文件。

这在数据库里是常识(PostgreSQL、RocksDB 的 WAL 落盘都严格处理了目录 fsync),但普通业务代码里 99% 的人不知道。

Q9 日志(journaling)的三种模式,差别在哪?

上一问说到掉电会丢数据。那文件系统怎么保证”掉电后文件系统不损坏”?

答案是日志(journaling)。它的核心思想我们会在第 14 章详细讲(WAL),这里先讲文件系统层面的实现。

日志解决的不是”丢数据”,是”文件系统结构损坏”。

掉电时最怕的不是丢几个字节,是文件系统的元数据结构损坏——比如一个 inode 说”我用了块 100”,但块 100 又被标记为空闲,下次分配给另一个文件,两个文件撞车,整个文件系统乱掉。

日志的做法:先写日志(intent),再改实际数据,最后标记日志完成。

正常流程:
  1. 写日志:"我要把 inode 5 的块指针从 A 改成 B"
  2. 改实际数据:inode 5 的块指针 = B
  3. 标记日志完成

掉电恢复:
  - 如果日志里有一条"未完成"的记录 → 重放它(redo)
  - 如果日志记录已完成,但数据没写 → 无所谓,下次覆盖

ext4 有三种日志模式(关键区别在”数据”进不进日志):

模式 1:journal(数据+元数据都进日志)
  写数据 → 先写进日志 → 再写到实际位置
  最安全,最慢(数据写两遍)
  掉电:数据和元数据都不丢

模式 2:ordered(默认,只有元数据进日志)
  写数据 → 直接写到实际位置
  写元数据 → 先写进日志 → 再写到实际位置
  保证:元数据不会损坏;数据在元数据之前落盘
  掉电:可能丢"最后一个写操作的数据",但文件系统结构完整

模式 3:writeback(只有元数据进日志,且不保证顺序)
  写数据和元数据都直接写,不保证顺序
  最快,最不安全
  掉电:可能元数据指向了"还没写完的数据块"(数据损坏)

关键理解 ordered 模式(默认):

它保证的是一件很微妙的事:“数据一定在元数据之前落盘”。

为什么这个重要?因为它保证了:文件系统不会出现”元数据说文件有 5 字节,但数据块还是旧的”这种不一致。

ordered 模式下的掉电,最坏情况是:文件大小没更新(还是旧的大小),但你写的数据可能部分或全部丢失。文件系统结构是完整的,fsck 不会报错,只是”你以为写了的数据没写进去”。

这就是为什么 Q8 说”掉电变 0 字节”——在 ordered 模式下,元数据(size)和数据都可能在 page cache 里没落盘,掉电就丢了。日志保护的是”文件系统不崩”,不是”你的数据不丢”。

要保证你的数据不丢,只能靠应用层 fsync。 文件系统的日志帮不了你。

Q10 rename 为什么是原子的?怎么用它做安全更新?

rename(old, new) 是文件系统里为数不多的原子操作。

为什么它是原子的?

因为 rename 不搬动数据,它只是改两个目录项:

rename("data.tmp", "data.txt") 做的事:
  1. 把目录里的 "data.tmp → inode X" 改成 "data.txt → inode X"
  2. 把原来的 "data.txt → inode Y" 移除

整个过程是一个元数据操作,要么成功要么失败,不存在中间状态。

这有什么价值?答案是:原子替换文件。

这是配置热更新、滚动发布、数据落盘的标准技巧:

// 安全更新一个文件的正确姿势
func safeWrite(path string, data []byte) error {
    tmp := path + ".tmp"
    // 1. 写临时文件
    f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
    if err != nil { return err }
    if _, err := f.Write(data); err != nil { return err }
    if err := f.Sync(); err != nil { return err }  // 2. fsync 数据
    if err := f.Close(); err != nil { return err }
 
    // 3. 原子替换
    if err := os.Rename(tmp, path); err != nil { return err }
 
    // 4. fsync 目录(保证 rename 本身也落盘)
    dir, _ := os.Open(filepath.Dir(path))
    defer dir.Close()
    dir.Sync()
    return nil
}

为什么这样写?每个步骤都在防什么:

1. 写临时文件(.tmp):如果写一半崩溃,原文件还在(.tmp 是半成品)
2. fsync 临时文件:保证临时文件数据完整落盘
3. rename 原子替换:一瞬间,路径从旧文件切到新文件
   → 任何时刻读 path 的进程,要么读到完整的旧文件,要么读到完整的新文件
   → 绝不可能读到"写了一半"的状态
4. fsync 目录:保证 rename 这个操作本身也落盘(否则掉电后 rename 丢失)

这个模式的巨大价值:

1. 读者永远读到完整文件:没有"读到一半"的状态
2. 写者崩溃不影响读者:最坏情况是 .tmp 残留,path 还是旧的
3. 多进程安全:多个进程同时更新,rename 的原子性保证 path 始终指向完整文件

这个模式在业界的应用:

  • 配置热更新:写新配置到 .tmp,rename 替换,进程 watch 到变化重新加载
  • K8s ConfigMap 挂载:kubelet 就是用”写 tmp + rename”来更新挂载的配置文件的
  • 数据库数据文件:很多存储引擎用”写新文件 + rename”来切换数据文件
  • 滚动发布:新版本二进制写到 .new,rename 替换,重启进程

一个非常深刻的类比:这个”写临时 + 原子改名”的模式,和数据库事务的”提交”是同一个思想。

数据库事务:先写 WAL 日志,最后 commit 时原子地更新状态。 文件更新:先写 .tmp,最后 rename 原子地切换。

核心都是:准备阶段随便失败(不影响已发布状态),提交阶段原子完成(要么旧要么新,没有中间态)。

这个”准备 + 原子提交”的模式,是分布式系统里无数设计的原型(两阶段提交、Git 的 commit、甚至 HTTP 的 201 Created + Location)。第 14、18 章会反复出现。


第五幕:性能与陷阱

Q11 为什么 ls 一个大目录会卡住?

你肯定遇到过:ls 一个有几万、几十万个文件的目录,卡好几秒甚至更久。

根本原因:目录是”线性存储”的,没有索引。

在 ext4 里,目录是一个特殊文件,内容是一串”目录项”(dentry entry),线性排列,没有 B 树、没有哈希索引:

目录文件的内容(简化):
┌─────────────────┐
│ "file1" → inode 100 │
│ "file2" → inode 101 │
│ "file3" → inode 102 │
│ ...                 │
│ "file50000" → inode 50000 │
└─────────────────┘

ls 要列出所有文件,就得从头到尾读这个目录,O(n)。

而且如果每个目录项是 256 字节,5 万个文件就是 12.5MB,从磁盘读进来(如果没缓存)+ 逐个解析,几秒钟很正常。

对比:为什么”按文件名找单个文件”不慢?

因为查找单个文件时,dentry cache 会帮忙。你第一次 open("/big/dir/file123") 可能慢,但之后 dentry 缓存了,就快了。

但 ls 是”列出全部”,dentry cache 帮不上忙——它还是得遍历整个目录。

更糟的情况:inode 元数据的随机读。

ls -l 不仅要读目录,还要读每个文件的 inode(为了拿大小、权限、时间戳)。

5 万个文件的 inode 分散在磁盘各处,ls -l 就是 5 万次随机读。机械硬盘上,每次随机读 10ms,就是 500 秒(8 分钟)。SSD 好很多,但也明显慢。

这个问题的深层原因:目录不是”索引”,是”文件”。

现代文件系统(ext4 有 htree 目录索引,btrfs/ZFS 有 B 树)其实做了改进。ext4 默认开启 dir_index,用哈希索引加速”单个文件查找”,但**“列出全部”仍然是 O(n)**,因为哈希索引不保序。

工程启示(这个很重要):

1. 别在一个目录放几十万个文件
   → 分层:/data/2026/08/30/xxx,按时间或哈希分片
   → 这是对象存储(S3)、日志系统、图片服务的标准做法

2. 这和"数据库单表太大"是同一个问题
   → 目录 = 一张没有索引的表,文件 = 行
   → 目录太大 = 全表扫描
   → 解决 = 分区(分层目录)/ 分表

3. 如果你必须处理海量文件,用专门的工具
   → find、ls 在这种目录上都很慢
   → 考虑用 ext4 的 dir_index、或者换 btrfs
   → 更根本的:换存储模型(对象存储、数据库)

Q12 ext4 的”延迟分配”是怎么让你丢数据的?

这个问题和 Q8 呼应,但更具体到 ext4 的一个著名 bug/特性。

延迟分配(delayed allocation)回顾:

write() 到文件:
  1. 数据进 page cache(内存)
  2. 不分配磁盘块(推迟!)
  3. 等 writeback 时才分配磁盘块 + 写数据

好处:多次小写合并成一次连续大块写,减少磁盘碎片,提升性能
坏处:write() 返回后,数据"还在内存里,磁盘上没块"

著名的”ext4 延迟分配丢数据”事件(2009 年):

场景:一个程序这样写文件:

// 错误但常见的写法
fd = open("file", O_WRONLY|O_CREAT);
write(fd, data, size);   // 数据进了 page cache,但块没分配
close(fd);               // close 不会自动 fsync!
// 程序退出,以为数据安全了
// 掉电 → 数据块没分配 → 文件是 0 字节

问题的核心:close() 不保证数据落盘,而延迟分配让”数据还没落盘”的情况更普遍。

在旧的 ext3 上,同样代码可能”侥幸”数据还在(因为 ext3 分配块更早)。而 ext4 的延迟分配,让”write 到落盘”的窗口更长,丢数据的概率更高。

这个事件引发的反思和修复:

  1. 修复一:ext4 后来加了保护——如果一个文件有”延迟分配但未落盘”的数据,rename/unlink 时会先强制刷盘。

  2. 修复二:业界重新强调了”写入者必须自己 fsync”的原则。

  3. 反思:这是”性能优化引入语义变化”的经典案例。延迟分配是纯性能优化,但它改变了”write 的持久化语义”(虽然文档上 write 从来就不保证持久化,但大家默认 ext3 的行为)。

这是一个极其重要的教训,值得反复咀嚼:

“优化”如果改变了行为语义,哪怕文档里写了”行为未定义”,也会造成真实的故障。

因为用户(程序员)依赖的不是文档,是”实际观察到的行为”。ext3 上 write+close 掉电不丢(大多数时候),ext4 上丢了,用户就会认为是”ext4 的 bug”。

在你的系统里也一样:

  • 你优化了缓存,让”读”变成”最终一致”,那些依赖”强一致读”的调用方就会出 bug
  • 你优化了写,让”写成功”变成”异步落盘”,那些依赖”同步”的调用方就会丢数据
  • 你加了重试,让”最多一次”变成”至少一次”,那些没做幂等的调用方就会重复扣款

优化前,先想清楚:这个优化改变了什么语义?谁依赖旧语义?


第六幕:抽象的反噬

Q13 为什么网络设备不是文件?ioctl 这个”逃生舱”说明了什么?

如果”一切皆文件”,那网络(socket)为什么不是文件?

答案:socket 是文件(有 fd),但它的操作和普通文件差异太大,read/write 这套接口不够用。

普通文件的操作:
  open / read / write / close / seek(定位)

socket 需要的操作:
  socket(创建,指定协议族)/ bind / listen / accept / connect
  sendto / recvfrom(带地址)
  setsockopt(设置超时、缓冲区、TCP_NODELAY...)
  ...

socket() 返回的确实是 fd,你也能对它 read/write。但建立连接、绑定地址、设置选项这些操作,read/write 表达不了。

所以有了 ioctl(input/output control)——一个”万能系统调用”,给那些”统一接口表达不了”的操作开的后门:

ioctl(fd, request, ...)  // request 是操作码,参数是变长的
// 比如:
ioctl(fd, FIONREAD, &n);          // 查还有多少字节可读
ioctl(fd, TIOCGWINSZ, &size);     // 查终端窗口大小
ioctl(fd, BLKGETSIZE64, &size);   // 查块设备大小

ioctl 的问题(这也是它被骂的原因):

  1. 类型不安全:request 是个 int 操作码,参数是 void*,传错类型编译器不报错,运行时崩。
  2. 无文档化:成千上万个操作码,分散在各处,没有统一文档。
  3. 破坏了抽象:本来说好”一切皆 read/write”,现在又开了个”什么都能干”的后门。

Linus Torvalds 对 ioctl 的评价是”设计上的败笔”,他认为统一接口的价值被 ioctl 这个”万能逃生舱”破坏了。

这个设计给你的启示(比技术本身重要):

抽象的价值,取决于它”漏不漏”。

一个好的抽象,应该覆盖 95% 的场景,剩下 5% 有明确的、可控的逃生口。

一个坏的抽象,是”看起来统一,但到处开洞”,结果你还是要理解底层,抽象反而成了负担。

判断标准:你的抽象下,调用方是否需要”知道抽象背后是什么”才能正确使用?

  • 如果不需要 → 好抽象(比如 Go 的 io.Reader)
  • 如果需要 → 抽象泄漏了(比如 socket 的 setsockopt,你必须知道 TCP 才能设对 TCP_NODELAY)

Q14 这套设计面临哪些结构性的问题?

最后泼冷水。文件系统这套设计,有几个根子上的矛盾:

问题一:fsync 困境(性能 vs 可靠性的死结)。

要快:write 到 page cache 就返回(数据在内存,掉电丢)
要可靠:每次 write 都 fsync(数据落盘,但慢 100-1000 倍)

没有中间状态。你要么快,要么可靠,不能兼得。

这是存储系统最根本的矛盾。所有数据库、消息队列、存储引擎,都在这个矛盾上做文章:

  • PostgreSQL:默认 write + 周期 fsync,靠 WAL 减少 fsync 次数
  • MySQL:innodb_flush_log_at_trx_commit 参数,1=每次 fsync(安全慢),0/2=不 fsync(快但丢)
  • Redis:AOF 有 appendfsync always/everysec/no 三档

没有银弹,只有权衡。

问题二:page cache 的”双重缓存”问题。

你的应用(比如数据库)自己有一套缓存(buffer pool),内核又有一层 page cache。两层缓存,数据存了两份,还可能不一致。

数据库 buffer pool(应用层缓存)
       ↓ 落盘
文件系统的 page cache(内核缓存)
       ↓ 落盘
磁盘

问题:
1. 内存浪费:同一份数据,buffer pool 一份 + page cache 一份
2. 一致性:数据库以为落盘了,其实还在 page cache(没 fsync)
3. 性能:写要经过两层,读也可能重复缓存

解法是 O_DIRECT(绕过 page cache):

open("data", O_RDWR|O_DIRECT);  // 直接 I/O,绕过 page cache

但代价是:你放弃了 page cache 的预读、合并写、缓存命中。这些你都要自己实现。所以 O_DIRECT 是”专业用户”的工具,普通程序用它反而更慢。

问题三:文件系统 vs 数据库,谁该管数据。

这是个哲学问题:文件系统本质是一个”通用、但简单的数据库”(存储 + 索引 + 事务),而数据库是一个”专用、但复杂的存储引擎”。

文件系统:通用、简单、性能一般、功能有限
数据库:专用、复杂、性能好、功能强(事务、索引、查询)

什么时候用文件系统?数据是"文件"(文档、图片、日志、代码)
什么时候用数据库?数据是"记录"(有结构、要查询、要事务)

问题四:POSIX 语义在现代硬件上的过时。

POSIX 文件 API 是 1970 年代设计的,面向”慢速机械硬盘 + 单核 CPU”。现代是”NVMe SSD + 多核 + 海量并发”。

几个过时的点:

1. read/write 是同步、拷贝式的:每次都要用户态/内核态拷贝
   → 现代用 mmap、sendfile、io_uring(第 10 章)

2. 目录是线性的:几十万文件就卡
   → 现代文件系统加了 B 树,但 POSIX 语义没跟上

3. 单文件锁(flock/fcntl)粒度太粗:锁整个文件
   → 现代需要字节级锁、或者直接上数据库

4. 元数据操作慢:stat、open 每次都要查 dentry/inode
   → 这是为什么数据库要缓存元数据、为什么"冷启动"慢

问题五:抽象的”层次税”(layer tax)。

每一层抽象(应用 → VFS → 文件系统 → 块设备 → 磁盘)都抽一点税:

应用要读 4KB:
  1. 应用层:函数调用
  2. VFS 层:查找 dentry、inode、加锁
  3. 文件系统层:查块映射、分配、日志
  4. 块设备层:合并 I/O、调度
  5. 磁盘:真正读

每一层都有开销。对 99% 的场景,这些开销值得(换来通用性)。
但对极致性能场景(数据库、对象存储),这些开销是"税"。

所以高手的选择从来不是”用文件系统”还是”不用文件系统”,而是”我在哪一层工作”。

  • 普通业务:用文件系统(省心)
  • 高性能存储:绕过 page cache(O_DIRECT)
  • 极致性能:绕过文件系统,直接操作块设备(RocksDB 的某些模式)
  • 数据库内核:自己实现存储引擎,文件系统只是”块分配器”

每一层抽象都是”用通用性换性能”,你越往下钻,越要自己承担上层帮你的东西。


思考题

思考题 1

你的服务要写一个”关键数据文件”,要求:任何时刻(包括崩溃、掉电),读取这个文件的人要么读到完整的旧数据,要么读到完整的新数据,绝不能读到一半。请设计完整的写入方案,并说明每一步在防什么。

参考答案

这是 Q10 的完整展开。方案就是”写临时文件 + fsync + 原子 rename + fsync 目录”。

完整方案(Go):

package main
 
import (
    "os"
    "path/filepath"
)
 
// SafeWriteFile 原子地写入文件:保证读者永远读到完整内容
func SafeWriteFile(path string, data []byte) error {
    dir := filepath.Dir(path)
    tmp := filepath.Join(dir, ".tmp-"+filepath.Base(path))
 
    // 第一步:写临时文件(用 O_EXCL 防止并发冲突)
    f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
    if err != nil {
        return err
    }
 
    // 第二步:写入全部数据
    if _, err := f.Write(data); err != nil {
        f.Close()
        os.Remove(tmp)
        return err
    }
 
    // 第三步:fsync 数据(确保数据真正落盘)
    // 关键!没有这步,掉电后临时文件可能是空的
    if err := f.Sync(); err != nil {
        f.Close()
        os.Remove(tmp)
        return err
    }
 
    if err := f.Close(); err != nil {
        os.Remove(tmp)
        return err
    }
 
    // 第四步:原子替换(rename 是原子的)
    if err := os.Rename(tmp, path); err != nil {
        os.Remove(tmp)
        return err
    }
 
    // 第五步:fsync 目录(确保 rename 本身落盘)
    // 关键!没有这步,掉电后 rename 可能"回滚",path 指向旧文件或不存在
    d, err := os.Open(dir)
    if err != nil {
        return err
    }
    defer d.Close()
    return d.Sync()
}

每一步在防什么:

步骤防什么缺了会怎样
写临时文件写一半崩溃污染正式文件正式文件被写了一半,读者读到半成品
f.Write 完整写入数据不全同上
f.Sync数据只在内核 page cache,掉电丢失临时文件是空的/半成品,rename 后正式文件损坏
f.Closefd 泄漏、资源不释放轻微,但应该关闭
os.Rename新旧切换的中间态不用 rename 而是直接写,就有”写一半”的窗口
d.Syncrename 操作本身没落盘掉电后 rename 丢失,path 指向旧文件(可能还是半成品)

为什么 rename 能保证原子性?

因为 rename 是内核里的单个元数据操作,它在文件系统层面要么完成要么不完成,不存在”做了一半”的状态(这是日志文件系统保证的)。读者在任何时刻看到的 path,要么指向旧 inode,要么指向新 inode。

一个容易忽略的细节:并发写。

如果两个 goroutine/进程同时写同一个文件,上面的方案会冲突(O_EXCL 保证临时文件名不冲突,但两个都 rename 到同一个 path,最后一个 wins)。如果业务上需要,要加文件锁或应用层锁。

更进一步的追问:如果读者也要”读到最新”,怎么办?

上面的方案只保证”读到完整”,不保证”读到最新”。读者可能读到旧数据(因为 rename 之前读者已经打开了旧文件)。

如果需要”读写一致”,要引入:

  1. 版本号:文件名带版本 data.v1、data.v2,读者通过一个”指针文件”找当前版本
  2. 文件锁:读者和写者用 flock 协调
  3. 换用数据库:如果一致性要求高,文件系统本来就不该是答案

这道题的深层考点:

不只是”会不会写这段代码”,而是:

  1. 知不知道 fsync 的必要性:90% 的人会漏掉 f.Sync,他们以为 write 就落盘了
  2. 知不知道 fsync 目录:95% 的人会漏掉,他们不知道 rename 本身也要落盘
  3. 知不知道 rename 的原子性来源:不是”rename 快”,是”它是单个元数据操作”
  4. 有没有考虑并发:生产环境的写入几乎都有并发

思考题 2

你的服务每秒要写 1000 条日志(每条 100 字节),要求”日志一条都不能丢”(比如审计日志)。你会怎么设计?如果要求放宽到”允许丢最后 1 秒”,方案又有什么变化?

参考答案

这道题的核心是:在”不丢”和”快”之间,找到正确的工程位置。

先算账:

要求"一条不丢" = 每条日志都要落盘 = 每条都要 fsync

fsync 的成本:
  机械硬盘:~10ms/次 → 每秒最多 100 次 → 达不到 1000 条/秒
  SATA SSD:~1ms/次 → 每秒最多 1000 次 → 刚好卡线
  NVMe SSD:~0.1ms/次 → 每秒最多 10000 次 → 可以

所以第一条结论:能否”每条都 fsync”,取决于硬件。

在机械硬盘上,“每条日志都 fsync”物理上做不到 1000 条/秒(单盘)。你必须批量 fsync。

方案一:严格不丢(每条 fsync),但靠批量摊薄。

核心思想:“每条日志落盘” ≠ “每条日志单独 fsync”。

可以用组提交(group commit):

多个写日志的请求,攒一小批(比如 10 条或 10ms 内的),
一起写 + 一次 fsync。

这样:
  10 条日志,1 次 fsync → fsync 成本摊薄 10 倍
  1000 条/秒,100 次 fsync/秒 → 机械盘勉强能扛,SSD 轻松

代价:延迟增加。每条日志的”落盘时间”从”立刻”变成”最多等一批”(比如 10ms)。

这其实就是数据库的组提交、Kafka 的批量刷盘、以及第 12 章要讲的”摊销”思想。

方案二:WAL(先写日志,再合并)。

不直接写日志文件,而是:

1. 日志先写到一个"追加写"的 WAL 文件(顺序写,快)
2. 定期(比如 1 秒)fsync 这个 WAL
3. 后台把 WAL 合并、归档到正式日志文件

追加写 + 顺序写是最快的磁盘写模式(没有寻道),所以 WAL 能支撑很高的吞吐。

方案三:异步 + 有界缓冲(允许丢最后一点)。

如果允许”丢最后 1 秒”,那就简单多了:

1. 日志写到一个内存环形缓冲区(channel + buffer)
2. 一个后台 goroutine 每秒批量刷盘 + fsync
3. 掉电/崩溃:丢最多 1 秒(缓冲区里的)日志

这就是 Redis AOF appendfsync everysec 的思路。

关键设计:缓冲要”有界”。

type AsyncLogger struct {
    ch      chan []byte
    mu      sync.Mutex
    dropped uint64  // 统计丢了多少
}
 
func NewAsyncLogger(path string) *AsyncLogger {
    l := &AsyncLogger{ch: make(chan []byte, 10000)}
    go l.writer(path)  // 后台刷盘
    return l
}
 
func (l *AsyncLogger) Write(entry []byte) {
    select {
    case l.ch <- entry:   // 缓冲没满,入队
    default:              // 缓冲满了
        atomic.AddUint64(&l.dropped, 1)
        // 关键决策:是"丢弃新日志"还是"阻塞等待"?
        // 审计日志:阻塞等待(宁可慢,不能丢)
        // 普通日志:丢弃 + 告警(宁可丢,不能拖慢主流程)
    }
}
 
func (l *AsyncLogger) writer(path string) {
    var buf []byte
    ticker := time.NewTicker(time.Second)
    for {
        select {
        case entry := <-l.ch:
            buf = append(buf, entry...)
            if len(buf) > 64*1024 {  // 攒够 64KB 就刷
                l.flush(buf)
                buf = buf[:0]
            }
        case <-ticker.C:
            if len(buf) > 0 {        // 每秒兜底刷一次
                l.flush(buf)
                buf = buf[:0]
            }
        }
    }
}
 
func (l *AsyncLogger) flush(buf []byte) {
    f, _ := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0644)
    f.Write(buf)
    f.Sync()   // fsync,落盘
    f.Close()
}

“缓冲满了”怎么办?这是必答题:

场景策略理由
审计日志(法律要求)阻塞等待,或者直接失败不能丢,宁可慢
交易日志阻塞 + 告警丢了是事故
普通业务日志丢弃 + 告警不能因为日志拖垮主流程
监控指标采样丢弃丢了无妨

对比两种要求的方案差异:

严格不丢允许丢 1 秒
方案组提交 fsync / WAL异步 + 有界缓冲 + 每秒 fsync
吞吐受 fsync 次数限制高(批量化)
延迟每条要等组提交低(写内存即返回)
掉电损失0最多 1 秒
复杂度中低

这道题想让你意识到:

  1. “不丢”是有物理成本的(fsync 次数),不是喊口号
  2. 组提交是”不丢”和”快”之间的桥梁(第 12 章重点)
  3. 缓冲必须”有界”,否则内存无限增长(第 16 章背压)
  4. “满了怎么办”必须提前想清楚,不能等出事故了才想

思考题 3

为什么数据库要用 O_DIRECT 绕过 page cache,而一般的文件读写(比如 cp 命令)不要?如果你给一个 Go 服务写”文件缓存”,你会自己实现缓存还是依赖 page cache?

参考答案

先回答”为什么数据库要 O_DIRECT”:

数据库(MySQL InnoDB、PostgreSQL、RocksDB)有一个共同点:它们自己就有一层精密的缓存(buffer pool)。

数据库的双重缓存问题:

┌─────────────────────────────┐
│  数据库 buffer pool(应用缓存)│  ← 数据库自己管,LRU,精确知道冷热
└──────────────┬──────────────┘
               │ 读/写
┌──────────────▼──────────────┐
│  page cache(内核缓存)       │  ← 内核管,伪 LRU,粗略
└──────────────┬──────────────┘
               │
              磁盘

问题在哪:

  1. 内存浪费:同一份数据(比如一个 16KB 的页),buffer pool 里一份,page cache 里又一份。存了两遍。

  2. 缓存决策冲突:数据库的 buffer pool 用精确的 LRU/LFU 决定”谁该留在内存”,但 page cache 用粗略的双链表 LRU。内核可能会把数据库刚淘汰的页又缓存起来(因为内核不知道数据库的意图)。

  3. 写路径冗余:数据库写一个页,先写 buffer pool,再写 page cache(write 到文件),page cache 再落盘。写了两遍。

  4. 一致性负担:数据库要保证”落盘了”(比如 WAL fsync),就必须 fsync 到 page cache 以下的层次,但 page cache 是否真的落盘,数据库控制不了(受 dirty_ratio 等参数影响)。

所以数据库用 O_DIRECT:

open("data.ibd", O_RDWR | O_DIRECT);
// 直接 I/O:绕过 page cache,数据直接从 buffer pool 到磁盘

好处:

  • 不浪费内存(数据只有 buffer pool 一份)
  • 缓存决策完全由数据库控制
  • 写路径直接,fsync 语义清晰

代价:

  • 放弃 page cache 的预读(readahead)→ 数据库要自己实现预读
  • 放弃 page cache 的合并写 → 数据库要自己合并
  • 放弃 page cache 的缓存命中 → 数据库靠 buffer pool 自己扛
  • O_DIRECT 有对齐要求(地址、大小要对齐 512 字节或 4KB)

那为什么 cp 不要 O_DIRECT?

因为 cp 没有自己的缓存,它完全依赖 page cache:

cp 读文件 → page cache(顺便缓存了,下次读快)
cp 写文件 → page cache(writeback 落盘)

对 cp 这种”一次性”操作,page cache 是纯收益:它没有”双重缓存”的问题,因为它自己根本没有缓存。

回到你的问题:Go 服务写”文件缓存”,自己实现还是依赖 page cache?

决策树:

你的服务有"领域知识"吗(知道哪些数据热、哪些冷、访问模式)?
├─ 没有 → 依赖 page cache
│   理由:内核做了二十年,比你靠谱
│   适用:日志、配置文件、简单的数据文件
│
├─ 有,但很弱 → 依赖 page cache + 少量 madvise 提示
│   用 madvise(MADV_WILLNEED) 预读、MADV_DONTNEED 释放
│   适用:知道"接下来要读哪块"的场景
│
└─ 有,且很强 → 自己实现缓存 + O_DIRECT
    理由:你的精确知识能打败内核的通用策略
    适用:数据库、存储引擎、有明确冷热的数据

一个关键的判断标准(这比技术细节重要):

你的缓存决策,能否比内核的”伪 LRU”更准确?

  • 如果只是”最近访问的缓存起来” → 你实现的和 page cache 一样,甚至更差,别自己写
  • 如果知道”这份数据 30 秒内一定还会被访问”(比如正在处理的请求) → 你可以用更精确的策略,值得自己写
  • 如果知道”这是用户 A 的数据,A 是 VIP,不能淘汰” → 内核不知道 VIP,你必须自己写

总结成一句话:

通用缓存(page cache)适合”没有领域知识的通用场景”,专用缓存(buffer pool)适合”有精确领域知识的专业场景”。

而”自己实现”还是”依赖内核”,取决于你有没有内核没有的知识。没有,就别重复造轮子;有,就自己造。


思考题 4(开放题)

“文件系统本质上就是一个数据库,数据库本质上就是一个文件系统。“这句话你认同吗?它们的分界线到底在哪?什么时候你该用文件系统,什么时候该用数据库?

参考答案

开放题,答案见仁见智,但我给你一个能立住的框架。

先拆这句话:

“文件系统是一个数据库”:对。文件系统有存储(数据块)、索引(inode/dentry/目录)、事务(日志 journaling)、并发控制(锁)。

“数据库是一个文件系统”:也对。数据库最终把数据存在文件里(或者裸设备上),它自己管理”如何组织这些文件”。

它们的共同点:

都是"在持久化存储上,组织、索引、检索数据"

文件系统: 数据单元是"文件",索引是"路径 → inode",查询是"按路径找文件"
数据库:   数据单元是"记录",索引是"字段 → 记录",查询是"按条件找记录"

它们的分界线(这才是关键):

维度文件系统数据库
数据单元文件(不透明的字节流)记录(有结构、有字段)
查询方式按路径(精确)按条件(范围、模糊、聚合)
一致性保证弱(文件级原子性)强(事务、ACID)
并发控制粗(文件锁)细(行锁、MVCC)
数据关系无(文件间无关系)有(外键、join、约束)
适用数据非结构化(文档、图片、代码)结构化(订单、用户、商品)

一句话分界:

当你需要”按内容查数据”时,用数据库;当你只需要”按名字存取不透明数据”时,用文件系统。

展开说:

用文件系统,如果:
  - 数据是"文件"(图片、视频、PDF、代码)
  - 按"名字/路径"访问就够了(不需要按内容查询)
  - 一次读写整个文件(不需要更新文件中间的某条记录)
  - 没有跨文件的事务需求

用数据库,如果:
  - 数据是"记录"(用户、订单、日志条目)
  - 要按条件查询("查所有金额>100的订单")
  - 要更新单条记录("把订单 123 的状态改成已支付")
  - 需要跨记录/跨表的事务

中间地带(这是最值得思考的部分):

现实中很多场景在两者之间,选型就变得微妙:

场景一:海量图片/视频存储。

数据是"文件"(图片),但数量巨大(亿级),还要按多种条件查(时间、用户、标签)

方案 A:文件系统存文件 + 数据库存元数据(标准做法)
  文件存磁盘/对象存储,路径 = hash(内容) 或 时间/用户分层
  数据库(或 ES)存"图片ID → 路径、标签、时间"的索引
  查询:先查数据库拿到路径,再读文件

方案 B:全放数据库(BLOB)
  简单,但数据库会变得巨大,备份、迁移都慢
  一般只在"图片小、数量不多"时用

场景二:日志系统。

日志是"记录"(要按时间、关键字查询),但量巨大(TB 级)

方案 A:文件系统存日志文件(按时间切分)+ 日志分析工具(ELK)
  写快(追加写),但查询要用专门工具

方案 B:时序数据库(ClickHouse、InfluxDB)
  结构化、可查询、压缩好,但写入路径复杂

场景三:配置管理。

配置文件(JSON/YAML)是"文件",但可能有大量配置项、要按项查询更新

小规模:文件(简单直接)
大规模:配置中心(etcd/Consul/Nacos,本质是数据库)

一个深刻的观察:

“文件系统 vs 数据库”的边界,正在被”对象存储”和”NoSQL”模糊。

  • 对象存储(S3):像文件系统(按 key 存对象),但没目录、没层次,本质是个巨大的 key-value 数据库
  • NoSQL(如 MongoDB):像数据库,但数据模型是”文档”,接近”文件”

这说明:文件系统和数据库不是两个截然不同的东西,而是一个光谱的两端。 中间有无数种”类文件数据库”和”类数据库文件系统”。

那工程上怎么选?给你一个决策顺序:

1. 数据是"不透明字节流"还是"有结构记录"?
   ├─ 字节流 → 文件系统
   └─ 有结构 → 数据库

2. 访问是"按名字"还是"按内容"?
   ├─ 按名字 → 文件系统
   └─ 按内容 → 数据库

3. 需要"跨数据的事务/一致性"吗?
   ├─ 不需要 → 文件系统
   └─ 需要 → 数据库

4. 数据量和访问频率如何?
   ├─ 量小、访问少 → 文件系统(简单)
   └─ 量大、访问频繁 → 数据库(需要索引、缓存、并发控制)

5. 团队和运维成本?
   ├─ 文件系统:零运维(就是磁盘)
   └─ 数据库:要维护、备份、调优(有运维成本)

最终答案:

这句话”半对”——文件系统和数据库本质都是”持久化数据的组织方式”,在这个抽象层次上它们是一回事。

但它们在不同层次上工作:文件系统管”字节流”(不关心内容),数据库管”记录”(关心内容结构)。

所以不是”用哪个”,而是”你的数据在哪个层次上被理解”:

  • 内容对系统是”黑盒”(图片、视频)→ 文件系统
  • 内容对系统是”可解析的结构”(订单、用户)→ 数据库

而真正的高手,会在合适的场景,把文件系统当数据库用(比如用文件名编码元数据、用目录层级做索引),或者把数据库当文件系统用(比如把 BLOB 存进数据库、用数据库存配置)。理解了本质,边界就不再是约束,而是工具。


一页纸总结

┌──────────────────────────────────────────────────────────────┐
│  第 03 章 · 文件系统:一切皆文件的野心与代价                       │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  【核心抽象】                                                  │
│    一切资源 → open/read/write/close 统一接口                    │
│    统一的是"调用方式",不是"语义"(这是抽象泄漏的根源)             │
│                                                              │
│  【三层结构】                                                  │
│    inode      = 文件的"本体"(元数据,无文件名)                  │
│    dentry     = 路径解析缓存(纯内存,像 TLB)                    │
│    page cache = 文件内容缓存(用内存换速度)                      │
│                                                              │
│  【链接的真相】                                                │
│    硬链接 = 同一 inode 的多个名字(引用计数)                     │
│    软链接 = 指向"名字"的名字(可能悬空)                          │
│    删除文件 = 引用计数减一,不是抹数据                            │
│                                                              │
│  【持久化的铁律】                                               │
│    write() 只保证"到了内存"(page cache)                        │
│    fsync() 才保证"到了磁盘"                                     │
│    新建文件还要 fsync 父目录                                     │
│    日志(journaling) 保护"文件系统不崩",不保护"你的数据不丢"       │
│                                                              │
│  【原子更新模式】                                               │
│    写 .tmp → fsync → rename(原子)→ fsync 目录                 │
│    = "准备可失败 + 提交原子"(数据库事务的雏形)                   │
│                                                              │
│  【三个可迁移的原则】                                            │
│    1. 统一的签名 ≠ 统一的语义(别被接口骗了)                     │
│    2. 优化改变语义时,要想到谁依赖旧语义                          │
│    3. 有领域知识才自己实现缓存,否则依赖通用层                     │
│                                                              │
│  【一句话】                                                     │
│    文件系统用缓存和日志,在又慢又易坏的磁盘上伪装"又快又可靠";      │
│    理解它的裂缝(缓存丢失、抽象泄漏),才算真正会用文件。            │
│                                                              │
│  【下一章预告】                                                  │
│    文件搞清楚了,但它只是"设备"的一种——                           │
│    下一章讲设备与 I/O,以及为什么"统一接口"在硬件面前总会破功。      │
└──────────────────────────────────────────────────────────────┘