换个思维理解B+树索引

注:本文阐述的索引演化是为了辅助理解索引结构,部分概念经过了简化,具体细节上的实现可能有差别

  假设有一张表:

create table i_t(
	`num` bigint;
	`age` int
)

  现在需要插入5条数据:

insert into i_t value(3,18);
insert into i_t value(4,19);
insert into i_t value(1,20);
insert into i_t value(2,25);
insert into i_t value(5,12);

  现在我们首先需要设计数据库的存储模型,因为这些数据最终需要存储在磁盘中,那么如何存储呢?很简单,我们只需要一通乱存,即使五条数据在盘面上相隔十万八千里也无所谓,反正数据存储下来了,然后由于数据是分散的,所以每行记录需要增加一个空间来保存下一行数据的地址,需求完成~(可以先看看磁盘I/O极简总结了解一下磁盘读取)
在这里插入图片描述
  数据存储好之后,要做的就是查询。假设现在要在这个数据集中查询num==5的那条数据:

select * from i_t where num = 5;

  我们先从磁盘将第一条记录(num==3)所在块加载到内存,判断发现num!=5,那么从第一条记录保存的第二条记录的地址继续从磁盘寻找第二条记录所在块,加载到内存,继续判断,还是发现num!=5,那么还是需要继续从磁盘查找,后面的几行数据类似。于是执行这条SQL,一共需要5次磁盘随机I/O。其实早在第一次磁盘读取的时候,并不是只读取一个块,因为预读的机制,也加载了很多(页的整数倍)操作系统默认认为可能将会使用的数据,但是由于我们第二条记录隔的"太远",所以很遗憾不在这些数据中。
  磁盘随机I/O的效率是很低的,而根据磁盘扇区、磁盘块和操作系统按页预读的原理,我们如果把这些数据都”放到一起“(不一定非要物理严格连续),使得一次磁盘I/O就能把它们都读到内存,然后在内存中遍历匹配num==5的记录,减少了磁盘I/O,效率肯定会高不少。
  那么基于这个思想,我们可以抽象出一个节点的概念,对于我们插入的数据都放到一个节点中,这个节点最好能充分利用磁盘预读的特性,那么我们可以将这个节点的大小设置为操作系统的整数倍,然后把数据都放到这个节点中,使得一次磁盘I/O就能将它们都加载到内存:
在这里插入图片描述
  按照这个存储模式,我们执行这条SQL,需要首先从磁盘把这个节点加载到内存,然后遍历节点中的记录,直到找到num==5的这条记录,只有一次磁盘I/O,的确是比之前快多了。
  但是即使只有一次磁盘I/O,数据都加载到内存之后,我们还是需要遍历节点中的所有记录才能定位我们需要寻找的数据,有没有什么办法可以优化呢?很难,因为这些数据的排列没有任何规律,我们很难找到合适的检索算法,但是如果这些数据是根据num字段排好序的呢?那可以基于有序的特性,为其建立一个目录,从逻辑上将这些数据分为几个段,在目录中通过二分法查找到数据所在的段,然后再到具体的分段中去寻找。这样通过分段+目录的方式,就能在大数据量下减少很多不必要的数据过滤(可以参考跳表),定会有不小的性能提升。
  另外,数据有序说不定还会在后面给我们带来不小的惊喜。但是按照这个思想,每次数据插入的时候,我们都将数据按照num字段排一下顺序,才能保证节点中的数据是有序的:
在这里插入图片描述
  显然排序也会消耗性能。那如果数据插入的顺序就是按照num字段排好序的是不是就更好了?只需要不断往后追加数据就行,没有了排序的工作。
  数据有序之后,我们按照前面的设想为节点中的数据进行逻辑分段,然后建立一个目录,结构如下:
在这里插入图片描述
  有了这个目录之后,如果我们要查找num==5的数据,那么可以通过目录二分查找跳过第一个分段,直接定位到第二个分段,减少了工作量,特别是在数据量更大的时候,带来的收益会更大,这是一个以空间换时间的思路。
  但是话说回来,数据量越来越大,这个节点所占用的空间也就越来越大,我们设计这个节点的初衷是尽量利用磁盘预读的特点,尽量减少磁盘I/O,如果这个节点太大,一次性load到内存也不现实。
  自然,我们需要规定好这个节点占用的磁盘空间大小,前面已经提到了,为了更好地利用磁盘预读,就设置为操作系统页的整数倍,比如4K、8K、16K等都可以,当节点数据占满了的时候,就需要开辟一个新的节点。如果要保证两个节点物理上连续,那维护成本有点高,还是使用链表结构组织即可(现在num最大值增长到了10):
