mysql存储引擎InnoDB详解,从底层看清InnoDB数据结构

InnoDB一个支持事务安全的存储引擎,同时也是mysql的默认存储引擎。本文主要从数据结构的角度,详细介绍InnoDB行记录格式和数据页的实现原理,从底层看清InnoDB存储引擎。

InnoDB简介

大家都知道mysql中数据是存储在物理磁盘上的,而真正的数据处理又是在内存中执行的。由于磁盘的读写速度非常慢,如果每次操作都对磁盘进行频繁读写的话,那么性能一定非常差。为了上述问题, InnoDB将数据划分为若干页,以页作为磁盘与内存交互的基本单位,一般页的大小为16KB 。这样的话,一次性至少读取1页数据到内存中或者将1页数据写入磁盘。通过减少内存与磁盘的交互次数,从而提升性能。

其实,这本质上就是一种典型的缓存设计思想,一般缓存的设计基本都是从 时间维度 或者 空间维度 进行考量的:

时间维度 :如果一条数据正在在被使用,那么在接下来一段时间内大概率还会再被使用。可以认为 热点数据缓存 都属于这种思路的实现。
空间维度 :如果一条数据正在在被使用,那么存储在它附近的数据大概率也会很快被使用。 InnoDB的数据页 和 操作系统的页缓存 则是这种思路的体现。

InnoDB行格式

mysql是以记录(一行数据)为单位向数据表中插入数据的,这些记录在磁盘上的存放方式称为 行格式 。mysql支持4种不同类型的行格式: Compact 、 Redundant (比较老,本文就不具体介绍了)、 Dynamic 、 Compressed 。

我们可以在创建或修改表的语句中指定行格式:

CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称
ALTER TABLE 表名 ROW_FORMAT=行格式名称
比如,我们要创建一个行格式为 Compact ,字符集为 ascii 的数据表 record_format_demo ,sql如下:

mysql> CREATE TABLE record_format_demo (
-> c1 VARCHAR(10),
-> c2 VARCHAR(10) NOT NULL,
-> c3 CHAR(10),
-> c4 VARCHAR(10)
-> ) CHARSET=ascii ROW_FORMAT=COMPACT;
Query OK, 0 rows affected (0.03 sec)
假设我们向 record_format_demo 表中插入了2行数据:

mysql> SELECT * FROM record_format_demo;
±-----±----±-----±-----+
| c1 | c2 | c3 | c4 |
±-----±----±-----±-----+
| aaaa | bbb | cc | d |
| eeee | fff | NULL | NULL |
±-----±----±-----±-----+
2 rows in set (0.00 sec)

需要C/C++ Linux服务器架构师学习资料l加qun(812855908)获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享在这里插入图片描述

COMPACT行格式

在这里插入图片描述

从上图可以看出,一条完整的记录包含 记录的额外信息 和 记录的真实数据 两大部分。

记录的额外信息
记录的额外信息主要包含3类: 变长字段长度列表 、 NULL值列表 和 记录头信息 。

变长字段长度列表
mysql中支持一些变长数据类型(比如 VARCHAR(M) 、 TEXT 等),它们存储数据占用的存储空间不是固定的,而是会随着存储内容的变化而变化。为了准确描述这种数据,这种变长字段占用的存储空间要同时包含:

真正的数据内容
占用的字节数
在Compact行格式中,把 所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表,各变长字段数据占用的字节数按照列的顺序 逆序 存放 。

我们以 record_format_demo 第一行数据为例。由于 c1 、 c2 和 c4 都是变成数据类型( VARCHAR(10) ),因此要将这3列值得长度保存在记录的开头处。
在这里插入图片描述

另外需要注意的一点是, 变长字段长度列表中只存储值为 非NULL 的列内容占用的长度,值为 NULL 的列的长度是不储存的 。也就是说对于第二条记录来说,因为c4列的值为NULL,所以第二条记录的变长字段长度列表只需要存储c1和c2列的长度即可。

NULL值列表

对于可为NULL的列,为了节约存储空间,mysql不会将 NULL 值保存在 记录的真实数据 部分。而是会将其保存在 记录的额外信息 里面的 NULL值列表 中。

