Tree new bee 从文件系统的索引设计到B树

147 阅读12分钟

在青训营的阅读打卡活动中看到MySQl 索引之道 - 掘金 (juejin.cn)这篇文章时,突然讲到mysql索引的原理,B+树。但可怜我没有学过数据结构,只好一点点补课了~

B树

www.bilibili.com/video/BV1mY… 相关的视频。

B-Tree的引入:

磁盘查找数据效率低,什么原因?

先复习以下几天前在《码农翻天》里看到的一些东西。因为CPU速度要远远大于磁盘IO速度,所以CPU与内存不能直接交互。数据会先被从磁盘中加载到内存中,然后再去执行CPU的指令。

CPU速度很快,和内存的交互也很快,所以造成查找数据效率低最可能发生在内存与磁盘之间的IO中。

影响IO效率的因素:
  1. 读写数据越大,速度越慢 select * 要比select a 慢。(*要将所有数据加载到内存中)
  2. 读写次数越多,速度越慢 如果顺序查找,最差的情况下最后一个才能找到;如果使用二分查找,时间复杂度在log级,读写次数少,速度要比顺序查找快。(SSD要比机械硬盘快)

索引:

  1. 为了更快地查询数据。如字典中的拼音(key-value),部首查字法。
  2. 基本上是基于键值对
索引为什么不能是线性的:

对于一个文件系统的索引,如果使用顺序存储(如一个数组)。每一个下标就对应一个具体的值。这样做的问题是 查找的效率很慢(顺序查找);对于插入和删除,算法开销很大。

如果使用哈希呢?

如果使用哈希的方式来设计,根据哈希函数,计算一个下标就可以找到。

但是哈希函数不可避免会有哈希冲突,造成数据不均匀(对于不同key计算出同一个下标)。就会产生大量的线性查询。而且如果是范围查询,只能挨个遍历,算法复杂度为n。

所以哈希也不合适,优点是等值查询比较快,缺点是hash冲突,造成数据散列不均匀,产生大量的线性查询,效率低。

有哪些树?

二叉树,BST二叉排序树,AVL平衡二叉树,红黑树,B树,B+树

二叉树:无序,需要遍历查找

二叉排序树

插入数据时使数据有序。左子树不为空时节点的值小于根节点,右子树不为空时节点的值小于根节点。

好处:如果要查找37,从根节点开始,37要比46小,根据 "左子树不为空时节点的值小于根节点", 直接在左子树上查找,到了下一个根节点24,37要比24大,直接在右树上查找,时间复杂度为logn。

缺点:如果在插入数据时按照从小到大插入,则树会变成:

图1

又变成了顺序查询,查询效率很低。

解决方法:在按顺序插入时,如果出现从小到大插入的情况,则对树进行旋转。这就是平衡二叉树

平衡二叉树

www.jianshu.com/p/dc0cde44c…

,且具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。

插入数据时要根据平衡因子保持树的平衡。

平衡因子:左子树 和 右子树的高度之差的绝对值不大于1。

图2 平衡后

在构建一棵平衡二叉树的过程中,当有新的节点要插入时,检查是否因插入后而破坏了树的平衡,如果是,则需要做旋转去改变树的结构。

右旋:

左旋:

左旋就是将节点的右支往左拉,右子节点变成父节点,并把晋升之后多余的左子节点出让给降级节点的右子节点;

右旋就是反过来,将节点的左支往右拉,左子节点变成了父节点,并把晋升之后多余的右子节点出让给降级节点的左子节点。

左旋就是往左变换,右旋就是往右变换。不管是左旋还是右旋,旋转的目的都是将节点多的一支出让节点给另一个节点少的一支

详细内容看卡片。

如何从图1根据平衡因子到图2?

12->24 左子树高度为0 右子树高度为1

12->24->37 左子树高度为0 右子树高度为2 左旋 将24作为根节点 12作为左子树根节点,37作为右子树根节点

46作为37根节点的右子树,53来了以后,左旋。以46作为新的该子树的根节点,37作为左子树根节点,53作为右子树根节点。

每一次插入都可能会造成算法的开销。

平衡二叉树实际上是用插入的算法成本来弥补查询的效率。

对于查询的操作可以弥补二叉排序树的缺点。但对于写入频繁操作,旋转的开销太大;

红黑树

红黑树仍然是排序树。

与平衡二叉树相比,要求最长子树不超过最短子树的二倍即可。(为了不去做大量的旋转,属于折中的做法)

保证查找效率的同时,插入的开销也还行。

问题:当数据量很大时,树的深度会很深,需要查找的次数就更多。IO就会变慢。

(因为文件系统的索引保存在磁盘中)

如果随着数据的插入,树不变深,不就可以解决这个问题了吗?

上面的问题都是基于二叉树的,如果在树有序的前提下,把"叉"变多,问题就得到了解决。

B树

一个有序的多路查询树。

满足下列要求的m叉树就是B树:

  1. 树中的每个节点至多右m个孩子节点(即至多有m-1个关键字)(关键字相当于索引)
  2. 每个结点的结构为:

n表示这个结点有几个关键字(上面说了最多m-1,这里是具体几个)。p0表示第一个子树的地址(指针),k1是一个关键字,p1同样是一个地址..以此类推。两个指针夹着一个关键字。

如:

其中每一行都是(2)的格式。

对于第一行,n=1。因为有一个关键字37(k),关键字左右两边的指针(p0,p1)分别指向左子树和右子树,其中左子树只有一个关键字25,所以n=1。右子树有两个关键字40和85,所以n=2,两个关键字左右两侧一共有三个子树。以此类推。

