经典系统实践|给 Go 文件系统加上最小 WAL / Journal:事务边界、Redo Log 与崩溃恢复
上一篇文章,我们已经用 Go 搭了一个最小可理解文件系统骨架。
它已经有了:
- superblock
- inode table
- block bitmap
- data blocks
- 目录项
- 路径查找
- 最朴素的
create/read/write/delete
如果只在正常路径上跑,这个 toy filesystem 已经能说明很多问题。
但上一篇其实也故意留了一个最关键的坑没有填:
它在掉电或进程异常退出时,极其脆弱。
比如一次简单写入,就可能出现这些中间状态:
- bitmap 已经标记 block 被占用,但 inode 还没指过去
- inode 已经指向新 block,但数据块内容还没完全写好
- 目录项已经暴露出新名字,但对应 inode 还没完成初始化
- 旧 block 已经释放,但新 block 还没真正变成可见版本
只要你真的把文件系统往前实现一步,这些问题几乎一定会撞上。
所以这篇文章,我们接着上一篇继续做一件非常自然的事:
给这个 Go 文件系统补上一层最小 WAL / journal。
这里我还是强调一下目标边界。
这篇不是要复刻 ext4 的 jbd2,也不是要实现一套工业级日志子系统。目标仍然是:
- 用最小复杂度把“事务更新”这件事写出来
- 让你真正看清为什么日志是必要的
- 把 redo log、commit record、replay 恢复这些概念落到代码结构上
读完之后,你应该至少能回答这些问题:
- 为什么简单文件系统迟早会需要 WAL / journal
- 事务边界应该怎么定义
- redo log 和 undo log 的取舍是什么
- commit record 为什么是恢复语义的关键
- recovery 启动时应该重放什么、忽略什么
1. 先把问题说透:没有日志时,崩溃一致性为什么必然会坏
我们先回到上一篇那个最简单的写路径。
假设 WriteFile() 做了这几步:
- 在 bitmap 里分配几个新 block
- 把用户数据写入这些 block
- 更新 inode 的 direct 指针和 size
- 如果是新文件,还要更新父目录项
- 更新 superblock 的空闲计数
注意,这里一次逻辑操作已经改了多个物理位置:
- bitmap block
- data blocks
- inode table block
- directory data block
- superblock
问题就来了。
如果你在第 2 步和第 3 步之间掉电,恢复后到底应该看到:
- 新版本可见
- 旧版本可见
- 一部分新版本可见
- 完全不可预测
如果系统不能明确回答这个问题,它就没有真正的崩溃一致性语义。
而没有日志的朴素写法,通常给出的答案就是第 4 种:
完全不可预测。
这并不是 toy filesystem 太差,而是一个一般性规律:
只要一次逻辑操作会改多个磁盘位置,而这些修改又不是原子完成,你就迟早要面对事务问题。
2. 先选策略:为什么这里适合做 redo log,而不是 undo log
给文件系统补日志,大方向通常有两条:
- undo log
- redo log
2.1 undo log 的思路
先把“旧值”记下来,如果更新过程中崩溃,就回滚到旧值。
2.2 redo log 的思路
先把“新值”记下来,如果更新过程中崩溃,就把已提交但尚未完全落到 home location 的修改重新做一遍。
对于我们这个最小 Go 文件系统,我更建议先实现 redo log,原因很直接:
- 它更贴近很多文件系统和数据库里常见的 write-ahead 思想
- 恢复逻辑更直观:看到 committed txn 就重放
- 对教学来说,更容易把“先写日志、后写正式位置”讲清楚
所以这篇我们选择的模型是:
physical redo log。
也就是:日志里直接记录“哪个 block 将被写成什么内容”。
这比 logical log 更笨重,但对于 toy filesystem 非常合适,因为它最容易理解。
3. 先定义事务边界:不是每次 block write 都是一个事务
日志系统里最容易先写错的地方,不是格式,而是事务边界。
我们需要先回答:
什么叫一次原子操作?
在这个 toy filesystem 里,可以先约定:
- 一次
Create(path)是一个事务 - 一次
WriteFile(inode, data)是一个事务 - 一次
Delete(path)是一个事务 - 一次
Mkdir(path)是一个事务
也就是说,事务边界对齐的是“文件系统 API 语义”,而不是底层单个 block 修改。
这很重要,因为用户真正关心的不是:
- 某个 bitmap bit 有没有改成功
而是:
- 这个文件到底创建成功没有
- 这次写入到底整体算成功没有
- 这个删除到底是不是已经生效
所以事务边界应该尽量对齐外部语义边界。
4. 最小 WAL 的磁盘布局怎么放
上一篇我们的磁盘布局大致是:
+------------+------------+-------------+-------------+
| superblock | inode area | block bitmap| data blocks |
+------------+------------+-------------+-------------+
现在既然要加 WAL,最简单的办法就是从一开始就预留一段日志区域:
+------------+------------+-----------+-------------+-------------+
| superblock | inode area | WAL area | block bitmap| data blocks |
+------------+------------+-----------+-------------+-------------+
比如我们可以重新约定:
- block 1 存 superblock
- block 2 ~ 33 存 inode table
- block 34 ~ 65 存 WAL
- block 66 存 data block bitmap
- block 67 之后存 data blocks
为什么单独预留 WAL 区?
因为对最小实现来说:
- 简单
- 容易推导恢复逻辑
- 不用先实现复杂循环日志或动态扩展
先把“有没有正确的事务恢复语义”做出来,比日志空间利用率更重要。
5. superblock 也要知道 WAL 的位置
所以 superblock 最自然的扩展就是补几项日志区字段:
type Superblock struct {
Magic uint32
BlockSize uint32
TotalBlocks uint32
InodeCount uint32
InodeStart uint32
InodeBlocks uint32
WALStart uint32
WALBlocks uint32
BitmapStart uint32
DataStart uint32
RootInode uint32
FreeBlockCount uint32
_ [4040]byte
}
这类字段看起来平平无奇,但它们其实在表达一个非常核心的事实:
日志区本身也是文件系统元数据的一部分。
6. 不要先做复杂 journal,先做“整块 redo”就够了
工业级 journal 很复杂,是因为它要解决:
- 并发事务
- 检查点
- 循环复用
- checkpoint 与 recovery window 控制
- 日志空间回收
- 元数据与数据不同策略
但对我们的 toy filesystem,第一版完全没必要碰这些。
我们只需要一个最小模型:
- 一个事务由若干条 block image 组成
- 每条日志记录描述“目标 block 编号 + 新 block 内容”
- 最后写一个 commit record
- 只有看到 commit record,恢复时才重放这个事务
这其实已经足够把 WAL 的灵魂讲清楚了。
7. 最小日志格式怎么设计
先定义三类记录:
TxnBeginTxnDataTxnCommit
最小 on-disk 结构可以这样定义:
type RecordType uint32
const (
RecTxnBegin RecordType = 1
RecTxnData RecordType = 2
RecTxnCommit RecordType = 3
)
type LogRecordHeader struct {
Type RecordType
TxnID uint64
BlockNo uint32
Length uint32
}
然后:
TxnBegin没有 payloadTxnData的 payload 是一个完整 block imageTxnCommit没有 payload
一笔事务的日志流就会长成:
BEGIN(txn=101)
DATA(txn=101, block=66, payload=...)
DATA(txn=101, block=12, payload=...)
DATA(txn=101, block=3, payload=...)
COMMIT(txn=101)
这就是最小可理解 redo log。
为什么 TxnData 直接放完整 block image
因为我们想避免第一版就引入 partial update merge 问题。
如果日志只写“偏移 + 改动字节”,那恢复时你还得考虑:
- home block 当前是什么状态
- 多次更新同一个 block 怎么合并
- 崩溃时 block 半写怎么办
而整块 redo image 的好处是:
恢复时直接把那一块重新写回去即可。
笨一点,但特别清楚。
8. 运行时对象怎么建:先分清 home write 和 log write
在 Go 代码里,一个很自然的抽象是把“事务内准备修改的 block”先缓存在内存里:
type BlockImage struct {
BlockNo uint32
Data [BlockSize]byte
}
type Transaction struct {
ID uint64
Writes map[uint32][BlockSize]byte
}
type Journal struct {
StartBlock uint32
BlockCount uint32
NextTxnID uint64
}
type FileSystem struct {
f *os.File
sb Superblock
journal Journal
}
这里有个非常关键的设计点:
事务内的写,不要立刻写 home location。
而是:
- 先把要写成的最终 block image 放进
txn.Writes - 提交时先顺序写 WAL
fsyncWAL- 再把这些 block image 写回正式位置
- 最后清理或截断 WAL
这就是 write-ahead logging 的核心。
9. 一次事务提交到底做什么
现在我们把事务提交流程完整写出来。
最小的正确顺序通常是:
- 写
BEGIN - 写所有
DATA - 写
COMMIT fsyncWAL- 把
DATA里的 block image 写回 home location fsynchome location 对应镜像文件- 把 WAL 标记为空,或在下次启动时视为已 checkpoint
伪代码:
function commit(txn):
write_log(BEGIN, txn.id)
for each block_write in txn.writes:
write_log(DATA, txn.id, block_no, block_image)
write_log(COMMIT, txn.id)
fsync(log)
for each block_write in txn.writes:
write_home_block(block_no, block_image)
fsync(fs_image)
clear_log()
fsync(log)
这个顺序最重要的约束是:
正式数据位置的修改,不允许先于对应 WAL commit 持久化。
否则“write-ahead”就破了,恢复时也就没有可靠依据了。
10. 用 Go 写一个最小 Commit() 骨架
先给一个足够接近真实代码的版本:
func (fs *FileSystem) Commit(txn *Transaction) error {
if err := fs.writeBegin(txn.ID); err != nil {
return err
}
for blockNo, image := range txn.Writes {
if err := fs.writeLogData(txn.ID, blockNo, image[:]); err != nil {
return err
}
}
if err := fs.writeCommit(txn.ID); err != nil {
return err
}
if err := fs.f.Sync(); err != nil {
return err
}
for blockNo, image := range txn.Writes {
if err := fs.writeBlock(blockNo, image[:]); err != nil {
return err
}
}
if err := fs.f.Sync(); err != nil {
return err
}
if err := fs.clearJournalArea(); err != nil {
return err
}
return fs.f.Sync()
}
这段代码当然还很粗糙,比如:
fs.f.Sync()同时覆盖日志区和 home location,不够细- 没有 checksum
- 没有循环日志
- 没有并发控制
但从“教学型最小正确性”角度,它已经足够表达 WAL 提交语义。
11. 事务内的修改不该直接调 writeBlock()
上一篇里我们写文件时,很可能会这样做:
blockNo, _ := fs.allocBlock()
fs.writeBlock(blockNo, buf)
inode.Direct[i] = blockNo
fs.storeInode(inodeNo, inode)
有了 journal 之后,这种写法就该整体改掉。
正确思路应该更像:
txn := fs.Begin()
bitmapBlock := fs.cloneBitmapBlock()
markAllocated(bitmapBlock, blockNo)
txn.Write(BitmapBlockNo, bitmapBlock)
txn.Write(blockNo, dataBlock)
inodeBlockNo, inodeBlockImage := fs.cloneInodeBlock(inodeNo)
patchInode(inodeBlockImage, inodeNo, newInode)
txn.Write(inodeBlockNo, inodeBlockImage)
err := fs.Commit(txn)
注意这里的关键变化:
事务先在内存里构造出“未来各个 block 应该长什么样”,然后一次性提交。
这和数据库里的 page-oriented WAL 非常像。
12. 恢复为什么只需要盯住 committed transaction
redo log 的恢复逻辑之所以好理解,关键就在于 commit record。
启动恢复时,你只需要扫描日志区,回答两个问题:
- 这笔事务有没有
BEGIN - 它有没有对应的
COMMIT
如果只有 begin,没有 commit,那么这笔事务直接丢弃。
如果 begin 和 commit 都在,那么不管 home location 当时写到哪一步,都直接按日志里的 block image 全量重放。
这就是最小 redo recovery 的核心语义:
只重放已经提交的事务。
13. 最小恢复流程可以怎么写
恢复流程的伪代码大致如下:
function recover():
txns = scan_journal()
for txn in txns:
if not txn.has_commit:
continue
for record in txn.data_records:
write_home_block(record.block_no, record.image)
fsync(fs_image)
clear_journal()
fsync(fs_image)
对应 Go 代码骨架可以写成:
func (fs *FileSystem) Recover() error {
txns, err := fs.scanJournal()
if err != nil {
return err
}
for _, txn := range txns {
if !txn.Committed {
continue
}
for _, rec := range txn.Records {
if rec.Type != RecTxnData {
continue
}
if err := fs.writeBlock(rec.BlockNo, rec.Payload); err != nil {
return err
}
}
}
if err := fs.f.Sync(); err != nil {
return err
}
if err := fs.clearJournalArea(); err != nil {
return err
}
return fs.f.Sync()
}
这段代码最重要的不是实现细节,而是恢复策略足够明确:
- 不回滚未提交事务
- 不猜测 home location 当前状态
- 已提交事务直接 redo
这就是 redo log 相对直观的地方。
14. 为什么 commit record 比 begin record 更关键
很多人第一次看 WAL,会觉得 begin 和 commit 只是形式上的配对。
其实不是。
在恢复语义里,真正关键的是 commit record。
原因是:
BEGIN只能说明“这笔事务曾经开始过”COMMIT才说明“这笔事务对外承诺过成功”
所以恢复时的判断标准不是:
- 有没有看到日志内容
而是:
- 有没有看到持久化完成的 commit 证据
如果你把这件事想透,很多数据库和文件系统里的恢复逻辑都会顺很多。
15. 为什么光有 WAL 还不够,还要有 checksum 或长度校验
如果日志区本身在崩溃时只写了一半怎么办?
比如:
- header 写了一半
- payload 长度字段已写,但实际 block image 还没完整落盘
- commit record 只有前半截
这时候恢复扫描器如果不做边界校验,就可能把损坏日志误认为有效记录。
所以哪怕是最小实现,我也建议至少加两个字段:
- record 总长度
- payload checksum
例如:
type LogRecordHeader struct {
Magic uint32
Type RecordType
TxnID uint64
BlockNo uint32
Length uint32
Checksum uint32
}
这样恢复扫描时可以先做:
- magic 校验
- length 边界检查
- checksum 校验
这并不能让日志系统变成工业级,但已经足以避免最粗暴的误判。
16. 最小 journal 和真正文件系统 journal 的差别在哪里
写到这里,很容易产生一个误解:
“原来 journal 就是把整个 block 先记一份,再重放。”
这只是第一步,不是全貌。
真实文件系统里的 journal 往往还要解决更多问题:
- 并发事务隔离
- checkpoint
- 日志空间循环复用
- metadata-only journaling
- data journaling 与 ordered mode
- barrier / flush 顺序控制
- 恢复窗口和 replay 成本控制
但不要因此低估这个最小版本。
因为你只要把这一版自己写过一遍,就已经掌握了最关键的主干:
先把即将发生的修改稳定记录下来,再让正式位置去追日志。
这一点,从 toy filesystem 到 ext4、XFS、数据库 buffer pool,思想上是高度统一的。
17. 一个更接近真实世界的优化方向:metadata journal,data direct write
我们前面为了教学简单,采用的是 physical redo log,甚至可以把数据块也完整记进日志。
这当然最容易理解,但代价也很明显:
- 大文件写入时日志量翻倍
- 写放大很重
- recovery 日志扫描成本高
所以真实文件系统很常见的一种折中是:
- 数据块走正常写路径
- 元数据更新进 journal
- 通过 ordered 语义保证相关数据块先于元数据提交
这其实就是上一篇里提到的 ext4 data=ordered 一类思路。
如果你准备继续迭代这个 Go 文件系统,一个很自然的第二版就是:
- 只把 bitmap / inode / directory block 这些元数据块写进 WAL
- data block 不进日志,但要求 commit 前它们先完成持久化
这样会更接近真实系统的工程折中。
18. 从对象存储视角回看,WAL 为什么如此熟悉
如果你本来就是做对象存储、KV 或 LSM 系统的,这篇讲到这里应该会有一种强烈既视感。
因为这里的核心逻辑其实和很多上层存储引擎几乎同构:
- 先写日志
fsync日志- 再更新正式数据结构
- 崩溃恢复时以日志为准
区别只在于:
- 文件系统 journal 记录的是 block / metadata update
- 数据库 WAL 记录的是 page change 或 logical record
- 对象存储 manifest/WAL 记录的是 object version 或 segment visibility
但它们在工程哲学上是一样的:
把“算数的瞬间”从正式数据位置,前移到一份更容易顺序追加、更容易恢复判断的日志上。
这就是日志如此普遍的根因。
19. 一份更完整的最小骨架
如果把这一篇的关键对象收一下,一个最小可理解版本大概会长这样:
type Transaction struct {
ID uint64
Writes map[uint32][BlockSize]byte
}
type Journal struct {
StartBlock uint32
BlockCount uint32
NextTxnID uint64
}
func (fs *FileSystem) Begin() *Transaction
func (txn *Transaction) Write(blockNo uint32, data [BlockSize]byte)
func (fs *FileSystem) Commit(txn *Transaction) error
func (fs *FileSystem) Recover() error
func (fs *FileSystem) scanJournal() ([]ScannedTxn, error)
func (fs *FileSystem) clearJournalArea() error
而一条典型写路径会从:
alloc block -> write block -> update inode -> update bitmap
变成:
clone bitmap block -> patch bitmap in memory -> txn.Write(...)
clone data block image -> fill content -> txn.Write(...)
clone inode block -> patch inode in memory -> txn.Write(...)
Commit(txn)
也就是说,系统思维从“立刻修改正式位置”,切换成了“先构造一个事务版本,再提交”。
这一步非常关键。
20. 总结:日志不是额外功能,而是多点更新系统的必然结果
如果把这篇文章压缩成一句话,那就是:
一旦你的文件系统里一次逻辑操作需要同时更新多个物理位置,WAL / journal 基本就不是可选优化,而是迟早要补上的一致性机制。
在上一篇最小文件系统里,我们已经看到:
- 文件写入会同时动 bitmap、inode、data block
- 文件创建还会再加上 directory block
- 删除会牵涉名字可见性和空间回收顺序
这些操作只要没有事务边界,就会在崩溃场景下变得不可靠。
而这一篇做的事情,本质上就是把那条缺失的边补上:
- 先定义事务
- 先写 redo log
- 用 commit record 决定哪些事务算数
- 启动恢复时只重放 committed transaction
这套东西看起来像是“多写了一层日志”,但它真正改变的是系统语义:
从“修改可能部分生效”变成“恢复后要么看到旧状态,要么看到一个可被日志重建的新状态”。
下一篇如果继续写这个系列,一个非常自然的方向就是:
把这套最小 WAL 再推进一步,做成 metadata journaling + ordered data write,并对比 ext4 data=ordered 为什么是一个经典工程折中。