具体的做法是先统计表中允许存储 NULL 值的列,然后将每个允许存储 NULL 值的列对应一个二进制位(1:值为 NULL ,0:值不为 NULL )用来表示是否存储 NULL 值,并按照逆序排列。MySQL规定 NULL值列表必须用整数个字节的位表示 ,如果使用的二进制位个数不是整数个字节,则在字节的高位补0。

对应 record_format_demo 表中, c1 、 c3 、 c4 都是允许存储NULL值的。前两条记录在填充了 NULL 值列表后的示意图就是这样:
在这里插入图片描述

记录头信息
记录头信息是由固定的5个字节(40位)组成, 不同的位代表不同的含义:
在这里插入图片描述
暂时不详细展开。

记录的真实数据

记录的真实数据除了包含各列具体的数据外,还会自动添加一些隐藏列数据。
在这里插入图片描述
实际上这几个列的真正名称其实是:DB_ROW_ID、DB_TRX_ID、DB_ROLL_PTR,为了美观才写成了row_id、transaction_id和roll_pointer。

只有当数据库没有定义 主键 或者 唯一键 时,隐藏列 row_id 才会存在,并且将其作为数据表 主键 。

因为表 record_format_demo 并没有定义主键,所以MySQL服务器会为每条记录增加上述的3个列。现在看一下加上 记录的真实数据 的两个记录的数据结构:在这里插入图片描述

CHAR(M)列的存储格式

对于 CHAR(M) 类型的列来说,当列采用的是定长字符集时,该列占用的字节数不会被加到变长字段长度列表,而 如果采用变长字符集时,该列占用的字节数也会被加到变长字段长度列表 。

另外有一点还需要注意,变长字符集的 CHAR(M) 类型的列要求至少占用 M 个字节,而 VARCHAR(M) 却没有这个要求。比方说对于使用 utf8 字符集的 CHAR(10) 的列来说,该列存储的数据字节长度的范围是 10~30 个字节,即使我们向该列中存储一个空字符串也会占用 10 个字节。

行溢出数据

VARCHAR(M)最多能存储的数据
MySQL对一条记录占用的最大存储空间是有限制的,除了 BLOB 或者 TEXT 类型的列之外, 其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过65535个字节 。可以不严谨的认为, mysql一行记录占用的存储空间不能超过65535个字节 。这个65535个字节除了列本身的数据之外,还包括一些其他的数据(storage overhead),比如说我们为了存储一个VARCHAR(M)类型的列,其实需要占用3部分存储空间:

真实数据
真实数据占用字节的长度
NULL值标识,如果该列有NOT NULL属性则可以没有这部分存储空间
假设 varchar_size_demo 只有一个 VARCHAR 类型的字段,那么该字段最大占用的65532个字节。因为真实数据的长度可能占用2个字节, NULL值标识 需要占用1个字节。如果该 VARCHAR 类型的列没有 NOT NULL 属性,那最多只能存储 65532 个字节的数据。如果该列是 ascii 字符集,对应的最大字符数最大为 65532 ;如果是 utf8 字符集,则对应的最大字符数为 21844 。

记录中的数据太多产生的溢出
我们以ascii字符集下的 varchar_size_demo 表为例,插入一条记录:

mysql> CREATE TABLE varchar_size_demo(
-> c VARCHAR(65532)
-> ) CHARSET=ascii ROW_FORMAT=Compact;
Query OK, 0 rows affected (0.01 sec)

mysql> INSERT INTO varchar_size_demo© VALUES(REPEAT(‘a’, 65532));
Query OK, 1 row affected (0.00 sec)
mysql中磁盘与内存交互的基本单位是页,一般为16KB,16384个字节,而一行记录最大可以占用 65535 个字节,这就造成了 一页存不下一行数据的情况 。在Compact和Redundant行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的一部分数据,把剩余的数据分散存储在几个其他的页中,然后记录的真实数据处用20个字节存储指向这些页的地址,从而可以找到剩余数据所在的页,如图所示:
在这里插入图片描述

