ITPub博客

首页 > > > Notes on BoltDB

Notes on BoltDB

作者:飘絮絮絮丶 时间:2022-03-10 14:20:51 0 删除 编辑

BoltDB 实现了一个 COW BTree, 本身功能比较简单, 代码也比较简洁。我们进入代码之前,可以先讲讲它的大致流程。

这篇文章写的时候,已经有一篇很好的博客了:youjiali1995.github.io/

我不打算讲的那么细,就介绍一下大致的流程。

API

在讨论实现之前,先要理解 boltdb api, 庆幸的是,这部分逻辑比较简单,可以看看这里:zupzup.org/boltdb-examp

磁盘读写

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.

  1. branchPage 就是 BTree 非叶子结点的 page
  2. leafPage 就是 BTree 叶子结点的 page
  3. metaPage 是存 BTree 元信息的 page
  4. freelistPage 比较特殊,因为 BTree 是 COW 的,所以每次写 page 都会写入新的 page, 旧的 page 会被 free 掉。freelist 存储 free 状态的 page 列表,当我们还需要 page 的时候,可以从 freelist 里面拿。

接下来讲讲写入的逻辑,freelist branch 和 leaf 三种 page, 写入的时候 page id 是这样找到的:

  1. 先尝试从 freelist 找到连续的、空闲的 page id
  2. 上一个失败的话,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

我比较犹豫的是,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()
}
  1. bucket 本身以上面代码中的 bucket 格式被存储在 page 中,bucket 本身可能指向自己 bucket 的 root page, 也可能是 inline 的,即这个 bucket 不够大的话,会整个 bucket 在别的 page 中
  2. bolt 会有个 root bucket, 存储在 meta 上。你可以当作 root bucket
  3. 读/写 bucket 的时候,会创建和 Tx 和 DB 对象关联的 Bucket

就,感觉 bucket 这一节我罗列了很多代码,但是讲的不是很好,可以看看 reference 里面的内容吧。注意下后面 Bucket 和 bucket 的区别

page 层

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 存储引擎的磁盘对象介绍完了,那么读写它总要经过内存的,我们罗列一下内存的对象:

  1. DB ,整个 boltdb 的 instance。开启读写事务都要通过这个对象
  2. Bucket,内存中的 bucket 对象,用户可以理解为一组不重合的 kv 对,bucket 也能拥有子 bucket
  3. Tx 事务对象,读/写都要创建对应的对象(当然读写事务和只读事务是有区别的),会持有 root Bucket 和 DB 的指针
  4. Cursor: Bucket 的 kv 迭代器,是一个内存对象,由 Bucket 创建,会持有 *Tx
  5. freelist 持有 free 的 page id 的对象,他不存 page 的内存块,只是是会存 page id 以供分配。
  6. 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
}
  1. *Bucket 记录了自己锁在的 Bucket, 内存中的 Bucket 会维护一些 page 缓存和 *Tx 相关的信息
  2. parent 是内存中的父节点,因为这个 BTree 没有指向右边节点的链接,有的时候在内存中要靠 parent 完成一些操作
  3. unbalanced 和 spill 是内存变量,删除的时候,会被设置成 unbalanced, 插入的时候,会被设置成 spilled, 暗示要合并/分裂。
  4. inodes inodes 存储具体的 value, 这个地方要非常注意,它存储的 []byte 可能是 mmap 映射来的只读部分。当然插入的时候,也会插入有序的 inodes 中

node 实现了读/写/平衡的操作,能够写到连续的 page 中,或者从 page 中读起来。

读事务

读事务不会直接涉及 freelist. 它会涉及 DB 的 活跃读事务表 (DB.txns) , 事务 id 的高水位 tx.meta.txid 和 DB 的 meta

DB.View 会创建一个只读事务,它会:

  1. 持有 meta 的锁,拷贝 meta 的 root.bucket 和基本信息
  2. 把这个事务加入活跃事务表 DB.txns,这里有可能有相同 txn_id 的读事务,因为读用的是 tx.meta.txid 的高水位,不会影响 id

用户可以用 Bucket 接口、Cursor 接口读。提交的时候,这里会走 tx.rollback, 走到 tx.close. 它会从活跃读事务表中移除这个 txn.

写事务

写比读复杂很多,而且需要涉及整个系统的逻辑,还是很复杂的。这个链路会很长。

  1. DB.Update 开启一个读写事务
  2. 和读事务一样拷贝 meta, 其中它递增了 tx.meta.txid
  3. 遍历活跃读事务表,从 freelist 中 release 掉活事务表里面的最小的 txn_id, 因为从那之前的就再也不会被读到了
    1. 这个地方可能看着有点不明白,freelist 存放着两种状态的 page id, 一种是 free 的,就是随便可以用的,一种是 pending 的,就是事务写成功了,但是可能有事务正在读的。放到 free 的里面,相当于 GC 掉对应的 page。所以,DB.txns 维护了活跃的读的事务,它们的 txn_id 最小值之前的事务再也不会被读到了,所以可以丢到 free 的状态。
    2. 这个活跃事务表被持久化的时候,会把 pendings 和 free 状态的一起刷下去,因为一旦崩溃了,就再也没有读事务了,对之前的引用会消失。
    3. 关于为什么这个时候处理,可以看看这篇博客的 isolation 这节:youjiali1995.github.io/ 。这里是认为在写事务开始时候处理,是说允许让写下去的时候,还在有读事务进行(否则要上 metadata 的锁)
// 看来是有剩余空间就丢到 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.
}

  1. DB 会允许写入,Bucket 会记录写了哪些 node 和 子 bucket。凡写入,都会加载page 到内存 node, 然后记录自己写过的地方。
  2. 需要 Commit 的时候,db 会依次递归的从根 bucket 开始 rebalance + spill ,然后写到连续的 page 中
    1. Rebalance 会让 page 有基本的大小,把过小的 page 合并
    2. spill 会把过大的 node 分裂成多个,这个过程是递归的,先把儿子分裂,再分裂自己,如果自己原本是 root, 分裂后产生了新的 parent, 那么会递归的去分裂这个 parent
    3. 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 {...}
  1. 事务先写内容 page, 包括 branch leaf 和 freelist, 等写入成功后,再写入 meta page
  2. 完成事务

Reference

来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/69946831/viewspace-3137572/ ,如需转载,请注明出处,否则将追究法律责任。

上一篇: 8.5 优化 InnoDB 表
请登录后发表评论 登录
全部评论

注册时间:2019-09-09

  • 博文量
    2646
  • 访问量
    42183