Q先生的世界

面朝大海,春暖花开

BoltDB 源码分析(三):提交、freelist 与崩溃恢复

前两篇我们已经把静态结构拆开了:

  1. DBTxmetammap 如何搭起运行时框架
  2. page、node、Bucket、Cursor 如何表达一棵可读可写的 B+Tree

最后这一篇进入 BoltDB 最有“数据库味”的部分:

  1. 一次提交到底怎么落盘
  2. freelist 为什么不是一个简单的空闲页数组
  3. 没有 WAL 的情况下,BoltDB 的崩溃一致性靠什么成立
  4. 这套设计的性能收益和现实边界各是什么

前文:

  1. BoltDB 源码分析(一):事务、mmap 与文件格式
  2. BoltDB 源码分析(二):B+Tree、Bucket 与 Cursor

1. Commit() 是 BoltDB 全部一致性语义的主舞台

如果你只看 tx.goCommit() 的主流程其实非常短:

  1. rebalance()
  2. spill()
  3. 重写 freelist
  4. 必要时 grow 文件
  5. write() 写脏页
  6. writeMeta() 写 meta
  7. close() 收尾

代码不长,但这几个步骤的顺序几乎不能随便换。

因为它们背后遵循的是一个很明确的原则:

先把新版本所有数据页准备好并持久化,最后再切换“当前版本入口”。

而这个“入口”就是 meta page。

只要你抓住这个原则,很多细节都会变得自然。


2. 第一步:rebalance() 先把删除留下来的结构债清掉

Commit() 开头先执行 tx.root.rebalance()

这样做的原因很直接:

  1. 删除操作前面只是把 node 标成 unbalanced
  2. 如果不先修树,后面 spill 出去的就是一棵结构已经失衡的树
  3. 失衡结构还会影响后续 split/spill 的页布局判断

所以 rebalance 本质是在 commit 前做一次结构收口:

  1. 过瘦的节点尝试和兄弟合并
  2. 父节点对应边界项被修正
  3. 根节点必要时坍缩

这里有个工程上的好处:

把“写操作期间的局部脏状态”压缩到提交前统一治理,可以让普通写 API 很薄。

代价则是 commit 延迟会承担更多结构整理成本。


3. 第二步:spill() 把内存 node 树重写成新页

上一篇已经分析过 spill() 的基本过程,这里从提交语义再看一遍。

3.1 spill 本质上在做 copy-on-write

一个 node 如果原来对应旧 pgid,spill 时会先把旧页 free(txid, page),然后为新 node allocate() 新页。

注意这个 free() 并不是“立刻可复用”,而是先进入 freelist 的 pending[txid]

所以这个时刻的语义其实是:

  1. 旧页从逻辑上已经被新版本淘汰
  2. 但考虑到老读事务还可能看见它
  3. 暂时只能把它标成“未来可回收”

3.2 为什么先 spill 子 bucket,再 spill 当前 bucket

因为父 bucket 中保存子 bucket 的 value 里,要么内联了子 bucket 内容,要么至少要写入子 bucket 最新的 header/root。

如果子 bucket 还没先 spill 完:

  1. 父节点里没法知道子 bucket 最终 root 在哪
  2. 父 value 的序列化结果就不稳定

所以顺序必须是自底向上。

3.3 spill 结束后,tx.meta.root.root 还没切换

这一点很重要。

spill 只是把新树页写进了事务脏页集合,并更新了 bucket 的 root 指向;直到 Commit() 里:

tx.meta.root.root = tx.root.root

新版本根才正式进入“将被提交的 meta 视图”。

所以你可以把 spill 理解成“构造候选新世界”,而不是“对外宣布新世界已经生效”。


4. 第三步:为什么 freelist 也要重写成 page

很多人第一次读 BoltDB,会觉得 freelist 只是内存里的辅助结构。但在 BoltDB 里,freelist 也是持久化元数据的一部分。

提交时会发生这几件事:

  1. 先把旧 freelist 所在页本身也 free(txid, oldFreelistPage)
  2. 根据当前 freelist 大小分配新页
  3. freelist.write(p) 把空闲页列表写进去
  4. 更新 tx.meta.freelist = p.id

这一步非常关键,因为 BoltDB 重启后并不会扫描整库去推断哪些页空闲,而是直接从 meta 找到 freelist page 读取。

所以 freelist 并不是“缓存”,而是数据库当前空间状态的一部分事实记录

这也意味着:

  1. freelist 自己也有空间成本
  2. freelist 自己也需要版本切换
  3. freelist 自己也必须被纳入提交顺序控制

5. freelist 为什么要分 idspendingcache

这是理解 BoltDB MVCC 回收机制的关键。