在这里插入图片描述
  现在我们有了两个节点,而且每行数据不论在节点内看,还是在整体上看,都是有序的。基于这样一个结构,如果我们要查找:

select * from i_t where num = 10;

  该如何查找呢?显然,需要先把第一个节点加载到内存,然后在内存中先通过节点一的目录确定num==10的数据可能在第二个分段,然后去第二个分段中查找,发现最大的num也才等于5,就说明要查找的数据要么没有,要么就在后面的节点。所以就需要加载第二个节点到内存,好在第一个节点有第二个节点的地址指针,我们能直接定位到第二个节点。第二个节点加载到内存之后,还是要重复之前的查找逻辑,根据第二个节点的目录确定数据可能在第二个分段,然后直接到第二个分段中查找,最终找到了num==10的数据。
  这个流程看起来没有什么问题,如德芙般丝滑,但是,既然我们现在有了两个节点,那么后面可能会有三个节点,四个节点,n个节点:
在这里插入图片描述
  如果我们要查找num==892的数据呢?岂不是要从第一个节点一直遍历到最后一个节点了?而一次节点加载可能就会对应有磁盘I/O发生,那太可怕了。要解决这个问题其实很简单,我们现在遇到的问题其实和最开始在单节点内遇到的问题是一样的:链表太长了,还必须从头开始遍历。我们在单个节点内建立了目录,其实在这里也可以为节点建立目录,思路也差不多,就是将这些节点内最小的num值(也就是节点内第一行数据的num值)提取出来放到一个上层节点中,当然也要保持节点的起始地址(num最大值增长到了15):
在这里插入图片描述
  现在我们又有了一层目录,如果要查询:

select * from i_t where num = 15;

  是不是就有两种方式了?

  • 第一种就是上面描述的从底层左边第一个节点开始依次往后遍历,这种方式需要3次磁盘I/O
  • 第二种就是把上层目录加载到内存,然后通过二分查找确定数据可能在哪个子节点,然后把对应的子节点加载到内存,按照前文在节点内检索的方式检索数据。

  很明显,num==15的数据只可能在最右边的节点,然后把最右边的节点载入内存,最终找到数据,这种方式只需要2次磁盘I/O,而且对于上层目录,由于只有num字段和地址指针,我们甚至可以直接把这个目录放到内存,就只需要一次磁盘I/O了!随着数据的不断增长,节点越来越多,我们上层目录节点内也可以随着存放更多的子节点,这个结构带来的磁盘I/O次数减少会更加乐观。
  到现在我们已经体会到了数据有序给我们带来的巨大惊喜!但是话说回来,这个上层目录节点也是一个节点,还是遵循我们前面定义的节点的大小,虽然相比较于底层节点没有存储所有数据,但总归是有个上限。如果上层目录节点内存满了怎么办呢?你肯定已经想到了,这又和我们前面遇到一个节点内数据占满了需要开辟新节点一样,目录节点占满了也可以开辟新的目录节点嘛!按照同样的逻辑,我们开辟新的目录节点之后结构成了这样(由于图片尺寸问题,这里就假设上层目录节点内最多只能包含两个字节点,num最大值增长到了20):
在这里插入图片描述
  现在上层目录节点变成了两个,底层节点变成了四个。如果现在要查询:

select * from i_t where num = 20;

  该如何查找呢?还是有两种方式,一种还是是从左下第一个节点开始依次往后查询,一共需要4次磁盘I/O;第二种还是通过上层目录,但是这里发现一个问题,我们怎么找到第二个上层目录呢?或者说我们希望能利用这两个上层目录来确定数据可能在哪个底层节点上。可能你已经想到了,逻辑和之前都是一样的,就是为这两个上层目录节点再创建一个上层目录。创建的方式也一样,提取各自节点内最小的num值,同时需要保持他们各自的地址指针:
在这里插入图片描述
  现在再来查找num==20的数据:先加载顶层节点到内存,通过二分法确定数据可能在右边,然后加载第二层右边目录到内存,再通过二分法确定数据可能在右下节点,再加载右下节点到内存,根据前面节点内搜索逻辑找到数据。一共经历了3次磁盘I/O,顶层节点也只有num字段和地址指针,我们把它放到内存,第二层目录如果有条件的话,我们也放入内存,是不是就只有一次磁盘I/O了?

