小编在看etcd存储(store)模块的时候,发现它在进行key和keyIndex转换的时候,用到了btree包(http://godoc.org/github.com/google/btree)。btree是Google开源的一个Go语言的BTree实现,整个代码不到1000行,实现的非常简练,组织分层也做的很好,并对gc和并发读写做了很多优化,值得一读。小编打算用两篇文章讲解BTree内容,本文上篇主要介绍实现原理,下篇主要介绍btree源码实现。
BTree和B-Tree都是指B树,不要把B-Tree理解成了B-树。关于BTree在《计算机程序设计与艺术》和《算法导论》对其描述是不一样的。一种是通过"阶"的概念来定义的,另一种是通过"度"的概念定义的,两者描述的本质是一样的。
我们先来看通过阶的概念的定义,阶指一个节点的最大子树的个数,定义如下:
❝树中每个节点至多有m颗子树 若根节点不是叶子节点,则至少有两颗子树 除根节点之外的所有非终端节点至少有「m/2」颗子树 所有的非终端节点中包含关键字和指向子树根节点的指针 所有叶子节点在同一个层次上 ❞
下面是算法导论中通过度来定义BTree, 度是一个节点子树的个数,对于一颗最小度为t(t>=2)的BTree,节点中的关键字为key,有如下限制:
❝除了根节点之外,其他节点的t-1<=len(key)<=2t-1 非叶子节点的子树个数t<=len(children)<=2t ❞
两者有点细微的差别,根据《计算机程序设计艺术》的定义,最小的树为3阶BTree,即树的非叶子节点的子树的个数为2或3,根据《算法导论》的定义,最小的树为2度数,对应的前者的阶数为4,即树的非叶子节点个数可以为2、3或者4. etcd中用到的btree包是按这里的度的定义实现的。下图是一个度为3的BTree,除了叶子节点,每个节点的子树个数不是2个就是3个,0004节点的子树有2个,0047|0051节点的子树有3个。
BTree常用于实现数据库索引,例如在MongoDB中的索引是用BTree实现的,MySQL中的innodb存储引擎用B+树存储索引信息。通过前面的定义可以看到,BTree是一种平衡多路查找树,与AVL树和红黑树等二叉树比较起来,BTree通过多叉,降低了树的高度,从而减少了查询的次数。为啥数据库的索引采用BTree实现呢?因为数据库的索引信息以树形结构存放在磁盘上,对于高度为h的树,最多需要进行h次查找,对于存放在磁盘上的文件来说,需要读取磁盘h次,而读取磁盘的操作与操作内存相比是很慢的,一次磁盘读取耗时为寻道时间+旋转磁头时间+数据读取传输耗时。普通的机械硬盘,寻道时间大概在10毫秒左后,旋转磁头时间就是磁头移动到对应磁道后,旋转到对应扇区所需要的时间,可以看到整个磁盘IO操作是个很耗时的过程。而BTree降低了树的高度,减少了磁盘读取次数,所以数据库的索引采用BTree或B+树实现。
BTree的核心操作包含树的创建,树中节点的删除,元素的查找。下面分别分析每个操作的具体实现。
插入操作是向BTree中插入一条记录,即向里面添加一个key-value键值对。如果要插入的key在BTree中已存在,则用当前的value更新之前的旧value.如果BTree中不存在这个key,则在它的叶子节点中进行插入操作。具体算法流程如下:
下面以度(Degree=3)BTree的创建过程为例,介绍插入过程。
向BTree中插入4,插入后只有一个key,因为这是首次插入。
向BTree中插入51,直接将51加入与4同节点中,此时该节点有2个key,满足每个节点不超过2个key的性质.
向BTree中插入38, 度为3的BTree,每个节点最多有2个key, 此时节点有3个key,不满足BTree性质,将中间的key提升到父节点中,调整之后符合BTree定义,插入操作结束。
向BTree中插入43,添加到叶子节点51所在的位置。
向BTree中插入48,添加48到43|51所在的节点后,此时该节点不满足BTree性质,对其进行拆分,将中间的48加入到父节点(38所在的节点),43|48|51节点中的key被分成43和51两部分,48的左右子树分别指向它。
向BTree中插入1
向BTree中插入10,此时1|4|10节点不满足BTree性质,需要进行分裂,将4插入到父节点中,插入之后,父节点4|30|48也不满足BTree性质,继续对其进行分裂。
插入的核心就是当节点的key的数量不满足BTree性质时,向上进行分裂,即将当前节点的中间key插入到父节点中,这样能够确保分裂之后节点的层次深度保持不变。如果父节点也不满足BTree性质,继续对其分裂,这样一路向上,直到根节点,保证整颗树🌲层次一致,即多路平衡性。
从BTree中删除一个元素,根据删除元素的所在的节点是叶子节点还是非叶子节点分为两种情况。下面对这两种情况做一个简单的分析:
删除元素38后,得到新的BTree树如下
但是这里有一种特殊情况,如果将要删除的元素的子树的最右侧的叶子节点的元素移走之后,可能会导致叶子节点为空,此时叶子节点不满足BTree性质。这种情况只做上面的处理还不够,还要做其他处理。这种情况在2中删除叶子节点的元素导致叶子节点为空也存在,所以放到下面一起分析说明。
处理方法是,如果该叶子节点的兄弟节点有富余的元素,可以借一个过来,如果兄弟节点也没有富余的元素,这时候要对叶子节点进行合并。
下面的BTree删除叶子节点中的元素z, 删除z之后节点C不满足BTree性质。假定该BTree是m阶BTree,根据BTree性质,非根节点元素的个数n, 则n满足ceil(m/2)-1<=n<=m-1. 那说明删除z之后,节点C的元素个数为 ceile(m/2)-2. 其兄弟节点A的元素个数 > ceil(m/2)-1,即节点A最少元素个数为 ceil(m/2),将元素x加入到节点C元素的最左边,然后将节点A最后一个元素y放入到x位置,最后将节点A中的元素y删除,此时又是BTree了。
删除元素z之前
删除元素z之后
上面讨论的是叶子节点的兄弟节点有富余的元素,可以从兄弟节点借。如果兄弟节点的元素也不富余的情况怎么处理呢?嗯,方法是将叶子节点进行合并。下面的m阶BTree中删除叶子节点C中的元素z之后,不满足BTree性质,说明此时节点C在删除z之后剩余的节点数为ceil(m/2)-2, 其兄弟节点A没有富余元素,说明节点A的元素个数为ceil(m/2)-1.处理方法是将元素x下移,与节点A和节点C合并,合并之后的节点我们记为节点AC,则节点AC的元素个数为 ceil(m/2)-1+ceil(m/2)-2+1 = ceil(m/2)+ceil(m/2)-2, 可以证明合并之后的节点数是小等于m-1的,即ceil(m/2)+ceil(m/2)-2<=m-1. 这样合并之后,又是BTree了。如果合并之后节点B不满足BTree性质,因为从节点B移走了元素x, 则处理方式就是这里介绍的两种方法,从节点B的兄弟节点借,如果兄弟节点没有富余元素,则继续合并,最后直到根节点,会将BTree的高度降低。
删除元素z之前
删除元素z之后
下面是一颗度为3的BTree, 现在删除元素90
删除90之后,叶子节点变为空,此时不满足BTree性质,根据前面介绍的处理方法,尝试从它的兄弟节点借一个元素,但是它的兄弟节点也只有一个元素44,只能进行合并处理,将父节点的元素45和44合并。
但此时父节点中的元素为空了,不满足BTree性质,于是对父节点采用从它的兄弟节点借或者合并的方法,而此时它的兄弟节点中也只有一个元素22,所以只能进行合并,将根节点的中的元素41和21合并,BTree的高度减少一层,得到如下的BTree.
BTree是一种多路平衡树,同时也满足有序性,对于每个节点,它左边子树的所有元素都小于该节点中最小的元素,它右边子树的所有元素都大于该节点中最大的元素。每个节点的内部元素也是有序的。所以采用类似二叉树的中序遍历,得到元素一定是有序的。所以BTree中查找元素的过程很简单,从根节点开始,每次可以定位可能所在的1个子节点,这样一路向下查询,如果在内部节点中没有找到,最后达到叶子节点,如果叶子节点也没有,则说明要查询的元素不在BTree中,整个查找过程很简单,这里就不在画图举例说明了。