Q先生的世界

面朝大海,春暖花开

经典系统实践|给 Go 文件系统加上最小 WAL / Journal:事务边界、Redo Log 与崩溃恢复

上一篇文章,我们已经用 Go 搭了一个最小可理解文件系统骨架。

它已经有了:

  1. superblock
  2. inode table
  3. block bitmap
  4. data blocks
  5. 目录项
  6. 路径查找
  7. 最朴素的 create/read/write/delete

如果只在正常路径上跑,这个 toy filesystem 已经能说明很多问题。

但上一篇其实也故意留了一个最关键的坑没有填:

它在掉电或进程异常退出时,极其脆弱。

比如一次简单写入,就可能出现这些中间状态:

  1. bitmap 已经标记 block 被占用,但 inode 还没指过去
  2. inode 已经指向新 block,但数据块内容还没完全写好
  3. 目录项已经暴露出新名字,但对应 inode 还没完成初始化
  4. 旧 block 已经释放,但新 block 还没真正变成可见版本

只要你真的把文件系统往前实现一步,这些问题几乎一定会撞上。

所以这篇文章,我们接着上一篇继续做一件非常自然的事:

给这个 Go 文件系统补上一层最小 WAL / journal。

这里我还是强调一下目标边界。

这篇不是要复刻 ext4 的 jbd2,也不是要实现一套工业级日志子系统。目标仍然是:

  1. 用最小复杂度把“事务更新”这件事写出来
  2. 让你真正看清为什么日志是必要的
  3. 把 redo log、commit record、replay 恢复这些概念落到代码结构上

读完之后,你应该至少能回答这些问题:

  1. 为什么简单文件系统迟早会需要 WAL / journal
  2. 事务边界应该怎么定义
  3. redo log 和 undo log 的取舍是什么
  4. commit record 为什么是恢复语义的关键
  5. recovery 启动时应该重放什么、忽略什么

1. 先把问题说透:没有日志时,崩溃一致性为什么必然会坏

我们先回到上一篇那个最简单的写路径。

假设 WriteFile() 做了这几步:

  1. 在 bitmap 里分配几个新 block
  2. 把用户数据写入这些 block
  3. 更新 inode 的 direct 指针和 size
  4. 如果是新文件,还要更新父目录项
  5. 更新 superblock 的空闲计数

注意,这里一次逻辑操作已经改了多个物理位置:

  1. bitmap block
  2. data blocks
  3. inode table block
  4. directory data block
  5. superblock

问题就来了。

如果你在第 2 步和第 3 步之间掉电,恢复后到底应该看到:

  1. 新版本可见
  2. 旧版本可见
  3. 一部分新版本可见
  4. 完全不可预测

如果系统不能明确回答这个问题,它就没有真正的崩溃一致性语义。

而没有日志的朴素写法,通常给出的答案就是第 4 种:

完全不可预测。

这并不是 toy filesystem 太差,而是一个一般性规律:

只要一次逻辑操作会改多个磁盘位置,而这些修改又不是原子完成,你就迟早要面对事务问题。


2. 先选策略:为什么这里适合做 redo log,而不是 undo log

给文件系统补日志,大方向通常有两条:

  1. undo log
  2. redo log

2.1 undo log 的思路

先把“旧值”记下来,如果更新过程中崩溃,就回滚到旧值。

2.2 redo log 的思路

先把“新值”记下来,如果更新过程中崩溃,就把已提交但尚未完全落到 home location 的修改重新做一遍。

对于我们这个最小 Go 文件系统,我更建议先实现 redo log,原因很直接:

  1. 它更贴近很多文件系统和数据库里常见的 write-ahead 思想
  2. 恢复逻辑更直观:看到 committed txn 就重放
  3. 对教学来说,更容易把“先写日志、后写正式位置”讲清楚

所以这篇我们选择的模型是:

physical redo log

也就是:日志里直接记录“哪个 block 将被写成什么内容”。

这比 logical log 更笨重,但对于 toy filesystem 非常合适,因为它最容易理解。


3. 先定义事务边界:不是每次 block write 都是一个事务

日志系统里最容易先写错的地方,不是格式,而是事务边界。

我们需要先回答:

什么叫一次原子操作?

