Q先生的世界

面朝大海,春暖花开

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

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

  1. DB、Tx、meta、mmap 如何搭起运行时框架
  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.go,Commit() 的主流程其实非常短:

  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 为什么要分 ids、pending 和 cache

这是理解 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,会更容易看清它们各自到底在为哪些能力额外付费。