Q先生的世界

面朝大海,春暖花开

BoltDB 源码分析(一):事务、mmap 与文件格式

很多人第一次接触 BoltDB,通常是因为 etcd 或者某个 Go 项目里出现了它的名字。再进一步,大家很快会记住几个标签:

  1. 纯 Go
  2. 单文件
  3. B+Tree
  4. mmap
  5. 单写多读

这些标签都没错,但它们只够让你“知道 BoltDB 是什么”,还不够让你真正理解它为什么能工作、为什么只能有一个写事务、为什么长时间读事务会让文件膨胀、为什么它的崩溃恢复依赖双 meta page。

这一组文章,我准备直接按源码来拆。

第一篇先解决三个根问题:

  1. BoltDB 的单文件到底长什么样
  2. Open() 之后,数据库是怎么通过 mmap 和事务组织内存视图的
  3. 为什么它能做到“多个读事务并发 + 单个写事务串行”,以及这个模型的代价是什么

本文分析基于原始仓库 boltdb/bolt 的实现。今天生产里更常见的是 etcd-io/bbolt,但核心设计和绝大多数代码路径是同一套思路,所以把这套源码读透,迁移到 bbolt 也没有难度。

后续两篇:

  1. BoltDB 源码分析(二):B+Tree、Bucket 与 Cursor
  2. BoltDB 源码分析(三):提交、freelist 与崩溃恢复

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,而是下面这些底层概念:

  1. DB:文件、mmap、锁、freelist、活跃事务的总入口
  2. Tx:一次一致性视图,读事务直接看 mmap,写事务在内存里做脏页聚合
  3. page:磁盘文件上的基本物理单位
  4. meta:整个数据库的超级块,记录 root bucket、freelist 起点、高水位 pgid、事务号
  5. freelist:哪些 page 目前可复用,哪些 page 已被释放但还不能马上复用
  6. node:写事务里把 page 反序列化后的内存树节点,用于 split / rebalance / spill

所以你应该把 BoltDB 理解成:

一个把整个数据文件映射到内存中,以 page 为基本单位组织 B+Tree,并通过 copy-on-write 事务提交新版本 root 的嵌入式数据库。

这里有两个关键词一定要记住:

  1. mmap 只是一种访问文件的方式,不等于持久化协议本身
  2. BoltDB 的一致性核心不是“就地修改”,而是“写新页 + 切换 meta”

后面几篇所有细节,其实都围绕这两句话展开。


2. 从 DB 结构体看数据库的真实职责

db.go 里,DB 结构体几乎把 BoltDB 的运行时模型全暴露出来了。几个字段尤其关键:

  1. file:底层数据库文件句柄
  2. dataref / data / datasz:当前 mmap 区域及其大小
  3. meta0 / meta1:映射后的两个 meta page
  4. rwtx:当前唯一的写事务
  5. txs:当前所有只读事务
  6. freelist:页分配与回收状态
  7. rwlock:保证同一时刻只有一个写事务
  8. metalock:保护 meta 读写与事务初始化
  9. mmaplock:保护 remap 过程,防止事务拿到悬空指针

只看这些字段,你就能推出 BoltDB 的三个基础事实:

  1. 它把数据库文件直接映射进当前进程地址空间
  2. 它显式维护活跃读事务集合,所以知道“最老读视图”还停留在哪个事务号
  3. 它不支持多个并发写者,因为写事务在很多内部结构上根本不是隔离的

这和很多服务端数据库的设计差异很大。比如在 PostgreSQL 里,后台有 buffer pool、WAL、checkpointer、vacuum 等复杂部件;而 BoltDB 选择把复杂度压到最少:

  1. 没有独立 server 进程
  2. 没有 WAL 文件
  3. 没有后台 compaction 线程
  4. 没有锁管理器去协调多个 writer

换来的好处是实现小、部署简单;代价是并发写能力和空间回收策略都比较克制。


3. Open() 做了什么:不是简单打开文件,而是在建立整个运行时世界

Open() 是理解 BoltDB 的最佳入口,因为它几乎把所有全局不变量都初始化了一遍。

3.1 文件锁先于一切

Open() 先打开文件,然后调用 flock()

这里有个非常容易被忽视但极其重要的点:

  1. 读写模式下拿的是排他锁
  2. 只读模式下拿的是共享锁

原因并不复杂:

BoltDB 的元数据页和 freelist 都是在单文件里维护的,如果两个进程同时以读写方式打开,同一个数据库文件就会出现两套互相不知道对方存在的“写事务世界观”,结果必然损坏。

所以 One bolt.Open at a time. 这句话真正精确的含义不是“只能 open 一次”,而是:

同一个数据库文件,在跨进程层面只能有一个读写 owner。

3.2 空文件会被初始化成 4 个关键 page

如果 info.Size() == 0db.init() 会构造一个最小数据库。源码里直接写死了前四页的布局:

  1. page 0:meta0
  2. page 1:meta1
  3. page 2:freelist
  4. page 3:空的 leaf page,作为根 bucket 的根页