在这个 toy filesystem 里,可以先约定:

  1. 一次 Create(path) 是一个事务
  2. 一次 WriteFile(inode, data) 是一个事务
  3. 一次 Delete(path) 是一个事务
  4. 一次 Mkdir(path) 是一个事务

也就是说,事务边界对齐的是“文件系统 API 语义”,而不是底层单个 block 修改。

这很重要,因为用户真正关心的不是:

  1. 某个 bitmap bit 有没有改成功

而是:

  1. 这个文件到底创建成功没有
  2. 这次写入到底整体算成功没有
  3. 这个删除到底是不是已经生效

所以事务边界应该尽量对齐外部语义边界。


4. 最小 WAL 的磁盘布局怎么放

上一篇我们的磁盘布局大致是:

+------------+------------+-------------+-------------+
| superblock | inode area | block bitmap| data blocks |
+------------+------------+-------------+-------------+

现在既然要加 WAL,最简单的办法就是从一开始就预留一段日志区域:

+------------+------------+-----------+-------------+-------------+
| superblock | inode area | WAL area  | block bitmap| data blocks |
+------------+------------+-----------+-------------+-------------+

比如我们可以重新约定:

  1. block 1 存 superblock
  2. block 2 ~ 33 存 inode table
  3. block 34 ~ 65 存 WAL
  4. block 66 存 data block bitmap
  5. block 67 之后存 data blocks

为什么单独预留 WAL 区?

因为对最小实现来说:

  1. 简单
  2. 容易推导恢复逻辑
  3. 不用先实现复杂循环日志或动态扩展

先把“有没有正确的事务恢复语义”做出来,比日志空间利用率更重要。


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 很复杂,是因为它要解决:

  1. 并发事务
  2. 检查点
  3. 循环复用
  4. checkpoint 与 recovery window 控制
  5. 日志空间回收
  6. 元数据与数据不同策略

但对我们的 toy filesystem,第一版完全没必要碰这些。

我们只需要一个最小模型:

  1. 一个事务由若干条 block image 组成
  2. 每条日志记录描述“目标 block 编号 + 新 block 内容”
  3. 最后写一个 commit record
  4. 只有看到 commit record,恢复时才重放这个事务

这其实已经足够把 WAL 的灵魂讲清楚了。


7. 最小日志格式怎么设计

先定义三类记录:

  1. TxnBegin
  2. TxnData
  3. TxnCommit

最小 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
}

然后:

  1. TxnBegin 没有 payload
  2. TxnData 的 payload 是一个完整 block image
  3. TxnCommit 没有 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 问题。

如果日志只写“偏移 + 改动字节”,那恢复时你还得考虑:

  1. home block 当前是什么状态
  2. 多次更新同一个 block 怎么合并
  3. 崩溃时 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。

而是:

  1. 先把要写成的最终 block image 放进 txn.Writes
  2. 提交时先顺序写 WAL
  3. fsync WAL
  4. 再把这些 block image 写回正式位置
  5. 最后清理或截断 WAL

这就是 write-ahead logging 的核心。


9. 一次事务提交到底做什么

现在我们把事务提交流程完整写出来。

最小的正确顺序通常是:

  1. BEGIN
  2. 写所有 DATA
  3. COMMIT
  4. fsync WAL
  5. DATA 里的 block image 写回 home location
  6. fsync home location 对应镜像文件
  7. 把 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()
}

这段代码当然还很粗糙,比如:

  1. fs.f.Sync() 同时覆盖日志区和 home location,不够细
  2. 没有 checksum
  3. 没有循环日志
  4. 没有并发控制

但从“教学型最小正确性”角度,它已经足够表达 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。

启动恢复时,你只需要扫描日志区,回答两个问题:

  1. 这笔事务有没有 BEGIN
  2. 它有没有对应的 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()
}

这段代码最重要的不是实现细节,而是恢复策略足够明确:

  1. 不回滚未提交事务
  2. 不猜测 home location 当前状态
  3. 已提交事务直接 redo

这就是 redo log 相对直观的地方。


14. 为什么 commit record 比 begin record 更关键

很多人第一次看 WAL,会觉得 begin 和 commit 只是形式上的配对。

其实不是。

在恢复语义里,真正关键的是 commit record。

原因是:

  1. BEGIN 只能说明“这笔事务曾经开始过”
  2. COMMIT 才说明“这笔事务对外承诺过成功”

所以恢复时的判断标准不是:

  1. 有没有看到日志内容

