Mysql进阶索引篇02——InnoDB存储引擎的数据存储结构

MySQL
352
0
0
2022-12-06
标签   MySQL索引

前言

前面我们已经剖析了mysql中InnoDBMyISAM索引的数据结构,了解了B+树的设计思想、原理,并且介绍了B+树与Hash结构平衡二叉树AVL树B树等的区别和实际应用场景。

页和页之间并不一定在物理上相连,只是在逻辑上使用双向链表关联。指针、记录究竟是如何存储的呢?其实这就需要联系我们之前提到的行格式了。数据查找在页目录中二分法快速定位到,上面的过程都与页的内部结构相关,本文将详细的阐述。

img

这篇文章将对InnoDB引擎的数据存储结构介绍,带大家熟悉数据库的页的存储结构与行格式,为之后的调优做准备。

1.数据库的存储结构:页

索引实际上是存储在文件上的,确切的说是存储在页结构中的。存储引擎负责数据的读、写操作,不同的存储引擎的数据格式可能不同。本文所介绍的数据库存储结构基于Mysql的InnoDB存储引擎。这也是我们实际工作中所使用的。

1.1 磁盘与内存交互的基本单位:页

InnoDB将数据划分为若干页,Mysql页的默认大小是16kb.可以使用下面的命令进行查看。

img

sql server中,页的默认大小是8kb。Oracle中使用*(Block)的概念来作为磁盘与内存的基本交互单位,块的大小可以是2kb,4kb,8kb,16kb,32kb和64kb。

这里我们强调,mysql中磁盘与内存交互的基本单位是页,这表示我们在磁盘与内存之间进行数据交互,最少是一页,并且每次交互都是整数页。即使我们数据存储只存储了一行,数据库I/O的操作单位也是一页。这样设计其实也是为了提高效率,毕竟I/O的时间消耗很大,不可能读、写一次数据就进行一次磁盘的I/O操作。

1.2 页上层结构

img

页的上层结构是,一个区会分配64个连续页,区的大小正好是1MB(16kb * 64)。

由一个区或多个区组成。段是数据库的分配单位,比如我们创建一个表,就会创建一个表段,创建一个索引,就会创建一个索引段。总而言之,不同类型的数据库对象对应不同类型的段。

这里我们举一个例子,部队中能够与敌军战斗的基本单位是一个兵,就像数据库中内存与磁盘交互的基本单位是一个页。但是一场战争派出去的不可能只是一个兵,而是更大的单位,比如师。对应的,数据库内存分配的最小单位是一个块。

表空间(table space)是一个逻辑容器。表空间中存储的是一个或者多个段,每个段只能属于一个表空间。表空间又可以分为系统表空间,用户表空间撤销表空间临时表空间等。

2.页的内部结构

常见的页有数据页(保存b+树节点)、系统页Undo页事务数据页

页可以划分为如下的七个结构。

img

这七个结构的作用如下。

img

为了方便大家的理解,我们将他们归纳为三个部分,进行讲解。

2.1 文件头与文件尾

File Header与File Trailer主要是用于描述页的通用信息,用于页与页的交互或者数据的校验等。

  • FILE_PAGE_OFFSET:记录页编号,InnoDB可以通过页号唯一确定一个页。
  • FILE_PAGE_TYPE:代表当前页的类型,比如FILE_PAGE_UNDO_LOG(Undo日志页)、FILE_PAGE_TYPE_SYS(系统页)、FILE_PAGE_INDEX(索引页/数据页)。
  • FILE_PAGE_PREVFILE_PAGE_NEXT上一页与下一页。
  • FILE_PAGE_SAPCE_OR_CHKSUM:校验和,对于一个较长的字符串,我们可以通过算法将其计算得到较短的字符串,即校验和,当我们需要比较两个长的字符串是否相等时,就比较他们校验和即可。同样我们也可以用校验和比较两个页是否相等。 文件尾页同样有校验和,它们是对应的。当我们对磁盘进行数据更新的刷盘执行I/O时,如果由于断电等原因导致数据传输中断,很可能一个页没有操作完。而页是数据库中磁盘与内存交互的基本单位,为了能够保证一致性,我们需要回滚或者将未写完的数据写完。我们如何判断是否写完呢?就是通过校验和。 具体的过程是:在内存向磁盘写入数据时,先更新File Header中的校验和,如果文件未正常写完,头尾校验和会不一致,如果未刷盘的数据有记录,就继续刷完,否则回滚。
  • FILE_PAGE_LSN:日志序列号,记录文件最后修改时对应的日志序列位置。尾部也有该字段,同样是为了校验页面的完整性。