(把关键字理解成分割点)

关键字的排序也符合VAL

上图中也给出了B树的其他性质。 今天先到这里,关于B树的查询,添加,删除,以及B+树的相关知识等我明天睡起来再学。加油!

B树的查找

给一个3阶的B树,给出在B树中查找37的方法。

B树仍然是一个多路有序的树,查找方法与前面的树一致。

37比48小,在左子树查找,比25大,在右子树查找

住要注意的是:上面的情况是只对于一个节点而言的,如果有多个节点,那么节点也是有序的。

结合操作系统,B树的查找过程

磁盘预读

  1. 内存跟磁盘发生数据交互时,一般有一个最小的逻辑单元,称之为页,datapage。
  2. 页的大小一般由操作系统决定,一般是4k或8k,在数据交互时,可以取页的整数倍来进行读取。

比如对于一个15.8kb的文件,它占用的空间要大于15.8kb。

B树作为索引时的过程:

每一个节点有三部分内容,键值(表中的主键),指针(指向子树),数据,n(几个关键字)

B树的每一个节点是放在一个磁盘块(datapage)里的,如果使用B树做索引,规定磁盘块的大小为16k。

比如要查找一个关键字为28的数据,过程是这样的:

先把根节点(磁盘块1)加载到内存中,28>16,28<34。找到了p2, --第一次IO。

顺着p2找到磁盘块3,28比25大,比31小,找到了磁盘块2的p2, --第二次IO

顺着p2找到磁盘块8,返回关键字对应的data。

注意:

上面的例子中每个pagedata大小为16k, 总共做了3次IO,一共消耗48K。这48K总共能保存多少记录?

假设一条记录占用1k,如果B树中的指针和关键字不占空间,则每块磁盘块都可以记录16条记录。3层一共可以记录16×16×16的记录。 (2的12次方,4096条数据)

(为什么是乘:第一层记录16个数据,分割出17个指针,每个指针对应一个16k大小的子树.. 应该不会对应17个子树吧,毕竟这种情况下是在假设指针和关键字不占空间)

当数据量大时,如要存1w条,即使不考虑关键字和指针占用的内存,三阶的B树也只能存4096条,存不下怎么办?

加一层?深度变深,IO次数增加,查询速度变慢。

为啥要变深?

B树的非叶子节点上除了存放关键字和指针外,还存放了数据(占了大量的空间),16k占满后不得不增加深度。

B+树

如果非叶子节点上不存数据,只存指针和关键字,那么每个磁盘块就可以存更多的指针和关键字,然后把所有数据都挂在叶子节点上(没有下一层了)

(叶子节点与非叶子节点:叶子节点: 就是没有子节点的节点)

B树到B+树,非叶子节点只存记录和指针,数据统一存储在叶子节点上。

如果磁盘块大小为16k,指针和记录为一对的话占10字节,则一个节点可以存1600条数据,三层可以存储1600的三次,千万条记录。

B+树做到了在查找速度几乎不变的同时,可以存储更多多的数据。

一个疑问:如果要查找28(根节点)时,如何查找?

B树的删除

五阶B树表示该树最多有五个分支

二叉树: 指针 key 指针

对于二叉树的删除

一个节点中只有一个关键字,所以对于二叉树,删除关键字就表示删除该节点。

如要删除k2,则就是找到k2的下一个节点k3,将k1与K2链接的指针指向k3

而对于B树,一个节点中不止有一个关键字,可能有k1,k2,k3。。如过要删除k2,不再是删除整个节点。而是找到k2的下一节点的一个关键字,把该关键字赋值到k2处

B树删除的两个大类:

首先来回顾一下B树的特点

  1. 每个节点最多有m个子节点 (5阶B树,每个节点最多有5个子节点)
  2. 除根节点和叶子节点,其他每个节点至少有m/2个子节点(向上取整) (5阶B树,非叶子节点至少有3个子节点)
  3. 若根节点不是叶子节点,则其至少有两个子节点
  4. 除根节点外,其他节点都包含n个key,其中m/2 -1 <=n<=m-1 (2到4个key)

删除时也要符合上面的要求,如要删除第二层右子树的200,如果删除该关键字,则右子树不满足(4),所以用下一节点(叶子节点)上的关键字如230赋值到200的位置。

删除要满足的条件

"富有":删除一个key,节点剩余的Key数量依然大于等于m/2-1。则直接删除该关键字。

否则将下一节点的关键字赋值到要删除的key处,并将原处的key删除。


如果不"富有",即该节点处的key=m/2-1,并且该节点为叶子节点(无法用下一节点),则该节点的父节点拉一个关键字下来到该子节点处代替原来的关键字,与之同级的富有的兄弟节点派出一个关键字到父节点处

如下面的例子,要删除第三层的关键字180

如果兄弟节点也不富裕的话,向爸爸借,借的过程是这样:把删除后剩余的关键字合并给不富裕的兄弟节点,然后为了保持B树的结构,爸爸节点中的一个关键字拉到自己的节点里(因为合并节点后必然会有大于小于的问题),如果爸爸节点也不富裕,则向爷爷节点执行同样的操作,向爸爸的兄弟借,爸爸的兄弟也没有就向爷爷借。如果爷爷也没有,这时候就只能降低树的高度,爸爸强行把爷爷拉下来和自己合并在一起,降低高度后如果没有根节点了,爸爸就和兄弟合并。

如果兄弟没钱,但爸爸很富裕,要删除叶子节点,那就是向爸爸借点,然后和兄弟合并。 B树大概是这么一回事,B树的删除部分比较绕,做起图来好麻烦,但还是比较清晰的。