而是:

  1. 有没有看到持久化完成的 commit 证据

如果你把这件事想透,很多数据库和文件系统里的恢复逻辑都会顺很多。


15. 为什么光有 WAL 还不够,还要有 checksum 或长度校验

如果日志区本身在崩溃时只写了一半怎么办?

比如:

  1. header 写了一半
  2. payload 长度字段已写,但实际 block image 还没完整落盘
  3. commit record 只有前半截

这时候恢复扫描器如果不做边界校验,就可能把损坏日志误认为有效记录。

所以哪怕是最小实现,我也建议至少加两个字段:

  1. record 总长度
  2. payload checksum

例如:

type LogRecordHeader struct {
    Magic    uint32
    Type     RecordType
    TxnID    uint64
    BlockNo  uint32
    Length   uint32
    Checksum uint32
}

这样恢复扫描时可以先做:

  1. magic 校验
  2. length 边界检查
  3. checksum 校验

这并不能让日志系统变成工业级,但已经足以避免最粗暴的误判。


16. 最小 journal 和真正文件系统 journal 的差别在哪里

写到这里,很容易产生一个误解:

“原来 journal 就是把整个 block 先记一份,再重放。”

这只是第一步,不是全貌。

真实文件系统里的 journal 往往还要解决更多问题:

  1. 并发事务隔离
  2. checkpoint
  3. 日志空间循环复用
  4. metadata-only journaling
  5. data journaling 与 ordered mode
  6. barrier / flush 顺序控制
  7. 恢复窗口和 replay 成本控制

但不要因此低估这个最小版本。

因为你只要把这一版自己写过一遍,就已经掌握了最关键的主干:

先把即将发生的修改稳定记录下来,再让正式位置去追日志。

这一点,从 toy filesystem 到 ext4、XFS、数据库 buffer pool,思想上是高度统一的。


17. 一个更接近真实世界的优化方向:metadata journal,data direct write

我们前面为了教学简单,采用的是 physical redo log,甚至可以把数据块也完整记进日志。

这当然最容易理解,但代价也很明显:

  1. 大文件写入时日志量翻倍
  2. 写放大很重
  3. recovery 日志扫描成本高

所以真实文件系统很常见的一种折中是:

  1. 数据块走正常写路径
  2. 元数据更新进 journal
  3. 通过 ordered 语义保证相关数据块先于元数据提交

这其实就是上一篇里提到的 ext4 data=ordered 一类思路。

如果你准备继续迭代这个 Go 文件系统,一个很自然的第二版就是:

  1. 只把 bitmap / inode / directory block 这些元数据块写进 WAL
  2. data block 不进日志,但要求 commit 前它们先完成持久化

这样会更接近真实系统的工程折中。


18. 从对象存储视角回看,WAL 为什么如此熟悉

如果你本来就是做对象存储、KV 或 LSM 系统的,这篇讲到这里应该会有一种强烈既视感。

因为这里的核心逻辑其实和很多上层存储引擎几乎同构:

  1. 先写日志
  2. fsync 日志
  3. 再更新正式数据结构
  4. 崩溃恢复时以日志为准

区别只在于:

  1. 文件系统 journal 记录的是 block / metadata update
  2. 数据库 WAL 记录的是 page change 或 logical record
  3. 对象存储 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 基本就不是可选优化,而是迟早要补上的一致性机制。

在上一篇最小文件系统里,我们已经看到:

  1. 文件写入会同时动 bitmap、inode、data block
  2. 文件创建还会再加上 directory block
  3. 删除会牵涉名字可见性和空间回收顺序

这些操作只要没有事务边界,就会在崩溃场景下变得不可靠。

而这一篇做的事情,本质上就是把那条缺失的边补上:

  1. 先定义事务
  2. 先写 redo log
  3. 用 commit record 决定哪些事务算数
  4. 启动恢复时只重放 committed transaction

这套东西看起来像是“多写了一层日志”,但它真正改变的是系统语义:

从“修改可能部分生效”变成“恢复后要么看到旧状态,要么看到一个可被日志重建的新状态”。

下一篇如果继续写这个系列,一个非常自然的方向就是:

把这套最小 WAL 再推进一步,做成 metadata journaling + ordered data write,并对比 ext4 data=ordered 为什么是一个经典工程折中。