《MySQL是怎样运行的:从根儿上理解MySQL》笔记4

第四节:InnoDB记录结构

-----------------------------------------------------------
准备工作

  到现在为止,MySQL对于我们来说还是一个黑盒,我们只负责使用客户端发送请求并等待服务器返回结果,表中的数据到底存到了哪里?以什么格式存放的?MySQL是以什么方式来访问的这些数据?

  MySQL服务器上负责对表中数据的读取和写入工作的部分是存储引擎,而服务器又支持不同类型的存储引擎,比如InnoDB、MyISAM、Memory啥的,不同的存储引擎一般是由不同的人为实现不同的特性而开发的,真实数据在不同存储引擎中存放的格式一般是不同的,甚至有的存储引擎比如Memory都不用磁盘来存储数据,也就是说关闭服务器后表中的数据就消失了。

-----------------------------------------------------------
InnoDB页简介

  InnoDB是一个将表中的数据存储到磁盘上的存储引擎,所以即使关机后重启我们的数据还是存在的。而真正处理数据的过程是发生在内存中的,所以需要把磁盘中的数据加载到内存中,如果是处理写入或修改请求的话,还需要把内存中的内容刷新到磁盘上。而我们知道读写磁盘的速度非常慢,和内存读写差了几个数量级,所以当我们想从表中获取某些记录时,InnoDB存储引擎需要一条一条的把记录从磁盘上读出来么?不,那样会慢死,InnoDB采取的方式是:将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,InnoDB中页的大小一般为 16 KB。也就是在一般情况下,一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。

InnoDB行格式
  --每一行数据的存储结构--

  我们平时是以记录为单位来向表中插入数据的,这些记录在磁盘上的存放方式也被称为行格式或者记录格式。设计InnoDB存储引擎的大叔们到现在为止设计了4种不同类型的行格式,分别是Compact、Redundant、Dynamic和Compressed行格式,随着时间的推移,他们可能会设计出更多的行格式,但是不管怎么变,在原理上大体都是相同的。

指定行格式的语法
  我们可以在创建或修改表的语句中指定行格式:
    CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称
    ALTER TABLE 表名 ROW_FORMAT=行格式名称
  比如我们在xiaohaizi数据库里创建一个演示用的表record_format_demo,可以这样指定它的行格式:
mysql> USE xiaohaizi;
Database changed
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)
  可以看到我们刚刚创建的这个表的行格式就是Compact,另外,我们还显式指定了这个表的字符集为ascii,因为ascii字符集只包括空格、标点符号、数字、大小写字母和一些不可见字符,所以我们的汉字是不能存到这个表里的。

-----------------------------------------------------------
COMPACT行格式

--------------------------
记录的额外信息

  这部分信息是服务器为了描述这条记录而不得不额外添加的一些信息,这些额外信息分为3类,分别是变长字段长度列表、NULL值列表和记录头信息,我们分别看一下。

变长字段长度列表

  我们知道MySQL支持一些变长的数据类型,比如VARCHAR(M)、VARBINARY(M)、各种TEXT类型,各种BLOB类型,我们也可以把拥有这些数据类型的列称为变长字段,变长字段中存储多少字节的数据是不固定的,所以我们在存储真实数据的时候需要顺便把这些数据占用的字节数也存起来,这样才不至于把MySQL服务器搞懵,所以这些变长字段占用的存储空间分为两部分:
    1.真正的数据内容
    2.占用的字节数
  在Compact行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表,各>--变长字段--数据占用的字节数--按照列的顺序--逆序--存放,我们再次强调一遍,是逆序存放!

实际数据:

record_format_demo:
mysql> INSERT INTO record_format_demo(c1, c2, c3, c4) VALUES('aaaa', 'bbb', 'cc', 'd'), ('eeee', 'fff', NULL, NULL);
Query OK, 2 rows affected (0.02 sec)
Records: 2 Duplicates: 0 Warnings: 0

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)

