作者:jaydenwen,腾讯PCG后台开发工程师
本篇(上)介绍1-3.2章节,余下内容请期待明天发布的(下)篇。
前言
- boltdb简要介绍
1.1 boltdb是什么?
1.2 为什么要分析boltdb?
1.3 boltdb的简单用法
1.4 boltdb的整体数据组织结构
1.5 总结
2.boltdb的核心数据结构分析
2.1 boltdb page结构
2.2 元数据页
2.3 空闲列表页
2.4 分支节点页
2.5 叶子节点页
2.6 总结 - boltdb的b+树(Bucket、node)分析
3.1 boltdb的Bucket结构
3.2 Bucket遍历之Cursor
3.2.1 Cursor结构
3.2.2 Cursor对外接口
3.2.3 Seek(key)、First()、Last()实现分析
3.3 node节点的相关操作
3.3.1 node节点的定义
3.3.2 node节点和page转换
3.3.3 node节点的增删改查
3.3.4 node节点的分裂和合并
3.4 Bucket的相关操作
3.4.1 创建一个Bucket
3.4.2 获取一个Bucket
3.4.3 删除一个Bucket
3.5 key&value的插入、获取、删除
3.5.1 插入一个key&value
3.5.2 获取一个key&value
3.5.3 删除一个key&value
3.5.4 遍历Bucket中所有的key&value
3.6 Bucket的页分裂、页合并
3.7 总结 - boltdb事务控制
4.1 boltdb事务简介
4.2 boltdb事务Tx定义
4.3 Begin()实现
4.4 Commit()实现
4.5 Rollback()实现
4.6 总结 - boltdb的DB对象分析
5.1 DB结构
5.2 对外接口
5.3 Open()实现分析
5.4 db.View()实现分析
5.5 db.Update()实现分析
5.6 db.Batch()实现分析
5.7 总结 - 参考资料
7.结尾
8.招聘
前言
本文采用自底向上的方式来介绍boltdb内部的实现原理。其实我们经常都在采用自底向上或者自顶向下这两种方式来思考和求解问题。
例如:我们阅读源码时,通常都是从最顶层的接口点进去,然后层层深入内部。这其实本质上就是一种自顶向下的方式。再比如我们平常做开发时,都是先将系统进行拆分、解耦。然后一般都会采用从下而上或者从上而下的方式来进行开发迭代。 又比如在执行OKR的时候,我们通常都是先定目标、然后依据该目标再层层分解。最后分解到可执行的单元为止。这其实是一种自顶向下的方式;在真正执行时,我们通常又是从原先分解到最底层的原子单元开始执行,然后层层递进。最终所有的原子单元执行完后,我们的目标也就实现了。这其实又是自底向上的完成任务方式。
前面所提到的上下其实是指某些事物、组件、目标内部是存在相互依赖、因果、先后、递进关系,而依赖方或者结果方则位于上,被依赖方、原因方位于下;个人认为自下而上的方式比较适合执行每一个任务或者解决子问题,每一层做完时,都可以独立的进行测试和验证。当我们层层自下而上开发。最后将前面的所有东西拼接在一起时,就构成了一个完整的组件或者实现了该目标。
回到最初的话题,为什么本文要采用自底向上的方式来写呢? 对于一个文件型数据库而言,所谓的上指的是暴露给用户侧的调用接口。所谓的下又指它的输出(数据)最终要落到磁盘这种存储介质上。采用自底向上的方式的话,也就意味着我们先从磁盘这一层进行分析。然后逐步衍生到内存;再到用户接口这一层。层层之间是被依赖的一种关系。这样的话,其实就比较好理解了。在本文中,本人采用自底向上的方式来介绍。希望阅读完后,有一种自己从0到1构建了一个数据库的快感。
当然也可以采用自顶向下的方式来介绍,这时我们就需要在介绍最上层时,先假设它所依赖的底层都已经就绪了,我们只分析当层内容。然后层层往下扩展。
之前和一位大佬进行过针对此问题的探讨,在不同的场景、不同的组件中。具体采用自底向上还是自顶向下来分析。见仁见智,也具体问题具体分析。
在开始主要内容之前,先交代一些题外话。
本文核心内容主要摘自个人上周完结的自底向上剖析boltdb源码 一书。属于原先书籍的精简版。精简版的目的在于保证核心内容完整的前提下,把不太重要的部分和代码进行裁剪。大家可以根据需要自行选择阅读在线版或者精简版。
书栈网在线or app端
https://www.bookstack.cn/books/jaydenwen123-boltdb_book
个人github博客
https://jaydenwen123.github.io/boltdb/
下面开始正文介绍。
- boltdb简要介绍
本章是我们的开篇,我们主要从以下几个方面做一个讲述。希望这章让大家认识一下boltdb,知道它是什么?做什么?后续所有的内容都建立再此基础上,给大家详细介绍它内部是怎么做的。因此本章内容的定位是为后续章节做一个过渡和铺垫,对boltdb比较熟悉的童鞋可以直接跳过前三节,直接从第四节阅读。本章的主要内容从以下几个方面展开:
boltdb是什么?
为什么要分析boltdb?
boltdb的简单用法
boltdb的整体数据组织结构
1.1 boltdb是什么?
在用自己的话介绍boltdb之前,我们先看下boltdb官方是如何自我介绍的呢?
Bolt is a pure Go key/value store inspired by [Howard Chu’s][hyc_symas][LMDB project][lmdb]. The goal of the project is to provide a simple,fast, and reliable database for projects that don’t require a full database server such as Postgres or MySQL.
Since Bolt is meant to be used as such a low-level piece of functionality,simplicity is key. The API will be small and only focus on getting values and setting values. That’s it.
看完了官方的介绍,接下来让我用一句话对boltdb进行介绍:
boltdb是一个纯go编写的支持事务的文件型单机kv数据库。
下面对上述几个核心的关键词进行一一补充。
纯go: 意味着该项目只由golang语言开发,不涉及其他语言的调用。因为大部分的数据库基本上都是由c或者c++开发的,boltdb是一款难得的golang编写的数据库。
支持事务: boltdb数据库支持两类事务:读写事务、只读事务。这一点就和其他kv数据库有很大区别。
文件型: boltdb所有的数据都是存储在磁盘上的,所以它属于文件型数据库。这里补充一下个人的理解,在某种维度来看,boltdb很像一个简陋版的innodb存储引擎。底层数据都存储在文件上,同时数据都涉及数据在内存和磁盘的转换。但不同的是,innodb在事务上的支持比较强大。
单机: boltdb不是分布式数据库,它是一款单机版的数据库。个人认为比较适合的场景是,用来做wal日志或者读多写少的存储场景。
kv数据库: boltdb不是sql类型的关系型数据库,它和其他的kv组件类似,对外暴露的是kv的接口,不过boltdb支持的数据类型key和value都是[]byte。
1.2 为什么要分析boltdb?
前文介绍完了什么是boltdb。那我们先扪心自问一下,为什么要学习、分析boltdb呢?闲的吗? 答案:当然不是。我们先看看其他几个人对这个问题是如何答复的。
github用户ZhengHe-MD是这么答复的:
要达到好的学习效果,就要有输出。以我平时的工作节奏,在闲暇时间依葫芦画瓢写一个键值数据库不太现实。于是我选择将自己对源码阅读心得系统地记录下来,最终整理成本系列文章,旨在尽我所能正确地描述 boltDB。 恰好我在多次尝试在网上寻找相关内容后,发现网上大多数的文章、视频仅仅是介绍 boltDB的用法和特性。因此,也许本系列文章可以作为它们以及 boltDB 官方文档 的补充,帮助想了解它的人更快地、深入地了解 boltDB。 如果你和我一样是初学者,相信它对你会有所帮助;如果你是一名经验丰富的数据库工程师,也许本系列文章对你来说没有太多新意。
微信公众号作者TheFutureIsOurs是这么答复的: boltdb源码阅读
最近抽时间看了boltdb的源码,代码量不大(大概4000行左右),而且支持事务,结构也很清晰,由于比较稳定,已经归档,确实是学习数据库的最佳选择。而且不少出名的开源项目在使用它,比如etcd,InfluxDB等。
下面我来以自身的角度来回答下这个问题:
首先在互联网里面,所有的系统、软件都离不开数据。而提到数据,我们就会想到数据的存储和数据检索。这些功能不就是一个数据库最基本的吗。从而数据库在计算机的世界里面有着无比重要的位置。作为一个有梦想的程序员,总是想知其然并知其所以然。这个是驱动我决定看源码的原因之一。
其次最近在组里高涨的系统学习mysql、redis的氛围下,我也加入了阵营。想着把这两块知识好好消化、整理一番。尤其是mysql,大家主要还是以核心学习innodb存储引擎为目标。本人也不例外,在我看完了从根儿上理解mysql后。整体上对innodb有了宏观和微观的了解和认识,但是更近一步去看mysql的代码。有几个难点:1.本人项目主要以golang为主。说实话看c和c++的项目或多或少有些理解难度;2.mysql作为上古神兽,虽然功能很完善,但是要想短期内看完源码基本上是不可能的,而工作之余的时间有限,因此性价比极低。而boltdb完美的符合了我的这两个要求。所以这就是选择boltdb的第二个原因,也是一个主要原因。
最后还是想通过分析这个项目,在下面两个方面有所提升。
一方面让自己加深原先学习的理论知识;
另外一方面也能真正的了解工程上是如何运用的,理论结合实践,然后对存储引擎有一个清晰的认识;
介绍完了boltdb是什么?为什么要分析boltdb后,我们就正式进入主题了。让我们先以一个简单例子认识下boltdb。
1.3 boltdb的简单用法
其实boltdb的用法很简单,从其项目github的文档里面就可以看得出来。它本身的定位是key/value(后面简称为kv)存储的嵌入式数据库,因此那提到kv我们自然而然能想到的最常用的操作,就是set(k,v)和get(k)了。确实如此boltdb也就是这么简单。
不过在详细介绍boltdb使用之前,我们先以日常生活中的一些场景来作为切入点,引入一些在boltdb中抽象出来的专属名词(DB、Bucket、Cursor、k/v等),下面将进入正文,前面提到boltdb的使用确实很简单,就是set和get。但它还在此基础上还做了一些额外封装。下面通过现实生活对比来介绍这些概念。
boltdb本质就是存放数据的,那这和现实生活中的柜子就有点类似了,如果我们把boltdb看做是一个存放东西的柜子的话,它里面可以存放各种各样的东西,确实是的,但是我们想一想,所有东西都放在一起会不会有什么问题呢?
咦,如果我们把钢笔、铅笔、外套、毛衣、短袖、餐具这些都放在一个柜子里的话,会有啥问题呢?这对于特那些别喜欢收拾屋子,东西归类放置的人而言,简直就是一个不可容忍的事情,因为所有的东西都存放在一起,当东西多了以后就会显得杂乱无章。
在生活中我们都有分类、归类的习惯,例如对功能类似的东西(钢笔、铅笔、圆珠笔等)放一起,或者同类型的东西(短袖、长袖等)放一起。把前面的柜子通过隔板来隔开,分为几个小的小柜子,第一个柜子可以放置衣服,第二个柜子可以放置书籍和笔等。当然了,这是很久以前的做法了,现在买的柜子,厂家都已经将其内部通过不同的存放东西的规格做好了分隔。大家也就不用为这些琐事操心了。既然这样,那把分类、归类这个概念往计算机中迁移过来,尤其是对于存放数据的数据库boltdb中,它也需要有分类、归类的思想,因为归根到底,它也是由人创造出来的嘛。
好了到这儿,我们引入我们的三大名词了**“DB”、“Bucket”、“k/v”**。
DB: 对应我们上面的柜子。
Bucket: 对应我们将柜子分隔后的小柜子或者抽屉了。
k/v: 对应我们放在抽屉里的每一件东西。为了方便我们后面使用的时候便捷,我们需要给每个东西都打上一个标记,这个标记是可以区分每件东西的,例如k可以是该物品的颜色、或者价格、或者购买日期等,v就对应具体的东西啦。这样当我们后面想用的时候,就很容易找到。尤其是女同胞们的衣服和包包,哈哈
再此我们就可以得到一个大概的层次结构,一个柜子(DB)里面可以有多个小柜子(Bucket),每个小柜子里面存放的就是每个东西(k/v)啦。
那我们想一下,我们周末买了一件新衣服,回到家,我们要把衣服放在柜子里,那这时候需要怎么操作呢?
很简单啦,下面看看我们平常怎么做的。
**第一步:**如果家里没有柜子,那就得先买一个柜子;
**第二步:**在柜子里找找之前有没有放置衣服的小柜子,没有的话,那就分一块出来,总不能把新衣服和钢笔放在一块吧。
**第三步:**有了放衣服的柜子,那就里面找找,如果之前都没衣服,直接把衣服打上标签,然后丢进去就ok啦;如果之前有衣服,那我们就需要考虑要怎么放了,随便放还是按照一定的规则来放。这里我猜大部分人还是会和我一样吧。喜欢按照一定的规则放,比如按照衣服的新旧来摆放,或者按照衣服的颜色来摆放,或者按照季节来摆放,或者按照价格来摆放。
我们在多想一下,周一早上起来我们要找一件衣服穿着去上班,那这时候我们又该怎么操作呢?
**第一步:**去找家里存放东西的柜子,家里没柜子,那就连衣服都没了,尴尬…。所以我们肯定是有柜子的,对不对
**第二步:**找到柜子了,然后再去找放置衣服的小柜子,因为衣服在小柜子存放着。
**第三步:**找到衣服的柜子了,那就从里面找一件衣服了,找哪件呢!最新买的?最喜欢的?天气下雨了,穿厚一点的?天气升温了,穿薄一点的?今天没准可能要约会,穿最有气质的?…
那这时候根据不同场景来确定了规则,明确了我们要找的衣服的标签,找起来就会很快了。我们一下子就能定位到要穿的衣服了。嗯哼,这就是排序、索引的威力了
如果之前放置的衣服没有按照这些规则来摆放。那这时候就很悲剧了,就得挨个挨个找,然后自己选了。哈哈,有点全表扫描的味道了
啰里啰嗦扯了一大堆,就是为了给大家科普清楚,一些boltdb中比较重要的概念,让大家对比理解。降低理解难度。下面开始介绍boltdb是如何简单使用的。
使用无外乎两个操作:set、get
func main() {
// 我们的大柜子
db, err := bolt.Open("./my.db", 0600, nil)
if err != nil {
panic(err)
}
defer db.Close()
// 往小柜子里放东西
err = db.Update(func(tx *bolt.Tx) error {
//我们的小柜子
bucket, err := tx.CreateBucketIfNotExists([]byte(“user”))
if err != nil {
log.Fatalf(“CreateBucketIfNotExists err:%s”, err.Error())
return err
}
//放入东西
if err = bucket.Put([]byte(“hello”), []byte(“world”)); err != nil {
log.Fatalf(“bucket Put err:%s”, err.Error())
return err
}
return nil
})
if err != nil {
log.Fatalf(“db.Update err:%s”, err.Error())
}
// 从柜子里取东西
err = db.View(func(tx *bolt.Tx) error {
//找到柜子
bucket := tx.Bucket([]byte(“user”))
//找东西
val := bucket.Get([]byte(“hello”))
log.Printf(“the get val:%s”, val)
val = bucket.Get([]byte(“hello2”))
log.Printf(“the get val2:%s”, val)
return nil
})
if err != nil {
log.Fatalf(“db.View err:%s”, err.Error())
}
}
1.4 boltdb的整体数据组织结构
下面这幅图完整的展示了boltdb中数据在磁盘文件(file)、文件中的每页(page)上的存储格式以及内存(bucket、node)中b+树形式的组织情况。先从整体上给大家展示一下,大家暂时看不懂不要紧,后面章节会详细的分析每一部分的内容。
1.5 总结
在此做一个小结,本章我们首先对boltdb进行一个简要的介绍,让大家知道boltdb是什么,做什么用的。接着又回答了为什么要分析boltdb。在第三节中通过类比现实生活的场景给大家介绍了一下boltdb的简单用法。既然我们用起来了boltdb,那我们就留下了一个悬念,它内部是如何运转的呢?在分析悬念之前,我们在第四节中,从整体对大家展示了boltdb中数据时如何组织存储的。后面的内容都是围绕着这张图来展开的。
2.boltdb的核心数据结构分析
从一开始,boltdb的定位就是一款文件数据库,顾名思义它的数据都是存储在磁盘文件上的,目前我们大部分场景使用的磁盘还是机械磁盘。而我们又知道数据落磁盘其实是一个比较慢的操作(此处的快慢是和操作内存想对比而言)。所以怎么样在这种硬件条件无法改变的情况下,如何提升性能就成了一个恒定不变的话题。而提升性能就不得不提到它的数据组织方式了。所以这部分我们主要来分析boltdb的核心数据结构。
我们都知道,操作磁盘之所以慢,是因为对磁盘的读写耗时主要包括:寻道时间+旋转时间+传输时间。而这儿的大头主要是在寻道时间上,因为寻道是需要移动磁头到对应的磁道上,通过马达驱动磁臂的移动是一种机械运动,比较耗时。我们往往对磁盘的操作都是随机读写,简而言之,随机读写的话,需要频繁移动磁头到对应的磁道。这种方式性能比较低。还有一种和它对应的方式:顺序读写。顺序读写的性能要比随机读写高很多。
因此,所谓的提升性能,无非就是尽可能的减少磁盘的随机读写,更大程度采用顺序读写的方式。这是主要矛盾,不管是mysql的innodb还是boltdb他们都是围绕这个核心来展开的。如何将用户写进来在内存中的数据尽可能采用顺序写的方式放进磁盘,同时在用户读时,将磁盘中保存的数据以尽可能少的IO调用次数加载到内存中,近而返回用户。这里面就涉及到具体的数据在磁盘、内存中的组织结构以及相互转换了。下面我们就对这一块进行详细的分析。
这里面主要包含几块内容:一个是它在磁盘上的数据组织结构page、一个是它在内存中的数据组织结构node、还有一个是page和node之间的相互转换关系。
这里先给大家直观的科普一点:
set操作: 本质上对应的是 set->node->page->file的过程
get操作: 本质上对应的是 file->page->node->get的过程
2.1 boltdb page结构
在boltdb中,一个db对应一个真实的磁盘文件。而在具体的文件中,boltdb又是按照以page为单位来读取和写入数据的,也就是说所有的数据在磁盘上都是按照页(page)来存储的,而此处的页大小是保持和操作系统对应的内存页大小一致,也就是4k。每页由两部分数据组成:页头数据+真实数据,页头信息占16个字节,下面的页的结构定义
type pgid uint64
type page struct {
// 页id 8字节
id pgid
// flags:页类型,可以是分支,叶子节点,元信息,空闲列表 2字节,该值的取值详细参见下面描述
flags uint16
// 个数 2字节,统计叶子节点、非叶子节点、空闲列表页的个数
count uint16
// 4字节,数据是否有溢出,主要在空闲列表上有用
overflow uint32
// 真实的数据
ptr uintptr
}
其中,ptr是一个无类型指针,它就是表示每页中真实的存储的数据地址。而其余的几个字段(id、flags、count、overflow)为我们前面提到的页头信息。 下图展现的是boltdb中page的数据存储方式。
在boltdb中,它把页划分为四类:
page页类型 类型定义 类型值 用途 分支节点页 branchPageFlag 0x01 存储索引信息(页号、元素key值) 叶子节点页 leafPageFlag 0x02 存储数据信息(页号、插入的key值、插入的value值) 元数据页 metaPageFlag 0x04 存储数据库的元信息,例如空闲列表页id、放置桶的根页等 空闲列表页 freelistPageFlag 0x10 存储哪些页是空闲页,可以用来后续分配空间时,优先考虑分配
boltdb通过定义的常量来描述
// 页头的大小:16字节
const pageHeaderSize = int(unsafe.Offsetof(((*page)(nil)).ptr))
const minKeysPerPage = 2
//分支节点页中每个元素所占的大小
const branchPageElementSize = int(unsafe.Sizeof(branchPageElement{}))
//叶子节点页中每个元素所占的大小
const leafPageElementSize = int(unsafe.Sizeof(leafPageElement{}))
const (
branchPageFlag = 0x01 //分支节点页类型
leafPageFlag = 0x02 //叶子节点页类型
metaPageFlag = 0x04 //元数据页类型
freelistPageFlag = 0x10 //空闲列表页类型
)
同时每页都有一个方法来判断该页的类型,我们可以清楚的看到每页时通过其flags字段来标识页的类型。
// typ returns a human readable page type string used for debugging.
func (p *page) typ() string {
if (p.flags & branchPageFlag) != 0 {
return “branch”
} else if (p.flags & leafPageFlag) != 0 {
return “leaf”
} else if (p.flags & metaPageFlag) != 0 {
return “meta”
} else if (p.flags & freelistPageFlag) != 0 {
return “freelist”
}
return fmt.Sprintf(“unknown<%02x>”, p.flags)
}
下面我们一一对其数据结构进行分析。
2.2 元数据页
每页有一个meta()方法,如果该页是元数据页的话,可以通过该方法来获取具体的元数据信息。
// meta returns a pointer to the metadata section of the page.
func (p *page) meta() *meta {
// 将p.ptr转为meta信息
return (*meta)(unsafe.Pointer(&p.ptr))
}
详细的元数据信息定义如下:
type meta struct {
magic uint32 //魔数
version uint32 //版本
pageSize uint32 //page页的大小,该值和操作系统默认的页大小保持一致
flags uint32 //保留值,目前貌似还没用到
root bucket //所有小柜子bucket的根
freelist pgid //空闲列表页的id
pgid pgid //元数据页的id
txid txid //最大的事务id
checksum uint64 //用作校验的校验和
}
下图展现的是元信息存储方式。
下面我们重点关注该meta数据是如何写入到一页中的,以及如何从磁盘中读取meta信息并封装到meta中。
- meta->page
// write writes the meta onto a page.
func (m *meta) write(p *page) {
if m.root.root >= m.pgid {
panic(fmt.Sprintf(“root bucket pgid (%d) above high water mark (%d)”, m.root.root, m.pgid))
} else if m.freelist >= m.pgid {
panic(fmt.Sprintf(“freelist pgid (%d) above high water mark (%d)”, m.freelist, m.pgid))
}
// Page id is either going to be 0 or 1 which we can determine by the transaction ID.
// 指定页id和页类型
p.id = pgid(m.txid % 2)
p.flags |= metaPageFlag
// Calculate the checksum.
m.checksum = m.sum64()
// 这儿p.meta()返回的是p.ptr的地址,因此通过copy之后,meta信息就放到page中了
m.copy(p.meta())
}
// copy copies one meta object to another.
func (m *meta) copy(dest *meta) {
*dest = *m
}
2. page->meta
page.go
// meta returns a pointer to the metadata section of the page.
func (p *page) meta() *meta {
// 将p.ptr转为meta信息
return (*meta)(unsafe.Pointer(&p.ptr))
}
2.3 空闲列表页
空闲列表页中主要包含三个部分:所有已经可以重新利用的空闲页列表ids、将来很快被释放掉的事务关联的页列表pending、页id的缓存。详细定义在freelist.go文件中,下面给大家展示其空闲页的定义。
// 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.
}
// newFreelist returns an empty, initialized freelist.
func newFreelist() *freelist {
return &freelist{
pending: make(map[txid][]pgid),
cache: make(map[pgid]bool),
}
}
下图展示的是空闲列表的存储方式。
- freelist->page
将空闲列表转换成页信息,写到磁盘中,此处需要注意一个问题,页头中的count字段是一个uint16,占两个字节,其最大可以表示2^16 即65536个数字,当空闲页的个数超过65535时时,需要将p.ptr中的第一个字节用来存储其空闲页的个数,同时将p.count设置为0xFFFF。否则不超过的情况下,直接用count来表示其空闲页的个数
// write writes the page ids onto a freelist page. All free and pending ids are
// saved to disk since in the event of a program crash, all pending ids will
// become free.
//将 freelist信息写入到p中
func (f *freelist) write(p *page) error {
// Combine the old free pgids and pgids waiting on an open transaction.
// Update the header flag.
// 设置页头中的页类型标识
p.flags |= freelistPageFlag
// The page.count can only hold up to 64k elements so if we overflow that
// number then we handle it by putting the size in the first element.
lenids := f.count()
if lenids == 0 {
p.count = uint16(lenids)
} else if lenids < 0xFFFF {
p.count = uint16(lenids)
// 拷贝到page的ptr中
f.copyall((([maxAllocSize]pgid)(unsafe.Pointer(&p.ptr)))[:])
} else {
// 有溢出的情况下,后面第一个元素放置其长度,然后再存放所有的pgid列表
p.count = 0xFFFF
(([maxAllocSize]pgid)(unsafe.Pointer(&p.ptr)))[0] = pgid(lenids)
// 从第一个元素位置拷贝
f.copyall(((*[maxAllocSize]pgid)(unsafe.Pointer(&p.ptr)))[1:])
}
return nil
}
// copyall copies into dst a list of all free ids and all pending ids in one sorted list.
// f.count returns the minimum length required for dst.
func (f *freelist) copyall(dst []pgid) {
// 首先把pending状态的页放到一个数组中,并使其有序
m := make(pgids, 0, f.pending_count())
for _, list := range f.pending {
m = append(m, list…)
}
sort.Sort(m)
// 合并两个有序的列表,最后结果输出到dst中
mergepgids(dst, f.ids, m)
}
// mergepgids copies the sorted union of a and b into dst.
// If dst is too small, it panics.
// 将a和b按照有序合并成到dst中,a和b有序
func mergepgids(dst, a, b pgids) {
…
// Assign lead to the slice with a lower starting value, follow to the higher value.
lead, follow := a, b
if b[0] < a[0] {
lead, follow = b, a
}
// Continue while there are elements in the lead.
for len(lead) > 0 {
// Merge largest prefix of lead that is ahead of follow[0].
n := sort.Search(len(lead), func(i int) bool { return lead[i] > follow[0] })
merged = append(merged, lead[:n]…)
if n >= len(lead) {
break
}
// Swap lead and follow.
lead, follow = follow, lead[n:]
}
// Append what’s left in follow.
_ = append(merged, follow…)
…
}
2. page->freelist
从磁盘中加载空闲页信息,并转为freelist结构,转换时,也需要注意其空闲页的个数的判断逻辑,当p.count为0xFFFF时,需要读取p.ptr中的第一个字节来计算其空闲页的个数。否则则直接读取p.ptr中存放的数据为空闲页ids列表
//从磁盘中的page初始化freelist
// read initializes the freelist from a freelist page.
func (f *freelist) read(p page) {
// If the page.count is at the max uint16 value (64k) then it’s considered
// an overflow and the size of the freelist is stored as the first element.
idx, count := 0, int(p.count)
if count == 0xFFFF {
idx = 1
// 用第一个uint64来存储整个count的值
count = int((([maxAllocSize]pgid)(unsafe.Pointer(&p.ptr)))[0])
}
// Copy the list of page ids from the freelist.
if count == 0 {
f.ids = nil
} else {
ids := ((*[maxAllocSize]pgid)(unsafe.Pointer(&p.ptr)))[idx:count]
f.ids = make([]pgid, len(ids))
copy(f.ids, ids)
// Make sure they’re sorted.
sort.Sort(pgids(f.ids))
}
// Rebuild the page cache.
f.reindex()
}
// reindex rebuilds the free cache based on available and pending free lists.
func (f *freelist) reindex() {
f.cache = make(map[pgid]bool, len(f.ids))
for _, id := range f.ids {
f.cache[id] = true
}
for _, pendingIDs := range f.pending {
for _, pendingID := range pendingIDs {
f.cache[pendingID] = true
}
}
}
2.4 分支节点页
分支节点主要用来构建索引,方便提升查询效率。下面我们来看看boltdb的分支节点的数据是如何存储的。
- 分支节点页中元素定义:
分支节点在存储时,一个分支节点页上会存储多个分支页元素即branchPageElement。这个信息可以记做为分支页元素元信息。元信息中定义了具体该元素的页id(pgid)、该元素所指向的页中存储的最小key的值大小、最小key的值存储的位置距离当前的元信息的偏移量pos。下面是branchPageElement的详细定义:
// branchPageElement represents a node on a branch page.
type branchPageElement struct {
pos uint32 //该元信息和真实key之间的偏移量
ksize uint32
pgid pgid
}
// key returns a byte slice of the node key.
func (n branchPageElement) key() []byte {
buf := ([maxAllocSize]byte)(unsafe.Pointer(n))
// pos~ksize
return (*[maxAllocSize]byte)(unsafe.Pointer(&buf[n.pos]))[:n.ksize]
}
2. 分支节点页page中获取下标为index的某一个element的信息和获取全部的elements信息
// branchPageElement retrieves the branch node by index
func (p *page) branchPageElement(index uint16) branchPageElement {
return &(([0x7FFFFFF]branchPageElement)(unsafe.Pointer(&p.ptr)))[index]
}
// branchPageElements retrieves a list of branch nodes.
func (p page) branchPageElements() []branchPageElement {
if p.count == 0 {
return nil
}
return (([0x7FFFFFF]branchPageElement)(unsafe.Pointer(&p.ptr)))[:]
}
下图展现的是非叶子节点存储方式。
在内存中,分支节点页和叶子节点页都是通过node来表示,只不过的区别是通过其node中的isLeaf这个字段来区分。下面和大家分析分支节点页page和内存中的node的转换关系。
下面在介绍具体的转换关系前,我们介绍一下内存中的分支节点和叶子节点是如何描述的。
// node represents an in-memory, deserialized page.
type node struct {
bucket *Bucket
isLeaf bool
unbalanced bool
spilled bool
key []byte
pgid pgid
parent *node
children nodes
inodes inodes
}
在内存中,具体的一个分支节点或者叶子节点都被抽象为一个node对象,其中是分支节点还是叶子节点主要通通过其isLeaf字段来区分。下面对分支节点和叶子节点做两点说明:
对叶子节点而言,其没有children这个信息。同时也没有key信息。isLeaf字段为true,其上存储的key、value都保存在inodes中
对于分支节点而言,其具有key信息,同时children也不一定为空。isLeaf字段为false,同时该节点上的数据保存在inode中。
为了方便大家理解,node和page的转换,下面大概介绍下inode和nodes结构。我们在下一章会详细介绍node。
const (
bucketLeafFlag = 0x01
)
type nodes []*node
func (s nodes) Len() int { return len(s) }
func (s nodes) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
func (s nodes) Less(i, j int) bool { return bytes.Compare(s[i].inodes[0].key, s[j].inodes[0].key) == -1 }
// inode represents an internal node inside of a node.
// It can be used to point to elements in a page or point
// to an element which hasn’t been added to a page yet.
type inode struct {
// 表示是否是子桶叶子节点还是普通叶子节点。如果flags值为1表示子桶叶子节点,否则为普通叶子节点
flags uint32
// 当inode为分支元素时,pgid才有值,为叶子元素时,则没值
pgid pgid
key []byte
// 当inode为分支元素时,value为空,为叶子元素时,才有值
value []byte
}
type inodes []inode
3. page->node
通过分支节点页来构建node节点
// 根据page来初始化node
// read initializes the node from a page.
func (n *node) read(p *page) {
n.pgid = p.id
n.isLeaf = ((p.flags & leafPageFlag) != 0)
n.inodes = make(inodes, int(p.count))
for i := 0; i < int(p.count); i++ {
inode := &n.inodes[i]
if n.isLeaf {
// 获取第i个叶子节点
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”)
}
// Save first key so we can find the node in the parent when we spill.
if len(n.inodes) > 0 {
// 保存第1个元素的值
n.key = n.inodes[0].key
_assert(len(n.key) > 0, “read: zero-length node key”)
} else {
n.key = nil
}
}
4. node->page
将node中的数据写入到page中
// write writes the items onto one or more pages.
// 将node转为page
func (n *node) write(p *page) {
// Initialize page.
// 判断是否是叶子节点还是非叶子节点
if n.isLeaf {
p.flags |= leafPageFlag
} else {
p.flags |= branchPageFlag
}
// 这儿叶子节点不可能溢出,因为溢出时,会分裂
if len(n.inodes) >= 0xFFFF {
panic(fmt.Sprintf(“inode overflow: %d (pgid=%d)”, len(n.inodes), p.id))
}
p.count = uint16(len(n.inodes))
// Stop here if there are no items to write.
if p.count == 0 {
return
}
// Loop over each item and write it to the page.
// b指向的指针为提逃过所有item头部的位置
b := (*[maxAllocSize]byte)(unsafe.Pointer(&p.ptr))[n.pageElementSize()*len(n.inodes):]
for i, item := range n.inodes {
_assert(len(item.key) > 0, “write: zero-length inode key”)
// Write the page element.
// 写入叶子节点数据
if n.isLeaf {
elem := p.leafPageElement(uint16(i))
elem.pos = uint32(uintptr(unsafe.Pointer(&b[0])) - uintptr(unsafe.Pointer(elem)))
elem.flags = item.flags
elem.ksize = uint32(len(item.key))
elem.vsize = uint32(len(item.value))
} else {
// 写入分支节点数据
elem := p.branchPageElement(uint16(i))
elem.pos = uint32(uintptr(unsafe.Pointer(&b[0])) - uintptr(unsafe.Pointer(elem)))
elem.ksize = uint32(len(item.key))
elem.pgid = item.pgid
_assert(elem.pgid != p.id, “write: circular dependency occurred”)
}
// If the length of key+value is larger than the max allocation size
// then we need to reallocate the byte array pointer.
//
// See: https://github.com/boltdb/bolt/pull/335
klen, vlen := len(item.key), len(item.value)
if len(b) < klen+vlen {
b = (*[maxAllocSize]byte)(unsafe.Pointer(&b[0]))[:]
}
// Write data for the element to the end of the page.
copy(b[0:], item.key)
b = b[klen:]
copy(b[0:], item.value)
b = b[vlen:]
}
// DEBUG ONLY: n.dump()
}
2.5 叶子节点页
叶子节点主要用来存储实际的数据,也就是key+value了。下面看看具体的key+value是如何设计的。 在boltdb中,每一对key/value在存储时,都有一份元素元信息,也就是leafPageElement。其中定义了key的长度、value的长度、具体存储的值距离元信息的偏移位置pos。
// leafPageElement represents a node on a leaf page.
// 叶子节点既存储key,也存储value
type leafPageElement struct {
flags uint32 //该值主要用来区分,是子桶叶子节点元素还是普通的key/value叶子节点元素。flags值为1时表示子桶。否则为key/value
pos uint32
ksize uint32
vsize uint32
}
// 叶子节点的key
// key returns a byte slice of the node key.
func (n leafPageElement) key() []byte {
buf := ([maxAllocSize]byte)(unsafe.Pointer(n))
// pos~ksize
return (*[maxAllocSize]byte)(unsafe.Pointer(&buf[n.pos]))[:n.ksize:n.ksize]
}
// 叶子节点的value
// value returns a byte slice of the node value.
func (n leafPageElement) value() []byte {
buf := ([maxAllocSize]byte)(unsafe.Pointer(n))
// key:pos~ksize
// value:pos+ksize~pos+ksize+vsize
return (*[maxAllocSize]byte)(unsafe.Pointer(&buf[n.pos+n.ksize]))[:n.vsize:n.vsize]
}
下面是具体在叶子节点的page中获取下标为index的某个key/value的元信息。根据其元信息,就可以进一步获取其存储的key和value的值了,具体方法可以看上面的key()和value()
// leafPageElement retrieves the leaf node by index
func (p *page) leafPageElement(index uint16) leafPageElement {
n := &(([0x7FFFFFF]leafPageElement)(unsafe.Pointer(&p.ptr)))[index]
// 最原始的指针:unsafe.Pointer(&p.ptr)
// 将其转为([0x7FFFFFF]leafPageElement)(unsafe.Pointer(&p.ptr))
// 然后再取第index个元素 (([0x7FFFFFF]leafPageElement)(unsafe.Pointer(&p.ptr)))[index]
// 最后返回该元素指针 &((*[0x7FFFFFF]leafPageElement)(unsafe.Pointer(&p.ptr)))[index]
// (([0x7FFFFFF]leafPageElement)(unsafe.Pointer(&p.ptr)))
// ([0x7FFFFFF]leafPageElement)(unsafe.Pointer(&p.ptr))==>[]leafPageElement
// &leafElements[index]
return n
}
// leafPageElements retrieves a list of leaf nodes.
func (p page) leafPageElements() []leafPageElement {
if p.count == 0 {
return nil
}
return (([0x7FFFFFF]leafPageElement)(unsafe.Pointer(&p.ptr)))[:]
}
下图展现的是叶子节点存储方式。
其具体叶子节点页page转换成node时的转变过程如同分支节点转换的方法一样,此处就不做赘述,可以参考2.4节介绍的read()和write()方法
2.6 总结
本章中我们重点分析了boltdb中的核心数据结构(page、freelist、meta、node)以及他们之间的相互转化。 在底层磁盘上存储时,boltdb是按照页的单位来存储实际数据的,页的大小取自于它运行的操作系统的页大小。在boltdb中,根据存储的数据的类型不同,将页page整体上分为4大类:
- 元信息页(meta page)
- 空闲列表页(freelist page)
- 分支节点页(branch page)
- 叶子节点页(leaf page)
在page的头信息中通过flags字段来区分。
在内存中同样有对应的结构来映射磁盘上的上述几种页。如元信息meta、空闲列表freelist、分支/叶子节点node(通过isLeaf区分分支节点还是叶子节点)。我们在每一节中先详细介绍其数据结构的定义。接着再重点分析在内存和磁盘上该类型的页时如何进行转换的。可以准确的说,数据结构属于boltdb核心中的核心。梳理清楚了每个数据结构存储的具体数据和格式后。下一章我们将重点分析其另外两个核心结构bucket和node。
- boltdb的b+树(Bucket、node)分析
在第一章我们提到在boltdb中,一个db对应底层的一个磁盘文件。一个db就像一个大柜子一样,其中可以被分隔多个小柜子,用来存储同类型的东西。每个小柜子在boltdb中就是Bucket了。bucket英文为桶。很显然按照字面意思来理解,它在生活中也是存放数据的一种容器。目前为了方便大家理解,在boltdb中的Bucket可以粗略的认为,它里面主要存放的内容就是我们的k/v键值对啦。但这儿其实不准确,后面会详细说明。下面详细进行分析Bucket。在boltdb中定义有bucket、Bucket两个结构。我们在此处所指的Bucket都是指Bucket哈。请大家注意!
3.1 boltdb的Bucket结构
先来看官方文档的一段描述Bucket的话。
Bucket represents a collection of key/value pairs inside the database.
下面是Bucket的详细定义,本节我们先暂时忽略事务Tx,后面章节会详细介绍事务
// 16 byte
const bucketHeaderSize = int(unsafe.Sizeof(bucket{}))
// Bucket represents a collection of key/value pairs inside the database.
// 一组key/value的集合,也就是一个b+树
type Bucket struct {
*bucket //在内联时bucket主要用来存储其桶的value并在后面拼接所有的元素,即所谓的内联
tx *Tx // the associated transaction
buckets map[string]*Bucket // subbucket cache
page *page // inline page reference,内联页引用
rootNode *node // materialized node for the root page.
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.
// 填充率,默认值0.5
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()
}
// newBucket returns a new bucket associated with a transaction.
func newBucket(tx *Tx) Bucket {
var b = Bucket{tx: tx, FillPercent: DefaultFillPercent}
if tx.writable {
b.buckets = make(map[string]*Bucket)
b.nodes = make(map[pgid]*node)
}
return b
}
下图展现的是数据在bucket中的存储方式。
上面是一个Bucket的定义,在开始下面的内容前,我们先提前介绍一下另一个角色Cursor,因为后面会频繁的用到它。大家在这里先知道,一个Bucket就是一个b+树就可以了。我们后面会对其进行详细的分析。
3.2 Bucket遍历之Cursor
本节我们先做一节内容的铺垫,暂时不讲如何创建、获取、删除一个Bucket。而是介绍一个boltdb中的新对象Cursor。
答案是:所有的上述操作都是建立在首先定位到一个Bucket所属的位置,然后才能对其进行操作。而定位一个Bucket的功能就是由Cursor来完成的。所以我们先这一节给大家介绍一下boltdb中的Cursor。
我们先看下官方文档对Cursor的描述
Cursor represents an iterator that can traverse over all key/value pairs in a bucket in sorted order.
用大白话讲,既然一个Bucket逻辑上是一颗b+树,那就意味着我们可以对其进行遍历。前面提到的set、get操作,无非是要在Bucket上先找到合适的位置,然后再进行操作。而“找”这个操作就是交由Cursor来完成的。简而言之对Bucket这颗b+树的遍历工作由Cursor来执行。一个Bucket对象关联一个Cursor。下面我们先看看Bucket和Cursor之间的关系。
// Cursor creates a cursor associated with the bucket.
// The cursor is only valid as long as the transaction is open.
// Do not use a cursor after the transaction is closed.
func (b *Bucket) Cursor() *Cursor {
// Update transaction statistics.
b.tx.stats.CursorCount++
// Allocate and return a cursor.
return &Cursor{
bucket: b,
stack: make([]elemRef, 0),
}
}
3.2.1 Cursor结构
从上面可以清楚的看到,在获取一个游标Cursor对象时,会将当前的Bucket对象传进去,同时还初始化了一个栈对象,结合数据结构中学习的树的知识。我们也就知道,它的内部就是对树进行遍历。下面我们详细介绍Cursor这个人物。
// Cursor represents an iterator that can traverse over all key/value pairs in a bucket in sorted order.
// Cursors see nested buckets with value == nil.
// Cursors can be obtained from a transaction and are valid as long as the transaction is open.
//
// Keys and values returned from the cursor are only valid for the life of the transaction.
//
// Changing data while traversing with a cursor may cause it to be invalidated
// and return unexpected keys and/or values. You must reposition your cursor
// after mutating data.
type Cursor struct {
bucket *Bucket
// 保存遍历搜索的路径
stack []elemRef
}
// elemRef represents a reference to an element on a given page/node.
type elemRef struct {
page *page
node *node
index int
}
3.2.2 Cursor对外接口
下面我们看一下Cursor对外暴露的接口有哪些。看之前也可以心里先想一下。针对一棵树我们需要哪些遍历接口呢?
主体也就是三类:定位到某一个元素的位置、在当前位置从前往后找、在当前位置从后往前找。
// First moves the cursor to the first item in the bucket and returns its key and value.
// If the bucket is empty then a nil key and value are returned.
// The returned key and value are only valid for the life of the transaction.
func (c *Cursor) First() (key []byte, value []byte)
// Last moves the cursor to the last item in the bucket and returns its key and value.
//…
func (c *Cursor) Last() (key []byte, value []byte)
// Next moves the cursor to the next item in the bucket and returns its key and value.
//…
func (c *Cursor) Next() (key []byte, value []byte)
// Prev moves the cursor to the previous item in the bucket and returns its key and value.
//…
func (c *Cursor) Prev() (key []byte, value []byte)
// Delete removes the current key/value under the cursor from the bucket.
// Delete fails if current key/value is a bucket or if the transaction is not writable.
func (c *Cursor) Delete() error
// Seek moves the cursor to a given key and returns it.
// If the key does not exist then the next key is used. If no keys follow, a nil key is returned.
// The returned key and value are only valid for the life of the transaction.
func (c *Cursor) Seek(seek []byte) (key []byte, value []byte)
3.2.3 Seek(key)、First()、Last()实现分析
由于篇幅有限,关于Seek()、First()、Last()、Next()、Prev()、Delete()这三个方法的内部实现。代码就不贴出来了。
未完待续:(下)篇