首页 > > > Notes on BoltDB
BoltDB 实现了一个 COW BTree, 本身功能比较简单, 代码也比较简洁。我们进入代码之前,可以先讲讲它的大致流程。
这篇文章写的时候,已经有一篇很好的博客了:https://youjiali1995.github.io/storage/boltdb/
我不打算讲的那么细,就介绍一下大致的流程。
在讨论实现之前,先要理解 boltdb api, 庆幸的是,这部分逻辑比较简单,可以看看这里:https://www.zupzup.org/boltdb-example/
BoltDB 用 pwrite(3) 来写盘,用 mmap(2) 来读盘。它会先 mmap 映射一块比较大的空间,然后每次写的时候,调用 pwrite(3) 来写。
这里要说到 Buffer 管理了。成熟的存储引擎大部分自己实现了一份 Buffer Pool Manager 来管理内存。BoltDB 比较小巧,利用了 mmap(2) 的文件映射,在只读模式下打开数据库文件,然后读取对应的内容。
Golang 的 syscall.Mmap 有 mmap 的功能,但是 CGo 和 syscall.Mmap 申请的内存都不由 Go runtime 管,应该是直接由 OS 管的你需要手动的去负责它们,比如下面的 case:
mmap 可以映射大于本文件的空间,pwrite 写大于文件的空间的时候,行为类似 lseek,会把文件撑大。db 的元信息会记录文件分配的 size, 那么,BoltDB 会重新调用 DB.mmap,来重新映射。这个时候 BoltDB 会对内存的内容走一次 dereference,这个调用会把 mmap 的内容拷贝一份,然后解除对 mmap 的 []byte 的引用。
别的文章应该已经介绍过磁盘结构详细的 align 了,我就讲讲大致流程。
bolt 的磁盘结构以 page 为组织单位,page 的大小是
page 有下面几种:
const (
branchPageFlag = 0x01
leafPageFlag = 0x02
metaPageFlag = 0x04
freelistPageFlag = 0x10
)
为了便于理解,我们可以把 BoltDB 的结构先验的想象成一个 COW BTree.
branchPage 就是 BTree 非叶子结点的 pageleafPage 就是 BTree 叶子结点的 pagemetaPage 是存 BTree 元信息的 pagefreelistPage 比较特殊,因为 BTree 是 COW 的,所以每次写 page 都会写入新的 page, 旧的 page 会被 free 掉。freelist 存储 free 状态的 page 列表,当我们还需要 page 的时候,可以从 freelist 里面拿。接下来讲讲写入的逻辑,freelist branch 和 leaf 三种 page, 写入的时候 page id 是这样找到的:
meta 的 page 维护了现有 page id 的高水位(即最大值),然后用这个值当作 page id 写入对于 meta ,情况比较特殊:我们先想想一下 meta 按照如上的逻辑写入,那我们 DB.open 打开一个文件的时候,meta 可能飞了:我们需要确定 meta 在哪,然后去读它。
meta 的写入采取这样的逻辑:他被固定在 1 两个 page 里面,轮换写入。这个时候如果写坏了,就当 cow 写入 Commit 失败,用另一个 meta. 在读的时候,我们 meta 里面会有写入事务的信息,可以根据这个判断:最后成功写入 的 meta。
那么,读取的时候,先读出 meta, 然后确定两个 meta 里面最后成功写入的,然后根据这个信息把 root page 和 freelist 加载出来。代码比较简单,可以看看:
// 通过 db 的信息给出真正的 meta, 有可靠的看可靠的,都可靠看事务大的,都没有就 panic 吧。
// meta retrieves the current meta page reference.
func (db *DB) meta() *meta {
// We have to return the meta with the highest txid which doesn't fail
// validation. Otherwise, we can cause errors when in fact the database is
// in a consistent state. metaA is the one with the higher txid.
metaA := db.meta0
metaB := db.meta1
if db.meta1.txid > db.meta0.txid {
metaA = db.meta1
metaB = db.meta0
}
// Use higher meta page if valid. Otherwise fallback to previous, if valid.
if err := metaA.validate(); err == nil {
return metaA
} else if err := metaB.validate(); err == nil {
return metaB
}
// This should never be reached, because both meta1 and meta0 were validated
// on mmap() and we do fsync() on every write.
panic("bolt.DB.meta(): invalid meta pages")
}
我比较犹豫的是,bucket 这个内容应该在什么地方介绍。我们可以看 API 那节提供的例子:
func addWeight(db *bolt.DB, weight string, date time.Time) error {
err := db.Update(func(tx *bolt.Tx) error {
err := tx.Bucket([]byte("DB")).Bucket([]byte("WEIGHT")).Put([]byte(date.Format(time.RFC3339)), []byte(weight))
if err != nil {
return fmt.Errorf("could not insert weight: %v", err)
}
return nil
})
fmt.Println("Added Weight")
return err
}
// 读
err = db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("DB")).Bucket([]byte("WEIGHT"))
b.ForEach(func(k, v []byte) error {
fmt.Println(string(k), string(v))
return nil
})
return nil
})
这里读/写都用到了 bucket。bucket 是个 boltdb 中比较核心的东西,它是逻辑上不重复 key-value 对的集合。那么我们罗列一下事实:
// Bucket represents a collection of key/value pairs inside the database.
type Bucket struct {
// bucket: <seq, page_id>
*bucket
/**
* 下面是对应的内存结构
*/
tx *Tx // the associated transaction
// Note: 只有写事务才有 buckets cache
buckets map[string]*Bucket // subbucket cache
// inline page
page *page // inline page reference
// root page 在内存中的缓存. 这个的 root 是 tree 的 root.
rootNode *node // materialized node for the root page.
// Note: 只有写事务才有 nodes cache
nodes map[pgid]*node // node cache
// Sets the threshold for filling nodes when they split. By default,
// the bucket will fill to 50% but it can be useful to increase this
// amount if you know that your write workloads are mostly append-only.
//
// This is non-persisted across transactions so it must be set in every Tx.
FillPercent float64
}
// bucket represents the on-file representation of a bucket.
// This is stored as the "value" of a bucket key. If the bucket is small enough,
// then its root page can be stored inline in the "value", after the bucket
// header.
//
// In the case of inline buckets, the "root" will be 0.
type bucket struct {
root pgid // page id of the bucket's root-level page
sequence uint64 // monotonically incrementing, used by NextSequence()
}
bucket 格式被存储在 page 中,bucket 本身可能指向自己 bucket 的 root page, 也可能是 inline 的,即这个 bucket 不够大的话,会整个 bucket 在别的 page 中meta 上。你可以当作 root bucketbucket 的时候,会创建和 Tx 和 DB 对象关联的 Bucket
就,感觉 bucket 这一节我罗列了很多代码,但是讲的不是很好,可以看看 reference 里面的内容吧。注意下后面 Bucket 和 bucket 的区别
page 是磁盘上对应的结构,代码里用的是 os.GetPageSize(), 或者使用 meta 里面的 page size. 也就是说,它会使用这个数据库文件第一次初始化时候系统的 Page 大小。我们可以默认当成 4096bytes。
Page 的结构如下:
// 对应的 page id
type pgid uint64
type page struct {
id pgid
// 对应上面的 flag, 有几种类型:
// * freelist: 单独的 freelist page
// 下面三种都是对应到 Btree 的
// * leaf
// * meta
// * branch
flags uint16
// count 是一个 u16, 所以有的地方最大只给了 0xFFFF
count uint16
// overflow page, 一个 id, 表示 [id, id + overflow] 是这个 page 的逻辑范围
overflow uint32
// 保存的实际内容
// 这个 ptr 实际上是 data 段
ptr uintptr
}
ptr 会保存 mmap 读的内容,overflow 表示溢出页。在产生 page, 也就是 split 的时候,具体实现会每个 page 最少存两个 k-v 对。如果某个 key-value pair 很大,这里会存到 overflow page 中。
boltdb 存储引擎的磁盘对象介绍完了,那么读写它总要经过内存的,我们罗列一下内存的对象:
DB ,整个 boltdb 的 instance。开启读写事务都要通过这个对象Bucket,内存中的 bucket 对象,用户可以理解为一组不重合的 kv 对,bucket 也能拥有子 bucketTx 事务对象,读/写都要创建对应的对象(当然读写事务和只读事务是有区别的),会持有 root Bucket 和 DB 的指针Cursor: Bucket 的 kv 迭代器,是一个内存对象,由 Bucket 创建,会持有 *Tx
freelist 持有 free 的 page id 的对象,他不存 page 的内存块,只是是会存 page id 以供分配。node : page 对象的内存化这里需要介绍一下 node, 这个 case 其实相对 page 来说比较难理解:
node 是内存中的 page 结构,实际上,page 和 overflow page 会写到同一个 node 里面。它的结构如下:
// node represents an in-memory, deserialized page.
type node struct {
// 自身存在的 bucket, 对于 branch 和 leaf 来说,共享同一个 bucket
bucket *Bucket
// 是否是 leaf page
isLeaf bool
// 下面应该和 btree 的状态有关。
// unbalanced 和 rebalance 有关,暗示某个 leaf 可能过小,需要 balance
// spill 和某个过大有关,暗示需要 spill
unbalanced bool
spilled bool
// node 起始的 key
// NOTE: key 的内容可能引自 mmap, 可能是不可修改的
key []byte
pgid pgid
parent *node
// 子节点
children nodes
// 存储 Page 中的 value
// inode 类型类似 tagged enum, 是一个 flag + [ leaf 的值 | branch 的值 ]
// NOTE: inode 的内容引自 mmap, 是不可修改的
inodes inodes
}
*Bucket 记录了自己锁在的 Bucket, 内存中的 Bucket 会维护一些 page 缓存和 *Tx 相关的信息parent 是内存中的父节点,因为这个 BTree 没有指向右边节点的链接,有的时候在内存中要靠 parent 完成一些操作unbalanced 和 spill 是内存变量,删除的时候,会被设置成 unbalanced, 插入的时候,会被设置成 spilled, 暗示要合并/分裂。inodes inodes 存储具体的 value, 这个地方要非常注意,它存储的 []byte 可能是 mmap 映射来的只读部分。当然插入的时候,也会插入有序的 inodes 中node 实现了读/写/平衡的操作,能够写到连续的 page 中,或者从 page 中读起来。
读事务不会直接涉及 freelist. 它会涉及 DB 的 活跃读事务表 (DB.txns) , 事务 id 的高水位 tx.meta.txid 和 DB 的 meta
DB.View 会创建一个只读事务,它会:
root.bucket 和基本信息DB.txns,这里有可能有相同 txn_id 的读事务,因为读用的是 tx.meta.txid 的高水位,不会影响 id用户可以用 Bucket 接口、Cursor 接口读。提交的时候,这里会走 tx.rollback, 走到 tx.close. 它会从活跃读事务表中移除这个 txn.
写比读复杂很多,而且需要涉及整个系统的逻辑,还是很复杂的。这个链路会很长。
DB.Update 开启一个读写事务tx.meta.txid
freelist 中 release 掉活事务表里面的最小的 txn_id, 因为从那之前的就再也不会被读到了
freelist 存放着两种状态的 page id, 一种是 free 的,就是随便可以用的,一种是 pending 的,就是事务写成功了,但是可能有事务正在读的。放到 free 的里面,相当于 GC 掉对应的 page。所以,DB.txns 维护了活跃的读的事务,它们的 txn_id 最小值之前的事务再也不会被读到了,所以可以丢到 free 的状态。// 看来是有剩余空间就丢到 freelist 里面,page_id 是可以重用的 id
// 这个应该是一个磁盘化结构。在 `read` 和 `write` 两个地方读写盘。
// 但是 ids 会持久化, 本来以为 pending 是个内存结构,但是实际上 pending 也是会持久化的,它处理的是两种内容:
// 1. 正在写入事务写过的 page
// 2. 写完了,正在被读引用的 page
// 把 1 写下去实际上是危险的,我看了下代码,只有 Commit 的时候,确保成功,才会调用 write, 这个时候 1 在逻辑是被转成 2 了。
// cache...你见过 cache 非内存的不。
//
// freelist represents a list of all pages that are available for allocation.
// It also tracks pages that have been freed but are still in use by open transactions.
type freelist struct {
ids []pgid // all free and available free page ids.
pending map[txid][]pgid // mapping of soon-to-be free page ids by tx.
cache map[pgid]bool // fast lookup of all free and pending page ids.
}
rebalance + spill ,然后写到连续的 page 中parent
spill 有个地方实现的有点 hack, 我不太喜欢。这里 rebalance 的时候,会给 rebalance 的 node 重新设置 parent. 但是 spill 的时候,因为spill 之后就只要刷 page, 不再要处理 node 了,所以他不会给设置 children node 设置 parent。// spill writes the nodes to dirty pages and splits nodes as it goes.
// Returns an error if dirty pages cannot be allocated.
//
// 1. 先递归对所有 children 调用 spill
// 2. split 自己, 按照 pageSize 和 percent 分成 n 个 page, 他们会共有一个 parent.
// 3. 针对2的结果,有必要的话 free 掉旧 page,然后 allocate 新的 page id 写上去
// 4. 如果分裂出来的新父节点需要 spill, 那就 spill 它,保证整个树操作完成
// 5. **注意**: commit 的时候会 rebalance && spill, spill 之后,node 不再会被读,所以递归的时候,子 node 不需要维护关系。
func (n *node) spill() error {...}
branch leaf 和 freelist, 等写入成功后,再写入 meta page来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/69946831/viewspace-3137572/ ,如需转载,请注明出处,否则将追究法律责任。