注:由于图片尺寸的原因,这里的目录层节点内,都只有两个子节点,所以看不出很大的差距。实际上,一个目录节点内可以包含很多子节点,这里可以算一下:地址指针占6个字节,num假设为bigint类型,占8个字节,目录内一个子节点占用14个字节,如果一个节点设置的大小是16K,忽略其它在具体实现时为了性能或其他原因可能需要消耗的额外存储,那么一个目录节点就可以存储16*1024/14=1170个子节点,这个就能看出来巨大的差别了,如果到了这个数量级,那么会节省大量的磁盘I/O。

  现在我们来考虑这个sql:

select * from i_t where id < 16 order by id desc limit 4;

  基于这个结构,要实现这个sql其实是有困难的,我们只有一种方式可以选择,那就是从左下角第一个节点开始往后遍历,收集数据,然后取最后4条数据。这个相当于是一个倒序查询需求。其实我们可以在现有结构上稍作修改,来优化这种情况:把底层节点之间的单向指针扩展为双向指针,给一个节点提供向前搜索的能力。
在这里插入图片描述
  根据这个结构,我们无法通过目录节点直接定位到某一行数据,只能通过目录定位到某一个底层节点,然后把底层节点读入内存,在内存中进行检索。对于底层节点,我们内置了一个目录页来提高节点内数据的检索速度。其实这些目录节点也是一个节点,加载到内存后也需要检索,才能确定接下来该读取哪个底层节点。所以对于目录节点在内存中的检索我们也可以采取诸如二分法的方式提高检索效率,还是由于图片尺寸的问题,部分地方的展示会有缩减。

总结

  可以看到这整个目录最终成了一个多路搜索树的结构,可以称之为索引树,整个索引树的检索过程也是一个”多分“的过程。只有叶子节点才有完整的数据,非叶子节点只保留了索引字段和子节点的地址指针,而且每个叶子节点的第一个索引字段(最小的那个)还被冗余存储在目录节点。左下角的第一个节点内的第一条数据一定是最小的(对于num字段),而且整体上是往后依次递增的。由于索引树包含了所有的完整数据,可以称之为”聚簇索引“。
  每一个节点,不论是叶子节点还是非叶子节点,我们都以为单位来管理(数据库中的页,不是操作系统的页),为了更好地利用预读,将其设置为操作系统页的整数倍。只是对于叶子节点,姑且可以称之为数据页,目录节点可以称之为索引页,num字段可以称之为索引字段,在这里还可以将其描述为主键
  每个节点内通过一个内置目录来提高节点内数据的检索速度。为了应对个各种查询场景,我们还将叶子节点之间的连接扩展为了双向指针。
  基于这样的结构,要查询一条数据我们有两种方式:

  • 一种是从左下角第一个叶子节点开始依次横向检索
  • 一种是从树的根节点开始往下检索

  第一种方式就可以称之为全表扫描,第二种就算是使用了索引。如果使用全表扫描,可能会有大量的磁盘I/O和数据筛选,而如果能使用到索引,就能在极大程度上减少磁盘I/O和数据筛选,因为我们能通过索引快速定位叶子节点。

  从结构上看,设计有以下关键点:

  • 有序,这个是整个索引结构的基础,高性能的基石
  • 只有数据页(叶子节点)才有完整的数据,笼统的说,索引页只保存索引字段和地址指针,这样就能保证一个索引页中能包含更多的子节点,极大的降低树的高度
  • 依托于第二点,每个索引页能保存更多的子节点,就意味着树的高度能被控制在一定的范围,而对索引影响最大的就是树的高度,因为一个节点就可能意味着一次磁盘I/O。事实上,如果将页设置为16K,在通常情况下,一个三层的索引树可以为千万级的数据提供服务
  • 数据页之间通过双向指针连接
  • 通过索引树只能定位到一个页,而不能定位到页内具体的某一行数据,还是需要将页加载到内存,然后在内存中检索。虽然内存检索很快,但是也要想办法提高页内数据的检索效率,而且数据的有序性也为我们提供了优化的先决条件

  这个就是MySQL中innodb B+树索引的一个大体结构,只是在设计上有很多细节。比如innodb页的结构其实很复杂,除了数据记录(User Records)、可支持提高页内数据检索效率的结构(Page Directory、Infimum、Supremum等),还有File Header、Page Header、File Trailer等等,这是一整套完整的设计,考虑了数据完整、检索效率、存储效率、容错等等各个方面。

注:本文基于博主个人理解,如果错误,感谢指出!

猜你喜欢

转载自blog.csdn.net/huangzhilin2015/article/details/115060065
今日推荐