第四章、InnoDB记录存储结构
前言:文章是根据《MySQL是怎样运行的:从根儿上理解 MySQL》这本书整理而来,作者小孩子4919。上一章主要介绍的MySQL的字符集以及比较规则,已经初步了解了MySQL服务器和客户端是如何传递数据的。这一章主要介绍数据到底怎么存储的呢?这一章当然是以默认的存储引擎InnoDB为例,其他的存储引擎都大同小异。
第一节、InnoDB页简介
InnoDB是一个存储引擎,它可以把表中的数据存到磁盘中,这样即使断电也不会丢失数据了。
真正的数据处理是发生在内存中的,需要把磁盘中的数据加载到内存上,如果是增加或者修改等操作,还需要把内存的内容刷到磁盘上去。也就是说内存只是一个临时中转站,最后数据都要持久化到磁盘里才安全。
如果一条一条的把数据从磁盘中读出就太慢了,InnoDB采用了分页的方式,以页作为磁盘和内存交互的基本单位。InnoDB中页的大小是16KB,也就是最少一次读取16KB的内容至内存或把16KB内容刷入磁盘。
第二节、InnoDB行格式
1、指定行格式
向表中插入数据一般都是以记录为单位的,也就是行格式或者叫做记录格式。InnoDB存储引擎包含4种不同类型的行格式:
- compact
- redundant
- dynamic
- compressed
那么如何指定行格式呢:
#创建
create table 表名(列的信息) row_format=行格式名称;
#修改
alter table 表名 row_format=行格式名称;
这里需要用到一个案例,方便后面内容的理解:
create table record_format_demo(
c1 varchar(10),
c2 varchar(10) not null,
c3 char(10),
c4 varchar(10)
) charset=ascii row_format=compact;
然后插入了两条记录:
| c1 | c2 | c3 | c4 |
|---|---|---|---|
| aaaa | bbb | cc | d |
| eeee | fff | null | null |
2、compact行格式
compact行格式示意图如下:
上图可见,一条完整的记录分为记录的额外信息和记录的真实数据两部分。
(1)记录的额外信息
记录的额外信息包括变长字段长度列表、Null值列表、记录头信息。
变长字段长度列表
MySQL中有一些可变长的数据类型,比如varchar(m),varbinary(m),各种text类型,各种blob类型。因为这些字段的长度是不确定的,所以MySQL把它们实际占用的字节数也存起来了,这些字段实际记录了两部分:
- 实际数据内容
- 数据占用的字节数
在compact行格式下,所有变长字段的真实数据占用的字节数都存放在上图开头的位置,各个变长字段真实数据占用的字节输按照列的顺序逆序存放。(看着很乱😑,直白点说,我这个数据存放是新的在上,旧的在下)
看第一条记录是如何插入的:
| c1 | c2 | c3 | c4 |
|---|---|---|---|
| aaaa | bbb | cc | d |
因为c1、c2、c4都是可变长的字段,那么这三个列值的实际占用的字节数都要存入上面可变长字段长度列表中。继续分析,因为字符集使用的是ascii码,每个字符使用一个字节来编码,那旧可知道各个字段的长度了。
| 列名 | 存储内容 | 内容长度(十进制) | 内容长度(十六进制) |
|---|---|---|---|
| c1 | aaaa | 4 | 0x04 |
| c2 | bbb | 3 | 0x03 |
| c4 | d | 1 | 0x01 |
那么按照要求逆序存放,就是01 03 04,实际存储的时候是没有空格的,就是010304。
上面这些数都比较小,所以使用一个字节就可以表示了,如果再大一点就需要使用两个字节表示了。那么到底什么时候使用一个字节,什么时候使用两个字节呢?这里有个规则,可以了解一下。
三个参数:
- 某个字符集的最大字节数W:最多需要W个字节表示一个字符,看上一章的表
- varchar(M):最多存多少个字符
- 某个可变字段实际存储的字符串占用的字节数:L
- 那么如果M×W<=255(一个字节是8位,也就是2^8=256-1):这个时候使用1个字节就行
- 如果M×W>255,有两种情况:
- L<=127,使用1个字节
- L>127,使用2个字节
而且可变长字段长度列表只存储非null的值,比如第二条记录的c4是null,那么就不记录进来,那么eeee是4个字节,fff是三个字节,所以存储03 04即可。
如果表中所有字段都是不变的数据类型,或者是null,那么可变长字段长度列表就不需要了。
Null值列表
列值可能存在很多的null,占用一定的空间,所以compact行格式单独分配了Null值列表来存储null值。
这个操作的步骤如下:
(1)先统计列表哪些列可存储null值
(2)如果没有可存储null值列,那么Null值列表也就不需要了。如果存在这样的列,那就给每个列分配一个二进制位,按照列的逆序排列。二进制位值为1表示该列为null,值为0时表示列值不为null
(3)看上面这个表,其中c1、c3、c4都可以存储null值,那么一共分配三个二进制位,按照逆序排列
(4)Null值列表规定必须要用整数个字节的位表示,没有的前面补0,那么这个就得用一个字节表示,也就是8位,图如下
(5)现在该存储值了,根据上面说的,null用1表示,非null用0表示,分别存储一下之前的两条记录。第一条记录四个值都不是null,所以就是这三位就是000,最后的值就是0x00;第二个c1有值,c3为null,c4为null,那么按照逆序存放就是110,换算成十六进制就是0x06,图如下
最后记录到文件中就是下面这样:
记录头信息
记录头固定是5个字节(5×8=40位),描述记录的一些属性。
下面这个表不需要记住。
| 名称 | 大小(单位:bit) | 描述 |
|---|---|---|
| 预留位1 | 1 | 没有使用 |
| 预留位2 | 1 | 没有使用 |
| delete_mask | 1 | 标记该记录是否被删除 |
| min_rec_mask | 1 | B+树的每层非叶子节点中的最小记录都会添加该标记 |
| n_owned | 4 | 表示当前记录拥有的记录数 |
| heap_no | 13 | 表示当前记录在记录堆的位置信息 |
| record_type | 3 | 表示当前记录的类型,0表示普通记录,1表示B+树非叶节点记录,2表示最小记录,3表示最大记录 |
| next_record | 16 | 表示下一条记录的相对位置 |
所以目前这个记录头信息不需要再知道,先知道这两条记录在这个位置有值就行。
上面记录的一些额外信息就完成了,下面就是真实数据了。
(2)记录的真实数据
当前这个表中,记录的真实信息除了记录c1、c2、c3、c4的值,还要记录一些其他的列(我们叫做隐藏列),如下:
| 列名 | 是否必须 | 占用空间 | 描述 |
|---|---|---|---|
| row_id | 否 | 6字节 | 行ID,唯一标识一条记录 |
| trx_id | 是 | 6字节 | 事务ID |
| roll_pointer | 是 | 7字节 | 指针回滚 |
InnoDB的主键生成策略:会优先使用用户定义的主键作为主键;没有定义则选择一个不许为null值的unique键为主键;如果这两个都没有定义,那么会添加一个row_id隐藏列作为主键。
再看这个表,row_id没有指定,所以肯定会生成一个隐藏列;并且会给每条记录添加一个trx_id和roll_pointer列(这两个是必须有的)。
因为这个表的字符集选择了ascii,ascii字符集对照如下:
那么这两条记录各个字段分别如下:
- c1:第一条是aaaa,实际是0x61616161;第二条的eeee,实际是0x65656565
- c2:第一条是bbb,实际是0x626262;第二条的fff,实际是0x666666
- c3:第一条是cc,实际是0x6363;第二条为null,那就不写了
- c4:第一条是d,实际是0x626262;第二条为null,那就不写了
所以它们分别是:
主要看c3这个列,因为它是char(10),所以它是十个字节,但是这里只用到了两个字节,其他需要补为空格,空格为20。
compact行格式下,在使用像ascii字符集这种定长字符集时,char(M)不会被存储到变长字段长度列表;而使用像utf8(1-3个字节)、gbk(1-2个字节)这种可变长度字符集,char(M)这个字符按也会加到变长字段长度列表中。
比如修改c3的字符集为utf8,因为这个列变为了不定长,那么它也要放到变长字段长度列表中,char(10)占用10个字节,10(十进制)变为0x0A,那么存入:01 0A 03 04。
3、redundant行格式
redundant格式比较古老,稍微了解一下即可。
把表改为如下格式:
alter table record_format_demo row_format=redundant;
(1)字段长度偏移列表
因为不再是变长字段长度列表了,那么它会把该记录中所有列的长度信息都按照逆序存储到这个位置(包括隐藏列),这里记录的是偏移量,采用相邻偏移量的差值来计算每个列值的长度。
长度偏移量:25 24 1A 17 13 0C 06
变为顺排序:06 0C 13 17 1A 24 25
然后就可以换算为10进制得到各自的偏移总量:06、12、19、23、26、36、37
差值:6字节、6字节、7字节、4字节、3字节、10字节、1字节
(2)记录头信息
redundant记录头信息一共用6个字节,48位来计算。
| 名称 | 大小(单位:bit) | 描述 |
|---|---|---|
| 预留位1 | 1 | 没有使用 |
| 预留位2 | 1 | 没有使用 |
| deleted_mask | 1 | 标记该记录是否被删除 |
| min_rec_mask | 1 | B+树的每层非叶子节点中的最小记录都会添加该标记 |
| n_owned | 4 | 表示当前记录拥有的记录数 |
| heap_no | 13 | 表示当前记录在记录堆的位置信息 |
| n_field | 10 | 表示记录中列的数量 |
| 1byte_offs_flag | 1 | 标记字段长度偏移列表中每个列对应的偏移量是使用1个字节还是2个字节表示的 |
| next_record | 16 | 表示下一条记录的相对位置 |
这里头信息看不懂,后面会解释。
(3)1byte_offs_flag的选择
每个列的字段长度偏移量可以用1个字节或2个字节存储,什么时候用1个字节,什么时候用2个呢?
- 真实记录的字节树不大于127,采用1个字节
- 真实记录的字节数大于127小于32767时,用2个字节
- 当大于32767时,暂时不考虑了
当1byte_offs_flag的值为1,时表示1个字节存储
当1byte_offs_flag的值为0,时表示2个字节存储
(4)null值的处理
对应偏移量第一位设置是否是null,为1是null,否则就不是。
- 如果存储null值的字段长度为定长类型,那么null值也会占用记录的真实数据部分,并且该字段数据使用0x00填充
- 如果存储null值的字段长度为非定长类型,那么不记录,不占用存储空间
(5)char(M)类型
不管是什么字符集,真实数据存储空间的大小都是一个字符最多需要的字节数和M的乘积。
(6)溢出列
比如InnoDB是按照页为单位存储数据的,假如数据大于一页,(前面章节说过了,一页就是16kb,16384个字节)那么就会导致存不下,这个存不下的列就称为溢出列。
compact和redundant行格式下,某一个列如果占用空间多,那么就只存储该列的一部分,也就是字符串的前798个字节,然后把其他信息分散存储于其他页中,这个列的真实数据位置就用20个字节来存储这些页的地址,
不只是varchar(M)会成为溢出列,像text、blob也可能成为溢出列。
4、dynamic和compress行格式
MySQL5.7默认的就是dynamic行格式。它们与compact行格式只是在溢出列上有点区别:
它们不会把前768页存到真实数据处,而是把所有真实数据都存到溢出页,把地址存到真实数据的位置。
compress会采用压缩算法对页面进行压缩。