2.2 记录部分:空闲空间、最大/小记录、用户记录

img

在最开始,我们会将已存储的数据按照行格式放到User Records,每插入一个数据,就会从Free Space申请一个记录大小的空间划分到User Records,直到Free Space都被转化为User Records空间,会申请一个新的页。User Records中的数据是按照单链表进行连接的。具体细节我们将在介绍行格式的用户头信息时介绍。

在介绍最小记录和最大记录之前,我们先思考一个问题,有必要对记录大小进行比较吗?当然是有必要的,因为我们的节点都是进行排序的。既然需要排序,就需要进行大小的比较了。那么,最大记录和最小记录的作用是什么呢?实际上,它就相当于记录的头、尾节点,这里我们先简单介绍,后面我们介绍完行格式,大家会对这部分理解更为深刻。在mysql中,最小记录与最大记录的格式非常简单,可以参考下图。

img

2.3 页目录与页头

page_decotory主要是为了方便数据进行二分查找。页目录是不是一开始就生成的呢?比如我们有1000条记录,是不是一开始就按照主键生成这1000条记录的页目录?这样做对于存储的占用显然是很高的。

实际上,mysql数据库做法如下。

  • 将每页所有记录(包括最小记录与最大记录,不包含已删除记录)进行了分组,每个组取一个记录放入页目录中,占一个槽位(slot).最小记录单独为1组,最大记录所在组一般是有1-8条记录,其它组一般有4-8条记录。
  • 每组的最后一条记录会存储一个n_owned属性,代表该组有几个记录。
  • 页目录用于存储每组最后一个记录的地址偏移量。 下图表示了这个过程,其中每个记录的第一个字段就是n_owned.

img

现在我们已经知道页目录是如何生成的了,那么我们如何利用页目录进行数据的查找呢?实际上就是利用二分法对需要查找的数据与页目录中数据进行比较。比如一个数据确定比槽2的更大,比槽3的更小,那就可以去槽3所链接的分组4进行查找了。它会从槽2所链接的分组3最后一个记录指针链接到分组4的第一个元素,然后进行遍历查找。

接下来我们看看page header中记录了什么信息。这些信息大概了解下,主要是页的内部结构的一些信息。

img

其中PAGE_DIRECTION记录当前新插入记录是需要在上一条记录的前面还是后面插入。

2.4 从存储角度看普通索引和唯一索引有什么不同

我们到目前为止已经了解了页的内部结构和索引的数据结构。接下来我们深入思考一个问题。

  • 普通索引和唯一索引有什么不同? 唯一索引指的是对索引字段加了唯一约束的索引,因此该字段不会重复。我们学习了页的存储结构以后知道,一般一个页默认大小是16kb,可以存放上千条记录,而且这些元素的存放是有序的,元素之间通过指针相连接。因此,普通索引一般只是在找到第一个元素后再多往后进行几次查找即可,其时间消耗并不大(真正消耗时间的是磁盘I/O)。

3.InnoDB行格式

3.1 行格式简介与操作sql

行格式就是记录的存储格式。

行格式一般有CompactDynamicCompressedRedundant几种。

可以使用如下的查询语句查询Mysql数据库的默认行格式。在mysql8与mysql5.7中,默认行格式都是dynamic

img

可以使用下列语句查询某个表实际使用的行格式。

img

使用如下语句可以在创建表时指定其行格式。

img

修改表的行格式。

img

3.2 Compact行格式

compact是一种经典的行格式,它也是mysql5.1的默认行格式,我们把它作为讲解的重点介绍。其示意图如下。

img

现在我们对这个结构进行里的各个部分进行依次讲解。

3.2.1 变长字段长度列表

