BoltDB 源码分析(三):提交、freelist 与崩溃恢复
前两篇我们已经把静态结构拆开了:
DB、Tx、meta、mmap如何搭起运行时框架- page、node、Bucket、Cursor 如何表达一棵可读可写的 B+Tree
最后这一篇进入 BoltDB 最有“数据库味”的部分:
- 一次提交到底怎么落盘
- freelist 为什么不是一个简单的空闲页数组
- 没有 WAL 的情况下,BoltDB 的崩溃一致性靠什么成立
- 这套设计的性能收益和现实边界各是什么
前文:
1. Commit() 是 BoltDB 全部一致性语义的主舞台
如果你只看 tx.go,Commit() 的主流程其实非常短:
rebalance()spill()- 重写 freelist
- 必要时 grow 文件
write()写脏页writeMeta()写 metaclose()收尾
代码不长,但这几个步骤的顺序几乎不能随便换。
因为它们背后遵循的是一个很明确的原则:
先把新版本所有数据页准备好并持久化,最后再切换“当前版本入口”。
而这个“入口”就是 meta page。
只要你抓住这个原则,很多细节都会变得自然。
2. 第一步:rebalance() 先把删除留下来的结构债清掉
Commit() 开头先执行 tx.root.rebalance()。
这样做的原因很直接:
- 删除操作前面只是把 node 标成
unbalanced - 如果不先修树,后面 spill 出去的就是一棵结构已经失衡的树
- 失衡结构还会影响后续 split/spill 的页布局判断
所以 rebalance 本质是在 commit 前做一次结构收口:
- 过瘦的节点尝试和兄弟合并
- 父节点对应边界项被修正
- 根节点必要时坍缩
这里有个工程上的好处:
把“写操作期间的局部脏状态”压缩到提交前统一治理,可以让普通写 API 很薄。
代价则是 commit 延迟会承担更多结构整理成本。
3. 第二步:spill() 把内存 node 树重写成新页
上一篇已经分析过 spill() 的基本过程,这里从提交语义再看一遍。
3.1 spill 本质上在做 copy-on-write
一个 node 如果原来对应旧 pgid,spill 时会先把旧页 free(txid, page),然后为新 node allocate() 新页。
注意这个 free() 并不是“立刻可复用”,而是先进入 freelist 的 pending[txid]。
所以这个时刻的语义其实是:
- 旧页从逻辑上已经被新版本淘汰
- 但考虑到老读事务还可能看见它
- 暂时只能把它标成“未来可回收”
3.2 为什么先 spill 子 bucket,再 spill 当前 bucket
因为父 bucket 中保存子 bucket 的 value 里,要么内联了子 bucket 内容,要么至少要写入子 bucket 最新的 header/root。
如果子 bucket 还没先 spill 完:
- 父节点里没法知道子 bucket 最终 root 在哪
- 父 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 也是持久化元数据的一部分。
提交时会发生这几件事:
- 先把旧 freelist 所在页本身也
free(txid, oldFreelistPage) - 根据当前 freelist 大小分配新页
- 调
freelist.write(p)把空闲页列表写进去 - 更新
tx.meta.freelist = p.id
这一步非常关键,因为 BoltDB 重启后并不会扫描整库去推断哪些页空闲,而是直接从 meta 找到 freelist page 读取。
所以 freelist 并不是“缓存”,而是数据库当前空间状态的一部分事实记录。
这也意味着:
- freelist 自己也有空间成本
- freelist 自己也需要版本切换
- freelist 自己也必须被纳入提交顺序控制
5. freelist 为什么要分 ids、pending 和 cache
这是理解 BoltDB MVCC 回收机制的关键。
5.1 ids:已经真正可分配的空闲页
这些页可以直接被 allocate(n) 拿出来复用。
5.2 pending:逻辑上已经释放,但还不能安全复用的页
键是 txid,值是该事务释放的页集合。
它们暂时不能进 ids,因为:
- 老读事务可能还在看旧版本树
- 那些树上仍然引用这些页
- 如果太早复用,新写入会覆盖老读事务视图中的内容
所以 pending 的存在,本质上是在实现:
“页已从新版本断开引用” 与 “页已对所有活跃旧读视图不可见” 之间的时间差管理。
5.3 cache:快速判断某页是否已经被 free/pending
它解决两个问题:
free()时快速检测 double freePage()/ 检查逻辑里快速判断某页是不是自由页
所以 freelist 并不是简单 list,而是一个小型页状态管理器。
6. release(minid - 1) 是 BoltDB 空间回收的关键一句
写事务开始时会根据活跃只读事务求出最小事务号 minid,然后把 <= minid - 1 的 pending 页全部释放到 ids。
它背后的逻辑可以展开成一句完整的话:
只有当某个页对应的释放事务,比所有仍活跃的读事务都要旧时,才能确认再也没有人会通过旧 root 访问到它。
这其实就是 BoltDB 的 page-level MVCC 回收规则。
你可以拿一个例子来体会:
- 读事务 R1 在
txid=100打开 - 写事务 W101 提交,释放了一批旧页,进入
pending[101] - 即使之后又来了很多写事务,只要 R1 还没关
- 那批页都不能进
ids
所以文件膨胀的根因不是“freelist 失效”,而是:
- 旧页确实已经无逻辑价值
- 但旧快照还在
- BoltDB 只能保守地继续持有这些页
这跟数据库里常说的 snapshot too old / vacuum lag 本质上是同类问题,只不过 BoltDB 把单位从 record/version 简化成了 page。
7. 第四步:grow() 和 allocate() 如何配合文件扩容
当 freelist 没有可用连续页时,allocate() 会从 tx.meta.pgid 之后切新页。
如果这次分配超出了当前 mmap 大小:
- 先尝试
db.mmap(minsz) - 提交阶段若高水位上升,再
db.grow(...)
这里有两个容易混淆的量:
meta.pgid:逻辑上已使用到哪里filesz/datasz:物理文件和 mmap 当前大小
BoltDB 会按 AllocSize 做批量扩容,目的是减少频繁 truncate + fsync 的成本。
所以 page 分配不是“每缺一页就立刻把文件加一页”,而是以分配块为单位增长。
这也是为什么 BoltDB 的文件大小变化通常是台阶状,而不是线性一页一页涨。
8. 第五步:write() 先写脏页,再 fdatasync()
tx.write() 做的事情很硬核,也很干脆:
- 把事务脏页按
pgid排序 - 逐页写到对应文件偏移
- 处理 overflow page 的大块写入
- 完成后调用
fdatasync(),除非显式配置了NoSync
为什么要排序?
- 便于顺序写
- 保持写出过程更线性
- 对调试和行为可预期性也更友好
为什么要先写数据页,再写 meta?
因为如果反过来:
- 新 meta 已经指向新 root
- 但新 root 下面某些页其实还没落盘
- 重启后读取新 meta,就会指向一棵不完整的树
而现在的顺序是:
- 新树页先完整落盘
- meta 最后才指过去
- 崩溃时最多丢掉“尚未被 meta 采纳”的新页
这正是 BoltDB 无 WAL 提交协议成立的关键。
9. 第六步:writeMeta() 是真正的“提交点”
writeMeta() 会把当前 tx.meta 序列化到一页临时 buffer 中,然后:
tx.meta.write(p)内部根据txid % 2决定写到 page 0 还是 1- 写入新的 checksum
writeAt()到文件指定位置- 再做一次
fdatasync()
这里的事务提交语义非常像:
“所有新数据页已准备完毕,现在把数据库入口切到新版本。”
因此如果一定要找 BoltDB 的 commit point,那就是 新 meta 成功落盘并同步完成 的那一刻。
在这之前,所有工作都只是为提交做准备;在这之后,新事务打开时看到的就会是新的 root/freelist/high-water-mark。
10. 双 meta page 如何支撑崩溃恢复
现在可以把整个恢复逻辑串起来了。
10.1 正常情况下
- 旧 meta 指向旧树
- 新事务写出新树页
- 最后把新 meta 写到另一张 meta page
- 新 meta 的
txid更大
10.2 如果崩溃发生在写数据页期间
旧 meta 仍然指向旧树,新 meta 尚未切换。
重启后:
- 读取两张 meta
- 较新的那张可能不存在或不合法
- 回退到旧 meta
结果是事务未提交,但数据库仍一致。
10.3 如果崩溃发生在写新 meta 过程中
这时可能出现:
- 新 meta checksum 不对
- 新 meta 部分写入损坏
而 DB.meta() 的策略是优先选择 txid 更高且 validate() 通过的那张;若高 txid 那张坏了,则回退到另一张。
因此 BoltDB 的恢复不是“修复半成品”,而是“在两代根描述符之间做选择”。
10.4 这套机制的边界在哪里
它不是银弹。它依赖几个重要假设:
fdatasync()的语义可靠- 文件系统不会让“已报告成功的关键写入”无故消失
- 页级写入和 meta 写入的顺序大体符合预期
所以 BoltDB 的崩溃一致性是工程上很强的,但不是建立在全知全能前提上。它是通过简化写入模型,把风险收敛到少数关键顺序约束上。
11. 为什么 NoSync 很危险
源码注释对 NoSync 说得非常直接:THIS IS UNSAFE。
原因不复杂。
如果关闭同步:
write()后不强制刷盘writeMeta()后也不强制刷盘- 操作系统可能把关键数据长期留在缓存里
这样一来,BoltDB 原本依赖的“先数据页持久化,再 meta 持久化”的顺序保障就会被显著削弱。
在批量导入、可以容忍整个导入重来的场景下,它也许值得;但只要你把它当常规配置,实际上就是在主动削弱整个数据库的一致性基础。
12. 站在源码视角,BoltDB 的性能画像是什么
12.1 它擅长什么
- 单进程嵌入式读多写少场景
- 需要稳定、有序遍历的 KV 存储
- 希望部署和运维复杂度极低的场景
- 数据量适中,且写并发要求不高的元数据类工作负载
这也是为什么 etcd 早期会把它用于底层持久化。像 Raft 状态机这类场景:
- 一致性由上层协议控制
- 本地存储更看重简单和可靠
- 写入虽然关键,但并不是多 writer 高冲突 OLTP
BoltDB 在这种位置上很合适。
12.2 它不擅长什么
- 高频并发写入
- 长事务很多的场景
- 需要在线压缩、后台回收、复杂查询的场景
- 超大 value 和高度碎片化写入模式
根因都能直接追溯到源码设计:
- 单 writer 锁死了写并行度上限
- 长读事务拖住 freelist 释放
- 没有 WAL/后台整理线程/压缩器
- 大 value 往往需要 overflow page,增加空间管理复杂度
13. 如果你要在生产里用 BoltDB,最该记住的源码级约束
- 读事务一定要尽快关闭,否则你不是“浪费一点资源”,而是在阻止旧页回收
- 不要把返回的 value 切片带出事务生命周期,它指向的往往就是 mmap 区域
- 不要期待多个 goroutine 高频并发写能扩展得很好,因为 writer 从架构上就是串行的
- 对
NoSync保持敬畏,它优化的是延迟,支付的是持久性保证 - 文件不断变大时,先怀疑长读事务和 pending freelist,而不是先怀疑“BoltDB 不会释放空间”
这些都不是“经验技巧”,而是源码结构直接推出的使用纪律。
14. 这一组文章最后的总收束
如果要用一句话概括 BoltDB 的源码设计,我会这样说:
它把数据库问题收缩成“mmap 上的 page 图 + 单 writer copy-on-write + 双 meta 提交”,然后用非常少的机制完成了一个足够可靠、足够高效的嵌入式 B+Tree 存储。
它真正漂亮的地方,不在于它功能多,而在于它对问题边界的选择非常克制:
- 不做多 writer
- 不做复杂后台服务
- 不做过度抽象
- 只围绕 page、tree、meta、freelist 把核心闭环做完整
这让它非常适合作为“源码阅读型数据库”:
- 你能在相对有限的代码量里看见事务、空间回收、树结构和崩溃恢复是怎样连成一体的
- 也能清楚看见,一个设计一旦选择了 mmap + 单 writer,会在哪些地方收获巨大简洁性,又会在哪些地方付出代价
如果把这三篇都读下来,你再去看 bbolt、LMDB,甚至再回头看 PostgreSQL、RocksDB,会更容易看清它们各自到底在为哪些能力额外付费。