持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第2天,点击查看活动详情
blotdb【etcd底层的kv存储】,本文改编自作者:青藤木鸟 www.qtmuniao.com/2020/11/29/…, 转载请注明出处
概览
- B+树:对快速读取和范围查询比较友好
- LSM-tree:通过WAL和多级数据组织以牺牲部分读性能,换随机写性能
Boltdb:使用了一个顶层B+树(root bucket),记录子B+树根的page id,以及data bucket记录正常用户数据或者是子bucket B+树根的page id。
相较于B+树的特殊之处
- 节点的分支个数不是一个固定范围,而是依据其所存元素大小之和来限制的,这个上限即为页大小。
- 其分支节点(branch node)所存分支的 key,是其所指向分支的最小 key。
- 所有叶子节点并没有通过链表首尾相接起来。
- 没有保证所有的叶子节点都在同一层。
代码组织
- bucket.go:对 bucket 操作的高层封装。包括kv 的增删改查、子bucket 的增删改查以及 B+ 树拆分和合并。
- node.go:对 node 所存元素和 node 间关系的相关操作。节点内所存元素的增删、加载和落盘,访问孩子兄弟元素、拆分与合并的详细逻辑。
- cursor.go:实现了类似迭代器的功能,可以在 B+ 树上的叶子节点上进行随意游走。
文章通过拆解树的基本单元,其次剖析bucket的遍历实现,最后分析树的生长和平衡过程来解释BoltDB是如何进行索引设计的
1. 树的基本单元
node节点:B+ 树中的节点,在文件系统或者 mmap 后表现为 page,在内存中转换后成为 node。节点包括两种类型分支节点(branch node)和叶子节点(leaf node),用结构体中的标志位isLeaf来区分。
路径:树中的路径指树的根节点到当前节点的顺序经过的所有节点。
inode:所存元素的元信息;对于分支节点是key+pageid数组,对于叶子结点是kv数组。inode 会在 B+ 树中进行路由 —— 二分查找时使用。
新增节点:所有数据新增都发生在叶子节点,如果新增数据后B+树不平衡,就会通过spill操作进行拆分调整。
2. bucket的遍历实现
boltdb 使用栈保存遍历上下文实现了一个树节点顺序遍历的迭代器:cursor。在逻辑上可以理解为对某 B+ 树叶子节点所存元素遍历的迭代器。
主要思想:
cursor 最终目的是在所有叶子节点的元素进行遍历,但是叶子节点并没有通过链表串起来,因此需要借助一个 stack 数组记下遍历上下文——路径,来实现对前驱后继的快速(因为前驱后继与当前叶子节点大概率共享前缀路径)访问。
边界和细节如下:
- 每次移动,需要先找节点,再找节点中元素。
- 如果节点已经转换为 node,则优先访问 node;否则,访问 mmap 出来的 page。
- 分支节点所记元素的 key 为其指向的节点的 key,也即其节点所包含元素的最小 key。
- 使用 Golang 的
sort.Search获取第一个小于给定 key 的元素下标需要做一些额外处理。 - 几个边界判断,node 中是否有元素、index 是否大于元素值、该元素是否为子 bucket。
- 如果 key 不存在时,seek/search 定位到的是 key 应当插入的点。
3. 树的生长和平衡过程
1. 数据库初始化
-
B+树只包含一个空的叶子节点
-
生长由写事务对B+树的节点进行增删,调整
-
boltdb 使用 COW 的方式对节点进行修改,以保证不影响并发的读事务。即,将要修改的 page 读到内存,修改并调整后,申请新的 page 将变动后的 node 落盘。
cow : 写操作时,再将对象复制到新的内存空间中去,在这上面执行修改,以避免相互之间的影响
- 这样会导致一个key的修改引发对应叶子节点所在所有B+树的节点改变,如果修改频繁,建议批量更新
-
-
如果Bucket比较小,不会开辟新的page,而是将其内嵌在叶子节点中。
2. 事务开启时
-
在每次事务初始化时,会在内存中拷贝一份 root bucket 的句柄,以此作为之后动态加载修改路径上 node 的入口。
-
bucket.Put新增或者修改数据时,涉及两个阶段
- 查找定位:利用 cursor 定位到指定 key 所在 page
- 加载插入:加载路径上所有节点,并在叶子节点插入 kv
-
这个阶段的所有修改都发生在内存中,文件系统中保存的之前page组成的B+树结构并未遭到破坏,支持读事务的并发
3. 事务提交前
-
将用户的操作,使得大量的相关节点被转化成node加载到内存中,改动后的B+树由文件系统中的page和内存中的node共同构成
-
事务提交前,需要调整B+树,然后将所有改动的node序列化为page增量写入文件系统
-
核心逻辑:需要先调用 balance 进行无脑 merge,然后在调用 spill,按 pagesize 进行拆分后,写入脏页。
-
bucket.rebalance:将过小(key/size)的节点合并到邻居节点上。
- 先 merge 本身,再 merge 子 bucket
-
bucket.spill:将过大的节点拆分、将节点写入脏页(dirty page)。
- 先 split 子 bucket,再 split 本身
-
- 总结:在 db 初始化时,只有一个页保存 root bucket 的根节点。之后的 B+ 树在
bucket.Create的时候进行创建。初始时内嵌在父 bucket 的叶子节点中,读事务不会对 B+ 树结构造成任何改变,写事务中所有变动,会先写到内存中,在事务提交时,会进行平衡调整,然后增量的写入文件系统。随着写入数据的增多,B+ 树会不断进行拆分,变深,不在内嵌于父 bucket 中。
总结
- boltdb 使用类 B+ 树组织数据库索引,所有数据存在叶子节点,分支节点只用于路由查找。boltdb 支持 bucket 间的嵌套,在实现上表现为 B+ 树的嵌套,通过 page id 来维持父子 bucket 间的引用。
- boltdb 中的 B+ 树为了实现简单,没有使用链表将所有叶子节点串在一起。为了支持对数据的顺序遍历,额外实现了一个 curosr 遍历逻辑,通过保存遍历栈来提高遍历效率、快速跳转。
- boltdb 对 B+ 树的生长以事务为周期,而且生长只发生在写事务中。在写事务开始后,会复制 root bucket 的根节点,然后将改动涉及到的节点按需加载到内存,并在内存中进行修改。在写事务结束前,在对 B+ 树调整后,将所有改动涉及到的 node 申请新的 page,写回文件系统,完成 B+ 树一次生长。释放的树节点,在没有读事务占用后,会进入 freelist 供之后使用。
参考文献
- boltdb repo:github.com/boltdb/bolt