执行如下语句,创建一张新的数据表。

img

插入几条数据。

img

我们知道,在mysql数据库中,VARCHAR(M),VARBINARY(M),TEXTBLOB类型都是变长的。我们在上面指定col1时设置的长度是8,单我们实际使用时字段长度可能并没有到8(如’tong’这个数据字段)。变长字段的长度列表其实就是需要记录字段实际存储的长度。

注意,变长字段列表记录存储长度的顺序与我们变量声明的顺序是反过来的。比如我们声明两个Varchar字段的顺序是a(15),b(10),那么变长字段列表的存储的长度顺序就是10,15.

上面插入的第一条数据长度对应转为16进制如下。

img

因此变长长度列表存储的内容为060408.如下图。

img

3.2.2 Null值列表

Compact行格式会把Null值列统一管理起来。当然,如果表中不允许存放Null值,那么Null值列表就不存在了。

为什么要定义Null值列表呢?这是因为mysql数据库中定义的数据是对齐的。比如我们现在存储了四个数据(‘a,’Null,Null,‘b’),我们在存储完a以后再存储b,那么在查找数据的时候不是会造成混乱么?当然,我们也可以把Null值数据存储的空间提前预留出来,但是这无疑会浪费空间。因此,我们在记录信息之前开辟了一块空间来存储Null值的位置。而且这个实现也很简单,我们只需要一个bit位来存储是否字段是空。如果是空则置为1,否则为0。

上面第1条记录的Null值列表如下所示。同样,Null值列表的记录顺序与字段声明的顺序是相反的,并且col2声明是指定了Not Null非空约束,不会在Null值列表中进行记录。另外,如果声明时指定了主键,由于主键一定非空,因此也不会在声明列表中出现。

img

3.2.3 记录头信息

我们先创建一张数据表。

img

然后插入一些数据。重点关注下这些数据的记录头信息.

img

我们将上面的字段介绍如下。

delete_mask:该记录是否被删除。如果这个值是0,说明记录没有被删除,否则说明记录被删除。 由此可知记录删除是采用的逻辑删除,这是因为我们记录之间是紧密相连的,如果真正删除一个记录,将会需要导致后面的记录依次进行位移。所有被删除的记录会通过next_record构成一个垃圾链表,它们所占用的空间称为可重用空间

min_rec_mask:存储目录项记录中主键值最小的目录项记录置为1,其它情况都置0.

Record_type:记录类型,0表示普通记录,1表示B+树的非叶子节点(目录页节点)、2表示最小记录,3表示最大记录。

heap_no:表示当前记录在本页中的位置。我们注意到前面图片的第一条记录的heap_no是2,那么0和1呢?实际上,mysql会自动创建两条虚拟记录,即最小记录和最大记录。位于记录链表的最前面位置。由于这两个记录不是我们创建的,因此并没有存储在用户空间中,而是放在InfimumSupermun部分。他们其实是相当于头尾节点。

img

n_owned:页目录中每个组的最后一条记录会存储该组的记录数,作为n_owned字段。值的关注的是,在mysql中最小记录是一组,普通记录与其它记录是一组,因此最小记录中n_owned属性是1,最大记录的n_owned值是5.

next_record:它表示当前记录的真实数据到下一个记录的真实数据之间的偏移量

介绍完以上知识点,我们来举一个例子,比如我们需要删除第2条记录,那么记录行格式会发生什么变化呢?删除第2条记录后的示意图如下。可以看到,首先第2条记录的delete_mask将标记为1,next_record标记为0。它前一个节点第一条记录的next_record会指向第3条记录。同时最大记录中的n_owned属性值变为4.

img

3.2.4 真实数据

真实数据里除了真实列还存储了三个隐藏列。

img

实际上,这几个列的真实名字是DB_ROW_IDDB_TRX_IDDB_ROW_PTR

我们在上一篇文章介绍InnoDB索引的时候提到过,如果表中无主键,也没有适合做主键的(声明了唯一标识)其它列,会隐式的指定一个聚簇索引。实际上,在这种情况下就是会添加一个row_id的隐藏列。另外两个隐藏列与事务相关,我们会在之后介绍事务的博客中再进行介绍。