初始化时,meta 里会写入:

  1. magic:标识这是 Bolt 文件
  2. version:文件格式版本
  3. pageSize:创建时采用的 OS 页大小
  4. freelist = 2
  5. root.root = 3
  6. pgid = 4

这里的 pgid = 4 表示当前高水位线之后的下一个可分配 page id。也就是说,0 到 3 已经被占用了。

这个初始化特别值得品一下,因为它揭示了 BoltDB 的文件不是按“段”组织的,而是一个统一 page 空间:

  1. meta 本身也是 page
  2. freelist 也是 page
  3. B+Tree 根也是 page

这让实现非常统一,但也意味着“元数据更新”本质上仍然是 page 级写入问题。

3.3 为什么需要两个 meta page

db.mmap() 完成后,DB.meta() 总是选择 txid 更大且校验通过 的那份 meta。

这就是 BoltDB 崩溃恢复的核心锚点。

它不靠 WAL 回放,而是靠双 meta page 提供“两代超级块”:

  1. 提交新事务时,先把新数据页写盘
  2. 再把新 meta 写到 txid % 2 对应的 meta page 上
  3. 重启时,谁更新、谁校验通过,就认谁是最新版本

这件事的本质是:

数据页写入可以先发生很多次,但整个提交动作最终只有一个“指针切换”时刻,而这个指针就是 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 的读路径可以非常短:

  1. 找到 bucket 根页 id
  2. 沿 B+Tree 下钻
  3. 在 leaf page 上做二分
  4. 返回 key/value 对应的切片

只要这些页已经在 OS page cache 里,这个过程几乎就是内存访问。

4.2 它把缓存工作尽可能交给操作系统

BoltDB 自己没有复杂的 buffer cache。它更像是在说:

页面热不热、该不该预读、该不该回收,主要由操作系统的页缓存算法决定。

这种设计的优点是实现小,缺点是应用几乎没法像服务端数据库那样细粒度控制缓存策略。

4.3 它让“指针有效期”成为一个严肃问题

既然读事务拿到的是 mmap 中的直接指针,那 remap 就会变得危险。

如果底层文件增长,需要重新 mmap:

  1. 旧映射可能被解除
  2. 新映射地址可能变掉
  3. 所有还指向旧映射的 []bytepageleafPageElement 引用都可能悬空

BoltDB 为此做了两层处理:

  1. mmaplock 控制 remap 与事务生命周期
  2. 当存在写事务且可能 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()

于是自然推导出一个结果:

  1. 读事务越久,旧 mmap 生命周期越久
  2. 写事务如果需要扩容并 remap,就必须等所有旧读事务结束
  3. 如果读事务一直不关,写事务可能被卡在 remap 上

这也是源码注释里反复提醒的那句话:

长时间只读事务不仅阻止旧页回收,还可能阻止数据库 remap,从而拖慢甚至阻塞写事务。


5. BoltDB 的事务模型:为什么能“多读单写”

BoltDB 的事务实现非常漂亮,因为它把并发控制压缩到了很少几个点上。

5.1 只读事务:拿一个一致性快照

beginTx() 的核心步骤是:

  1. metalock.Lock()
  2. mmaplock.RLock()
  3. 拷贝当前 meta
  4. 把事务加入 db.txs

注意这里最关键的一点:只读事务会复制 meta,但不会复制整棵树

这意味着它获得的一致性视图,其实是:

  1. 某一时刻的 root bucket 指针
  2. 某一时刻的 freelist page id
  3. 某一时刻的高水位 pgid

之后整次读取都沿着这个 root 往下找,所以哪怕后面新的写事务已经提交了新 root,它也仍然稳定地看到旧版本。

这就是 BoltDB 的 MVCC 基础,只不过它的“版本”不是 row version,而是 root page graph

5.2 写事务:全局串行

beginRWTx() 一上来先拿 rwlock.Lock(),这就从根上限定了“同一时刻只有一个 writer”。

然后它也会复制 meta,并把 tx.meta.txid 加一。

这有两个重要后果:

  1. 写事务修改的是自己的事务视图,不会直接改当前共享 meta
  2. 新事务号天然可以作为 page 回收的边界判断依据

很多人会问,既然只有一个 writer,为什么还需要 MVCC?

答案是:因为 读者仍然是并发存在的

即使只有一个 writer,你也不能在原地覆盖旧页,因为旧读事务还可能在读旧树。

所以 BoltDB 的真正语义是:

  1. 写者之间不并发
  2. 读者之间并发
  3. 写者与读者通过 copy-on-write 和 page 延迟回收并发

5.3 为什么老读事务会导致空间回收延迟

写事务开始时,会遍历当前所有活跃只读事务,找出最小的 txid,然后调用:

db.freelist.release(minid - 1)

这句话非常关键。

它的意思是:

只有当一个 page 是被某个旧事务释放的,并且所有比它更老的读事务都已经结束之后,这个 page 才能真正回到“可重新分配”的 freelist。

因此:

  1. 长读事务不会阻止新写入
  2. 但它会阻止旧页复用
  3. 结果就是文件越来越大