第一行数据

  这些长度值需要按照列的逆序存放,所以最后变长字段长度列表的字节串用十六进制表示的效果就是(各个字节之间实际上没有空格,用空格隔开只是方便理解):

  由于第一行记录中c1、c2、c4列中的字符串都比较短,也就是说内容占用的字节数比较小,用1个字节就可以表示,但是如果变长列的内容占用的字节数比较多,可能就需要用2个字节来表示。具体用1个还是2个字节来表示真实数据占用的字节数, InnoDB有它的一套规则,我们首先声明一下W、M和L的意思:
    1.假设某个字符集中表示一个字符最多需要使用的字节数为W,也就是使用SHOW CHARSET语句的结果中的Maxlen列,比方说utf8字符集中的W就是3,gbk字符集中的W就是2,ascii字符集中的W就是1。
    2.对于变长类型VARCHAR(M)来说,这种类型表示能存储最多M个字符(注意是字符不是字节),所以这个类型能表示的字符串最多占用的字节数就是M×W。
    3.假设它实际存储的字符串占用的字节数是L。

  所以确定使用1个字节还是2个字节表示真正字符串占用的字节数的规则就是这样:
    条件一:如果M×W <= 255,那么使用1个字节来表示真正字符串占用的字节数。
  也就是说InnoDB在读记录的变长字段长度列表时先查看表结构,如果某个变长字段允许存储的最大字节数不大于255时,可以认为只使用1个字节来表示真正字符串占用的字节数。
  如果M×W > 255,则分为两种情况:
    条件二:如果L <= 127,则用1个字节来表示真正字符串占用的字节数。
    条件三:如果L > 127,则用2个字节来表示真正字符串占用的字节数。

  InnoDB在读记录的变长字段长度列表时先查看表结构,如果某个变长字段允许存储的最大字节数大于255时,该怎么区分它正在读的某个字节是一个单独的字段长度还是半个字段长度呢?设计InnoDB的大叔使用该字节的第一个二进制位作为标志位:如果该字节的第一个位为0,那该字节就是一个单独的字段长度(使用一个字节表示不大于127的二进制的第一个位都为0),如果该字节的第一个位为1,那该字节就是半个字段长度。
==>这就解释了为什么L要用127作为该算法的区分阈值
  对于一些占用字节数非常多的字段,比方说某个字段长度大于了16KB,那么如果该记录在单个页面中无法存储时,InnoDB会把一部分数据存放到所谓的溢出页中(我们后边会唠叨),在--变长字段长度列表处--只存储--留在本页面中--的长度,所以使用两个字节也可以存放下来。

  总结一下就是说:如果该可变字段允许存储的最大字节数(M×W)超过255字节并且真实存储的字节数(L)超过127字节,则使用2个字节,否则使用1个字节。
  另外需要注意的一点是,变长字段长度列表中只存储值为 非NULL 的列内容占用的长度,值为 NULL 的列的长度是不储存的 。

  对于第二条记录来说,因为c4列的值为NULL,所以第二条记录的变长字段长度列表只需要存储c1和c2列的长度即可。其中c1列存储的值为'eeee',占用的字节数为4,c2列存储的值为'fff',占用的字节数为3。数字4可以用1个字节表示,3也可以用1个字节表示,所以整个变长字段长度列表共需2个字节。

  注意:并不是所有记录都有这个 变长字段长度列表 部分,比方说表中所有的列都不是变长的数据类型的话,这一部分就不需要有。

--------------------------

NULL值列表

  我们知道表中的某些列可能存储NULL值,如果把这些NULL值都放到记录的真实数据中存储会很占地方,所以Compact行格式把这些值为NULL的列统一管理起来,存储到NULL值列表中,它的处理过程是这样的:

    1.首先统计表中允许存储NULL的列有哪些。
  我们前边说过,主键列、被NOT NULL修饰的列都是不可以存储NULL值的,所以在统计的时候不会把这些列算进去。比方说表record_format_demo的3个列c1、c3、c4都是允许存储NULL值的,而c2列是被NOT NULL修饰,不允许存储NULL值。
    2.如果表中没有允许存储 NULL 的列,则 NULL值列表 也不存在了,否则将每个允许存储NULL的列对应一个二进制位,二进制位按照列的顺序逆序排列,二进制位表示的意义如下:
      二进制位的值为1时,代表该列的值为NULL。
      二进制位的值为0时,代表该列的值不为NULL。
  再一次强调,二进制位按照列的顺序逆序排列,所以第一个列c1和最后一个二进制位对应。
    3.MySQL规定NULL值列表必须用整数个字节的位表示,如果使用的二进制位个数不是整数个字节,则在字节的高位补0。
  表record_format_demo只有3个值允许为NULL的列,对应3个二进制位,不足一个字节,所以在字节的高位补0
  以此类推,如果一个表中有9个允许为NULL,那这个记录的NULL值列表部分就需要2个字节来表示了。

--------------------------

记录头信息

  除了变长字段长度列表、NULL值列表之外,还有一个用于描述记录的记录头信息,它是由固定的5个字节组成。5个字节也就是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----表示下一条记录的相对位置----

  现在没必要把它们的意思都记住,记住也没啥用,现在只需要看一遍混个脸熟,等之后用到这些属性的时候我们再回过头来看。

--------------------------
记录的真实数据

  对于record_format_demo表来说,记录的真实数据除了c1、c2、c3、c4这几个我们自己定义的列的数据以外,MySQL会为每个记录默认的添加一些列(也称为隐藏列),具体的列如下:

