MySQL-关于InnoDB(1)
在MySQL中负责对表中数据的读取和写入工作的部分是存储引擎,我们在上一篇中也大体介绍了几种常见的存储引擎比如InnoDB、MyISAM、Memory。而这其中最最主要的存储引擎的就是InnoDB,本篇我们就来详细聊聊 InnoDB。
InnoDB 磁盘结构
一直都在提 InnoDB 怎么怎么样,闻声不如相见。我们得知道 InnoDB长什么样子吧,里面到底包含了哪些东西。在MySQL的官网中有这样一张图
在上图中我们可以看到整个 innoDB 的结构分了两部分,磁盘结构(On-Disk Structures)与内存结构(In-Memory Structur)。我们先来看它的磁盘结构,在上图中的磁盘结构部分我们看到最多的词是 Tablespace ,它里边有各种各样的 Tablespace。 按常规套路来说我们应该挨个看这些 Tablespace,但不打算花太多篇幅在 Tablespace 上,我们转变一下方向,从最基本的一行数据开始。
行格式
我们能看见的数据都是一行一行的存在表里的,那么这样一行数据记录在磁盘上的存放方式也被称为行格式或者记录格式。也就是说实际上在一条记录在磁盘上存储的方式并不是我们看到的表里的样子。InnoDB 存储引擎提供了四种不同类型的行格式 Compact、Redundant、Dynamic(MySQL 5.7及更高版本的默认格式)、Compressed。这里我们以 Compact 行格式为例解释这些行格式中最通用的部分(其他格式的结构大致相同具体细节感兴趣的小伙伴可自行研究)。我们先来看一下 Compact 行格式 长什么样子
Compact 行格式
在上图中可以看出一条完整的记录分为记录的额外信息和记录的真实数据两大部分。而记录的额外信息又分了三部分,我们来具体看一下这三部分是什么
变长字段长度列表
MySQL支持一些变长的数据类型,如VARCHAR(M)、VARBINARY(M)、TEXT类型、BLOB类型等。这些数据类型修饰的列称为变长字段。由于变长字段中存储多少字节的数据不是固定的,所以在存储真实数据的时候,需要把这些数据占用的字节数也存起来。在Compact行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表。各变长字段数据占用的字节数按照列的顺序逆序排放。逆序排放是为了提高性能和效率,特别是在需要读取数据列的长度信息的时候。
NULL值列表
当表中的某些列存的是NULL值,如果把这些NULL值都放到记录的真实数据中存储会很占空间,所以Compact行格式把这些值为NULL的列统一管理起来,存储到NULL值列表中。当然存的时候会统计表中允许存储NULL的列有哪些,主键列以及使用NOT NULL修饰的列都是不可以存储NULL值的,所以在统计的时候不会把这些列算进去。如果表中没有允许存储NULL的列,则NULL值列表也就不存在了。
记录的头信息
除了变长字段长度列表、NULL值以外,还有一个用于描述记录的记录头信息,它是由固定的5个字节组成。5个字节是40个二进制位,不同的位代表不同的意思。我们通过一个表格来看一下头信息中包含了哪些内容(先混个眼熟,后续用到哪个再回来一看就清晰了)
名称 | 占用大小(bit) | 说明 |
---|---|---|
预留位1 | 1 | 没有使用 |
预留位2 | 1 | 没有使用 |
delete_mask | 1 | 标记是否被删除 |
min_rec_mark | 1 | B+树的每层非叶子节点中的最小记录都会添加该标记 |
n_owned | 4 | 表示当前记录拥有的记录数 |
heap_no | 13 | 表示当前记录在记录堆的位置信息 |
record_type | 3 | 表示当前记录的类型,0表示普通记录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录 |
next_record | 16 | 表示下一条记录的相对位置 |
罗列了这么些枯燥的内容小伙伴们理解起来可能比较晦涩难懂,我们不妨举个例子来解释一下行格式的这些内容,假设你是一名合格的仓库管理员,我们将行格式比作一个个的货架,记录真实信息的列比做货架的列,列的值比做货架上的物品。作为仓库管理员的你需要对每一个货架都了如执掌。当货架(行)比较多的时候就需要给每个货架(行)提炼一些关键信息。再查找物品(数据)的时候才能精准的找到对应的货架(行)及物品(数据)。作为仓库管理员你要做下面几件事
需要知道货架每一列的物品有多少——变长字段长度列表
需要知道货架的哪一列是空的,将来补货的时候更容易找到地方放——NULL值列表
还需要知道货架里哪些物品出入库的情况——记录的头信息
这样解释应该对行格式的理解有一定的帮助,如果还是不理解也不用担心我们从实际的表数据出发来进行理解。执行如下 sql 语句
-- 创建表
CREATE TABLE t_record_format (
L1 VARCHAR(10),
L2 VARCHAR(10) NOT NULL,
L3 CHAR(10),
L4 VARCHAR(10)
) CHARSET=ascii ROW_FORMAT=COMPACT
-- 添加数据
INSERT INTO t_record_format(L1, L2, L3, L4) VALUES('aaaa', 'bbb', 'cc', 'd'), ('eeee', 'fff', NULL, NULL);
在上述 sql 语句中我们创建了 t_record_format 表并且指定了字符集(ASCII)与行格式(COMPACT)并且在表里加入了两条数据。这里为什么用 ASCII 字符集是因为一个ASCII字符占用的存储空间是1个字节方便理解。现在 t_record_format 表里面已经有两条数据了,按照变长字段长度列表逆序排放的规则,它的变长字段长度列表应该是这个样子
其中第一行变长字段长度列表记录的是 01 03 04 这是根据存储的数据转化成十六进制在逆序排放得到的。那为什么没有 02 这是因为L3字段类型是 char 属于非变长字段,另外变长字段长度列表中只存储值为 非NULL 的列内容占用的长度,值为 NULL 的列的长度是不存储的。所以第二行的边长字段列表记录的就是03 04。还需要注意的一点就是并不是所有记录都有这个变长字段长度列表部分,比方说表中所有的列都不是变长的数据类型的话,那这一部分就不需要有。
我们在表里字段存的字符串都很少,所以这些内容占用的字节数在变长字段长度列表中用一个字节就可以表示了,那如果占用的字节数很多就需要用两个字节来表示。怎么界定是用一个字节还是两个字节来表示,Innodb中有这样的规律,从两个方面来判断。
变长字段理论上占用的最大字节数,例如VARCHAR(50),字符集是ASCII (一个字符占一个字节) 那么理论上占用的最大字节数50*1,也就是 字符数 * 字符集对应字节数。
当 字符数 * 字符集对应字节数 <= 255 时,使用1个字节来表示真正字符串占用的字节数。也就是说InnoDB在读记录的变长字段长度列表时先查看表结构,如果某个变长字段允许存储的最大字节数不大于255时,可以认为只使用1个字节来表示真正字符串占用的字节数。
实际存储的字符串占用的字节数。
当 字符数 * 字符集对应字节数 > 255 时,就需要去判断实际存储的字符串占用的字节数了
如果实际存储的字符串占用的字节数 <= 127,则用1个字节来表示真正字符串占用的字节数。
如果实际存储的字符串占用的字节数 > 127,则用2个字节来表示真正字符串占用的字节数。
这里具体细节还是得参考专业资料,目前本人只了解了大概
理解NULL值列表
要理解NULL值列表,我们首先要看它是如何统计的
- 统计表中允许存储NULL的列有哪些
- 将每个允许存储NULL的列对应一个二进制位,二进制位按照列的顺序逆序排列 二进制为1代表该列的值为 NULL,反之二进制为0 代表该列的值不为 NULL。
- MySQL规定NULL值列表必须用整数个字节的位表示,如果使用的二进制位个数不是整数个字节,则在字节的高位补0
我们根据这几个步骤来推导一下 NULL值列表 存的是什么,还是以 t_record_format 这个表为例,首先表中的两行数据允许存储NULL的列是 L1,L3,L4
再根据对应二进制位以及逆序排列的原则进行调整,如下图所示
接下来再根据整数个字节的要求进行高位补0,调整后如下图所示
因此我们得到了这两行数据的 NULL值列表存储的值为 00 和 0x06 (二进制 110 转十六进制的结果)。再结合我们之前的 变长字段长度列表,我们的行格式结构图就变成了下面这样
隐藏字段
我们之前主要关注点都放在了行格式中记录的额外信息上,其实在记录的真实数据中除了我们自己定义的列以外MySQL 还定义了3个隐藏列(这里我们不对隐藏列做展开,后续的索引、事务的文章中会涉及到)
名称 | 是否必须 | 占用大小(字节) | 说明 |
---|---|---|---|
DB_ROW_ID | 否 | 6 | 行id |
DB_TRX_ID | 是 | 6 | 事务id |
DB_ROLL_PTR | 是 | 7 | 回滚指针 |
行溢出
VARCHAR(M)类型的列最多可以占用65535个字节,假设还是 ASCII 字符集,那么M = 65535 也就是 65535个字符(ASCII 字符集一个字符占一个字节,总共占65535个字节)。实际情况真的是这样吗?还是 t_record_format 这个表,我们把 L1字段的长度改为 65535 会出现如下错误
从报错信息里可以看出,MySQL对一条记录占用的最大存储空间是有限制的,除了BLOB或者TEXT类型的列之外,其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过65535个字节。所以MySQL服务器建议我们把存储类型改为TEXT或者BLOB的类型。所以这个65535个字节除了列本身的数据之外,还包括一些其他的数据,比如说一个VARCHAR(M)类型的列所包含的内容分3个部分
- 真实数据
- 真实数据占用字节的长度
- NULL值标识,如果该列有NOT NULL属性则可以没有这部分存储空间
在回到 L1 字段上来,我们来计算一下 L1 字段 VARCHAR(M) M的最大值,字符集是 ASCII,变长字段列表占2个字节,NULL值列表占1个字节,那么 M = (65535-2-1)/1 = 65532 。最大字符数和占用最大字节数均是 65532 换算一下的话 一个字段存64KB的数据。很显然这样大的数据无论实在存储还是性能上来说都不是最优解。那MySQL 是怎么做的呢?
试想一下,你还是那个合格的仓库管理员。有一天同一批次的物品来了大量货,你的货架装不下了。常规做法就是把多出的物品放到其他空余的地方,并标记好相关信息(比如按货号排好序,标注好货品名称之类)这样取货时才能快速找到对应的物品。同理在Compact 行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的一部分数据,把剩余的数据分散存储在几个其他的页中,然后记录的真实数据处用20个字节存储指向这些页的地址(当然这20个字节中还包括这些分散在其他页面中的数据的占用的字节数),从而可以找到剩余数据所在的页(关于页的相关内容我们接下来会说到,这里先有个印象)。简单来说对于Compact行格式,如果某一列中的数据非常多的话,在本记录的真实数据处只会存储该列的前768个字节的数据和一个指向其他页的地址,然后把剩下的数据存放 到其他页中,这个过程也叫做行溢出,存储超出768字节的那些页面也被称为溢出页。我们通过一个图来加深理解
页
我们已经了解了一行数据是怎样的结构,接下来我们开始页的部分。在 MySQL 数据库中,页(Page)是存储引擎(如 InnoDB)中磁盘和内存之间交互的基本单位。InnoDB中页的大小默认 16 KB,在一般情况下,一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。在 InnoDB 中还有不同类型的页来存储不同类型的数据如 Undo Page、Doublewrite Buffer Page、System Page 等等。不过这些页不是我们现在关注的重点,我们来看一下最基本的页的结构
整个页划分成了7个部分,有的部分占用的字节数是确定的,有的部分占用的字节数是不确定的。我们把图再转换成表格会更清晰一些
名称 | 中文名 | 占用大小(字节) | 说明 |
---|---|---|---|
File Header | 文件头 | 38 | 页的通用信息 |
Page Header | 页面头 | 56 | 数据页的信息 |
Infimum + Supremum | 最小记录和最大记录 | 26 | 两个虚拟行记录 |
User Records | 用户记录 | 随数据变化 | 实际存储的行记录 |
Free Space | 空闲空间 | 随数据变化 | 页中尚未使用的空间 |
Page Directory | 页面目录 | 随数据变化 | 页中的某些记录的相对位置 |
File Trailer | 文件尾部 | 8 | 校验页是否完整 |
看到这里有的小伙伴的耐心可能已经到底了,为了搞清楚行格式就铺垫了很多概念。到了页这一部分又这么多新东西,都失去了读下去的兴趣。我们这里简化一下只取其精华
数据存储在页中
通过表中页的七个部分我们可以得知,我们存储的一行行的数据(或称为记录) 都是放在 User Records 这部分里,但在初始化的页的时候并没有这一部分,随着我们往表里插入数据。页的结构发生如下的变化。
随着数据的不断插入,页中的记录会越来越来。此时页就需要担负起管理这些记录的职责了。就好比我们家里东西越来越多就得需要规整一下。那页是如何 ‘’ 规整 “ 这些记录的呢?这里我们需要回顾一下在行格式中的记录头信息的内容。
名称 | 占用大小(bit) | 说明 |
---|---|---|
预留位1 | 1 | 没有使用 |
预留位2 | 1 | 没有使用 |
delete_mask | 1 | 标记是否被删除 |
min_rec_mark | 1 | B+树的每层非叶子节点中的最小记录都会添加该标记 |
n_owned | 4 | 表示当前记录拥有的记录数 |
heap_no | 13 | 表示当前记录在记录堆的位置信息 |
record_type | 3 | 表示当前记录的类型,0表示普通记录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录 |
next_record | 16 | 表示下一条记录的相对位置 |
假设你还是那个合格的仓库管理员,一行记录比作一个货架,那么页就比作库房,货架(记录)在库房(页)中需要放在什么位置,是放在东边还是放在西边,放在前面还是放在后面。这时候我们发现记录的头信息中的 heap_no 能解决货架怎么摆放问题。
heap_no
这个属性就是表示当前记录在本页中的位置,既然货架(记录)的位置指定了,那就得按照指定的位置摆放。我们的库房(页)里是这样摆放的。
另外需要注意的一点是 InnoDB会给每个页插入两条伪记录(或者称为虚拟记录)这两个记录一个表示最大记录和最小记录。而记录大小的比较条件是主键id。也就是说我们在上图中所画的3条记录理论上是根据主键id排好的。最大记录和最小记录的结构也很简单,是是由5字节大小的记录头信息和8字节大小 的一个固定的部分组成的并且这两条记录放在 Infimum + Supremum 部分中,所以我们的图做了如下的改动
页中的记录按指定位置摆放的一个重要目的是为了空间的连续性,那么根据什么来保证这个连续性呢?就好比我们去逛超市的时候,卖铁锅的旁边会有炒勺,炒勺的旁边会有菜刀,菜刀的旁边会有碗筷等等,形成一个 铁锅——炒勺——菜刀——碗筷这样具有指向性的链,这样的数据结构我们就很容易的就想到是链表,而构建链表指向的属性就是 next_record
next_record
next_record 表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。比方说第一条记录的next_record值为32,意味着从第一条记录的真实数据的地址处向后找32个字节便是 下一条记录的真实数据。需要注意的是下一条记录指得并不是按照我们插 入顺序的下一条记录,而是按照主键值由小到大的顺序的下一条记录。而且规定 Infimum记录(也就是最小记录) 记录(也就是最小记录) 的下一条记录就是本页中主键值最小的用户记录,而本页中主键值最大的用户记录的下一条记录就是 Supremum记录(也就是最大记录)。根据这个特点我们的图又发生了变化。
上图中 next_record 的指针指向是向记录头信息和真实数据之间的位置这样做的好处是可以使记录中位置靠前的字段和它们对应的字段长度信息在内存中的距离更近,可能会提高高速缓存的命中率(这个点作为了解即可)。
库房(页)让你这个仓库管理员管理的井井有条,随着时间的推移我们货架上的物品过期了或者说变质了,需要扔掉。本着节约成本的角度来说肯定不可能把货架也扔掉,只扔掉物品就可以,货架还留着能装新的物品。那么类比到页中来,页里面对数据删除是如何做的呢?在记录头信息中有 delete_mask 这么一个属性
delete_mask
delete_mask 表示标记是否被删除,也就是说删除一条记录之后,该记录并没有在存储空间中删除,只是把 delete_mask 标记为已删除。同时这条记录的next_record 变为0,意味着没有下一条数据了。假如我们删除第2条数据,此时我们的图又发生了相应的变化。
我们已经知道一条数据删除时页里面做了什么,那么再插入数据时页又怎么做呢?这里还要区分插入的数据主键是不是跟 delete_mask 标记为已删除的数据主键相同。如果相同 InnoDB 直接复用原来被删除记录的存储空间,同时 delete_mask 改为0,next_record 改为指向下一条记录的偏移量, 反之则申请新的存储空间。看到这里有小伙伴会产生疑问,如果只标记是否删除,随着删除操作不断执行,那存储空间岂不是越来越小?在 MySQL 数据库中,我们可以通过执行 OPTIMIZE TABLE
命令,用于优化表的性能和空间利用。通过重新组织表的存储结构,去除碎片,OPTIMIZE TABLE
可以帮助提高查询性能、减少存储空间占用以及减少数据碎片(知道有这么回事,这里不做展开)。
在页里面找数据
作为一个合格的仓库管理员,你需要对每个库房(页) 中的物品(数据)了如指掌。比方说要找某样东西就能够快速的找到这个东西放在哪里。作为管理员要怎么做才能达到这个目标,要是有成千上万的物品总不能挨个找吧!那就得给所有的物品进行划分,比如说按类别划分,食品类、生活用品类、服装类等等。或者按位置进行划分,比如说库房东边放什么,西边放什么。总之对所有的物品得有一个明确的划分。那么页中的数据肯定也需要进行划分,我们来看一下页中数据划分的过程。
页中的数据分组
- 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。
- 每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的 n_owned 属性表示该记录拥有多少条记录,也就是该组内共有几条记录。
- 将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近页的尾部的地方,这个地方就是所谓的Page Directory,也就是页目录(此时就跟我们的页面各个部分的图呼应上了)。页面目录中的这些地址偏移量被称为槽(英文名:Slot),所以这个页面目录就是由槽组成的。
这个过程其实跟我们实际生活中物品划分的逻辑是一样,把库房中所有物品分成几份,记录每份物品的数量(n_owned 属性统计),标记好每份物品所在位置(槽)。说到这里我们的图又该变一变了
这里需要注意的是图中页目录部分中有两个槽,也就意味着我们的记录被分成了两个组,槽1代表最大记录的地址偏移量(指向Supremum);槽0代表最小记录的地址偏移量(指向Infimum)。
图中最小和最大记录的头信息中的n_owned属性分别记录的1和4,其中最小记录的n_owned值为1,这就代表着以最小记录结尾的这个分组中只有1条记录,也就是最小记录本身。最大记录的n_owned值为4,这就代表着以最大记录结尾的这个分组中只有4条记录,包括最大记录本身还有我们图中已有的3条记录。
分组规则
为什么这么划分呢?其实是有规定的,对于最小记录所在的分组只能有 1 条记录,最大记录所在的分组拥有的记录条数只能在 1~8 条之间,剩下的分组中记录的条数范围只能在是 4~8 条之间。我们来推导一下分组中数量划分的过程
- 初始情况下一个数据页里只有最小记录和最大记录两条记录,它们分属于两个分组。最小记录那个分组固定只有最小记录一条记录。
- 之后每插入一条记录,都会从页目录中找到主键值比本记录的主键值大并且差值最小的槽,然后把该槽对应的记录的n_owned值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8个
- 随着记录的不断增加,当最大记录的那个分组数达到8的时候,此时再插入新的数据时,就需要划分新的分组将最大记录组中的记录拆分成两个组,一个组中4条记录,包含最大记录的那个组5条记录。这个过程会在页目录中新增一个槽来记录这个新增分组中最大的那条记录的偏移量。
- 记录继续增加,如果某个组的记录数达到8条,再插入新记录时,又会将该组拆分成两个组,并在页目录中新增一个槽来记录新增分组中最大记录的偏移量。拆分条数按非最大/最小分组的最小条数 4 条来分。
我们还是通过图来展示多个分组的情况
查找记录
那如何利用槽来快速找到想要的数据,这里采用的是二分法。每个槽对应的记录都是该组中主键值最大的记录,所以通过二分法先找到中间的槽,找到中间槽后利用记录之间是链表的关系很容易找到该槽中最小主键值的记录。然后沿着链表向后遍历,根据记录 next_record属性比较该槽所在组的各个记录,最终找到目标记录。如果没找到,则从当前中间槽继续二分查找,重复之前的查找操作,直到找到记录为止。
页与页之间的联系
我们已经知道了InnoDB都是以页为单位存放数据的,如果一张表中的数据有很多一万条甚至十万条,那就需要分散到多个页中存储。这些页肯定也需要关联起来,比如页号,上一页或者下一页,这样页与页之间才有关联性。所以 File Header 部分描述了一些针对各种页都通用的一些信息,例如这个页的编号是多少,它的上一个页、下一个页等等,这个部分占用固定的38个字节。其中FIL_PAGE_PREV 和 FIL_PAGE_NEXT 就分别代表本页的上一个和下一个页的页号。这样通过建立一个双向链表将页与页之间关联起来,而无需这些页在物理上真正连着。需要注意的是,并不是所有类型的页都有上一个和下一个页的属性,这里我们说的是最基本的数据页。
表空间
终于我们来到了文章开头部分提到的表空间也就是 Tablespace。 在MySQL数据库中,表空间(Tablespace)是一个用于存储表和索引的物理逻辑结构,可以将其视为一个包含数据的容器。表空间由段(segment)、区(extent)、页(page)组成。其中,页是InnoDB存储引擎磁盘管理的最小单元。
段是表空间中的一个逻辑单位,用于组织和管理表中的数据。而区是表空间中用于存储数据的连续区域,由多个页组成,所以它们之间的关系是一个段包含多个区,每个区包含多个连续的页,一个区包含64个连续的页。
表空间又分为:
- 系统表空间:
- 所有InnoDB表默认存储在一个共享的系统表空间中。
- 它通常以ibdata1文件的形式存在。
- 系统表空间包含InnoDB数据字典(即InnoDB相关对象的元数据),以及双写缓冲(doublewrite buffer)、改变缓冲(change buffer)和undo日志(undo logs)等。
- 独立表空间:
- 用户可以为每个InnoDB表创建独立的表空间,通常以.ibd文件的形式存在。
- 这种方式可以更灵活地管理和迁移数据。
总结
本篇我们主要内容是 Innodb的磁盘结构,我们从一行记录的行格式开始,逐步延申到页的结构,其中行格式和页结构是比较重要的部分,涉及的内容也相对较多。表空间部分只是简单一带而过,其实深入研究起来表空间部分也是有很多内容可以总结的。介于能力不足,并没有对表空间有很深入的研究,有兴趣的小伙伴可自行研究。文中若有错误,还请批评指正。