文章目录
- 简介
- 数据库中的存储结构
- 数据库中的页结构
- 从数据页来看B+树的查询过程
- 总结
- 参考文献
简介
我们之前已经了解过数据库的B+树索引和Hash索引,这些索引信息以及数据记录都是保存在文件里的,确切的说是存储在页结构中。
本节,从我们将了解数据库的存储结构以及底层页结构的原理,从而加深我们对索引运行机制的认识。
主要包括以下几部分:
- 数据库中的存储结构是怎样的,页、区、段和表空间分别是指什么?
- 为什么页(Page)是数据库存储空间的基本单位?
- 从数据页的角度来看,B+树是如何进行查询的?
数据库中的存储结构
数据库中的记录是按照行来存储的,但是数据库的读取并不是以行为单位,否则一次读取(即一次IO)只能读一行,那整个查询的读取效率也太感人了。
因此在数据库中,每次读取其实是读取一页(Page)。不论是读一行,还是读多行,都是将这些行所在的页进行加载读取的过程。就是说,数据库管理存储的基本单位,是页(Page)。
而一个页中,可以存储多个行记录,即Row。
同时,在数据库中,还存在着区(Extent)、段(Segment)和表空间(Tablespace)。
行、页、区、段、表空间的关系如下图:
由上图可见,一个表空间包含多个段,一个段包含多个区,一个区包含多个页,而页,又是由一个个行组成的。
下面简单介绍下这些概念。
区,即Extent,是比页大一级的存储结构。在InnoDB存储引擎中,一个区会被分配64个连续的页。由于InnoDB中一个页的默认大小是16KB,所以一个区的大小是64*16KB=1MB。
段,即Segment,是区上的一级存储结构。区在文件系统是一个连续分配的空间,但是段不用,段中并不要求区与区之间是相邻的。段是数据库中的分配单位,不同数据库对象以不同的段形式存在着。当我们在创建数据表时,就会创建一个表段。当我们在创建索引的时候,就会创建一个索引段。
表空间(TableSpace)是一个逻辑容器。表空间存储的对象是段,一个表空间由一至多个段组成,一个段只能属于一个表空间。而整个数据库,是由一个或多个表空间组成,从管理上来讲,表空间可以划分为系统表空间、用户表空间、撤销表空间和临时表空间等。
InnoDB中有两种表空间:共享表空间和独立表空间。
共享表空间是指多张表可以共用一个表空间,独立表空间是指每张表有一个独立的表空间,这个表空间将只用于维护这张表自己的数据和索引信息。独立的表空间意味着可以很方便的在不同的数据库之间进行单表的迁移。
可以通过以下命令来查看InnoDB的表空间类型:
show variables like 'innodb_file_per_table';
输出为:
on表示每张表都会被单独保存为一个.bid
文件,即启用的是独立表空间。
数据库中的页结构
页,如果按照类型划分的话,常见的有数据页(保存B+树节点)、系统页、Undo页和事务数据页等。其中数据页是我们最常使用的页。
单个表页的大小限定了表行的最大长度,不同DBMS的默认表页大小不同。比如在MySQL的InnoDB中,默认页大小是16KB,可以通过以下命令查看:
show variables like '%innodb_page_size%';
而SQL Server中页大小是8KB,在Oracle中以术语"块",即block,来代表页,其支持的块大小为2KB、4KB、8KB、16KB、32KB和64KB。
页同时也是数据库中IO操作的最小单位,每一次IO操作都至少读取一页,这里面存储了数据库相关的内容。
数据页包括了七个部分,分别是:
- 文件头File Header;
- 页头(Page Header);
- 最大最小记录(Infimum + supremum);
- 用户记录(User Records)
- 空闲空间(Free Space)
- 页目录(Page Directory)
- 文件尾(File Tailer)
其示意图如下:
这7部分都有啥作用呢?教程里给简单总结了下:
实际上,这7部分可以归为3类:
- 文件通用部分
- 记录部分
- 索引部分
文件通用部分,就是文件头和文件尾,二者一前一后对一个页的内容进行封装,并且可以通过文件头和文件尾校验的方式,来确保页的传输是完整的。
在文件头上有两个字段,分别是FIL_PAGE_PREV和PIL_PAGE_NEXT,它们的作用相当于是指针,分别指向上一个数据页和下一个数据页。通过指针连起来的数据页相当于是一个双向链表,如图:
这些需要注意,采用链表这种结构之后,数据页之间就不需要是物理上的连续了,而是保证逻辑上的连续就可以。
文件头的作用讲完了,再讲讲文件尾的作用。
文件尾的最大作用,就是配合文件头,来校验一个页的数据完整性。举个例子,当我们进行页传输的时候,突然断电了,那么当前页大概率传输的不完整。那我怎么确认它是不是完整呢?
这时候通过文件尾的校验和(checksum,如MD5算法)与文件头的校验和做比对,如果两个值不相等,证明传输的有问题,需要进行重新传输,否则就认为当前页传输是正常完成。
记录部分,这部分是页的核心部分,毕竟页的主要作用就是用来存储。它包括了最大最小记录、用户记录、空闲空间。
其中,最大最小记录和用户记录占据了页结构的主要空间,至于空闲空间,顾名思义,当有新记录插进来的时候,就会从空闲空间里分配空间用于存储新纪录。这个过程,教程里也贴了图了,如下:
索引部分,其实主要指的是页目录,它起到了记录的索引的作用。
在页中,记录是以单向链表的形式进行存储的。单向链表的插入和删除都很方便,但就是检索很费劲,需要一条一条按顺序遍历检索,运气不好的话得把整个链表遍历一遍之后才能找到想要的值。
因此,在页目录中提供了二分查找的方式,用来提高对记录的查询效率,这个过程就相当于是给记录创建了一个目录,所以叫做页目录。
那么页目录是怎么形成的呢?
- 将所有记录分成几个组,这些记录包括最小记录和最大记录,但不包括标记为"已删除"的记录。
- 第1组,就是最小记录所在的分组,只保留一条记录;最后一组,就是最大记录所在的分组,会有1-8条记录;其余的组,记录的数量在4-8条之间。这样做的好处是,除了第1组之外,其余组的记录会尽量平分,保证查找效率的稳定性(保持每组数据差不多)。
- 在每组最后一条记录的头信息中,会存储该组一共有多少条记录,作为n_owned字段。
- 页目录里存储的是每组最后一条记录的地址偏移量(也被称为"槽",即slot),这些偏移量会按照先后顺序存储起来。其实还是指针的概念,每个槽相当于是指向对应组的最后一条记录的指针。
整个过程图示如下:
照这么看,页目录其实就是一堆指针的结合体,说它是索引并不为过。
那为什么说我们通过页目录来查找数据时,是在做二分查找呢?
还是以上图为例,假设我想查找主键为9的记录。
首先我找到槽的中间位置,即(0+4)/ 2 = 2,所以先定位到槽2,槽2对应的是分组3里的最后一条记录,我们从中取出主键数值是8。因为9大于8,所以接下来我们需要在槽编号为(2,4]范围内再度查找。
再次定位中间位置,即(2+4)/2=3,所以定位到槽3,槽3对应的是分组4里的最后一条记录,我们从中取出主键数值是12,所以下一步应该从槽3中进行查找。
遍历槽3中的所有记录,找到关键字为9的记录,取出该记录行的内容,本次查找结束。
可以看到,这就是二分查找的过程,或者严格来讲,是二分查找 + 局部遍历。
从数据页来看B+树的查询过程
MySQL的InnoDB引擎采用的是B+树作为索引,而索引可以分为聚集索引和非聚集索引(二级索引,指索引里存储的是指针,而不是数据行),这些索引都相当于是一棵B+树。
一棵B+树按照节点类型可以分为两部分:
- 叶子节点:B+树最底层的节点,高度为0,存储行记录;
- 非叶子节点:节点高度大于0,存储索引键和页面指针,并不存储行记录本身。
如图:
在一棵B+树中,每个节点都是一个页。
同一层的节点之间,通过页的结构(文件头中的两个指针)构成一个双向链表。
非叶子节点,包括了多个索引行,每个索引行存储索引键和指向下一层页面的页面指针。
叶子节点,存储了关键字和行记录。在节点内部(即页内部),记录是一个单向链表,可以通过页目录采用二分查找的方式来查找内部的记录。
所以,B+树进行记录检索的完整流程,其实是这样的:
首先从根节点开始,逐层搜索,直到找到叶子节点,即对应的数据页。在确定了待查找数据就存在于这个数据页上之后,我们将这个数据页加载到内存,通过页目录做二分查找,定位出一个粗略的记录分组,最后在这个分组里通过链表遍历的方式来找到指定记录行。
那么,普通索引和唯一性索引,在查找效率上有什么不同呢?
过程确实有不同,但是绝大多数情况下,其实检索效率不会有太大差别。
唯一索引就是在普通索引上增加了唯一性约束,也就是说关键字是唯一的,只要找到了待查找关键字之后就可以停止检索。
但普通索引不行,由于可能会重复,所以会存在多条满足情况的记录行。所以在找到对应的数据页之后,不能只查找一次就完事了,需要沿着链表多往下走几次,直到把满足情况的记录行都挑出来。这个过程是在内存中进行的,一般不会额外再读取其他页,所以对CPU来讲,消耗的这点时间属于是洒洒水啦。
因此,大部分情况下,二者是没有 明显差别的。
总结
同一棵树上,同一层的页之间采用双向链表连接,而在页内部,记录之间是采用单向链表的形式。
参考文献
- 27丨从数据页的角度理解B+树查询