内存一般都是由硅制的存储芯片组成,这种技术的每一个存储单位代价都要比磁存储技术昂贵两个数量级,因此基于磁盘技术的外存,容量比内存的容量至少大两个数量级。这也是目前PC通常内存几个G,而硬盘却可以成百上千G容量的原因。
假如我们要操作的数据集非常大,大到内存已经无法处理了怎么办?如数据库中上千万条记录的数据表、硬盘中的上万个文件等。在这种情况下,对数据的处理需要不断从硬盘等存储设备中调入或调出内存页面。
一旦涉及到这样的外部存储设备,关于时间复杂度的计算就会发生变化,访问该集合元素的时间已经不仅仅是寻找该元素所需比较次数的函数,我们必须考虑对硬盘等外部存储设备的访问耗时以及会对该设备做出多少次单独访问。
试想一下,为了要在一个拥有几十万个文件的磁盘中查找一个文本文件,你设计的算法需要读取磁盘上万次还是读取几十次,这是有本质差异的。此时,为了减少对外存设备的访问次数,我们就需要新的数据结构来处理这样的问题。
二叉树等结构每个结点只存储一个元素,在元素非常多的时候,就使得要么树的度(结点拥有的子树个数的最大值)非常大,要么树的高度非常大,甚至两者都必须足够大才行。这就使得内存存取外存的次数非常多。
多路查找树(multi-way search tree),其每一个结点的孩子数可以多于两个,其每一个结点可以存储多个元素。非常适合频繁访问外存的场景。
多路查找树有四种特殊形式:2-3树、2-3-4树、B树和B+树。
其每一个结点都具有两个孩子(我们称它为2结点)或三个孩子(我们称它为3结点)。
一个2结点包含一个元素和两个孩子(或没有孩子),且与二叉排序树类似,左子树包含的元素小于该元素,右子树包含的元素大于该元素。不过,与二叉排序树不同的是,这个2结点要么没有孩子,要有就有两个,不能只有一个孩子。
一个3结点包含一小一大两个元素和三个孩子(或没有孩子),一个3结点要么没有孩子,要么具有3个孩子。如果某个3结点有孩子的话,左子树包含小于较小元素的元素,右子树包含大于较大元素的元素,中间子树包含介于两元素之间的元素。
并且2-3树中所有的叶子都在同一层次上。下图是一棵有效的2-3树:
其实就是2-3树的概念扩展,包括了4结点的使用。一个4结点包含小中大三个元素和四个孩子(或没有孩子),一个4结点要么没有孩子,要么具有4个孩子。
它是一种平衡的多路查找树,2-3树和2-3-4树都是B树的特例。结点最大的孩子数目称为B树的阶(order),因此,2-3树是3阶B树,2-3-4树是4阶B树。
一个m阶的B树具有如下属性:
在B树上查找的过程是一个顺指针查找结点和在结点中查找关键字的交叉过程。比方说,我们要查找数字7,首先从外存(比如硬盘中)读取得到根结点3、5、8三个元素,发现7不在当中,但在5和8之间,因此就通过A2再读取外存的6、7结点,查找到所要的元素。
如果内存与外存交换数据次数频繁,会造成时间效率上的瓶颈,那么B树怎么就可以做到减少次数呢?
我们的外存,比如硬盘,是将所有的信息分割成相等大小的页,每次硬盘读写都是一个或多个完整的页。因此我们会对B树进行调整,使得B树的阶数与硬盘存储的页面大小相匹配。
InnoDB存储引擎逻辑页的大小默认是16KB,假设主键是8字节整型,指针类型大小一般是4到8字节,这里取8字节,那么一棵B树的阶大概是1001(即一个结点包含1000个关键字,1001个指针,(1000×8+1001×8)÷1024=15KB),高度为3,它可以存储超过10亿个关键字(第一层的根结点最多存1000个元素,第二层1001个结点,每个结点最多存1000个元素,第三层1001×1001个结点,每个结点最多存1000个元素,加起来1000+1001×1000+1001×1001×1000=1003003000),我们只要让根结点持久的保留在内存中,那么在这棵树上,寻找某一个关键字至多需要两次硬盘读取即可。
通过这种方式,在有限内存的情况下,每一次磁盘访问我们都可以获得最大数据量的数据。由于B树每个结点可以具备比二叉树多得多的元素,所以它减少了必须访问结点和数据块的数量,从而提高了性能。
在B树的基础上做了一点改造,主要是:每一个叶子结点都包含指向下一个叶子结点的指针,叶子结点之间按照key的大小进行排序,从而方便了key的范围遍历。
MySQL InnoDB存储引擎就是使用B+树作为其索引的数据结构。脱离开抽象的数据结构,在真实使用场景中,分支结点只存key不存data,那么每个分支结点就可以存更多的key,这样,树的高度就会变小,进而减少了I/O次数,提高了查询效率。
通常在B+Tree上有两个头指针,一个指向根结点,另一个指向key最小的叶子结点,所有叶子结点(即数据结点)之间是一种链式环结构。因此可以对B+Tree进行两种查找运算:一种是对于主键的范围查找和分页查找;另一种是从根结点开始,进行随机查找。
聚簇索引又叫聚集索引,它并不是一种单独的索引类型(也就是说它的数据结构还是B+树),而是一种数据存储方式。InnoDB的聚簇索引实际上是在同一个结构中保存了索引和数据行,其实就是我们上面提到的叶子结点保存的是数据行。因为无法同时把数据行存放在两个不同的地方,所以一个表只能有一个聚簇索引。
下图展示了聚簇索引中记录是如何存放的,在这个案例中,索引列是整型。
InnoDB存储引擎通过主键聚集数据,如果没有定义主键,InnoDB会选择一个唯一的非空索引代替。如果没有这样的索引,InnoDB会隐式的定义一个主键来作为聚簇索引。InnoDB只聚集在同一个页面中的记录,包含相邻键值的页面可能相距甚远。
非聚簇索引又叫非聚集索引,它是二级索引,聚簇索引是一级索引。一个表中,除了用主键作为排序key的聚簇索引以外,其他都是非聚簇索引。二级索引访问需要两次索引查找,因为二级索引的页子结点存放的是数据行的主键值,而不是行的物理位置指针,从二级索引拿到主键后,还要去一级索引的叶子结点访问数据行。
假设现在有个用户表,有id(整型),name,age三个列,其中id是主键,对name列建了一个索引。当我们根据name=c查询用户信息时,实际上是先从下图右边的二级索引查询到id的值1,然后再拿id=1去左边的一级索引查询得到用户信息。
我们管从二级索引到一级索引的这个过程叫回表。
如果二级索引的叶子结点中已经包含要查询的数据,那还有什么必要再回表查询呢?如果一个索引包含(或者说覆盖)所有需要查询的字段的值,我们就称之为“覆盖索引”。假如说我们现在要根据name在上述用户表中查询id,二级索引的叶子结点中已经包含id了,那就没有必要再去一级索引查询了,这种情况就是覆盖索引。