----列名----是否必须----占用空间----描述----
----row_id----否----6字节----行ID,唯一标识一条记录----
----transaction_id----是----6字节----事务ID----
----roll_pointer----是----7字节----回滚指针----

  实际上这几个列的真正名称其实是:DB_ROW_ID、DB_TRX_ID、DB_ROLL_PTR,我们为了美观才写成了row_id、transaction_id和roll_pointer。

--------------------------

INNODB表对主键的生成策略:

  优先使用用户自定义主键作为主键,如果用户没有定义主键,则选取一个Unique键作为主键,如果表中连Unique键都没有定义的话,则InnoDB会为表默认添加一个名为row_id的隐藏列作为主键。
  InnoDB存储引擎会为每条记录都添加 transaction_id 和 roll_pointer 这两个列,但是 row_id 是可选的(在没有自定义主键以及Unique键的情况下才会添加该列)。这些隐藏列的值不用我们操心,InnoDB存储引擎会自己帮我们生成的。

  因为表record_format_demo并没有定义主键,所以MySQL服务器会为每条记录增加上述的3个列。

  表record_format_demo使用的是ascii字符集,所以0x61616161就表示字符串'aaaa',0x626262就表示字符串'bbb',以此类推。
注意第1条记录中c3列的值,它是CHAR(10)类型的,它实际存储的字符串是:'cc',而ascii字符集中的字节表示是'0x6363',虽然表示这个字符串只占用了2个字节,但整个c3列仍然占用了10个字节的空间,除真实数据以外的8个字节的统统都用空格字符填充,空格字符在ascii字符集的表示就是0x20。
  注意第2条记录中c3和c4列的值都为NULL,它们被存储在了前边的NULL值列表处,在记录的真实数据处就不再冗余存储,从而节省存储空间。

--------------------------
CHAR(M)列的存储格式

  因为我们的record_format_demo表采用的是ascii字符集,这个字符集是一个定长字符集,也就是说表示一个字符采用固定的一个字节,如果采用变长的字符集(也就是表示一个字符需要的字节数不确定,比如gbk表示一个字符要1~2个字节、utf8表示一个字符要1~3个字节等)的话,c3列的长度也会被存储到变长字段长度列表中,比如我们修改一下record_format_demo表的字符集:

mysql> ALTER TABLE record_format_demo MODIFY COLUMN c3 CHAR(10) CHARACTER SET utf8;
Query OK, 2 rows affected (0.02 sec)
Records: 2 Duplicates: 0 Warnings: 0

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

  另外有一点还需要注意,变长字符集的CHAR(M)类型的列要求至少占用M个字节,而VARCHAR(M)却没有这个要求。比方说对于使用utf8字符集的CHAR(10)的列来说,该列存储的数据字节长度的范围是10~30个字节。即使我们向该列中存储一个空字符串也会占用10个字节,这是怕将来更新该列的值的字节长度大于原有值的字节长度而小于10个字节时,可以在该记录处直接更新,而不是在存储空间中重新分配一个新的记录空间,导致原有的记录空间成为所谓的碎片。

-----------------------------------------------------------
行溢出数据
VARCHAR(M)最多能存储的数据

  我们知道对于VARCHAR(M)类型的列最多可以占用65535个字节。其中的M代表该类型最多存储的字符数量,如果我们使用ascii字符集的话,一个字符就代表一个字节,VARCHAR(65535)是否可用?
MySQL对一条记录占用的最大存储空间是有限制的,除了BLOB或者TEXT类型的列之外,其他--所有的--列(不包括隐藏列和记录头信息)占用的字节长度--加起来--不能超过65535个字节。所以MySQL服务器建议我们把存储类型改为TEXT或者BLOB的类型。这个65535个字节除了列本身的数据之外,还包括一些其他的数据(storage overhead),比如说我们为了存储一个VARCHAR(M)类型的列,其实需要占用3部分存储空间:
  >真实数据
  >真实数据占用字节的长度
  NULL值标识,如果该列有NOT NULL属性则可以没有这部分存储空间
  如果该VARCHAR类型的列没有NOT NULL属性,那最多只能存储65532个字节的数据,因为真实数据的长度可能占用2个字节,NULL值标识需要占用1个字节;如果VARCHAR类型的列有NOT NULL属性,那最多只能存储65533个字节的数据,因为真实数据的长度可能占用2个字节,不需要NULL值标识。

  如果VARCHAR(M)类型的列使用的不是ascii字符集,那M的最大取值取决于该字符集表示一个字符最多需要的字节数。在列的值允许为NULL的情况下,gbk字符集表示一个字符最多需要2个字节,那在该字符集下,M的最大取值就是32766(也就是:65532/2),也就是说最多能存储32766个字符;utf8字符集表示一个字符最多需要3个字节,那在该字符集下,M的最大取值就是21844,就是说最多能存储21844(也就是:65532/3)个字符。

  上述所言在列的值允许为NULL的情况下,gbk字符集下M的最大取值就是32766,utf8字符集下M的最大取值就是21844,这都是在表中只有一个字段的情况下说的,一定要记住<一个行中的所有列>(不包括隐藏列和记录头信息)<占用的字节长度><加起来>不能超过65535个字节!