3.3 Compact行格式存储实例剖析

img

上面我们已经介绍了行格式,现在根据具体的实例进行下剖析。创建数据表并插入数据。

img

找到对应的mytest.idb文件(注:推荐使用notepad++并安装使用Hex-Editor插件)

img

读出来的有效数据内容如下。我们采用不同颜色对于行格式的不同部分做了区分。

img

  • 第一行中有03 02 01字段,这其实就是表中插入的第一条数据(‘a’,‘bb’,‘bb’,‘ccc’)的变长字段列表;
  • 之后紧跟的00 是Null值列表,4个字段都非空的,因此这里使用的是00。倒数第二行中红色的06表示第三条插入数据中非空的数据的表示。因为'd',Null,Null,'fff'中四个数据分别用1,0表示是否为空的情况是0 1 1 0,倒过来仍然是0 1 1 0,转为16进制信息就是06.
  • 之后的五个字节00 00 10 00 2c是记录头信息。其中2c就是next_record
  • 接下来的00 00 00 2b 68 00是隐藏主键DB_ROW_ID
  • 00 00 00 06 05DB_TRX_ID;
  • 接下来的7个字节80 00 00 00 32 01 10对应的回滚指针DB_ROW_PTR;
  • 接下来的61对应真实数据a,62 62对应bb,62 62 20 20 20 20 20 20 20 20表示的是定长的bb,其中20表示没有真实数据,之后的63 63 63则表示ccc

3.4 行溢出

我们在介绍另外两种行格式DynamicCompressed之前,先介绍下行溢出

InnoDB存储引擎可以将一条记录中的数据存储在真正的数据页面之外。下面将举例说明这一点,先创建一个数据表。

img

65535字节是VarCahr类型存储的最大长度,而ASCII码一个字符占一个字节,因此我们指定的VARCHAR(65535)就表示其存储的字符数是65535,这些字符占65535字节,正好是VARCHAR数据类型存储字节的上限。

img

如果您还不理解就可以不指定字符集试试。

img

上面的语句将会报错。

img

这是因为不指定字符集时,默认使用的是utf-8,一个字符占3个字节,因此存储的字符上限就是65535/3=21845。

言归正传,我们再来执行下最开始的建表语句。

img

居然还是报错了,错误信息如下。

img

这是为什么呢?其实是因为VARCHAR的数据是变长的,需要2个字节额外的空间来记录数据的长度,1个字节标识NULL值信息,因此存储空间不能够达到65535字节,只能达到65532字节。

img

当然,我们可以加上非空约束,这样就不用记录NULL值列表了。

img

我们之前介绍过一个页的大小是16kb,也就是16384字节,而现在我们一个字段就比它大。这就会导致行溢出

CompactRuduntant两种行格式中,对于占用空间非常大的列,在存储真实数据时只会存储真实数据的一部分。将剩余的数据存储到其它页中进行分页存储

3.5 Dynamic和Compressed行格式

Dynamic和Compressed行格式与Compact大体是相同的,不过这两种行格式对于行溢出的处理策略与Compact不同。

它们采取了完全行溢出的策略。也就是数据页不存储任何溢出真实数据,只是存储指针,将真实数据完全存储到其它页中。

img

Compressed还采用了zlib算法对数据进行压缩,因此对于BLOB、TEXT、VARCHAR等大长度类型的数据能够进行有效的存储。

3.6 Redundant行格式

Redundant是Mysql5.0之前InnoDB的行格式。Mysql5.0支持Redundant是为了兼容以前版本的页格式,其格式如下。

img

我们可以对比之前Compact行格式。

img

我们可以发现,Redundant采用字段长度偏移列表来定位数据,而Compact采用变长字段长度列表和Null值列表。如果是Compact行格式,不是变长数据就不会记录变长字段长度列表。而Redundant行格式必须所有列(包括隐藏列)的偏移长度都逆序进行记录,因此其名字是Redundant(冗余的)。

另外,其存储的是字段与记录开始位置的偏移长度,不如变长列表直接存储长度直观。

img

另外,Redundant因为所有的字段的偏移长度都记录了,也不用担心Null值导致记录的位置对不起的问题,所以没有Null值列表。