这也是很多人线上第一次踩 BoltDB 坑的地方。不是写多就一定涨文件,而是存在长寿命快照读时,释放页会长期滞留在 pending 状态


6. 事务关闭时到底释放了什么

6.1 只读事务关闭

读事务 Rollback() 最终会调用 db.removeTx(tx)

  1. 释放 mmaplock.RLock()
  2. db.txs 列表删除自己
  3. 更新统计信息

它不需要“回滚数据”,因为只读事务从来没改过任何页。

6.2 写事务关闭

写事务关闭时才会做几件全局性的事:

  1. 清空 db.rwtx
  2. rwlock.Unlock()
  3. 合并 freelist 相关统计
  4. 清空事务内的页缓存与 root 引用

所以 BoltDB 的写事务不是“轻量锁上下文”,而是对整个 DB 有全局影响的提交管道。

这也解释了为什么它不适合高并发多写场景。不是 API 上“不让你并发”,而是底层架构就没打算为多 writer 支付复杂度成本。


7. 文件格式最重要的几个结构

要真正读懂后面两篇,至少要先记住四种 page 类型。

7.1 page 头部

page.go 里的 page 结构很短:

  1. id:页号
  2. flags:页类型
  3. count:元素个数
  4. overflow:连续溢出页数量
  5. ptr:页内数据区起点

这说明 BoltDB 的大对象并不是单独存放的,而是通过 overflow 占用连续多个 page。

7.2 四种页类型

通过 flags 区分:

  1. branchPageFlag
  2. leafPageFlag
  3. metaPageFlag
  4. freelistPageFlag

这四类已经把 BoltDB 的全部核心状态覆盖了:

  1. 树的内部导航
  2. 树的叶子数据
  3. 数据库当前版本根
  4. 空闲空间管理

7.3 meta 是真正的“数据库超级块”

meta 中最关键的字段只有几个:

  1. root:顶层 bucket 头,其中 root.root 指向根页
  2. freelist:freelist 页起点
  3. pgid:下一个未分配页号,也就是高水位
  4. txid:当前最新事务号
  5. checksum:校验和

只要这份结构是可信的,BoltDB 就能从单文件里重新找到整个数据库世界。

所以你可以把 meta 看成“数据库版本描述符”。


8. 为什么 BoltDB 不需要 WAL

很多人读到这里会下意识问:

没有 WAL,怎么保证崩溃一致性?

答案是 BoltDB 选择了另一条路:

  1. 写事务不原地改旧页,而是申请新页写入
  2. 旧 root 仍然对旧读事务可见
  3. 所有新页写盘并 fdatasync() 后,最后才写 meta
  4. 重启时读取双 meta 中合法且较新的那一份

因此它不需要 redo log 去“重放未完成写入”,因为未被新 meta 指向的新页,天然就是不可达垃圾;而旧 meta 指向的旧树依然完整。

当然,这不等于 BoltDB 的持久化没有前提。它仍然依赖:

  1. 页写入顺序足够符合预期
  2. fdatasync() 真正完成持久化语义
  3. 文件系统与硬件不会产生超出假设范围的乱序或虚假成功

这类问题会放到第三篇专门展开。


9. 从读路径视角,再看一遍 BoltDB 为什么快

当你调用 View() + Bucket.Get() 时,源码层面发生的大致过程是:

  1. 创建只读事务,复制当前 meta
  2. 根据顶层 bucket 的 root 找到对应 page
  3. 用 cursor 沿 branch page 二分下钻
  4. 在 leaf page 上二分搜索具体 key
  5. 返回 mmap 中的 value 切片

它快的原因不是“算法多高级”,而是工程路径非常短:

  1. 不经网络
  2. 不做 SQL 解析
  3. 不走复杂 buffer manager
  4. 常见读路径几乎没有数据拷贝

但也正因为这条路径短,所以它对使用方式更敏感:

  1. 返回的 []byte 生命周期不能超出事务
  2. 不能长时间持有读事务
  3. 不能期待它像 LSM 数据库那样适合超高写入吞吐

10. 这一篇最该记住的几个结论

如果你读完只记住几句话,至少记住下面这些:

  1. BoltDB 的一致性根不是 bucket,而是 meta -> root page graph
  2. 只读事务复制的是 meta 视图,不是整库数据
  3. 写事务之所以只能有一个,是因为它在 page 分配、freelist、meta 提交上天然全局串行
  4. mmap 提供的是高效读路径,但也把 remap 和悬空引用问题带进来了
  5. 长读事务的真正危害不是“读慢”,而是阻止页回收、阻止 remap、推高文件膨胀风险

到这里,我们才算把 BoltDB 的地基搭起来。

下一篇开始进入树本身:

  1. bucket 为什么既像 namespace 又像子树根
  2. branch / leaf page 的元素布局如何支持有序遍历
  3. cursor 的栈式搜索为何能同时服务读和写
  4. node 的 materialize / split / rebalance 如何把页结构变成可修改的内存树

继续阅读:BoltDB 源码分析(二):B+Tree、Bucket 与 Cursor