5.1 ids:已经真正可分配的空闲页

这些页可以直接被 allocate(n) 拿出来复用。

5.2 pending:逻辑上已经释放,但还不能安全复用的页

键是 txid,值是该事务释放的页集合。

它们暂时不能进 ids,因为:

  1. 老读事务可能还在看旧版本树
  2. 那些树上仍然引用这些页
  3. 如果太早复用,新写入会覆盖老读事务视图中的内容

所以 pending 的存在,本质上是在实现:

“页已从新版本断开引用” 与 “页已对所有活跃旧读视图不可见” 之间的时间差管理。

5.3 cache:快速判断某页是否已经被 free/pending

它解决两个问题:

  1. free() 时快速检测 double free
  2. Page() / 检查逻辑里快速判断某页是不是自由页

所以 freelist 并不是简单 list,而是一个小型页状态管理器。


6. release(minid - 1) 是 BoltDB 空间回收的关键一句

写事务开始时会根据活跃只读事务求出最小事务号 minid,然后把 <= minid - 1 的 pending 页全部释放到 ids

它背后的逻辑可以展开成一句完整的话:

只有当某个页对应的释放事务,比所有仍活跃的读事务都要旧时,才能确认再也没有人会通过旧 root 访问到它。

这其实就是 BoltDB 的 page-level MVCC 回收规则。

你可以拿一个例子来体会:

  1. 读事务 R1 在 txid=100 打开
  2. 写事务 W101 提交,释放了一批旧页,进入 pending[101]
  3. 即使之后又来了很多写事务,只要 R1 还没关
  4. 那批页都不能进 ids

所以文件膨胀的根因不是“freelist 失效”,而是:

  1. 旧页确实已经无逻辑价值
  2. 但旧快照还在
  3. BoltDB 只能保守地继续持有这些页

这跟数据库里常说的 snapshot too old / vacuum lag 本质上是同类问题,只不过 BoltDB 把单位从 record/version 简化成了 page。


7. 第四步:grow()allocate() 如何配合文件扩容

当 freelist 没有可用连续页时,allocate() 会从 tx.meta.pgid 之后切新页。

如果这次分配超出了当前 mmap 大小:

  1. 先尝试 db.mmap(minsz)
  2. 提交阶段若高水位上升,再 db.grow(...)

这里有两个容易混淆的量:

  1. meta.pgid:逻辑上已使用到哪里
  2. filesz / datasz:物理文件和 mmap 当前大小

BoltDB 会按 AllocSize 做批量扩容,目的是减少频繁 truncate + fsync 的成本。

所以 page 分配不是“每缺一页就立刻把文件加一页”,而是以分配块为单位增长。

这也是为什么 BoltDB 的文件大小变化通常是台阶状,而不是线性一页一页涨。


8. 第五步:write() 先写脏页,再 fdatasync()

tx.write() 做的事情很硬核,也很干脆:

  1. 把事务脏页按 pgid 排序
  2. 逐页写到对应文件偏移
  3. 处理 overflow page 的大块写入
  4. 完成后调用 fdatasync(),除非显式配置了 NoSync

为什么要排序?

  1. 便于顺序写
  2. 保持写出过程更线性
  3. 对调试和行为可预期性也更友好

为什么要先写数据页,再写 meta?

因为如果反过来:

  1. 新 meta 已经指向新 root
  2. 但新 root 下面某些页其实还没落盘
  3. 重启后读取新 meta,就会指向一棵不完整的树

而现在的顺序是:

  1. 新树页先完整落盘
  2. meta 最后才指过去
  3. 崩溃时最多丢掉“尚未被 meta 采纳”的新页

这正是 BoltDB 无 WAL 提交协议成立的关键。


9. 第六步:writeMeta() 是真正的“提交点”

writeMeta() 会把当前 tx.meta 序列化到一页临时 buffer 中,然后:

  1. tx.meta.write(p) 内部根据 txid % 2 决定写到 page 0 还是 1
  2. 写入新的 checksum
  3. writeAt() 到文件指定位置
  4. 再做一次 fdatasync()

这里的事务提交语义非常像:

“所有新数据页已准备完毕,现在把数据库入口切到新版本。”

因此如果一定要找 BoltDB 的 commit point,那就是 新 meta 成功落盘并同步完成 的那一刻。

在这之前,所有工作都只是为提交做准备;在这之后,新事务打开时看到的就会是新的 root/freelist/high-water-mark。


10. 双 meta page 如何支撑崩溃恢复

现在可以把整个恢复逻辑串起来了。

10.1 正常情况下

  1. 旧 meta 指向旧树
  2. 新事务写出新树页
  3. 最后把新 meta 写到另一张 meta page
  4. 新 meta 的 txid 更大