其记录头信息如下,黄色是较Compact多的属性信息。它也没有Record_type属性。了解即可。

img

4.区、段和碎片区

4.1 为什么要有区

页与页是通过双向链表进行连接的,如果以页为单位分配存储空间,逻辑上相邻的两个页在物理磁盘上实际距离比较远。在进行范围查询时,如果页与页之间的距离过远,在进行磁盘I/O操作时加载页就需要花许多时间(寻道、旋转等),就是随机I/O。磁盘和内存的速度相差了好几个数量级,磁盘随机I/O会花大量时间在数据页的查找加载上,是非常慢的。

出于性能考虑,我们希望能够在相邻的位置存取数据,以便能够顺序读取数据页。当然,我们也不能够让所有数据都存储到连续的空间,毕竟越大的连续空间在磁盘上越难找到。因此我们引入了的概念。一个区就是64个在物理空间上连续的页。因此一个区的大小是16KB * 64 = 1MB。在表的数据量大的时候,我们就不再以页为单位进行存储空间的分配了,而是连续分配一个区,甚至是多个区。

4.2 为什么要有段

一个区中存放的页可能是数据页或者目录页,但当我们进行范围查询时,感兴趣的只有普通数据页。如果因为存放了目录页的原因,导致范围查找的效果大打折扣。我们希望一个区存储数据页就存储数据页,存储目录页就只存储目录页,因此出现了的概念。常见的段有数据段索引段回滚段。段是逻辑上的概念。由若干零散的页(碎片区中的页,下一节介绍)和完整的区所组成。

4.3 为什么要有碎片区

InnoDB存储引擎一个聚簇索引会生成两个段,数据段和索引段,而每个段是以区作为单位申请存储空间的,如果表的数据量只有几条,也需要申请2M的空间么(一个区的大小为1M)?而且每增加一个索引,又需要增加2M,这空间浪费的也太严重了。

因此提出了碎片区,在一个碎片区中,可以让多个段共用一个公共空间,一些页给段A,一些页给段B,让空间得到充分的利用。就好比一个大广场可以给体育生大篮球,也可以给大妈跳广场舞,但它不是任何人所独有,而是一块公共空间。 现在我们可以知道InnoDB存储引擎分配存储空间的具体策略了。

  • 在刚向表中插入数据时,先使用碎片区以页为单位进行存储空间的分配。
  • 当某个段占用的存储空间达到了32个碎片区以后,我们再以完整的区为单位分配存储空间。

4.4 区的分类

区大体可以分成四个类型。

  • 空闲区(Free):现在还没有用到这个区的任何页面。
  • 有剩余空间的碎片区(Free-Frag):表示碎片区中还有可用的页面。
  • 没有剩余空间的碎片区(Full-Frag)。
  • 附属于某个段的区(FSEG):专属于给某一个段使用的完整的区。

前三种类型的区都是独立的,直属于表空间。FSEG是属于段的。

5.表空间

表空间是一个逻辑上的容器。可以划分为:独立表空间系统表空间撤销表空间临时表空间等。

5.1 独立表空间

每个表对应一个表空间,也就是一个表的索引和数据会被单独保存在自己的表空间中,可以在不同的数据库之间进行数据的迁移

其空间回收也比较方便,可以通过Drop Table操作自动回收表空间。对于统计分析或者日志表,还可以在删除大量数据之后,通过alter table tableName engine=innodb回收不用的空间。这个特性使碎片空间不会太影响性能。

独立表空间的结构由段、区、页等组成,不再赘述。

一个新建表的.idb文件大小是96kb,也就是6个页面大小(Mysql5.7),当然随着数据量的增加,有些idb也是自扩展的,表空间的文件大小会变大。在mysql5.6后,默认使用的都是独立表空间。可使用下面的语句查询。

img

5.2 系统表空间

与独立表空间的结构基本类似,整个MySQL进程只有一个表空间,会额外记录一些关于整个系统的数据,这是独立表空间中所没有的。比如在information_schema中提供了以INNODB_SYS开头的一些表,用于帮助我们查看与数据库系统相关的信息(相当于数据字典的副本)。

img