经典系统实践|用 Go 实现一个最小可理解文件系统:Superblock、inode、Bitmap 与目录树
上一篇文章,我们从对象存储研发工程师的角度,把文件系统里最容易在生产中踩坑的几个语义拆了一遍:
- VFS 在抽象什么
- page cache 为什么既能救你,也能坑你
fsync、rename、目录持久化到底在保证什么- 为什么 immutable chunk 和 manifest publish 几乎是自然演化出来的设计
但如果只停留在“理解操作系统和现成文件系统”,很多概念还是容易浮在半空中。
比如:
- inode 到底怎么落盘
- block allocator 具体长什么样
- 路径查找究竟是在找什么
- 目录为什么本质上也是一种特殊文件
- 一次
create/write/read/delete到底会改哪些元数据
所以这篇文章我们反过来做一件更扎实的事:
不用 ext4、也不用 XFS,直接用 Go 写一个“最小可理解文件系统”。
注意,我这里的目标不是做一个生产可用文件系统,更不是要复刻 ext4。
目标只有两个:
- 把文件系统最核心的数据结构和数据流亲手搭出来
- 用最少的复杂度,把真实文件系统里最重要的约束讲清楚
为了把问题收窄,我们做如下约定:
- 文件系统存在一个普通磁盘镜像文件里,比如
fs.img - block 固定大小,比如
4096字节 - 只支持常规文件和目录
- 先不做权限、属主、符号链接、硬链接
- 先不做 page cache、journal、extent tree、B+Tree
- 先实现最核心的
mkfs/create/read/write/list/delete
也就是说,这是一套 toy filesystem,但它不是玩具概念文,而是一套足够接近真实实现的骨架。
读完之后,你应该至少能回答这些问题:
- 一个最小文件系统在磁盘上需要哪些区段
- superblock、inode table、bitmap、data blocks 分别保存什么
- 路径查找如何从
/a/b/c.txt走到目标 inode - 文件写入时哪些元数据需要一起更新
- 为什么崩溃一致性会逼着系统引入 log 或 journal
1. 先定目标:我们到底要实现什么样的文件系统
很多人第一次写“文件系统”,会下意识选择 FUSE。
FUSE 当然很好,它能让你在用户态快速挂一个文件系统出来,调试体验也不错。
但如果你的目标是理解文件系统本体,FUSE 其实有一个问题:
你很容易把注意力放到接口适配上,而不是磁盘布局和元数据组织上。
所以这篇文章我们故意不从 FUSE 开始,而是从一个更底层也更本质的模型开始:
Go process
-> open("fs.img")
-> treat it as a block device
-> manage superblock / inode table / data blocks by ourselves
这个模型的好处是:
- 所有状态都在你自己的代码里
- 每一次 block read/write 都是显式的
- 你能非常清楚地看到元数据是如何组织的
- 后面如果你想接到 FUSE,只是多一层接口适配
也就是说:
先实现 on-disk filesystem,再谈系统调用接口。
这是更稳的学习路径。
2. 一个最小文件系统,磁盘上至少要有什么
先不考虑任何高级特性,一个最小文件系统通常至少要有四类区域:
+------------+------------+-------------+-------------+
| superblock | inode area | block bitmap| data blocks |
+------------+------------+-------------+-------------+
分别解释一下。
2.1 superblock
superblock 是整个文件系统的“总说明书”。
它通常记录:
- magic number
- block size
- 总 block 数
- inode 总数
- 各区域起始位置
- root inode 编号
如果把文件系统看作一个小数据库,那 superblock 就像数据库文件头。
2.2 inode area
inode 区保存每个文件或目录的元数据。最小版本里至少要包括:
- 类型:文件还是目录
- 文件大小
- 直接数据块指针
- 已分配 block 数
这里先不做多级间接块,先只支持小文件。
2.3 block bitmap
bitmap 用来表示哪些 data block 已经被占用。
比如:
- bit = 0 表示空闲
- bit = 1 表示已分配
这是最常见、也最容易实现的空间分配方法。
2.4 data blocks
这里是真正存目录内容和文件内容的地方。
注意一个很关键的点:
目录本身也是数据。
只不过目录数据块里放的不是普通字节流,而是一组 name -> inode 记录。
3. 先把磁盘布局写死,反而更容易理解
为了避免一开始就陷入“通用设计”,我们先给一个非常固定的布局:
- block size =
4096 - block 0 保留不用
- block 1 存 superblock
- block 2 ~ 33 存 inode table
- block 34 存 data block bitmap
- block 35 之后存真正的数据块
这不是因为真实文件系统都这么简单,而是因为:
对于学习型实现,固定布局比抽象灵活性更重要。
可以先定义这些常量:
const (
BlockSize = 4096
TotalBlocks = 1024
InodeSize = 256
InodeCount = 512
SuperBlockNo = 1
InodeStartBlock = 2
InodeBlocks = 32
BitmapBlockNo = 34
DataStartBlock = 35
RootInodeNumber = 1
)
这时候整个镜像大小就是:
$$ 1024 \times 4096 = 4\text{ MiB} $$
虽然很小,但已经足够把核心机制跑起来。
4. 用 Go 定义 on-disk 结构时,先克制一下抽象欲望
实现文件系统时,一个很常见的错误是:Go 结构体写得很优雅,但根本不适合稳定落盘。
原因很简单:
- Go 结构体有对齐问题
- 切片、字符串、map 这种带引用语义的字段不能直接作为磁盘格式
- 你需要的是固定大小、可序列化、可随机定位的二进制布局
所以 on-disk 结构应该尽量朴素。
4.1 superblock
type Superblock struct {
Magic uint32
BlockSize uint32
TotalBlocks uint32
InodeCount uint32
InodeStart uint32
InodeBlocks uint32
BitmapStart uint32
DataStart uint32
RootInode uint32
FreeBlockCount uint32
_ [4048]byte
}
这里故意补齐到一个 block,原因是:
- superblock 单独占一个块最直观
- 更新和读取都简单
- 以后扩展字段也更方便
4.2 inode
type InodeType uint16
const (
TypeFree InodeType = 0
TypeFile InodeType = 1
TypeDir InodeType = 2
)
type Inode struct {
Type InodeType
_pad0 uint16
Size uint64
Direct [12]uint32
BlockCount uint32
_pad1 [192]byte
}
这个 inode 很小,也很刻意:
- 只支持 direct blocks
- 不支持 extent
- 不支持 mtime/ctime
- 不支持权限
但已经足够表达“文件内容在哪些 block 里”。
4.3 directory entry
目录项可以定义成固定大小:
type DirEntry struct {
Inode uint32
Name [60]byte
}
这样一条记录就是 64 字节,一个 4096 字节 block 恰好能放:
$$ 4096 / 64 = 64 $$
条目录项。
这比变长目录项简单得多,也很适合第一版实现。
5. 代码骨架先搭出来:FileSystem 对象需要持有什么
最小版本里,我们可以先把运行时对象定义得非常直接:
type FileSystem struct {
f *os.File
sb Superblock
}
然后先实现三类最基本能力:
- 按 block 读写
- 按 inode 编号读写 inode
- 分配和回收 data block
5.1 block 读写
func (fs *FileSystem) readBlock(blockNo uint32, buf []byte) error {
if len(buf) != BlockSize {
return fmt.Errorf("invalid block buffer size")
}
off := int64(blockNo) * BlockSize
_, err := fs.f.ReadAt(buf, off)
return err
}
func (fs *FileSystem) writeBlock(blockNo uint32, buf []byte) error {
if len(buf) != BlockSize {
return fmt.Errorf("invalid block buffer size")
}
off := int64(blockNo) * BlockSize
_, err := fs.f.WriteAt(buf, off)
return err
}
这里没有 page cache、没有 bio、没有 iomap,只有最核心的事情:
给我一个 block number,我就能读写那一块。
这就是所有更复杂机制的起点。
6. inode table 本质上就是“按编号随机访问的元数据数组”
假设每个 inode 固定 256 字节,那么我们可以直接按编号算它落在哪个 block、block 内偏移是多少。
func inodeOffset(inodeNo uint32) (blockNo uint32, offset int) {
idx := inodeNo - 1
byteOffset := idx * InodeSize
blockNo = InodeStartBlock + byteOffset/BlockSize
offset = int(byteOffset % BlockSize)
return
}
接着实现读取 inode:
func (fs *FileSystem) loadInode(inodeNo uint32) (Inode, error) {
var inode Inode
blockNo, offset := inodeOffset(inodeNo)
buf := make([]byte, BlockSize)
if err := fs.readBlock(blockNo, buf); err != nil {
return inode, err
}
r := bytes.NewReader(buf[offset : offset+InodeSize])
if err := binary.Read(r, binary.LittleEndian, &inode); err != nil {
return inode, err
}
return inode, nil
}
保存 inode 也类似:
func (fs *FileSystem) storeInode(inodeNo uint32, inode Inode) error {
blockNo, offset := inodeOffset(inodeNo)
buf := make([]byte, BlockSize)
if err := fs.readBlock(blockNo, buf); err != nil {
return err
}
var tmp bytes.Buffer
if err := binary.Write(&tmp, binary.LittleEndian, &inode); err != nil {
return err
}
copy(buf[offset:offset+InodeSize], tmp.Bytes())
return fs.writeBlock(blockNo, buf)
}
这一步一旦实现,你就已经真正拥有了“文件元数据落盘能力”。
7. mkfs 其实就是初始化一组彼此一致的元数据
很多人会把 mkfs 理解成“格式化磁盘”。
从实现角度看,它本质上是在做三件事:
- 清空整张磁盘镜像
- 写入初始 superblock
- 创建 root inode 和根目录数据块
伪代码可以写成:
function mkfs(image):
zero_all_blocks(image)
write_superblock()
mark_metadata_blocks_used()
root_inode = alloc_inode(TypeDir)
root_block = alloc_block()
root_inode.direct[0] = root_block
root_inode.size = size_of_dot_entries
write_inode(root_inode)
write_dir_entries(root_block, [".", ".."])
翻译成 Go,大致会是:
func Mkfs(path string) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644)
if err != nil {
return err
}
defer f.Close()
if err := f.Truncate(TotalBlocks * BlockSize); err != nil {
return err
}
fs := &FileSystem{f: f}
fs.sb = Superblock{
Magic: 0x20240324,
BlockSize: BlockSize,
TotalBlocks: TotalBlocks,
InodeCount: InodeCount,
InodeStart: InodeStartBlock,
InodeBlocks: InodeBlocks,
BitmapStart: BitmapBlockNo,
DataStart: DataStartBlock,
RootInode: RootInodeNumber,
FreeBlockCount: TotalBlocks - DataStartBlock - 1,
}
if err := fs.storeSuperblock(); err != nil {
return err
}
if err := fs.initBitmap(); err != nil {
return err
}
if err := fs.initRootDir(); err != nil {
return err
}
return fs.f.Sync()
}
这一段虽然简化了很多细节,但已经很好地说明:
mkfs 不是魔法,它只是把文件系统最初那组自洽状态写出来。
8. 路径查找的本质:不停读取目录文件
很多人第一次学文件系统时,会觉得路径查找是一个抽象的“系统调用行为”。
其实在最小模型里,它非常朴素:
- 从 root inode 开始
- 把路径按
/切开 - 在当前目录的数据块里找名字
- 找到对应 inode 后继续往下走
也就是说,路径查找本质上就是一轮又一轮目录扫描。
伪代码:
function lookup(path):
inode = root_inode
for part in split(path, "/"):
ensure inode is directory
entries = read_dir_entries(inode)
inode_no = find_name(entries, part)
inode = load_inode(inode_no)
return inode
Go 代码可以写成:
func (fs *FileSystem) Lookup(path string) (uint32, Inode, error) {
curNo := uint32(RootInodeNumber)
cur, err := fs.loadInode(curNo)
if err != nil {
return 0, Inode{}, err
}
for _, part := range splitClean(path) {
if cur.Type != TypeDir {
return 0, Inode{}, fmt.Errorf("not a directory: %s", part)
}
nextNo, err := fs.lookupInDir(cur, part)
if err != nil {
return 0, Inode{}, err
}
curNo = nextNo
cur, err = fs.loadInode(curNo)
if err != nil {
return 0, Inode{}, err
}
}
return curNo, cur, nil
}
这就是为什么上一篇文章里说:
目录本身也是文件。
因为路径查找并没有什么神秘能力,它只是在读一种特殊格式的数据文件。
9. create 文件时,至少要同时改三类东西
我们现在来实现最常见的操作:
create /docs/readme.txt
这个动作表面上像是“建了一个文件”,但底层至少会动三类状态:
- 分配一个新 inode
- 修改父目录内容,新增一条目录项
- 把新 inode 写回 inode table
如果新文件一开始为空,可能还不需要分配 data block;但元数据这三步已经跑不掉了。
伪代码:
function create(parent_dir, name):
inode_no = alloc_inode(TypeFile)
inode = empty_file_inode()
dir_entries = read_dir(parent_dir)
dir_entries.append(name -> inode_no)
write_inode(inode_no, inode)
write_dir(parent_dir, dir_entries)
这里马上就能看到崩溃一致性问题:
- 如果 inode 写了,但目录没写,会出现“孤儿 inode”
- 如果目录写了,但 inode 没写,路径会指向一个无效对象
这就是为什么真实文件系统最终会引入事务、journal 或 log。
最小版本里,我们先不解决这个问题,但一定要把它看见。
10. block allocator 不复杂,但它是所有写路径的入口
bitmap allocator 是整个 toy filesystem 里最值得先写对的一块。
思路很直接:
- 读取 bitmap block
- 从前往后找到第一个空闲 bit
- 把它置 1
- 持久化 bitmap
- 返回对应 data block number
代码可以写成:
func (fs *FileSystem) allocBlock() (uint32, error) {
buf := make([]byte, BlockSize)
if err := fs.readBlock(BitmapBlockNo, buf); err != nil {
return 0, err
}
for i := uint32(DataStartBlock); i < TotalBlocks; i++ {
bit := i - DataStartBlock
byteIdx := bit / 8
mask := byte(1 << (bit % 8))
if buf[byteIdx]&mask == 0 {
buf[byteIdx] |= mask
if err := fs.writeBlock(BitmapBlockNo, buf); err != nil {
return 0, err
}
fs.sb.FreeBlockCount--
if err := fs.storeSuperblock(); err != nil {
return 0, err
}
return i, nil
}
}
return 0, io.EOF
}
这段代码已经体现出文件系统实现里一个非常常见的特征:
数据本身还没写,元数据更新已经先开始了。
这也是为什么元数据路径经常比数据路径更难写对。
11. 写文件的第一版,先只支持覆盖式小文件
为了控制复杂度,我们先做一个限制:
- 文件大小不能超过
12 * 4096 - 写入接口一次覆盖整个文件
- 不支持 append
- 不支持洞洞文件
这是一个很强的限制,但它会让主路径非常清楚。
11.1 最小写入逻辑
function write_file(inode_no, data):
inode = load_inode(inode_no)
free old blocks
split data into blocks
for each chunk:
b = alloc_block()
write_block(b, chunk)
inode.direct[i] = b
inode.size = len(data)
inode.block_count = n
write_inode(inode_no, inode)
对应 Go 代码大致如下:
func (fs *FileSystem) WriteFile(inodeNo uint32, data []byte) error {
inode, err := fs.loadInode(inodeNo)
if err != nil {
return err
}
if inode.Type != TypeFile {
return fmt.Errorf("not a file")
}
if len(data) > len(inode.Direct)*BlockSize {
return fmt.Errorf("file too large")
}
if err := fs.freeInodeBlocks(&inode); err != nil {
return err
}
needed := (len(data) + BlockSize - 1) / BlockSize
for i := 0; i < needed; i++ {
blockNo, err := fs.allocBlock()
if err != nil {
return err
}
buf := make([]byte, BlockSize)
start := i * BlockSize
end := min(len(data), start+BlockSize)
copy(buf, data[start:end])
if err := fs.writeBlock(blockNo, buf); err != nil {
return err
}
inode.Direct[i] = blockNo
}
inode.Size = uint64(len(data))
inode.BlockCount = uint32(needed)
return fs.storeInode(inodeNo, inode)
}
这段代码虽然很朴素,但它有两个非常重要的教学价值:
- 你可以一眼看见数据块和 inode 元数据的关系
- 你也能一眼看见它的崩溃一致性问题
比如:
- 新 block 分配完了,但 inode 还没写,可能泄露空间
- inode 已经指向新 block,但某些 data block 还没写完整,可能读到脏内容
- 覆盖式更新会先 free 旧 block,这在崩溃场景下风险很大
真实文件系统不会这么天真,但第一版实现应该先把这种天真摆在明处。
12. 为什么真正安全的写法会自然走向 copy-on-write
看到上一节,你应该会很自然地问:
覆盖写既然这么危险,那更合理的方式是什么?
答案通常是:
- 先分配新 block
- 把新内容全部写好
- 最后再原子更新 inode 指针或更高层 manifest
- 旧 block 延后回收
这就是最小意义上的 copy-on-write。
在我们的 toy filesystem 里,也可以把写路径改成这样:
function safe_write_file(inode_no, data):
inode = load_inode(inode_no)
new_blocks = alloc_and_write_all(data)
new_inode = inode
new_inode.direct = new_blocks
new_inode.size = len(data)
write_inode(inode_no, new_inode)
free_old_blocks_later(inode.direct)
你会发现,这和上一篇对象存储文章里的 immutable chunk + manifest publish 几乎是一回事。
这不是巧合。
因为只要系统想在崩溃场景下减少“半写状态”,它就很容易从 overwrite 走向 CoW。
13. read 路径反而通常比 write 更简单
读取文件时,逻辑通常非常直接:
- 通过路径找到 inode
- 根据
inode.direct[]找到数据块 - 依次读出并拼接
- 按
inode.size截断尾部 padding
Go 代码:
func (fs *FileSystem) ReadFile(inodeNo uint32) ([]byte, error) {
inode, err := fs.loadInode(inodeNo)
if err != nil {
return nil, err
}
if inode.Type != TypeFile {
return nil, fmt.Errorf("not a file")
}
out := make([]byte, 0, inode.Size)
for i := uint32(0); i < inode.BlockCount; i++ {
buf := make([]byte, BlockSize)
if err := fs.readBlock(inode.Direct[i], buf); err != nil {
return nil, err
}
out = append(out, buf...)
}
return out[:inode.Size], nil
}
这里值得注意的是,read 路径之所以看起来简单,不是因为文件系统整体简单,而是因为:
写路径已经提前把复杂度都付掉了。
这在很多存储系统里都成立。
14. 目录为什么本质上是特殊文件,这件事在代码里会非常明显
我们来实现一个最小 ListDir。
如果目录 inode 的 direct block 里存的是一串 DirEntry,那么列目录其实只是:
- 读取目录 inode
- 读取它对应的数据块
- 按
DirEntry定长切分 - 过滤
inode == 0的空槽位
代码形态会非常接近普通文件读取,只是解释方式不同:
func (fs *FileSystem) listDirEntries(dir Inode) ([]DirEntry, error) {
if dir.Type != TypeDir {
return nil, fmt.Errorf("not dir")
}
var entries []DirEntry
for i := uint32(0); i < dir.BlockCount; i++ {
buf := make([]byte, BlockSize)
if err := fs.readBlock(dir.Direct[i], buf); err != nil {
return nil, err
}
for off := 0; off < BlockSize; off += 64 {
var ent DirEntry
r := bytes.NewReader(buf[off : off+64])
if err := binary.Read(r, binary.LittleEndian, &ent); err != nil {
return nil, err
}
if ent.Inode != 0 {
entries = append(entries, ent)
}
}
}
return entries, nil
}
你看,目录没有任何神秘性。
它只是“内容被解释为目录项数组的文件”。
这件事一旦在代码里真正写过一次,很多路径查找和目录更新问题就不再抽象了。
15. delete 操作为什么总是比看起来更危险
删除文件最朴素的想法是:
- 从父目录删除名字
- 释放数据块
- 释放 inode
但这三步的顺序其实非常敏感。
如果先释放 inode 或 block,再删目录项,路径可能短暂指向已回收对象。
如果先删目录项,再释放 block,崩溃后可能形成垃圾空间。
最小版本里,我们通常会优先保证“路径不可见”而不是“空间立即回收”:
function delete(parent, name):
inode_no = remove_dir_entry(parent, name)
mark_inode_deleted(inode_no)
enqueue_blocks_for_gc(inode_no)
这也是很多成熟系统喜欢把删除拆成两阶段的原因:
- 先逻辑删除
- 再物理回收
对象存储里的 tombstone 和后台 GC,骨子里也是同一种思想。
16. 一旦认真考虑崩溃一致性,你就会被迫引入 WAL 或 journal
我们前面故意先写了一个“功能上成立,但崩溃时很脆弱”的文件系统。
这是有意的,因为如果你没亲眼看到脆弱点,就很难真正理解 journal 为什么存在。
回头看一次简单的写文件:
- 分配 bitmap
- 写 data block
- 更新 inode
- 可能更新目录
- 可能更新 superblock 的 free count
这里任意一步中途掉电,都可能让文件系统变成半更新状态。
所以更合理的方向通常是:
- 先把“我要做哪些修改”记进 WAL
- WAL
fsync - 应用真正的数据块和元数据块修改
- 标记事务完成
最小伪代码可以写成:
function txn_write(op):
txn = begin_txn()
txn.log(bitmap_change)
txn.log(inode_change)
txn.log(data_block_change)
write_wal(txn)
fsync(wal)
apply_changes(txn)
mark_txn_committed(txn)
fsync(wal)
当然,真正的 journal / WAL 设计比这复杂很多,但这里最重要的是认识到:
只要一次逻辑操作会改多个物理位置,你迟早要面对事务问题。
17. 为什么 Go 很适合拿来写这种教学型文件系统
如果只看系统编程传统,很多人会本能地觉得“文件系统就该用 C 写”。
从生产内核视角看,这当然没问题;但从教学和原型验证视角,Go 其实非常合适。
原因大概有四个:
os.File.ReadAt/WriteAt很适合模拟 block deviceencoding/binary很适合处理固定格式的 on-disk 结构- 错误处理清晰,适合把失败路径写明白
- 开发效率高,便于快速迭代 toy filesystem
当然,Go 也有两个要特别小心的地方:
- 不要把切片、字符串、map 直接当成落盘结构
- 要明确 on-disk format 和 in-memory object 是两套东西
把这条边界守住,Go 会让这类练习非常顺手。
18. 如果你准备继续往下做,下一步该加什么
当最小版本跑通后,最自然的演进路径通常不是“立刻全量复刻 ext4”,而是按复杂度逐层加能力。
我会建议按这个顺序来:
第一层:把当前骨架补完整
- inode bitmap
- mkdir
- unlink
- 文件截断
- 基本
fsck
第二层:让数据结构更像真实文件系统
- indirect block
- extent
- 变长目录项
- free list 或更高效的 allocator
第三层:开始处理一致性和性能
- WAL / journal
- write-ahead metadata update
- page cache 模拟
- delayed allocation
第四层:接入真实接口
- FUSE 适配
readdir/open/read/write/create映射- 让宿主机真的能 mount 这个文件系统
这个顺序的好处是:
每一层都只增加一小段复杂度,但又能明显感觉到系统在变得更真实。
19. 一份更完整的最小骨架
如果把前面的关键片段收拢起来,一个最小版本大致会长这样:
type FileSystem struct {
f *os.File
sb Superblock
}
func Open(path string) (*FileSystem, error) {
f, err := os.OpenFile(path, os.O_RDWR, 0644)
if err != nil {
return nil, err
}
fs := &FileSystem{f: f}
if err := fs.loadSuperblock(); err != nil {
_ = f.Close()
return nil, err
}
if fs.sb.Magic != 0x20240324 {
_ = f.Close()
return nil, fmt.Errorf("invalid filesystem")
}
return fs, nil
}
func (fs *FileSystem) Create(path string) error
func (fs *FileSystem) Lookup(path string) (uint32, Inode, error)
func (fs *FileSystem) ReadFile(inodeNo uint32) ([]byte, error)
func (fs *FileSystem) WriteFile(inodeNo uint32, data []byte) error
func (fs *FileSystem) ListDir(path string) ([]string, error)
func (fs *FileSystem) Delete(path string) error
你会发现,这套接口其实已经很接近很多上层存储引擎内部对 local store 的抽象了。
原因也不神秘:
无论你做的是文件系统、对象存储本地盘引擎,还是一个 append-only log store,本质上都在回答同一类问题:
- 元数据怎么组织
- 数据块怎么定位
- 空间怎么分配
- 更新如何保持一致
20. 总结:自己写一遍最小文件系统,很多概念就真正落地了
如果把这篇文章压缩成一句话,那就是:
理解文件系统,最稳的方法之一不是继续背概念,而是亲手用 Go 写一个足够小、但结构完整的 on-disk filesystem。
当你真的把 superblock、inode、bitmap、directory entry、path walk、block allocator 和 write path 写出来之后,很多以前看起来抽象的概念会突然变得非常具体:
- inode 不再只是书上的名词,而是 inode table 里一个固定偏移的数据结构
- 路径查找不再神秘,而是不断读取目录文件并做名字匹配
- 写入不再只是“把字节写进去”,而是数据块、bitmap、inode、目录项一起变化
- 崩溃一致性不再是口号,而是每一步修改顺序稍有不慎就会出错
这也是为什么我一直觉得:
对象存储工程师如果认真写过一次 toy filesystem,再回头看 ext4、XFS、LSM、WAL、manifest publish,很多设计会突然显得异常统一。
因为它们本质上都在做同一件事:
把“数据放哪、怎么找、怎么改、崩溃后怎么算数”这四个问题,组织成一套尽量高效且可恢复的规则。
下一篇如果继续写这个话题,一个很自然的方向就是:
在这个 Go 文件系统骨架上,再补一层最小 WAL / journal,把崩溃一致性真正落到代码里。
那时候你会更直观地看到,为什么几乎所有成熟存储系统最后都会长出日志。