boltdb 源码导读(二):boltdb 索引设计(1)

简介: boltdb 源码导读(二):boltdb 索引设计(1)

boltdb 是市面上为数不多的纯 go 语言开发的、单机 KV 库。boltdb 基于  Howard Chu's LMDB 项目 ,实现的比较清爽,去掉单元测试和适配代码,核心代码大概四千多行。简单的 API、简约的实现,也是作者的意图所在。由于作者精力所限,原 boltdb 已经封版,不再更新。若想改进,提交新的 pr,建议去 etcd 维护的 fork 版本 bbolt。

为了方便,本系列导读文章仍以不再变动的原 repo 为基础。该项目麻雀虽小,五脏俱全,仅仅四千多行代码,就实现了一个基于 B+ 树索引、支持一写多读事务的单机 KV 引擎。代码本身简约朴实、注释得当,如果你是 go 语言爱好者、如果对 KV 库感兴趣,那 boltdb 绝对是不可错过的一个 repo。

本系列计划分成三篇文章,依次围绕数据组织索引设计事务实现等三个主要方面对 boltdb 源码进行剖析。由于三个方面不是完全正交解耦的,因此叙述时会不可避免的产生交织,读不懂时,暂时略过即可,待有全貌,再回来梳理。本文是第一篇, boltdb 数据组织。

概览

数据库中常用的索引设计有两种,一个是 B+ 树,一个是 LSM-tree。B+ 树比较经典,比如说传统单机数据库 mysql 就是 B+ 树索引,它对快速读取和范围查询(range query)比较友好。LSM-tree 是近年来比较流行的索引结构,Bigtable、LevelDB、RocksDB 都有它的影子;前面文章也有提到,LSM-tree 使用 WAL 和多级数据组织以牺牲部分读性能,换来强悍的随机写性能。因此,这也是一个经典的取舍问题。

BoltDB 在逻辑上以桶来组织数据,一个桶可以看做一个命名空间,是一组 KV 对的集合,和对象存储的桶概念类似。每个桶对应一棵 B+ 树,命名空间是可以嵌套的,因此 BoltDB 的 Bucket 间也是允许嵌套的。在实现上来说,子 bucket 的 root node 的 page id 保存在父 bucket 叶子节点上实现嵌套。

每个 db 文件,是一组树形组织的 B+ 树。我们知道对于 B+ 树来说,分支节点用于查找,叶子节点存数据。

  1. 顶层 B+ 树,比较特殊,称为 root bucket,其所有叶子节点保存的都是子 bucket B+ 树根的 page id 。
  2. 其他 B+ 树,不妨称之为 data bucket,其叶子节点可能是正常用户数据,也可能是子 bucket B+ 树根的 page id。

image.png

相比普通 B+ 树,boltdb 的 B+ 树有几点特殊之处:

  1. 节点的分支个数不是一个固定范围,而是依据其所存元素大小之和来限制的,这个上限即为页大小。
  2. 其分支节点(branch node)所存分支的 key,是其所指向分支的最小 key。
  3. 所有叶子节点并没有通过链表首尾相接起来。
  4. 没有保证所有的叶子节点都在同一层。

在代码组织上,boltdb 索引相关的源文件如下:

  1. bucket.go:对 bucket 操作的高层封装。包括kv 的增删改查、子bucket 的增删改查以及 B+ 树拆分和合并。
  2. node.go:对 node 所存元素和 node 间关系的相关操作。节点内所存元素的增删、加载和落盘,访问孩子兄弟元素、拆分与合并的详细逻辑。
  3. cursor.go:实现了类似迭代器的功能,可以在 B+ 树上的叶子节点上进行随意游走。

本文将由主要分三部分,由局部到整体来一步步揭示 BoltDB 是如何进行索引设计的。首先会拆解树的基本单元,其次剖析 bucket 的遍历实现,最后分析树的生长和平衡过程。

作者:青藤木鸟 https://www.qtmuniao.com/2020/12/14/bolt-index-design, 转载请注明出处

基本单元——节点(Node)

B+ 树的基本构成单元是节点(node),对应在上一篇中提到过文件系统中存储的页(page),节点包括两种类型,分支节点(branch node)和叶子节点(leaf node)。但在实现时,他们复用了同一个结构体,并通过一个标志位 isLeaf 来区分:

