B-tree在外部存储中的应用
大家知道我们在使用数据结构时,常常将其直接放置到内存中。但是大数据量只能放到磁盘中。这里要说的就是,在磁盘存储中,文件系统是如何利用B-tree结构来提高磁盘读取效率的。
首先来说一下内存和磁盘性能上的巨大差别。
大家知道数据在磁盘上存储在特定的扇区。因此要读写特定的数据,需要三步:
1,首先磁头必须移动到正确的磁道上。
2,然后需要等待磁片旋转,使磁头对准数据所在的位置。因此磁盘的转速很重要,我们更喜欢7200转/分钟的磁盘而不是5400转/分钟。
3,磁头读取或写入数据。
上述过程大概需要毫秒级的时间。而内存读取仅需要微秒级的时间。大体上说,二者的的速度差了大约1万倍。
磁盘上的数据都是按块存储的。磁盘驱动器一次最少读或写一个数据块。通常数据块为8k。磁头和磁片就位后,读写一块是非常快的。
因此从这点来说,块越大,读写的效率越高(因为一次可以读写更大的数据量)。但是数据块过大会导致很多时候读出的数据大部分没有用,只能被扔掉。所以这是一个平衡。
但是磁盘查找的一个好处是,块数比记录数要少的多。假设文件是按顺序存储的,查找可以用二分法,因此查找效率是可以接受的。但是插入和删除就很糟糕了,因为我们假设数据块是有序的,这样插入和删除都需要移动其他的数据块来保持数据块有序。
鉴于这些种种的问题,文件系统使用B-tree来管理数据块。B-tree是一种多叉树,即每个节点包含多个数据项,同时有多个子节点。结构和2-3-4树类似。需要注意的是B-tree的B既不是Binary也不是Balance,B-tree的发明人表明过,B没有含义。可认为B-tree是一种Balanced Search Tree。
使用B-tree时,数据块本身可以以任何顺序存储,因此B-tree的作用就是索引。文件系统根据磁盘数据生成B-tree结构,存在磁盘上。并且根据数据的变化实时更新B-tree中的内容以保持同步。
B-tree分两种
1,节点都可以保存数据。
2,仅叶节点保存数据 这样的B-tree效率更高,因为非叶节点只保存关键字和数据块编号(用int来存储)。由于传统的树结构没有节点和页节点的区别,因此在一些文献中称之为B+tree。
但不论哪种类型,B-tree节点也都是8k这样的数据块组成的。
在磁盘存储中,所使用B-tree是第二种类型。且在每个节点中,数据是按关键字顺序有序排列的。
在B-tree中查找数据和2-3-4树使用的搜索算法一样。插入比较复杂,这里就不说了。大家自己找资料看吧。
B-tree的效率
B-tree每层都可以覆盖很广数据,因此树的高度很低。一般个位数的读写次数就能完成对几十万数据的搜索。比用二分法查找顺序文件的效率要高的多。插入和删除的效率也很高。我们知道对有序数组的查找最快时间复杂度是O(logN),但在B-tree中搜索似乎更快,这是为什么呢?
原因是在有序数组中查找数据时,N是数组长度,即元素个数。例如要在长度1000的有序数组中查找,二分法的时间复杂度是Log1000。而在B-tree结构的索引中,数据实际是存在数据页面中的,这里假设每个数据页面存储100条记录,因此1000条记录需要10个数据页面,在10个数据页面中搜索的时间复杂度是Log10。因此看起来似乎B-tree搜索更快。而数据库服务器对磁盘的最小读写单元是一个数据页面,因此定位到数据页面后对B-tree的搜索就结束了。之后将整个数据页面读入内存中,而不会继续在数据页面内搜索。
B-tree是数据库索引使用的数据结构
B-tree是所有关系型数据索引所使用的数据结构,一些关系型数据库可能还提供其他数据结构的索引,但B-tree索引由于其具备的高效性,是所有关系型数据库索引必用的数据结构。近几年出现并开始流行的nosql数据库也是有索引的,例如MongoDB中的index也是B-tree结构,这是因为B-tree特别适用于磁盘存储所决定的。
首先需要说明的是,数据库表中的数据也是以数据块的形式存储在硬盘上的。因此对于数据库数据存储和前面说的外部存储是一样的。但是数据库索引有一些特殊性。
1,首先,数据库索引的B-tree只在叶节点保存真正的数据。
事实上对于聚集索引来说,叶节点就是表数据本身。而对于非聚集索引,由于最终需要定位到数据块上,因此可以认为使用非聚集索引的最后一步必然是查询聚集索引。查看SQL的执行计划就可以得到验证。
数据库聚集索引的原理很简单:在除叶节点之外的其他节点,存储的是下一层节点的数据页面的第一行记录。例如叶节点(也就是表本身)存储了以下数据页面:
数据块1:
人名 电话号码
arron 78437237
...
emily 87346528
数据块2:
emixy 87483823
...
family 8734663
那么上一层节点的数据页面存储的就是下层每个数据页面的第一行记录:
arron ,数据块号1
emixy ,数据块号2
这样如果查询emily这个人的号码,当在索引中查询到这一级的时候可知emily的记录必定在数据块1中。由于arron ,数据块号1这样一个记录的size很小,一个数据页面可以存储几百条。那么也就是说,在叶子级的上一级节点的一个数据页面就可以覆盖叶子节点几百个8k的数据页面。如果按叶子节点一个数据页面大约可存储100条记录计算,仅一层b-tree就可以让必须保存的数据量收敛了50000倍。(100条数据*500个数据页面=50000条记录,而上一级节点覆盖这些记录只需要一个数据页面)。这样一个很大的表,通过很少的几层B-tree就可以快速收敛到根节点。而b-tree索引的层数,决定了定位数据需要的步数(因为叶节点就已经是数据本身了)。
在这里我们可以看到B-tree给数据库带来的巨大性能提升:在一个大约有10w条记录的表中查询一条数据,仅需要大概3~4次查询。
2,其次,数据库的表数据通常是有序存储的。
大家也许对数据库的聚集索引(clustered-index)耳熟能详了。建立聚集索引会导致表数据在物理上按关键字排序。由于聚集索引的重要性,几乎所有的表都会建立聚集索引。
没有建立聚集索引的表称为heap。B-tree索引保存在磁盘中,数据库启动后,常用的索引会被读到内存中,并随时更新,并以某种频率同步到磁盘上。
两个问题:
1,如何对表记录排序。
对表记录排序和对文件系统中的数据排序一样,排序的对象都是数据块。因此方法都是归并排序(merge sort)。
第一步,读取一块,将记录排序后,写回磁盘中。
第二部,读取两个有序的块,合并成一个两块的有序序列。将两个块写回磁盘中。下次把每两个有序块合并成4块。如此反复,对所有的块都排序为止。
但是由于内存的限制,到排序后面的阶段时,必然会面对无法将两个很大的有序块一起读到内存中排序的情况。因此归并排序使用了很复杂的步骤来解决这个问题。大家自己去搜索吧。
2,插入数据时,如果已经建立了聚集索引,如何保持数据的有序。
由于聚集索引会按关键字排列数据块在磁盘上的物理位置,而在有序的数据块中插入新的数据块效率会很低。同时熟悉2-3-4树结构的同学也知道,树结构是有序的,在索引结构中插入新的节点有时会导致树结构重新调整,效率非常低。
因此数据库需要避免增加新的数据块以尽可能的提高效率。而填充因子(fill factor)就是被设计来减少这种中情况的:
如何在表中插入一条新数据时,尽可能的让文件系统不需要增加新的数据块呢?答案就是一开始就不要把数据块填满。
For example, specifying a fill-factor value of 80 means that 20 percent of each leaf-level page will be left empty, providing space for index expansion as data is added to the underlying table. The empty space is reserved between the index rows rather than at the end of the index.
When a new row is added to a full index page, the Database Engine moves approximately half the rows to a new page to make room for the new row.
(这句话相当有意思,因为对于典型的B-tree结构,当节点分裂发生时,就是将数据块中一半的数据移动到新的数据块。从这句话可知数据索引是完全的B-tree结构,使用的结构和算法都完全一样)。
上面的话是针对索引的数据块的,但普通数据的数据块也是完全一样的。数据库一般也可以指定表数据的填充因子,还可以单独给index指定不同的填充因子。