1. 简介
BoltDB 起源于 LMDB(Lightning Memory-Mapped Database),基本可以视作 LMDB 的 Golang 简易实现版(?)。
LMDB 目前仍广泛应用在机器学习领域,从名字也可以看出来它的主要卖点在于使用了内存映射(mmap)技术,这使得它的读性能有时甚至能赶上纯内存的数据库。BoltDB 同样也继承了这个优点,所以读请求居多的 etcd 才选择它作为底层存储(?)。
BoltDB 使用B+ tree
来索引数据,这在LSM tree
政治正确的今天,它倒显得有些复古。LSM 的特点在于写入简单,读取复杂。而B+ tree
正好相反,因此这里我们就以 BoltBD 的写入流程为例,重温一下这个古老的技艺。
2. 数据结构
BoltDB 使用的数据模型比较简单,比较重要的有page
、node
、freelist
、meta
、tx
。下面,按照自底向上的出场顺序,简单介绍下各个角色,更详细的解说请自行参阅代码。
2.1. 磁盘布局
2.1.1. Page
BoltDB 读写数据的基本单元是一个page
,默认大小为 4KiB,与操作系统的page size
对齐,这意味着即使用户只需读写1 Byte
的内容,但实际发生的 IO 大小却是 4KiB 的整数倍。磁盘中的文件可以理解为一个一个的page
顺序排列而成,每个page
的布局如下:
Page Header
共占 16 Byte,字段含义:
id
- page 在磁盘文件中的顺序编号,主要用于寻址。flags
用于表示该 page 所代表的类型,比如:branch
,leaf
,meta
或freelist
。count
- 该 page 中目前所容纳的元素个数,不同的类型有不同的解释,比如在叶子节点可以理解为 KV 键值对个数。overflow
- 如果该 page 所承载的逻辑对象超过 4KiB,那overflow
就代表接下来的若干个 page 表示的是同一逻辑对象。
剩余一个通用的ptr
字段用于指向实际内容,不同数据类型有着不同的解释方式。
2.1.2. 读流程
由于数据文件已经通过mmap
映射到了db.data
字段,所以读操作非常简单,我们把它当做一个无限长的一维数组,将page id
作为数组下标寻址,再来一次类型强转的操作就行了,整个过程完全实现了zero-copy
:
|
|
2.1.3. 写流程
写操作并不是通过mmap
完成,而是使用的pwrite
——在特定的 offset 写入内存中强转为byte
的数组:
|
|
所以 BoltDB 对磁盘文件的读写非常简单粗暴,就是一个byte
和page
之间的强制类型转换——没有考虑考虑大小端,也没有序列化和反序列化的开销,甚至没有 crc 校验,直接一个 dump 了一个原始的内存结构。
2.2. 内存布局
2.2.1. Node
磁盘中的page
,读取到内存中就是node
对象,这个实例化的过程,代码中用materialized
来描述。根据page.overflow
的取值,一个node
可能横跨多个page
。node
的逻辑含义代表一个B+ tree
的节点,其中两个比较重要的字段:
isLeaf
- 用来表示该node
存放的是一个中间的分支节点(branch),还是叶子节点(leaf)。inodes
- 一个有序数组,代表该page
中存放的 KV 对,通过二分查找可以快速定位指定的key
。
2.2.2. Branch Page
分支节点被划分成两段——指向key
位置的offset
和具体的key
,可以理解为一种定长header(offset)
+不定长body(key)
的编码方式,但offset
和key
分开存放。这使得查找的时候,可以在定长的offset
段通过二分快速定位到中间的某个key
的指针,再读取ksize
大小的内容,从而获得原始的key
。
整体布局是不是有点类似类似关系数据库中的Slotted Pages
?
2.2.3. Leaf Page
叶子节点跟分支节点很类似,只不过其中的pgid
字段被替换成了vsize
,代表存储的value
长度,而value
的值则被放置到了key
后面:
除了上述的B+ tree
节点外,还有两个元数据节点也比较重要:
2.2.4. Freelist
BoltDB 的B+ tree
借鉴了 append-only 的写入方式,这样能让写请求和读请求隔离开,互不影响,但一直 append 的话磁盘容易写爆,LSM 的做法就是后台分层 dump,而 BoltDB 的做法则是维护一个freelist
,用来记录已经被释放的page
,下次分配时,优先从freelist
中复用,实在没有再去re-mmap
扩容。
因此 BoltDB 的文件只会增大,而不会因为数据的删除而减少。
freelist
的再分配也实现的非常简单粗暴——从前往后查找,直到找到符合要求的n
个连续的页:
|
|
2.2.5. Meta
meta
页共两个(为什么使用两个我们下面会揭晓),固定在数据库文件的前两个 page 上。它提供了一些数据库全局的视角,比如:
root
- 当前生效的B+ tree
的根节点,写事务提交时,这个字段成功更新才意味事务成功提交了。freelist
- freelist 列表存在哪一页。pgid
- 当前已分配的page
最高值,用来做高水位检测。txid
- 事务版本号,由写事务递增,读事务读取txid
更高且有效的meta
页。它的主要作用是用来控制只读事务的可见性,以及释放已经关闭的读事务所占据的脏页。
官方文档提到,长时间不关闭的读事务会导致脏页无法被回收,因为一个读事务相当于 pin 住了它所有涉及的页,后续的写事务不能冒然回收并覆改。
3. 事务
BoltDB 实现了一个 MVCC 语义的事务,读事务读不到在它之后开启的写事务的更改,哪怕这个写事务已经提交。因为每个事务开始的时候都会将meta
信息 copy 一份在自己的内存里,相当于做了一个快照,这样之后不管磁盘上的meta
页如果变化,都不会影响到自己内存的拷贝。而且写事务回刷脏页都会重新分配一个空闲页,而不是原地覆盖,这就使得读写事务无论在磁盘还是内存中,都没有交集,从而实现了无锁环境下的并发读写。
整个 MVCC 的基础在于它的 COW(copy-on-write) 机制,写事务首先把要变更的页面读上来,在内存里完成业务的Put/Delete
操作,然后在提交的时候做一次 B 树的分裂和合并,使得整个变更路径上的节点都能维持平衡。最后再写到新的页面上去。
BoltDB 通过简单的 COW 机制实现了事务,避免了使用复杂的 redo/undo log。
4. 组合一下
介绍完上述概念后,我们终于可以理解 BoltDB 是怎样写数据的了:
- 用户开启一个读写事务,BoltDB 内部完成一些初始化动作,包括拷贝一份
meta
页到内存中。 - 用户调用更新接口
Put
,BoltDB 内部通过迭代器Cursor
定位到具体的page
和page
上的element
。 - 将
page
反序列化成内存中的node
,插入或更改对应的KV
。 - 用户调用
Commit
接口,BoltDB 通过合并和分裂操作,保持B+ tree
的平衡性,并将所有操作过的page
及其父节点标记为dirty
。 - 通过
freelist
搜索能容纳所有脏页的连续空闲列表,如果找不到,则扩大数据库文件,并重新mmap
。 - 脏页被回写到磁盘上,并通过
fdatasync
确保数据持久化成功。 - 指向最新根节点的
meta
页通过覆盖写的方式,写到文件头部,并通过fdatasync
确保元数据持久化成功。
4.1. 数据一致性保证
上述各个步骤都可能失败,我们来看看在各种异常场景下,BoltDB 是如何保证数据一致性的:
- 用户调用
Commit
前失败,数据未落盘,没有一致性问题。 - 用户调用
Commit
后,分配空闲页或者写脏页失败,Commit
调用返回出错,用户感知。 - 写脏页成功(包括
fdatasync
),表明数据页成功落盘,但更新meta
页失败,Commit
调用返回出错,用户感知。- 这其中,如果
meta
页并没有原子性的写完,那下一个事务在查找meta
的时候,meta.validate()
方法就会校验失败,从而防止该事务读到一个损坏的记录,这就是为什么要使用两个meta
页的原因。 meta
更新失败并不影响原来的那棵 B 树,且新写入的page
会在下一个事务中变成free page
被覆盖。
- 这其中,如果
- 更新
meta
页成功(包括fdatasync
),表明数据和元数据都已经持久化成功,Commit
可以给用户一个安全的承诺了。