Cursor是boltdb中的迭代器,它能够按顺序访问Bucket中的数据。在前面的文章中说过,一个Bucket是一颗B+Tree. Cursor任务是对B+Tree中节点中数据的访问,因为B+Tree树中的数据是保存在叶子节点,所以严格来说Cursor遍历访问的是叶子节点中的存储的数据。
前文对Cursor进行了概括说明,本小节将结合Cursor的数据结构定义,对其进行更详细的说明。
Cursor是boltdb中的一个结构体,定义在cursor.go文件中。它只有2个字段,如下所示。
一颗B+Tree包含多个节点,而每个节点中又装有很多数据。所以这是一个二维结构,定位一个数据,需要先定位到某个节点,在定位到节点中的第几个元素。通过下标索引访问stack锁定到某个节点(elemRef),在通过elemRef中的index便可锁定到具体的元素。
type Cursor struct {
bucket *Bucket
stack []elemRef
}
type elemRef struct {
page *page
node *node
index int
}
在说需要Cursor原因之前,我们先来看boltdb中B+Tree的结构。标准的B+Tree结构如下图所示,像MySQL中索引组织也是B+Tree,它的结构就是下面这样的。
所有的数据都存储在叶子节点上,对应到图中的绿色内容。图中一共有9个叶子节点,它们之间通过指针连接了起来,构成了一个链表结构。
而boltdb中B+Tree的叶子节点并没有通过链表连接起来,这样做的原因小编觉得是为了减少了维持B+Tree平衡性质的难度,但在遍历的时候增加了一定的困难。所以这里抽象了Cursor结构来进行遍历,在遍历的时候记下了遍历的路径,方便进行快速回溯。
遍历路径上的每步是elemRef结构,在前面Cursor结构体定义介绍中有说明。这里在强调下处理时node和page的差别,处理时优先使用node,否则使用page。因为node中的数据可能是更新过的,page中的数据来自mmap,是没有被更新的。
总结起来,因为boltdb中的B+Tree没有将叶子节点通过链表串联起来,为了能够方便对其访问,抽象处理了Cursor迭代器,来对其进行遍历操作。
Cursor的功能是对B+Tree的遍历访问,提供了First、Last、Next、Prev和Seek等方法,分别是查找第一个数据,最后一个数据,下一个数据,前一个数据以及定位到某个位置。
First、Last、Seek方法查询的是一个绝对位置,也就是说,调用它们不会受到当前Cursor游标位置的影响,都是从根节点开始查询。体现在代码中是它们都有重置c.stack = c.stack[:0]
操作,
Next、Prev查询的是相对于当前游标位置来说的,所以调用这两个方法,依赖于调用前游标所在的位置。它们实现中不会有重置c.stack操作。
为了代码复用,抽取了first、last、next、seek等内部方法,供对外提供方法的接口First、Last、Next、Prev和Seek调用。
First方法返回B+Tree中第一个数据,就是最左侧(如果里面元素非空)的叶子节点的第一个元素。处理逻辑概括起来分为3步:
func (c *Cursor) First() (key []byte, value []byte) {
_assert(c.bucket.tx.db != nil, "tx closed")
// 重置stack
c.stack = c.stack[:0]
// 加载根节点
p, n := c.bucket.pageNode(c.bucket.root)
// 根节点信息加入stack
c.stack = append(c.stack, elemRef{page: p, node: n, index: 0})
// 一路向下走到第一个叶子节点,内部通过for循环走到最左侧的叶子节点
c.first()
// https://github.com/boltdb/bolt/issues/450
// 叶子节点中没有元素,移动到下一个叶子节点
if c.stack[len(c.stack)-1].count() == 0 {
c.next()
}
// 获取最左侧叶子节点中的第一个key-value
k, v, flags := c.keyValue()
// 如果是一个桶节点,value为空
if (flags & uint32(bucketLeafFlag)) != 0 {
return k, nil
}
// 存储的是数据,返回key-value
return k, v
}
Last方法获取B+Tree中最后一个数据,处理过程与First类型,只不过在查找的时候一路向下向右走,定位到最右侧的叶子节点。具体处理过程也分为3步,与First类似,这里不在展开说明了。
func (c *Cursor) Last() (key []byte, value []byte) {
_assert(c.bucket.tx.db != nil, "tx closed")
c.stack = c.stack[:0]
// 加载根节点
p, n := c.bucket.pageNode(c.bucket.root)
ref := elemRef{page: p, node: n}
// 设置index为最后一个元素位置
ref.index = ref.count() - 1
// 加入路径缓存stack
c.stack = append(c.stack, ref)
// 一路向下走到最右侧的节点
c.last()
// 获取游标位置(最右边节点的最后一个)元素
k, v, flags := c.keyValue()
// 如果是Bucket,返回value为空
if (flags & uint32(bucketLeafFlag)) != 0 {
return k, nil
}
// 返回key-value
return k, v
}
Next方法获取下一个数据,是基于当前游标位置的下一个位置数据,实际调用的是内部的next方法,next方法分析见下文。
func (c *Cursor) Next() (key []byte, value []byte) {
_assert(c.bucket.tx.db != nil, "tx closed")
k, v, flags := c.next()
if (flags & uint32(bucketLeafFlag)) != 0 {
return k, nil
}
return k, v
}
next方法真正实现将游标移动到下一个位置,并返回它的数据。有两种情况需要考虑:
情况1的处理在下面for循环中的elem.index < elem.count()-1
,直接elem.index++即游走到了下一个元素位置。此种情况,c.stack = c.stack[:i+1]
会保持不变,调用c.first()
会立即返回,因为当前已在叶子节点。
情况2的处理也在for循环中,for循环会执行两次,情况1中for循环只会执行一次就break掉了。循环完之后,执行c.stack = c.stack[:i+1]
会切掉右边数据,相当于剪掉了B+Tree已处理过的节点,这个过程就是回溯。然后调用c.first()定位到它的邻近叶子节点。
func (c *Cursor) next() (key []byte, value []byte, flags uint32) {
for {
var i int
// 切片c.stack中保存的是访问的前缀信息,i从大到小循环,先处理游标最后位置
// 先在节点内部找到下一个元素,如果节点内部元素已全部访问完,i--走到下一个节点
for i = len(c.stack) - 1; i >= 0; i-- {
elem := &c.stack[i]
// 移动到节点中的下一个元素
if elem.index < elem.count()-1 {
elem.index++
break
}
}
// i为-1说明整颗树已经处理完了
if i == -1 {
return nil, nil, 0
}
// 清理掉已经处理过的节点,保留剩下未访问处理的
c.stack = c.stack[:i+1]
// 定位到剩下未处理完成的节点,c.stack[i]即为待处理的节点
// c.stack[i].index已经定位到节点中位置
c.first()
// https://github.com/boltdb/bolt/issues/450
// 节点中没有数据,跳出
if c.stack[len(c.stack)-1].count() == 0 {
continue
}
// 返回key-value
return c.keyValue()
}
}
Prev方法是Next方法的反过程,与Next方法类似,也有两种情况需要考虑,分别是当前游标位置处于节点中的内部,不是节点的第一个元素位置,和已处于第一个运算位置。
代码中的for循环,融合处理了上面两种情况,当循环结束的时候,调用c.last()方法定位到游标节点的最后一个元素位置。
func (c *Cursor) Prev() (key []byte, value []byte) {
_assert(c.bucket.tx.db != nil, "tx closed")
// 现在当前节点内部移动到前一个元素,特殊情况,当前已经位于节点的第一个元素位置
// 这时需要找到当前节点的前驱叶子节点
for i := len(c.stack) - 1; i >= 0; i-- {
elem := &c.stack[i]
if elem.index > 0 {
elem.index--
break
}
// 调整stack,c.stack[i]为当前活动的节点
c.stack = c.stack[:i]
}
// 已经遍历完整颗树
if len(c.stack) == 0 {
return nil, nil
}
// 调用c.last定位到c.stack游标位置节点的最后一个节点的最后一个元素
c.last()
k, v, flags := c.keyValue()
// 是桶,value为nil
if (flags & uint32(bucketLeafFlag)) != 0 {
return k, nil
}
return k, v
}
Seek方法将迭代器定位到给定key的位置,并返回key-value值。如果key不存在,迭代器会移动到下一个数据位置。处理的核心调用了内部的seek方法,下面分析这个处理流程。
func (c *Cursor) Seek(seek []byte) (key []byte, value []byte) {
k, v, flags := c.seek(seek)
if ref := &c.stack[len(c.stack)-1]; ref.index >= ref.count() {
k, v, flags = c.next()
}
if k == nil {
return nil, nil
} else if (flags & uint32(bucketLeafFlag)) != 0 {
return k, nil
}
return k, v
}
seek方法调用search方法完成key的定位,即查找key所在的位置,因为B+Tree中数据有序,search的时候采用二分查找sort.Search
实现快速定位。
查询流程是一个递归的过程,首先将根节点加载到stack中。递归的出口是走到了叶子节点,并查询给定的key在叶子节点中是否存在。如果当前是非叶子节点,优先从node中查找,如果node为空,则从page中查找。它们的内部也都会又调用search方法,所以形成了两条隐含的递归过程。
search-->searchNode-->search
serach-->searchPage-->search
searchNode和searchPage处理过程非常类似,我觉得源码实现有点重复,可以提取复用😁,处理过程非常简单,这里不再分析。
func (c *Cursor) seek(seek []byte) (key []byte, value []byte, flags uint32) {
_assert(c.bucket.tx.db != nil, "tx closed")
// 清理stack
c.stack = c.stack[:0]
// 从根节点开始查找
c.search(seek, c.bucket.root)
ref := &c.stack[len(c.stack)-1]
// key不存在
if ref.index >= ref.count() {
return nil, nil, 0
}
return c.keyValue()
}
func (c *Cursor) search(key []byte, pgid pgid) {
p, n := c.bucket.pageNode(pgid)
if p != nil && (p.flags&(branchPageFlag|leafPageFlag)) == 0 {
panic(fmt.Sprintf("invalid page type: %d: %x", p.id, p.flags))
}
e := elemRef{page: p, node: n}
c.stack = append(c.stack, e)
// e是叶子节点
if e.isLeaf() {
// 在叶子节点中查找key
c.nsearch(key)
return
}
if n != nil {
// 先从node中查找
c.searchNode(key, n)
return
}
// 从page中查找
c.searchPage(key, p)
}