《MySQL是怎样运行的 —— 从跟上理解MySQL》—— 第四章
一、前言
到现在为止,MySQL
对于我们来说还是一个黑盒,我们只负责使用客户端发送请求并等待服务器返回结果,表中的数据到底存到了哪里?以什么格式存放的?MySQL
是以什么方式来访问的这些数据?
前面提到过,MySQL服务器程序上负责对表中数据进行读取和写入的部分是存储引擎,而服务器又支持不同类型的存储引擎,不同的存储引擎一般是由不同的人为实现不同的特性而开发的,真实数据在不同存储引擎中存放的格式一般是不同的。
由于InnoDB是MySQL中默认的存储引擎,也是最常用的存储引擎,因此以它为核心来看看数据的存储结构。
二、 InnoDB页简介
InnoDB将一个表中的数据存储到磁盘上,在真正处理数据的时候,将数据从磁盘中加载内存中,同时还需要将内存中的内容刷新到磁盘上。由于磁盘和内存的读写速度差别较大,因此InnoDB不会一条一条读取记录,而是将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,InnoDB中页的大小一般为16KB。即在一般的情况下,依次最少将16KB的数据从磁盘加载到内存中、从内存刷新到磁盘上。
三、InnoDB行格式
数据在磁盘上存放方式也被称为行格式或者记录格式。InnoDB中存在四种不同类型的行格式,Compact
、Redundant
、Dynamic
、Compressed
。
3.1 指定行格式的语法
我们可以在创建或者修改表的时候指定行格式。
命令:
CREATE TABLE 表名( |
【🌰栗子】
USE xiaohaizi; |
上述创建的这个表的行格式
就是Compact
。此外,还显式的指定了表的字符集为ascii
,因为ascii
字符集只包括空格、标点符号、数字、大小写字母和一些不可见字符,所以我们的汉字是不能存到这个表里的。
插入数据:
INSERT INTO record_format_demo(c1, c2, c3, c4) VALUES('aaaa', 'bbb', 'cc', 'd'), ('eeee', 'fff', NULL, NULL); |
目前表的情况:
select * from record_format_demo; |
上述数据作为示例数据。
3.2 COMPACT行格式
从上图可以看出,一条完整的记录其实可以被分为记录的额外信息和记录的真实数据两大部分。
3.2.1 记录额外信息
变长字段长度列表
MySQL支持一些变长的数据类型,比如VARCHAR(M)
、VARBINARY(M)
、各种TEXT
类型,各种BLOB
类型,我们也可以把拥有这些数据类型的列称为变长字段。
变长字段中存储多少字节的数据是不固定的,因此我们在存储真实数据的时候需要把这些数据占用的字节数存储起来。
=> 这些变长字段占用的存储空间分为两部分:
- 真正的数据内容
- 占用的字节数
在Compact
行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表,各变长字段数据占用的字节数按列的顺序逆序存放。
【🌰栗子】record_format_demo
表中第一条记录
因为record_format_demo
表的c1
、c2
、c4
列都是VARCHAR(10)
类型的,也就是变长的数据类型,所以这三个列的值的长度都需要保存在记录开头处,因为record_format_demo
表中的各个列都使用的是ascii
字符集,所以每个字符只需要1个字节来进行编码,第一条记录各变长字段内容的长度:
列名 | 存储内容 | 内容长度(十进制表示) | 内容长度(十六进制表示) |
---|---|---|---|
c1 |
aaaa |
4 |
0x04 |
c2 |
bbb |
3 |
0x03 |
c4 |
d |
1 |
0x01 |
因为这些长度值需要按照列的顺序逆序存放,所以最后变长字段长度列表的字节串用十六进制表示的效果(实际上各个字节之间没有空格,只是为了理解方便):
01 03 04 |
由于第一行记录中c1
、c2
、c4
列中的字符串都比较短,也就是说内容占用的字节数比较小,用1个字节就可以表示,但是如果变长列的内容占用的字节数比较多,可能就需要用2个字节来表示。
具体用1个还是2个字节来表示真实数据占用的字节数,InnoDB有它自己的一套规则,举个例子来说明:
1)W
、M
和L
的意思:
- 假设某个字符集中表示一个字符最多需要使用的字节数为
W
,即使用SHOW CHARSET
语句的结果中的Maxlen
列,比如:utf8
字符集中的W
就是3
,gbk
字符集中的W
就是2
,ascii
字符集中的W
就是1 - 对于变长类型
VARCHAR(M)
来说,这种类型表示能存储最多M
个字符,所以这个类型能表示的字符串最多占用的字节数就是M×W
- 假设它实际存储的字符串占用的字节数是
L
2)使用1个字节还是2个字节的规则:
如果
M×W <= 255
,那么使用1个字节来表示真正字符串占用的字节数InnoDB在读记录的变长字段长度列表时先查看表结构,如果某个变长字段允许存储的最大字节数不大于255时,可以认为只使用1个字节来表示真正字符串占用的字节数。
如果
M×W > 255
- 如果
L <= 127
,则用1个字节来表示真正字符串占用的字节数 - 如果
L > 127
,则用2个字节来表示真正字符串占用的字节数
InnoDB在读记录的变长字段长度列表时先查看表结构,如果某个变长字段允许存储的最大字节数大于255时,该怎么区分它正在读的某个字节是一个单独的字段长度还是半个字段长度呢? 使用该字节的第一个二进制位作为标志位:如果该字节的第一个位为0,那该字节就是一个单独的字段长度(使用一个字节表示不大于127的二进制的第一个位都为0),如果该字节的第一个位为1,那该字节就是半个字段长度。
- 如果
总结:如果可变字段允许存储的最大字节数(M×W
)超过255字节并且真实存储的字节数(L
)超过127字节,则使用2个字节,否则使用1个字节。
📢注意:变长字段长度列表只存储值为非NULL的列内容占用的长度,值为NULL的列的长度是不存储的。
那么对于第二条记录来说,因为c4
列的值为NULL
,所以第二条记录的变长字段长度列表
只需要存储c1
和c2
列的长度即可。
并不是所有记录都有这个变长字段长度列表部分,比方说表中所有的列都不是变长的数据类型的话,这一部分就不需要有。
NULL值列表
Compact
行格式将值为NULL的列统一管理起来,存储到NULL值列表中。
1)统计表中允许存储NULL
的列
📢注意:如果表中没有允许存储NULL的列,则 NULL值列表也不存在了
2)将每个允许存储NULL
的列对应一个二进制位,二进制位按照列的顺序逆序排列
- 二进制位的值为
1
时,代表该列的值为NULL
- 二进制位的值为
0
时,代表该列的值不为NULL
3)MySQL
规定NULL值列表
必须用整数个字节的位表示,如果使用的二进制位个数不是整数个字节,则在字节的高位补0
【🌰栗子】表record_format_demo
只有3个值允许为NULL
的列,对应3个二进制位,不足一个字节,所以在字节的高位补0
。
对于第一条记录来说,c1
、c3
、c4
这3个列的值都不为NULL
,所以它们对应的二进制位都是0
,所以第一条记录的NULL值列表
用十六进制表示就是:0x00
对于第二条记录来说,c1
、c3
、c4
这3个列中c3
和c4
的值都为NULL
,所以它们对应的二进制位都是1
,所以第二条记录的NULL值列表
用十六进制表示就是:0x06
这两条记录在填充了NULL值列表
后的示意图:
记录头信息
除了变长字段长度列表
、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
中的两条记录的头信息:
3.2.2 记录的真实数据
在表中记录的真实数据除了自定义列的数据之外,MySQL会为每个记录默认添加一些列(也被称为隐藏列),具体的列信息:
列名 | 是否必须 | 占用空间 | 描述 |
---|---|---|---|
DB_ROW_ID |
否 | 6字节 | 行ID,唯一标识一条记录 |
DB_TRANSACTION_ID |
是 | 6字节 | 事务ID |
DB_ROLL_POINTER |
是 | 7字节 | 回滚指针 |
为了方便,在下文中,将上述三个隐藏列称为
row_id
、transaction_id
、roll_pointer
其中的row_id
不是必须的是因为InnoDB表对主键的生成策略:优先使用用户自定义主键作为主键,如果用户没有定义主键,则选取一个Unique
键作为主键,如果表中连Unique
键都没有定义的话,则InnoDB
会为表默认添加一个名为row_id
的隐藏列作为主键。
因为表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值列表中,在记录的真实数据处就不再冗余存储,从而节省存储空间。
3.2.3 CHAR(M)列的存储格式
record_format_demo
表的c1
、c2
、c4
列的类型是VARCHAR(10)
,而c3
列的类型是CHAR(10)
,在Compact
行格式下只会把变长类型的列的长度逆序存到变长字段长度列表
中。
但这只是因为record_format_demo
表采用的是ascii
字符集这个字符集是一个定长字符集,即表示一个字符采用固定的一个字节,如果采用变长的字符集,c3
列的长度也会被存储到变长字段长度列表
中,比如使用utf8字符集(1~3个字节表示一个字符)。
ALTER TABLE record_format_demo MODIFY COLUMN c3 CHAR(10) CHARACTER SET utf8; |
=> 对于 CHAR(M)
类型的列来说,当列采用的是定长字符集时,该列占用的字节数不会被加到变长字段长度列表,而如果采用变长字符集时,该列占用的字节数也会被加到变长字段长度列表。
除此之外,变长字符集的CHAR(M)
类型的列要求至少占用M
个字节。
【🌰栗子】
使用utf8
字符集的CHAR(10)
的列来说,该列存储的数据字节长度的范围是10~30个字节。即使我们向该列中存储一个空字符串也会占用10
个字节,这是为了将来更新该列的值的字节长度大于原有值的字节长度而小于10个字节时,可以在该记录处直接更新,而不是在存储空间中重新分配一个新的记录空间,导致原有的记录空间成为碎片。
3.3 REDUNDANT行格式
看书了解下就好
3.4 溢出列
3.4.1 VARCHAR(M)最多能存储的数据
对于VARCHAR(M)
类型的列最多可以占用65535
个字节。其中的M
代表该类型最多存储的字符数量,如果使用ascii
字符集的话,一个字符就代表一个字节。
CREATE TABLE varchar_size_demo ( |
从报错信息里可以看出,MySQL
对一条记录占用的最大存储空间是有限制的,除了BLOB
或者TEXT
类型的列之外,其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过65535
个字节。所以MySQL
服务器建议我们把存储类型改为TEXT
或者BLOB
的类型。
这个65535
个字节除了列本身的数据之外,还包括一些其他的数据(storage overhead
)
比如为了存储一个VARCHAR(M)
类型的列,其实需要三部分存储空间:
- 真实数据
- 真实数据占用字节的长度
NULL
值标识,如果该列有NOT NULL
属性则可以没有这部分存储空间
【🌰栗子】
如果该VARCHAR
类型的列没有NOT NULL
属性,那最多只能存储65532
个字节的数据,因为真实数据的长度可能占用2个字节,NULL
值标识需要占用1个字节
CREATE TABLE varchar_size_demo( |
如果VARCHAR
类型的列有NOT NULL
属性,那最多只能存储65533
个字节的数据,因为真实数据的长度可能占用2个字节,不需要NULL
值标识
DROP TABLE varchar_size_demo; |
如果VARCHAR(M)
类型的列使用的不是ascii
字符集,情况有所不同
DROP TABLE varchar_size_demo; |
从执行结果中可以看出,如果VARCHAR(M)
类型的列使用的不是ascii
字符集,那M
的最大取值取决于该字符集表示一个字符最多需要的字节数。在列的值允许为NULL
的情况下,gbk
字符集表示一个字符最多需要2
个字节,那在该字符集下,M
的最大取值就是32766
(也就是:65532/2
),也就是说最多能存储32766
个字符;utf8
字符集表示一个字符最多需要3
个字节,那在该字符集下,M
的最大取值就是21844
,就是说最多能存储21844
(也就是:65532/3
)个字符。
📢注意:这都是在表中只有一个字段的情况下说的。但是一点是明确的,一个行中的所有列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过65535个字节。
3.4.2 溢出列
以ascii
字符集下的varchar_size_demo
表为例,插入一条记录:
CREATE TABLE varchar_size_demo( |
其中的
REPEAT('a', 65532)
是一个函数调用,它表示生成一个把字符'a'
重复65532
次的字符串。
MySQL中磁盘和内存交互的基本单位是页,也就是说MySQL是以页为基本单位来管理存储空间的,我们的记录都会被分配到某个页中存储。而一个页的大小一般是16KB
,也就是16384
字节,而一个VARCHAR(M)
类型的列就最多可以存储65532
个字节,这样就可能造成一个页存放不了一条记录的情况。
在Compact
和Reduntant
行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的一部分数据,把剩余的数据分散存储在几个其他的页中,然后记录的真实数据处用20个字节存储指向这些页的地址(这20个字节中还包括这些分散在其他页面中的数据的占用的字节数),从而可以找到剩余数据所在的页。
对于Compact
和Reduntant
行格式来说,如果某一列中的数据非常多的话,在本记录的真实数据处只会存储该列的前768
个字节的数据和一个指向其他页的地址,然后把剩下的数据存放到其他页中,这个过程也叫做行溢出
,存储超出768
字节的那些页面也被称为溢出页
。简图:
📢注意:不只是VARCHAR(M)
类型的列,其他的
TEXT
、BLOB
类型的列在存储数据非常多的时候也会发生行溢出。
3.4.3 产生溢出页的临界点
发生行溢出
的临界点是什么呢?也就是说在列存储多少字节的数据时就会发生行溢出
?
MySQL中规定一个页中至少存放两行记录。
【🌰栗子】以上面的varchar_size_demo
表为例,它只有一个列c
,我们往这个表中插入两条记录,每条记录最少插入多少字节的数据才会行溢出
的现象呢?分析空间利用:
- 每个页除了存放记录以外,也需要存储一些额外的信息,额外信息加起来需要
136
个字节的空间,其他的空间都可以被用来存储记录 - 每个记录需要的额外信息是27字节
这27个字节包括下面这些部分:
- 2个字节用于存储真实数据的长度
- 1个字节用于存储列是否是NULL值
- 5个字节大小的头信息
- 6个字节的
row_id
列- 6个字节的
transaction_id
列- 7个字节的
roll_pointer
列
假设一个列中存储的数据字节数为n,那么发生行溢出
现象时需要满足
136 + 2 × (27 + n) > 16384
=>
n > 8098
。也就是说如果一个列中存储的数据不大于8098
个字节,那就不会发生行溢出
,否则就会发生行溢出
。
📢注意:这个8098
个字节的结论只是针对只有一个列的varchar_size_demo
表来说的,如果表中有多个列,上述式子需要进行修改。重点是:不用关注这个临界点是什么,只要知道如果向一个行中存储了很大的数据时,可能发生行溢出的现象。
3.5 DYNAMIC行格式和COMPRESSED行格式
这两个行格式和COMPACT
行格式类似,只不过在处理行溢出数据时有点儿分歧,它们不会在记录的真实数据处存储字段真实数据的前768
个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址。
COMPRESSED
行格式和DYNAMIC
不同的一点是,COMPRESSED
行格式会采用压缩算法对页面进行压缩,以节省空间。