10.2 如果崩溃发生在写数据页期间

旧 meta 仍然指向旧树,新 meta 尚未切换。

重启后:

  1. 读取两张 meta
  2. 较新的那张可能不存在或不合法
  3. 回退到旧 meta

结果是事务未提交,但数据库仍一致。

10.3 如果崩溃发生在写新 meta 过程中

这时可能出现:

  1. 新 meta checksum 不对
  2. 新 meta 部分写入损坏

DB.meta() 的策略是优先选择 txid 更高且 validate() 通过的那张;若高 txid 那张坏了,则回退到另一张。

因此 BoltDB 的恢复不是“修复半成品”,而是“在两代根描述符之间做选择”。

10.4 这套机制的边界在哪里

它不是银弹。它依赖几个重要假设:

  1. fdatasync() 的语义可靠
  2. 文件系统不会让“已报告成功的关键写入”无故消失
  3. 页级写入和 meta 写入的顺序大体符合预期

所以 BoltDB 的崩溃一致性是工程上很强的,但不是建立在全知全能前提上。它是通过简化写入模型,把风险收敛到少数关键顺序约束上。


11. 为什么 NoSync 很危险

源码注释对 NoSync 说得非常直接:THIS IS UNSAFE

原因不复杂。

如果关闭同步:

  1. write() 后不强制刷盘
  2. writeMeta() 后也不强制刷盘
  3. 操作系统可能把关键数据长期留在缓存里

这样一来,BoltDB 原本依赖的“先数据页持久化,再 meta 持久化”的顺序保障就会被显著削弱。

在批量导入、可以容忍整个导入重来的场景下,它也许值得;但只要你把它当常规配置,实际上就是在主动削弱整个数据库的一致性基础。


12. 站在源码视角,BoltDB 的性能画像是什么

12.1 它擅长什么

  1. 单进程嵌入式读多写少场景
  2. 需要稳定、有序遍历的 KV 存储
  3. 希望部署和运维复杂度极低的场景
  4. 数据量适中,且写并发要求不高的元数据类工作负载

这也是为什么 etcd 早期会把它用于底层持久化。像 Raft 状态机这类场景:

  1. 一致性由上层协议控制
  2. 本地存储更看重简单和可靠
  3. 写入虽然关键,但并不是多 writer 高冲突 OLTP

BoltDB 在这种位置上很合适。

12.2 它不擅长什么

  1. 高频并发写入
  2. 长事务很多的场景
  3. 需要在线压缩、后台回收、复杂查询的场景
  4. 超大 value 和高度碎片化写入模式

根因都能直接追溯到源码设计:

  1. 单 writer 锁死了写并行度上限
  2. 长读事务拖住 freelist 释放
  3. 没有 WAL/后台整理线程/压缩器
  4. 大 value 往往需要 overflow page,增加空间管理复杂度

13. 如果你要在生产里用 BoltDB,最该记住的源码级约束

  1. 读事务一定要尽快关闭,否则你不是“浪费一点资源”,而是在阻止旧页回收
  2. 不要把返回的 value 切片带出事务生命周期,它指向的往往就是 mmap 区域
  3. 不要期待多个 goroutine 高频并发写能扩展得很好,因为 writer 从架构上就是串行的
  4. NoSync 保持敬畏,它优化的是延迟,支付的是持久性保证
  5. 文件不断变大时,先怀疑长读事务和 pending freelist,而不是先怀疑“BoltDB 不会释放空间”

这些都不是“经验技巧”,而是源码结构直接推出的使用纪律。


14. 这一组文章最后的总收束

如果要用一句话概括 BoltDB 的源码设计,我会这样说:

它把数据库问题收缩成“mmap 上的 page 图 + 单 writer copy-on-write + 双 meta 提交”,然后用非常少的机制完成了一个足够可靠、足够高效的嵌入式 B+Tree 存储。

它真正漂亮的地方,不在于它功能多,而在于它对问题边界的选择非常克制:

  1. 不做多 writer
  2. 不做复杂后台服务
  3. 不做过度抽象
  4. 只围绕 page、tree、meta、freelist 把核心闭环做完整

这让它非常适合作为“源码阅读型数据库”:

  1. 你能在相对有限的代码量里看见事务、空间回收、树结构和崩溃恢复是怎样连成一体的
  2. 也能清楚看见,一个设计一旦选择了 mmap + 单 writer,会在哪些地方收获巨大简洁性,又会在哪些地方付出代价

如果把这三篇都读下来,你再去看 bbolt、LMDB,甚至再回头看 PostgreSQL、RocksDB,会更容易看清它们各自到底在为哪些能力额外付费。