// node 表示内存中一个反序列化后的 page
type node struct {
 bucket     *Bucket   // 其所在 bucket 的指针
 isLeaf     bool      // 是否为叶子节点
  // 调整、维持 B+ 树时使用
 unbalanced bool      // 是否需要进行合并
 spilled    bool      // 是否需要进行拆分和落盘
 key        []byte    // 所含第一个元素的 key
 pgid       pgid      // 对应的 page 的 id
 parent     *node     // 父节点指针
 children   nodes     // 孩子节点指针(只包含加载到内存中的部分孩子)
 inodes     inodes    // 所存元素的元信息;对于分支节点是 key+pgid 数组,对于叶子节点是 kv 数组
}

node/page 转换

page 和 node 的对应关系为:文件系统中一组连续的物理 page,加载到内存成为一个逻辑 page ,进而转化为一个 node。下图为一个在文件系统上占用两个 pagesize 空间的一段连续数据 ,首先 mmap 到内存空间,转换为 page 类型,然后通过 node.read 转换为 node 的过程。

image.png

其中 node.read 将 page 转换为 node 的代码如下:

// read 函数通过 mmap 读取 page,并转换为 node
func (n *node) read(p *page) {
  // 初始化元信息
 n.pgid = p.id
 n.isLeaf = ((p.flags & leafPageFlag) != 0)
 n.inodes = make(inodes, int(p.count))
  // 加载所包含元素 inodes
 for i := 0; i < int(p.count); i++ {
  inode := &n.inodes[i]
  if n.isLeaf {
   elem := p.leafPageElement(uint16(i))
   inode.flags = elem.flags
   inode.key = elem.key()
   inode.value = elem.value()
  } else {
   elem := p.branchPageElement(uint16(i))
   inode.pgid = elem.pgid
   inode.key = elem.key()
  }
  _assert(len(inode.key) > 0, "read: zero-length inode key")
 }
  // 用第一个元素的 key 作为该 node 的 key,以便父节点以此作为索引进行查找和路由
 if len(n.inodes) > 0 {
  n.key = n.inodes[0].key
  _assert(len(n.key) > 0, "read: zero-length node key")
 } else {
  n.key = nil
 }
}

node 元素 inode

inode 表示 node 所含的内部元素,分支节点和叶子节点也复用同一个结构体。对于分支节点,单个元素为 key+引用;对于叶子节点,单个元素为用户 kv 数据。

注意到这里对其他节点的引用类型为 pgid ,而非 node*。这是因为 inode 中指向的元素并不一定加载到了内存。boltdb 在访问 B+ 树时,会按需将访问到的 page 转化为 node,并将其指针存在父节点的 children 字段中, 具体的加载顺序和逻辑在后面小结会详述。

type inode struct {
  // 共有变量
 flags uint32
 key   []byte
  // 分支节点使用
  pgid  pgid   // 指向的分支/叶子节点的 page id
  // 叶子节点使用
 value []byte // 叶子节点所存的数据
}

inode 会在 B+ 树中进行路由——二分查找时使用。

新增元素

所有的数据新增都发生在叶子节点,如果新增数据后 B+ 树不平衡,之后会通过 node.spill 来进行拆分调整。主要代码如下:

// put inserts a key/value.
func (n *node) put(oldKey, newKey, value []byte, pgid pgid, flags uint32) {
 // 找到插入点
 index := sort.Search(len(n.inodes), func(i int) bool { return bytes.Compare(n.inodes[i].key, oldKey) != -1 })
  // 如果 key 是新增而非替换,则需为待插入节点腾空儿
 exact := (len(n.inodes) > 0 && index < len(n.inodes) && bytes.Equal(n.inodes[index].key, oldKey))
 if !exact {
  n.inodes = append(n.inodes, inode{})
  copy(n.inodes[index+1:], n.inodes[index:])
 }
  // 给要替换/插入的元素赋值
 inode := &n.inodes[index]
  inode.key = newKey
 // ...
}

该函数的主干逻辑比较简单,就是二分查找到待插入位置,如果是覆盖写则直接覆盖;否则就要新增一个元素,并整体右移,腾出插入位置。但是该函数签名很有意思,同时有 oldKeynewKey ,开始时感觉很奇怪。其调用代码有两处:

  1. 在叶子节点插入用户数据时,oldKey 等于 newKey,此时这两个参数是有冗余的。
  2. spill 阶段调整 B+ 树时,oldKey 可能不等于 newKey,此时是有用的,但从代码上来看,用处仍然很隐晦。

在和朋友讨论后,大致得出如下结论:为了避免在叶子节点最左侧插入一个很小的值时,引起祖先节点的 node.key 的链式更新,而将更新延迟到了最后 B+ 树调整阶段(spill 函数)进行统一处理 。此时,需要利用 node.put 函数将最左边的 node.key 更新为 node.inodes[0].key

  // 该代码片段在 node.spill 函数中
  if node.parent != nil { // 如果不是根节点
   var key = node.key // split 后,最左边 node
   if key == nil {    // split 后,非最左边 node
    key = node.inodes[0].key
   }
   node.parent.put(key, node.inodes[0].key, nil, node.pgid, 0)
   node.key = node.inodes[0].key
  }

