Bolt磁盘文件格式揭秘:Page页结构、元页与校验和完全解析
【免费下载链接】boltAn embedded key/value database for Go.项目地址: https://gitcode.com/gh_mirrors/bo/bolt
Bolt 是一款纯 Go 实现的嵌入式键值数据库(embedded key/value database),它的数据库文件就是磁盘上一个简单的二进制文件。读懂Bolt 磁盘文件格式,你也就掌握了它崩溃恢复与数据完整性的核心秘密。本文带你从零解析Bolt Page 页结构、元页(Meta Page)与校验和机制,让你彻底明白这个单文件数据库在磁盘上到底长什么样。
一、整体布局:一切皆 Page
Bolt 把整个数据库文件切分成固定大小的Page(页),页大小默认取操作系统的页大小(通常 4KB,见 db.go 中的defaultPageSize)。
| 页号 | 内容 | 说明 |
|---|---|---|
| 0 | Meta Page | 元页副本 A |
| 1 | Meta Page | 元页副本 B |
| 2~N | Branch / Leaf / Freelist 页 | B+树数据与空闲页清单 |
💡 元页有两份,这是 Bolt 崩溃恢复的关键——任何一份损坏时,还可以用另一份打开数据库。
四种页类型由页头flags字段区分,定义在 page.go:
| 常量 | 值 | 页类型 |
|---|---|---|
branchPageFlag | 0x01 | 分支页(B+树内部节点) |
leafPageFlag | 0x02 | 叶子页(存放真实键值) |
metaPageFlag | 0x04 | 元页 |
freelistPageFlag | 0x10 | 空闲页清单 |
二、Page页结构解剖:16字节页头
每个 Page 的页头结构非常精简,定义在 page.go:
type page struct { id pgid // 页号(8 字节) flags uint16 // 页类型标志(2 字节) count uint16 // 页内元素个数(2 字节) overflow uint32 // 溢出的连续页数量(4 字节) ptr uintptr // 指向页数据的内存地址 }三个字段值得重点关注:
id:页在文件中的序号,即页号 = 文件偏移 ÷ 页大小,可精确定位到磁盘位置;count:页内包含的分支/叶子元素个数,遍历页数据时用它控制边界;overflow:当单个值超过一个页的容量时,Bolt 会连续分配多个页,overflow记录后续页的数量——这也是为什么删除大数据后文件不会缩小的原因。
三、元页(Meta Page)详解:数据库的"身份证"
元页位于文件的第 0、1 号页,是打开数据库时最先被读取的结构。它的 Go 结构体定义在 db.go:
| 字段 | 类型 | 作用 |
|---|---|---|
magic | uint32 | 魔数0xED0CDAED,标识"这是个 Bolt 文件" |
version | uint32 | 文件格式版本,当前为 2(见 db.go) |
pageSize | uint32 | 页大小,用于把文件偏移换算成页号 |
flags | uint32 | 保留标志位 |
root | bucket | 根桶信息,指向 B+树的根页 |
freelist | pgid | 空闲页清单所在的页号 |
pgid | pgid | 已分配页的高水位(决定数据库大小) |
txid | uint64 | 最新已提交事务 ID |
checksum | uint64 | 整个元页内容的 FNV-1a 校验和 |
双元页交替写入
元页写入时的页号由事务 ID 决定(db.go):
p.id = pgid(m.txid % 2)也就是说:奇数事务提交写第 1 号页,偶数事务提交写第 0 号页。两个副本永远相差一个事务,任何时刻读取校验和通过的那个,就能得到最近一次完整提交的快照——这就是 Bolt 无需日志文件也能实现 ACID 的关键设计。
四、校验和:如何守护数据完整性
校验和的计算范围
校验和基于FNV-1a 64 位哈希,只对checksum字段之前的所有字段求和(db.go 的sum64()函数)。这样设计的好处:校验字段本身不参与哈希,写盘前先算好校验和,再整体拷贝进页。
打开时的三重验证
每次打开数据库,Bolt 都会对元页执行validate()检查(db.go):
| 检查项 | 失败时返回的错误 |
|---|---|
魔数 ≠0xED0CDAED | ErrInvalid(不是 Bolt 文件) |
| 版本不匹配 | ErrVersionMismatch(文件版本不兼容) |
| 校验和不一致 | ErrChecksum(数据可能写了一半) |
三个错误定义在 errors.go,例如ErrChecksum(errors.go)正是"元页写了一半"时的信号。
两阶段提交:校验和的最后一道保险
Bolt 的提交过程分两步(README.md "Reading the Source" 一节):
- 第一阶段:把脏数据页写入磁盘并
fsync(); - 第二阶段:写入
txid + 1的新元页并再次fsync()。
如果崩溃发生在第一阶段,数据页虽然已落盘,但没有任何元页指向它们,重启后自动被忽略;如果崩溃发生在第二阶段,新元页校验和不完整,validate()会直接拒绝它,回退到旧元页。部分写入的元页因校验和不合格而天然失效,数据库永远停留在上一个一致状态。
五、分支页与叶子页:B+树的数据载体
数据页由页头 + 元素数组组成,元素是变长的,通过偏移量(pos)间接引用页内后方的实际数据。
分支页元素(branchPageElement)
定义见 page.go:
| 字段 | 含义 |
|---|---|
pos | 键数据在页内的偏移 |
ksize | 键的长度 |
pgid | 指向子页的页号 |
分支页本身不存值,只负责"路由":用二分查找键,找到应走的子页号。
叶子页元素(leafPageElement)
定义见 page.go:
| 字段 | 含义 |
|---|---|
flags | 是否为子桶(bucketLeafFlag = 0x01) |
pos | 键数据在页内的偏移 |
ksize | 键长度 |
vsize | 值长度(紧跟在键之后) |
键和值都通过key()/value()方法(page.go)按偏移取出字节切片——这就是 Bolt 读事务"零拷贝"的由来:返回值直接指向 mmap 的内存,无额外分配。
六、实战:用 bolt 命令行查看元页
仓库自带boltCLI 工具,可以直接读取并打印元页信息,源码位于 cmd/bolt/main.go(其中 cmd/bolt/main.go 一行就会以十六进制打印Checksum)。
常用检查命令:
# 查看元页信息(页大小、事务ID、校验和等) bolt info my.db # 逐页解析整个文件的结构 bolt page my.db # 校验数据库完整性 bolt check my.db用bolt page你可以直观看到每个页的type、count、overflow字段,与上文 page.go 的PageInfo结构一一对应。
七、常见文件错误速查
| 错误 | 触发场景 | 排查建议 |
|---|---|---|
ErrInvalid | 魔数不符 | 文件根本不是 Bolt 数据库 |
ErrVersionMismatch | 格式版本不符 | 用与文件匹配的版本打开 |
ErrChecksum | 两份元页校验和都失败 | 文件可能被截断/误写,尝试从备份恢复 |
⚠️ 注意:Bolt 文件是内存映射的,字节序与机器相关(小端机器生成的文件不能直接给大端机器用),跨平台迁移请使用
Tx.WriteTo()做逻辑备份而非直接拷贝文件。
八、总结
回顾一下Bolt 磁盘文件格式的三个核心要点:
- 一切皆页:文件由定长页组成,页头仅 16 字节,类型靠
flags区分; - 双元页 + 事务交替:第 0、1 页存放两份元数据,
txid % 2决定写哪份,天然支持崩溃恢复; - FNV-1a 校验和:只覆盖
checksum之前的字段,让"写了一半的元页"自动失效,配合两阶段提交保证 ACID。
Bolt 代码量不到 3K 行,读懂 page.go、db.go 中的meta结构和 tx.go 的Commit()流程,你基本就打通了嵌入式数据库磁盘层的任督二脉——这也是它被推荐为学习数据库原理最佳入门源码的原因。
【免费下载链接】boltAn embedded key/value database for Go.项目地址: https://gitcode.com/gh_mirrors/bo/bolt
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考