BoltDB 源码分析(一):事务、mmap 与文件格式
很多人第一次接触 BoltDB,通常是因为 etcd 或者某个 Go 项目里出现了它的名字。再进一步,大家很快会记住几个标签:
- 纯 Go
- 单文件
- B+Tree
- mmap
- 单写多读
这些标签都没错,但它们只够让你“知道 BoltDB 是什么”,还不够让你真正理解它为什么能工作、为什么只能有一个写事务、为什么长时间读事务会让文件膨胀、为什么它的崩溃恢复依赖双 meta page。
这一组文章,我准备直接按源码来拆。
第一篇先解决三个根问题:
- BoltDB 的单文件到底长什么样
Open()之后,数据库是怎么通过mmap和事务组织内存视图的- 为什么它能做到“多个读事务并发 + 单个写事务串行”,以及这个模型的代价是什么
本文分析基于原始仓库 boltdb/bolt 的实现。今天生产里更常见的是 etcd-io/bbolt,但核心设计和绝大多数代码路径是同一套思路,所以把这套源码读透,迁移到 bbolt 也没有难度。
后续两篇:
1. 先建立总览:BoltDB 不是“文件版 map”,而是 mmap 上的事务化页存储
如果只看用户 API,你会觉得 BoltDB 很像一个嵌套 map:
db.Update(func(tx *bolt.Tx) error {
b, _ := tx.CreateBucketIfNotExists([]byte("users"))
return b.Put([]byte("42"), []byte("alice"))
})
但源码层面,它真正的核心对象并不是 Bucket,而是下面这些底层概念:
DB:文件、mmap、锁、freelist、活跃事务的总入口Tx:一次一致性视图,读事务直接看 mmap,写事务在内存里做脏页聚合page:磁盘文件上的基本物理单位meta:整个数据库的超级块,记录 root bucket、freelist 起点、高水位 pgid、事务号freelist:哪些 page 目前可复用,哪些 page 已被释放但还不能马上复用node:写事务里把 page 反序列化后的内存树节点,用于 split / rebalance / spill
所以你应该把 BoltDB 理解成:
一个把整个数据文件映射到内存中,以 page 为基本单位组织 B+Tree,并通过 copy-on-write 事务提交新版本 root 的嵌入式数据库。
这里有两个关键词一定要记住:
- mmap 只是一种访问文件的方式,不等于持久化协议本身
- BoltDB 的一致性核心不是“就地修改”,而是“写新页 + 切换 meta”
后面几篇所有细节,其实都围绕这两句话展开。
2. 从 DB 结构体看数据库的真实职责
在 db.go 里,DB 结构体几乎把 BoltDB 的运行时模型全暴露出来了。几个字段尤其关键:
file:底层数据库文件句柄dataref/data/datasz:当前 mmap 区域及其大小meta0/meta1:映射后的两个 meta pagerwtx:当前唯一的写事务txs:当前所有只读事务freelist:页分配与回收状态rwlock:保证同一时刻只有一个写事务metalock:保护 meta 读写与事务初始化mmaplock:保护 remap 过程,防止事务拿到悬空指针
只看这些字段,你就能推出 BoltDB 的三个基础事实:
- 它把数据库文件直接映射进当前进程地址空间
- 它显式维护活跃读事务集合,所以知道“最老读视图”还停留在哪个事务号
- 它不支持多个并发写者,因为写事务在很多内部结构上根本不是隔离的
这和很多服务端数据库的设计差异很大。比如在 PostgreSQL 里,后台有 buffer pool、WAL、checkpointer、vacuum 等复杂部件;而 BoltDB 选择把复杂度压到最少:
- 没有独立 server 进程
- 没有 WAL 文件
- 没有后台 compaction 线程
- 没有锁管理器去协调多个 writer
换来的好处是实现小、部署简单;代价是并发写能力和空间回收策略都比较克制。
3. Open() 做了什么:不是简单打开文件,而是在建立整个运行时世界
Open() 是理解 BoltDB 的最佳入口,因为它几乎把所有全局不变量都初始化了一遍。
3.1 文件锁先于一切
Open() 先打开文件,然后调用 flock()。
这里有个非常容易被忽视但极其重要的点:
- 读写模式下拿的是排他锁
- 只读模式下拿的是共享锁
原因并不复杂:
BoltDB 的元数据页和 freelist 都是在单文件里维护的,如果两个进程同时以读写方式打开,同一个数据库文件就会出现两套互相不知道对方存在的“写事务世界观”,结果必然损坏。
所以 One bolt.Open at a time. 这句话真正精确的含义不是“只能 open 一次”,而是:
同一个数据库文件,在跨进程层面只能有一个读写 owner。
3.2 空文件会被初始化成 4 个关键 page
如果 info.Size() == 0,db.init() 会构造一个最小数据库。源码里直接写死了前四页的布局:
page 0:meta0page 1:meta1page 2:freelistpage 3:空的 leaf page,作为根 bucket 的根页
初始化时,meta 里会写入:
magic:标识这是 Bolt 文件version:文件格式版本pageSize:创建时采用的 OS 页大小freelist = 2root.root = 3pgid = 4
这里的 pgid = 4 表示当前高水位线之后的下一个可分配 page id。也就是说,0 到 3 已经被占用了。
这个初始化特别值得品一下,因为它揭示了 BoltDB 的文件不是按“段”组织的,而是一个统一 page 空间:
- meta 本身也是 page
- freelist 也是 page
- B+Tree 根也是 page
这让实现非常统一,但也意味着“元数据更新”本质上仍然是 page 级写入问题。
3.3 为什么需要两个 meta page
db.mmap() 完成后,DB.meta() 总是选择 txid 更大且校验通过 的那份 meta。
这就是 BoltDB 崩溃恢复的核心锚点。
它不靠 WAL 回放,而是靠双 meta page 提供“两代超级块”:
- 提交新事务时,先把新数据页写盘
- 再把新 meta 写到
txid % 2对应的 meta page 上 - 重启时,谁更新、谁校验通过,就认谁是最新版本
这件事的本质是:
数据页写入可以先发生很多次,但整个提交动作最终只有一个“指针切换”时刻,而这个指针就是 meta 里的 root/freelist/high-water-mark。
你可以把它类比成“原子切换版本根”。只是这里没有真正的 CPU 原子指令,而是靠“新页先落盘 + meta 最后覆盖 + 双备份校验”来逼近这个效果。
4. mmap 在 BoltDB 里到底扮演什么角色
很多文章一提 BoltDB 就会说“它用 mmap,所以很快”。这句话太粗了。
准确一点讲,mmap 在 BoltDB 里至少承担了四层职责。
4.1 它把读路径变成“指针寻址”
DB.page(id) 的实现非常直接:
pos := id * pgid(db.pageSize)
return (*page)(unsafe.Pointer(&db.data[pos]))
这意味着只读事务查一页数据时,并不需要先把磁盘页 copy 到单独缓冲区,再交给上层解析。它直接在映射区域上做类型转换。
因此 BoltDB 的读路径可以非常短:
- 找到 bucket 根页 id
- 沿 B+Tree 下钻
- 在 leaf page 上做二分
- 返回 key/value 对应的切片
只要这些页已经在 OS page cache 里,这个过程几乎就是内存访问。
4.2 它把缓存工作尽可能交给操作系统
BoltDB 自己没有复杂的 buffer cache。它更像是在说:
页面热不热、该不该预读、该不该回收,主要由操作系统的页缓存算法决定。
这种设计的优点是实现小,缺点是应用几乎没法像服务端数据库那样细粒度控制缓存策略。
4.3 它让“指针有效期”成为一个严肃问题
既然读事务拿到的是 mmap 中的直接指针,那 remap 就会变得危险。
如果底层文件增长,需要重新 mmap:
- 旧映射可能被解除
- 新映射地址可能变掉
- 所有还指向旧映射的
[]byte、page、leafPageElement引用都可能悬空
BoltDB 为此做了两层处理:
- 用
mmaplock控制 remap 与事务生命周期 - 当存在写事务且可能 remap 时,先对已 materialize 的节点做
dereference(),把引用的 key/value 从 mmap 拷贝到堆上
所以要理解一个细节:
BoltDB 的 read-only fast path 依赖“直接引用 mmap”;而 write path 的某些节点一旦要跨 remap 生存,就必须主动解除对旧 mmap 的依赖。
4.4 它解释了为什么长读事务会拖累写事务
源码里 Begin(false) 会先拿 metalock,再拿 mmaplock.RLock()。只读事务关闭之前,这个读锁一直持有。
而 db.mmap() remap 时需要拿 mmaplock.Lock()。
于是自然推导出一个结果:
- 读事务越久,旧 mmap 生命周期越久
- 写事务如果需要扩容并 remap,就必须等所有旧读事务结束
- 如果读事务一直不关,写事务可能被卡在 remap 上
这也是源码注释里反复提醒的那句话:
长时间只读事务不仅阻止旧页回收,还可能阻止数据库 remap,从而拖慢甚至阻塞写事务。
5. BoltDB 的事务模型:为什么能“多读单写”
BoltDB 的事务实现非常漂亮,因为它把并发控制压缩到了很少几个点上。
5.1 只读事务:拿一个一致性快照
beginTx() 的核心步骤是:
metalock.Lock()mmaplock.RLock()- 拷贝当前
meta - 把事务加入
db.txs
注意这里最关键的一点:只读事务会复制 meta,但不会复制整棵树。
这意味着它获得的一致性视图,其实是:
- 某一时刻的 root bucket 指针
- 某一时刻的 freelist page id
- 某一时刻的高水位
pgid
之后整次读取都沿着这个 root 往下找,所以哪怕后面新的写事务已经提交了新 root,它也仍然稳定地看到旧版本。
这就是 BoltDB 的 MVCC 基础,只不过它的“版本”不是 row version,而是 root page graph。
5.2 写事务:全局串行
beginRWTx() 一上来先拿 rwlock.Lock(),这就从根上限定了“同一时刻只有一个 writer”。
然后它也会复制 meta,并把 tx.meta.txid 加一。
这有两个重要后果:
- 写事务修改的是自己的事务视图,不会直接改当前共享 meta
- 新事务号天然可以作为 page 回收的边界判断依据
很多人会问,既然只有一个 writer,为什么还需要 MVCC?
答案是:因为 读者仍然是并发存在的。
即使只有一个 writer,你也不能在原地覆盖旧页,因为旧读事务还可能在读旧树。
所以 BoltDB 的真正语义是:
- 写者之间不并发
- 读者之间并发
- 写者与读者通过 copy-on-write 和 page 延迟回收并发
5.3 为什么老读事务会导致空间回收延迟
写事务开始时,会遍历当前所有活跃只读事务,找出最小的 txid,然后调用:
db.freelist.release(minid - 1)
这句话非常关键。
它的意思是:
只有当一个 page 是被某个旧事务释放的,并且所有比它更老的读事务都已经结束之后,这个 page 才能真正回到“可重新分配”的 freelist。
因此:
- 长读事务不会阻止新写入
- 但它会阻止旧页复用
- 结果就是文件越来越大
这也是很多人线上第一次踩 BoltDB 坑的地方。不是写多就一定涨文件,而是存在长寿命快照读时,释放页会长期滞留在 pending 状态。
6. 事务关闭时到底释放了什么
6.1 只读事务关闭
读事务 Rollback() 最终会调用 db.removeTx(tx):
- 释放
mmaplock.RLock() - 从
db.txs列表删除自己 - 更新统计信息
它不需要“回滚数据”,因为只读事务从来没改过任何页。
6.2 写事务关闭
写事务关闭时才会做几件全局性的事:
- 清空
db.rwtx rwlock.Unlock()- 合并 freelist 相关统计
- 清空事务内的页缓存与 root 引用
所以 BoltDB 的写事务不是“轻量锁上下文”,而是对整个 DB 有全局影响的提交管道。
这也解释了为什么它不适合高并发多写场景。不是 API 上“不让你并发”,而是底层架构就没打算为多 writer 支付复杂度成本。
7. 文件格式最重要的几个结构
要真正读懂后面两篇,至少要先记住四种 page 类型。
7.1 page 头部
page.go 里的 page 结构很短:
id:页号flags:页类型count:元素个数overflow:连续溢出页数量ptr:页内数据区起点
这说明 BoltDB 的大对象并不是单独存放的,而是通过 overflow 占用连续多个 page。
7.2 四种页类型
通过 flags 区分:
branchPageFlagleafPageFlagmetaPageFlagfreelistPageFlag
这四类已经把 BoltDB 的全部核心状态覆盖了:
- 树的内部导航
- 树的叶子数据
- 数据库当前版本根
- 空闲空间管理
7.3 meta 是真正的“数据库超级块”
meta 中最关键的字段只有几个:
root:顶层 bucket 头,其中root.root指向根页freelist:freelist 页起点pgid:下一个未分配页号,也就是高水位txid:当前最新事务号checksum:校验和
只要这份结构是可信的,BoltDB 就能从单文件里重新找到整个数据库世界。
所以你可以把 meta 看成“数据库版本描述符”。
8. 为什么 BoltDB 不需要 WAL
很多人读到这里会下意识问:
没有 WAL,怎么保证崩溃一致性?
答案是 BoltDB 选择了另一条路:
- 写事务不原地改旧页,而是申请新页写入
- 旧 root 仍然对旧读事务可见
- 所有新页写盘并
fdatasync()后,最后才写 meta - 重启时读取双 meta 中合法且较新的那一份
因此它不需要 redo log 去“重放未完成写入”,因为未被新 meta 指向的新页,天然就是不可达垃圾;而旧 meta 指向的旧树依然完整。
当然,这不等于 BoltDB 的持久化没有前提。它仍然依赖:
- 页写入顺序足够符合预期
fdatasync()真正完成持久化语义- 文件系统与硬件不会产生超出假设范围的乱序或虚假成功
这类问题会放到第三篇专门展开。
9. 从读路径视角,再看一遍 BoltDB 为什么快
当你调用 View() + Bucket.Get() 时,源码层面发生的大致过程是:
- 创建只读事务,复制当前 meta
- 根据顶层 bucket 的 root 找到对应 page
- 用 cursor 沿 branch page 二分下钻
- 在 leaf page 上二分搜索具体 key
- 返回 mmap 中的 value 切片
它快的原因不是“算法多高级”,而是工程路径非常短:
- 不经网络
- 不做 SQL 解析
- 不走复杂 buffer manager
- 常见读路径几乎没有数据拷贝
但也正因为这条路径短,所以它对使用方式更敏感:
- 返回的
[]byte生命周期不能超出事务 - 不能长时间持有读事务
- 不能期待它像 LSM 数据库那样适合超高写入吞吐
10. 这一篇最该记住的几个结论
如果你读完只记住几句话,至少记住下面这些:
- BoltDB 的一致性根不是 bucket,而是
meta -> root page graph - 只读事务复制的是 meta 视图,不是整库数据
- 写事务之所以只能有一个,是因为它在 page 分配、freelist、meta 提交上天然全局串行
mmap提供的是高效读路径,但也把 remap 和悬空引用问题带进来了- 长读事务的真正危害不是“读慢”,而是阻止页回收、阻止 remap、推高文件膨胀风险
到这里,我们才算把 BoltDB 的地基搭起来。
下一篇开始进入树本身:
- bucket 为什么既像 namespace 又像子树根
- branch / leaf page 的元素布局如何支持有序遍历
- cursor 的栈式搜索为何能同时服务读和写
- node 的 materialize / split / rebalance 如何把页结构变成可修改的内存树