image.png

节点遍历

由于 Golang 不支持 Python 中类似 yield 机制,boltdb 使用栈保存遍历上下文实现了一个树节点顺序遍历的迭代器:cursor。在逻辑上可以理解为对某 B+ 树叶子节点所存元素遍历的迭代器。之前提到,boltdb 的 B+ 树没有使用链表将所有叶子节点串起来,因此需要一些额外逻辑来进行遍历中各种细节的处理。

这么实现增加了遍历的复杂度,但是减少了维持 B+ 树平衡性质的难度,也是一种取舍。

image.png

cursor 和某个 bucket 绑定,实现了以下功能,需要注意,当遍历到的元素为嵌套 bucket 时,value = nil

type Cursor
func (c *Cursor) Bucket() *Bucket // 返回绑定的 bucket
func (c *Cursor) Delete() error // 删除 cursor 指向的 key
func (c *Cursor) First() (key []byte, value []byte) // 定位到并返回该 bucket 第一个 KV 
func (c *Cursor) Last() (key []byte, value []byte)  // 定位到并返回该 bucket 最后一个 KV 
func (c *Cursor) Next() (key []byte, value []byte)  // 定位到并返回该 bucket 下一个 KV
func (c *Cursor) Prev() (key []byte, value []byte)  // 定位到并返回该 bucket 前一个 KV
func (c *Cursor) Seek(seek []byte) (key []byte, value []byte) // 定位到并返回该 bucket 内指定的 KV

由于 boltdb 中 B+ 树左右叶子节点并没有通过链表串起来,因此遍历时需要记下遍历路径以进行回溯。其结构体如下:

type Cursor struct {
 bucket *Bucket    // 使用该句柄来进行 node 的加载
 stack  []elemRef  // 保留路径,方便回溯
}

elemRef 结构体如下,page 和 node 是一一对应的,如果 page 加载到了内存中(通过 page 转换而来),则优先使用 node,否则使用 page;index 表示路径经过该节点时在 inodes 中的位置。

type elemRef struct {
 page  *page
 node  *node
 index int
}
相关文章
|
7月前
|
存储 缓存 关系型数据库
InnoDB 引擎底层存储和缓存原理
InnoDB 引擎底层存储和缓存原理
|
5月前
|
关系型数据库 数据库 存储
顺序读和InnoDB的数据组织
【7月更文挑战第7天】自增主键优化顺序读:保证数据物理排序,提升范围查询效率。InnoDB引擎中,主键决定数据在页的存储。当插入的数据引起页分裂,如从1、2、3、5、6、7插入4,会导致相邻逻辑页在磁盘上可能分散,影响性能。了解页结构深化数据库知识,面试时可根据情况深入讨论。
38 2
|
6月前
|
存储 自然语言处理 NoSQL
深入解析Elasticsearch的内部数据结构和机制:行存储、列存储与倒排索引之倒排索引(三)
深入解析Elasticsearch的内部数据结构和机制:行存储、列存储与倒排索引之倒排索引(三)
|
6月前
|
存储 JSON NoSQL
深入解析Elasticsearch的内部数据结构和机制:行存储、列存储与倒排索引之行存(一)
深入解析Elasticsearch的内部数据结构和机制:行存储、列存储与倒排索引之行存(一)
|
6月前
|
存储 自然语言处理 NoSQL
深入解析Elasticsearch的内部数据结构和机制:行存储、列存储与倒排索引之列存(二)
深入解析Elasticsearch的内部数据结构和机制:行存储、列存储与倒排索引之列存(二)
|
存储 关系型数据库 MySQL
mysql索引底层结构
mysql索引底层结构
88 0
|
7月前
|
存储 NoSQL Redis
Redis(三)存储原理与数据模型(hash冲突、渐进式rehash)
Redis(三)存储原理与数据模型(hash冲突、渐进式rehash)
154 0
|
存储 算法 关系型数据库
innodb逻辑存储结构整理
从innodb存储引擎的逻辑存储来看,所有数据都被逻辑地存放在一个空间中,称之为表空间tablespace,表空间又由段segment、区extent、页page组成。
110 0
|
缓存 NoSQL Go
Boltdb 源码导读(一):Boltdb 数据组织(2)
Boltdb 源码导读(一):Boltdb 数据组织(2)
265 0
Boltdb 源码导读(一):Boltdb 数据组织(2)