这种 在本记录的真实数据处只会存储该列的前768个字节的数据和一个指向其他页的地址,然后把剩下的数据存放到其他页中的情况就叫做 行溢出 ,存储超出768字节的那些页面也被称为 溢出页 。
在这里插入图片描述

行溢出的临界点

MySQL中规定一个页中至少存放两行记录。以上边的 varchar_size_demo 表为例,它只有一个列 c ,我们往这个表中插入两条记录,每条记录最少插入多少字节的数据才会行溢出的现象呢?这得分析一下页中的空间都是如何利用的。

每个页除了存放我们的记录以外,也需要存储一些额外的信息,大概132个字节。
每个记录需要的额外信息是27字节。
假设一个列中存储的数据字节数为n,如要要保证该列不发生溢出,则需要满足:

132 + 2×(27 + n) < 16384
结果是 n < 8099 。 也就是说如果一个列中存储的数据小于8099个字节,那么该列就不会成为溢出列 。如果表中有多个列,那么这个值更小。

Dynamic和Compressed行格式

mysql中默认的行格式就是 Dynamic 。 Dynamic 和 Compressed 行格式和 Compact 行格式很像,只是在处理 行溢出 数据上有差异。 Dynamic 和 Compressed 行格式不会在 记录的真实数据 出存放前768个字节,而是将所有字节都存储在其它页面中。 Compressed 行格式会采用压缩算法对页面进行压缩,以节省空间。
在这里插入图片描述

InnoDB数据页结构

我们已经知道页是InnoDB管理存储空间的基本单位,一个页的大小一般是16KB。InnoDB为了不同的目的设计了许多不同类型的页,我们这里主要关注 存储数据记录 的页,官方称为 索引页 。由于还没介绍索引,暂且我们先称为 数据页 吧。

数据页结构的快速浏览

数据页在结构上可以划分为多个部分,不同的部分有不同的功能,如下图所示:
在这里插入图片描述

一个InnoDB数据页被划分为了7个部分,下面大概描述一下这7个部分内容。
在这里插入图片描述

记录在页中的存储

用户自己的存储的数据会按照对应的 行格式 存在 User Records 中。实际上,新生成的页面是没有 User Records 的,只有当我们第一次插入数据时,才会从 Free Space 划一个记录大小的空间给 User Records 。当 Free Space 用完之后,就意味着当前的数据页也使用完了。
在这里插入图片描述

为了能够将 User Records 讲清楚,我们先得理解前面提到的 记录头信息 。

理解记录头信息

先简单介绍一下记录头信息各属性描述:
在这里插入图片描述

接下来以 page_demo 表为例,并插入一些数据,详细介绍记录头信息。

mysql> CREATE TABLE page_demo(
-> c1 INT,
-> c2 INT,
-> c3 VARCHAR(10000),
-> PRIMARY KEY (c1)
-> ) CHARSET=ascii ROW_FORMAT=Compact;
Query OK, 0 rows affected (0.03 sec)

mysql> INSERT INTO page_demo VALUES(1, 100, ‘aaaa’), (2, 200, ‘bbbb’), (3, 300, ‘cccc’), (4, 400, ‘dddd’);
Query OK, 4 rows affected (0.00 sec)
Records: 4 Duplicates: 0 Warnings: 0
这4条记录在InnoDB中的行格式如下(只展示记录头和真实数据),列中数据均用十进制表示:
在这里插入图片描述

我们对照着这个图来重点介绍几个属性的详细信息:

delete_mask :标记着当前记录是否被删除,0表示未删除,1表示删除。未删除的记录不会立即从磁盘上移除,而是先打上删除标记,所有被删除的记录会组成一个 垃圾链表 。之后新插入的记录可能会重用 垃圾链表 占用的空间,因此垃圾链表占用的存储空间也被成为 可重用空间 。
heap_no :表示当前记录在本页中的位置,比如上边4条记录在本页中的位置分别是 2、3、4、5 。实际上,InnoDB会自动为每页加上两条虚拟记录,一条是 最小记录 ,另一条是 最大记录 。这两条记录的构造十分简单,都是由 5字节大小的记录头信息 和 8字节大小的固定部分 (其实内容就是infimum或者supremum)组成的。这两条记录被单独放在 Infimum + Supremum 的部分。
在这里插入图片描述