--------------------------
记录中的数据太多产生的溢出

  我们以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(c) VALUES(REPEAT('a', 65532));
Query OK, 1 row affected (0.00 sec)
  其中的REPEAT('a', 65532)是一个函数调用,它表示生成一个把字符'a'重复65532次的字符串。前边说过,MySQL中磁盘和内存交互的基本单位是页,也就是说MySQL是以页为基本单位来管理存储空间的,我们的记录都会被分配到某个页中存储。而一个页的大小一般是16KB,也就是16384字节,而一个VARCHAR(M)类型的列就最多可以存储65532个字节,这样就可能造成一个页存放不了一条记录的尴尬情况。

  在Compact和Redundant行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的一部分数据,把剩余的数据分散存储在几个其他的页中,然后记录的真实数据处用20个字节存储指向这些页的地址(当然这20个字节中还包括这些分散在其他页面中的数据的占用的字节数),从而可以找到剩余数据所在的页。

  对于Compact和Redundant行格式来说,如果某一列中的数据非常多的话,在本记录的真实数据处只会存储该列的前768个字节的数据和一个指向其他页的地址,然后把剩下的数据存放到其他页中,这个过程也叫做行溢出,存储超出768字节的那些页面也被称为溢出页。
  最后需要注意的是,不只是 VARCHAR(M) 类型的列,其他的 TEXT、BLOB 类型的列在存储数据非常多的时候也会发生行溢出。

--------------------------
行溢出的临界点

那发生行溢出的临界点是什么呢?也就是说在列存储多少字节的数据时就会发生行溢出?
  MySQL中规定一个页中至少存放两行记录,至于为什么这么规定我们之后再说,现在看一下这个规定造成的影响。以上边的varchar_size_demo表为例,它只有一个列c,我们往这个表中插入两条记录,每条记录最少插入多少字节的数据才会行溢出的现象呢?这得分析一下页中的空间都是如何利用的。
  每个页除了存放我们的记录以外,也需要存储一些额外的信息,乱七八糟的额外信息加起来需要132个字节的空间(现在只要知道这个数字就好了),其他的空间都可以被用来存储记录。
每个记录需要的额外信息是27字节。
  这27个字节包括下边这些部分:
2个字节用于存储真实数据的长度
1个字节用于存储列是否是NULL值
5个字节大小的头信息
6个字节的row_id列
6个字节的transaction_id列
7个字节的roll_pointer列
  假设一个列中存储的数据字节数为n,设计MySQL的大叔规定如果该列不发生溢出的现象,就需要满足下边这个式子:
132 + 2×(27 + n) < 16384
  求解这个式子得出的解是:n < 8099。也就是说如果一个列中存储的数据小于8099个字节,那么该列就不会成为溢出列,否则该列就需要成为溢出列。不过这个8099个字节的结论只是针对只有一个列的varchar_size_demo表来说的,如果表中有多个列,那上边的式子和结论都需要改一改了,所以重点就是:你不用关注这个临界点是什么,只要知道如果我们一条记录的某个列中存储的数据占用的字节数非常多时,该列就可能成为溢出列。

--------------------------

Dynamic和Compressed行格式

mysql> show variables like "%ROW_FORMAT%";

+---------------------------+---------+
| Variable_name | Value |

+---------------------------+---------+
| innodb_default_row_format | dynamic |

+---------------------------+---------+
1 row in set (0.00 sec)

  下边要介绍另外两个行格式,Dynamic和Compressed行格式,我现在使用的MySQL版本是5.7,它的默认行格式就是Dynamic,这俩行格式和Compact行格式挺像,只不过在处理行溢出数据时有点儿分歧,它们不会在记录的真实数据处存储字段真实数据的前768个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址,就像这样:

image_1conbtnmr1sg1hao1nf41pi1eb72a.png-29.9kB
  Compressed行格式和Dynamic不同的一点是,Compressed行格式会采用压缩算法对页面进行压缩,以节省空间。

--------------------------
The default 16KB page size or larger is appropriate for a wide range of workloads, particularly for queries involving table scans and DML operations involving bulk updates
  默认的16KB或更大的页面大小适用于各种工作负载,特别是涉及表扫描和涉及批量更新的DML操作的查询。

原文地址:https://www.cnblogs.com/bruceChan0018/p/14898723.html