从图中我们可以看出来,最小记录和最大记录的 heap_no 值分别是0和1,也就是说它们的位置最靠前。

next_record :表示从 当前记录的真实数据到下一条记录的真实数据的地址偏移量 。可以简单理解为是一个单向链表,最小记录的下一个是第一条记录,最后一条记录的下一个是最大记录。为了更加形象的展示,我们可以用箭头来替代一下next_record中的地址偏移量:
在这里插入图片描述

从图中也能看出来, 用户记录实际上按照主键大小正序排序行成一个单向链表 。如果从中删除掉一条记录,这个链表也是会跟着变化的,比如我们把第2条记录删掉:
在这里插入图片描述

delete_mask
next_record
next_record

Page Directory(页目录)

我们已经知道,记录在页中按照主键大小正序串联成了一个单链表。如果我们要根据主键查找具体的某条记录应该怎么办,简单的方式是根据链表进行遍历。但是在数据量比较大的情况下,这种方式显然效率太差了。因此mysql使用了 Page Directory(页目录) 来解决这个问题。 Page Directory(页目录) 大致的原理如下:

Page Directory
mysql规定对于最小记录所在的分组只能有 1 条记录,最大记录所在的分组拥有的记录条数只能在 1-8 条之间,剩下的分组中记录的条数范围只能在是 4-8 条之间。

比方说现在的 page_demo 表中正常的记录共有18条,InnoDB会把它们分成5组,第一组中只有一个最小记录,如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZereP94x-1596671641800)( https://chentianming11.github.io/images/mysql/页目录.webp )]

通过 Page Directory 在一个数据页中查找指定主键值的记录的过程分为两步:

通过二分法确定该记录所在的槽,并找到该槽所在分组中主键值最小的那条记录。
通过记录的 next_record 属性遍历该槽所在的组中的各个记录。
对于链表的查询性能优化,思想上基本上都是通过 二分法 实现的。上面介绍的 Page Directory , 跳跃表 和 查找树 都是如此。

Page Header(页面头部)

Page Header 专门用来存储数据页相关的各种状态信息,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等。固定占用56个字节,各部分字节属性含义如下:
在这里插入图片描述

这里只是罗列出来,暂时不需要全部理解。

File Header(文件头部)

File Header 是用来描述各种页都适用的一些通用信息的,由以下内容组成:
在这里插入图片描述

这里只是罗列出来,暂时不需要全部理解。我们重点关注一下几个属性:

FIL_PAGE_SPACE_OR_CHKSUM
当前页面的校验和(checksum)。对于一个很长的字节串来说,我们可以通过某种算法来计算一个比较短的值来代表这个很长的字节串,这个比较短的值就称为 校验和 。通过 校验和 可以大幅度提升字符串等值比较的效率。
FIL_PAGE_OFFSET
每一个页都有一个唯一的页号, InnoDB 通过页号来可以定位一个页。
FIL_PAGE_TYPE
代表当前页的类型,我们前边说过,InnoDB为了不同的目的而把页分为不同的类型。
在这里插入图片描述

表示本页的上一个和下一个页的页号,各个页通过 FIL_PAGE_PREV 和F IL_PAGE_NEXT 形成双向链表。
在这里插入图片描述

File Trailer

mysql中内存和磁盘的基本交互单位是页。如果内存中页被修改了,那么某个时刻一定会将内存页同步到磁盘中。如果在同步的过程中,系统出现问题,就可能导致磁盘中的页数据没能完全同步,也就是发生了 脏页 的情况。为了避免发生这种问题,mysql在每个页的尾部加上了 File Trailer 来校验页的完整性。 File Trailer 由8个字节组成:

前4个字节代表页的校验和
这个部分是和File Header中的校验和相对应的。简单理解,就是 File Header 和 File Trailer 都有校验和,如果两者一致则表示数据页是完整的。否则,则表示数据页是 脏页 。
后4个字节代表页面被最后修改时对应的日志序列位置(LSN)
这个部分也是为了校验页的完整性的,暂不详细了解。

猜你喜欢

转载自blog.csdn.net/qq_40989769/article/details/107841350