MySQL一行记录是怎么存储的?
MySQL一行记录是怎么存储的?
如果你知道 MySQL 一行记录的存储结构,那么这个问题对你没什么难度。
如果你不知道也没关系,这次我跟大家聊聊 MySQL 一行记录是怎么存储的?
知道了这个之后,除了能应解锁前面这道面试题,你还会解锁这些面试题:
- MySQL 的 NULL 值会占用空间吗?
- MySQL 怎么知道 varchar(n) 实际占用数据的大小?
- varchar(n) 中 n 最大取值为多少?
- 行溢出后,MySQL 是怎么处理的?
这些问题看似毫不相干,其实都是在围绕「 MySQL 一行记录的存储结构」这一个知识点,所以攻破了这个知识点后,这些问题就引刃而解了。
好了,话不多说,发车!
MySQL 的数据存储
创建一个数据库
比如,我这里有一个名为 my_test 数据库,该 database 里有一张名为 t_order 数据库表。
mysql> SHOW VARIABLES LIKE 'datadir';
+---------------+-----------------+
| Variable_name | Value |
+---------------+-----------------+
| datadir | /var/lib/mysql/ |
+---------------+-----------------+
1 row in set (0.00 sec)
此时,理应生成一个用于存储的数据库文件。
大家都知道 MySQL 的数据都是保存在磁盘的,那具体是保存在哪个文件呢?
数据库文件的位置
这里以 InnoDB 存储引擎为基准展开讨论
MySQL 存储的行为是由存储引擎实现的,MySQL 支持多种存储引擎,不同的存储引擎保存的文件自然也不同。
InnoDB 是我们常用的存储引擎,也是 MySQL 默认的存储引擎。所以,本文主要以 InnoDB 存储引擎展开讨论。
我们创建数据库后,会创建一个目录 /var/lib/mysql/数据库名
,然后保存表结构和表数据的文件都会存放在这个目录里
然后,我们进入这个目录,看看里面有什么文件?
$ ls /var/lib/mysql/my_test # 可以看到该路径下有三个文件:
db.opt
t_order.frm
t_order.ibd
db.opt | 用来存储当前数据库的默认字符集和字符校验规则 |
t_order.frm | 保存 (t_order) 表的元数据信息的,主要包含表结构定义 |
t_order.ibd | 保存t_order 的表数据 |
好了,现在我们知道了一张数据库表的数据是保存在 表名字.ibd
的文件里的,这个文件也称为独占表空间文件。
数据库文件的结构
表空间文件的结构是怎么样的?
表空间由段(segment)、区(extent)、页(page)、行(row)组成,InnoDB存储引擎的逻辑存储结构大致如下图:
下面我们从下往上一个个看看。
行(row)
数据库表中的记录都是按行(row)进行存放的,每行记录根据不同的行格式,有不同的存储结构。
后面我们详细介绍 InnoDB 存储引擎的行格式,也是本文重点介绍的内容。
页(page)
读写的最小单位
记录是按照行来存储的,但是数据库的读取并不以「行」为单位,否则一次读取(也就是一次 I/O 操作)只能处理一行数据,效率会非常低。
因此,InnoDB 的数据是按「页」为最小单位来读写的(也即是内存管理的最小单位、内存和磁盘进行数据交换的最小单位)(默认每个页的大小为 16KB,也就是最多能保证 16KB 的连续存储空间)
即当需要读一条记录的时候,并不是将这个行记录从磁盘读出来,而是以页为单位,将其整体读入内存。
页的类型。常见的有:
- 数据页
- 其中,数据表中的行记录是用**「数据页」**来管理的,数据页的结构这里我就不讲细说了,之前文章有说过,感兴趣的可以去看这篇文章:换一个角度看 B+ 树。总之知道表中的记录存储在「数据页」里面就行。
- undo 日志页
- 溢出页
- 等等
- 数据页
区(extent)
我们知道 InnoDB 存储引擎是用 B+ 树来组织数据的。区的作用是使:B+ 树的相邻页的物理位置也相邻
区让B+ 树相邻页的物理位置也相邻
为什么要这样做:
B+ 树中每一层都是通过双向链表连接起来的,如果是以页为单位来分配存储空间,那么链表中相邻的两个页之间的物理位置并不是连续的,可能离得非常远,那么磁盘查询时就会有大量的随机I/O,随机 I/O 是非常慢的。
解决这个问题也很简单,就是让链表中相邻的页的物理位置也相邻,这样就可以使用顺序 I/O 了,那么在范围查询(扫描叶子节点)的时候性能就会很高。
那具体怎么解决呢?
在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区(extent)为单位分配。
每个区的大小为 1MB,连续的 64 个 (16KB的) 页会被划为一个区,这样就使得链表中相邻的页的物理位置也相邻,就能使用顺序 I/O 了
段(segment)
表空间是由各个段(segment)组成的,段是由多个区(extent)组成的。段一般分为三种:
- 索引段: 存放 B + 树的非叶子节点的区的集合;
- 数据段: 存放 B + 树的叶子节点的区的集合;
- 回滚段: 存放的是回滚数据的区的集合,之前讲事务隔离的时候就介绍到了 MVCC 利用了回滚段实现了多版本查询数据。
InnoDB 行格式有哪些
好了,终于说完表空间的结构了。接下来,就具体讲一下 InnoDB 的行格式了。
之所以要绕一大圈才讲行记录的格式,主要是想让大家知道行记录是存储在哪个文件,以及行记录在这个表空间文件中的哪个区域,有一个从上往下切入的视角,这样理解起来不会觉得很抽象。
4种行格式
行格式(row_format),就是一条记录的存储结构。
InnoDB 提供了 4 种行格式,分别是:
Redundant,
缺点:它是一种不紧凑的行格式
版本:是很古老的行格式了, MySQL 5.0 版本之前用的行格式,现在基本没人用了。
Compact,
优点:Compact 是一种紧凑的行格式,设计的初衷就是为了让一个数据页中可以存放更多的行记录。
版本:MySQL 5.0 之后引入了 Compact 行记录存储方式,从 MySQL 5.1 版本之后,行格式默认设置成 Compact。
Dynamic,
优点:Dynamic 和 Compressed 两个都是紧凑的行格式,它们的行格式都和 Compact 差不多,因为都是基于 Compact 改进一点东西。
版本:从 MySQL 5.7 版本之后,默认使用 Dynamic 行格式。
Compressed,
COMPACT 行格式
下面重点介绍 Compact 行格式。Redundant 现在基本没人用就不讲了,而 Dynamic 和 Compressed 这两个行格式跟 Compact 非常像,先弄懂 Compact 行格式,之后再去了解其他行格式,很快也能看懂。
先跟 Compact 行格式混个脸熟,它长这样:
一条完整的记录组成:
- 记录的额外信息
- 变长字段长度列表 ( Byte,可无)
- NULL 值列表 ( Byte,可无)
- 记录头信息
- delete_mask
- next_record
- record_type
- 记录的真实数据
- row_id (6Byte,可无,隐藏主键)
- trx_id (6Byte),事务id
- roll_pointer (7Byte)
- [...列值] ( Byte)
记录的额外信息
变长字段长度列表
数据准备
varchar(n)
和 char(n)
的区别是什么,相信大家都非常清楚。char 是定长的,varchar 是变长的,变长字段实际存储的数据的长度(大小)不固定的。
所以,在存储数据的时候,也要把数据占用的大小存起来,存到「变长字段长度列表」里面,读取数据的时候才能根据这个「变长字段长度列表」去读取对应长度的数据。其他 TEXT、BLOB 等变长字段也是这么实现的。
为了展示「变长字段长度列表」具体是怎么保存「变长字段的真实数据占用的字节数」,我们先创建这样一张表,字符集是 ascii(所以每一个字符占用的 1 字节),行格式是 Compact,t_user 表中 name 和 phone 字段都是变长字段:
CREATE TABLE `t_user` (
`id` int(11) NOT NULL, # 定长
`name` VARCHAR(20) DEFAULT NULL, # 变长字段
`phone` VARCHAR(20) DEFAULT NULL, # 变长字段
`age` int(11) DEFAULT NULL, # 定长
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB DEFAULT CHARACTER SET = ascii ROW_FORMAT = COMPACT; # 行格式
现在 t_user 表里有这三条记录:
数据分析
接下来,我们看看看看这三条记录的行格式中的 「变长字段长度列表」是怎样存储的。
来看第一条记录:
- name 列的值为 a,真实数据占用的字节数是 1 字节,十六进制 0x01;
- phone 列的值为 123,真实数据占用的字节数是 3 字节,十六进制 0x03;
- age 列和 id 列不是变长字段,所以这里不用管。
这些变长字段的真实数据占用的字节数会按照列的顺序逆序存放(等下会说为什么要这么设计),所以「变长字段长度列表」里的内容是「 03 01」,而不是 「01 03」。
来看第二条记录:同样的道理,我们也可以得出「变长字段长度列表」里的内容是「 04 02」,如下图:
来看第三条记录:phone 列的值是 NULL,NULL 是不会存放在行格式中记录的真实数据部分里的,所以「变长字段长度列表」里不需要保存值为 NULL 的变长字段的长度。
为什么「变长字段长度列表」和「 NULL 值列表」的信息要按照逆序存放?
特点
- 这个设计主要是因为「记录头信息」中指向下一个记录的指针,而这个指针是记录的「记录头信息」和「真实数据」之间的位置
好处:
- 向左读就是记录头信息,向右读就是真实数据,比较方便。
- 同时,逆序存放可以使得位置靠前的记录的真实数据和数据对应的字段长度信息可以同时在一个 CPU Cache Line 中,提高 CPU Cache 命中率
如果你不知道什么是 CPU Cache,可以看这篇文章,这属于计算机组成的知识。
每个数据库表的行格式都有「变长字段字节数列表」吗?
其实「变长字段字节数列表」不是必须的,只出现在数据表有变长字段的时候。
原因:没必要,不如去掉以节省空间。
NULL 值列表
表中的某些列可能会存储 NULL 值,如果把这些 NULL 值都放到记录的真实数据中会比较浪费空间,所以 Compact 行格式把这些值为 NULL 的列存储到 NULL值列表中。
如果存在允许 NULL 值的列,则每个列对应一个二进制位(bit),二进制位按照列的顺序逆序排列。
- 二进制位的值为
1
时,代表该列的值为NULL。 - 二进制位的值为
0
时,代表该列的值不为NULL。
另外,NULL 值列表必须用整数个字节的位表示(1字节8位),如果使用的二进制位个数不足整数个字节,则在字节的高位补 0
。
还是以 t_user 表的这三条记录作为例子:
接下来,我们看看看看这三条记录的行格式中的 NULL 值列表是怎样存储的。
来看第一条记录:第一条记录所有列都有值,不存在 NULL 值,所以用二进制来表示是酱紫的:
但是 InnoDB 是用整数字节的二进制位来表示 NULL 值列表的,现在不足 8 位,所以要在高位补 0,最终用二进制来表示是酱紫的:
所以,对于第一条数据,NULL 值列表用十六进制表示是 0x00。
来看第二条记录:第二条记录 age 列是 NULL 值,所以,对于第二条数据,NULL值列表用十六进制表示是 0x04。
来看第三条记录:第三条记录 phone 列 和 age 列是 NULL 值,所以,对于第三条数据,NULL 值列表用十六进制表示是 0x06。
我们把三条记录的 NULL 值列表都填充完毕后,它们的行格式是这样的:
每个数据库表的行格式都有「NULL 值列表」吗?
NULL 值列表也不是必须的。当数据表的字段都定义成 NOT NULL 的时候,这时候表里的行格式就不会有 NULL 值列表了。
所以在设计数据库表的时候,通常都是建议将字段设置为 NOT NULL,这样可以至少节省 1 字节的空间(NULL 值列表至少占用 1 字节空间)。
「NULL 值列表」是固定 1 字节空间吗?如果这样的话,一条记录有 9 个字段值都是 NULL,这时候怎么表示?
不是固定 1 字节。当一条记录有 9 个字段值都是 NULL,那么就会创建 2 字节空间的「NULL 值列表」,以此类推。
记录头信息
记录头信息中包含的内容很多,我就不一一列举了,这里说几个比较重要的:
delete_mask:
标识此条数据是否被删除。从这里可以知道,我们执行 detele 删除记录的时候,并不会真正的删除记录,只是将这个记录的 delete_mask 标记为 1。
next_record:
下一条记录的位置。从这里可以知道,记录与记录之间是通过链表组织的。在前面我也提到了,指向的是下一条记录的「记录头信息」和「真实数据」之间的位置,这样的好处是向左读就是记录头信息,向右读就是真实数据,比较方便。
record_type:
表示当前记录的类型,0表示普通记录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录
记录的真实数据
记录真实数据部分除了我们定义的字段,还有三个隐藏字段,分别为:row_id、trx_id、roll_pointer,我们来看下这三个字段是什么。
row_id (6Byte,可无,隐藏主键)
如果我们建表的时候指定了主键或者唯一约束列,那么就没有 row_id 隐藏字段了。如果既没有指定主键,又没有唯一约束,那么 InnoDB 就会为记录添加 row_id 隐藏字段。row_id不是必需的,占用 6 个字节。
trx_id (6Byte),事务id
事务id,表示这个数据是由哪个事务生成的。 trx_id是必需的,占用 6 个字节。
roll_pointer (7Byte)
这条记录上一个版本的指针。roll_pointer 是必需的,占用 7 个字节。
[...列值]
略
如果你熟悉 MVCC 机制,你应该就清楚 trx_id 和 roll_pointer 的作用了。
如果你还不知道 MVCC 机制,可以看完这篇文章,一定要掌握,面试也很经常问 MVCC 是怎么实现的。
一行的最大字节
一行的最大字节
也就是说,除了 TEXT、BLOBs 大对象类型的列,一行记录限制最大为 65535 字节(注意是一行的总长度,不是一列,而是多列的和)
varchar(n) 中 n 最大取值为多少?
知道了这个前提之后,我们再来看看这个问题:「varchar(n) 中 n 最大取值为多少?」
varchar(n) 字段类型的 n 代表的是最多存储的字符数量,并不是字节大小哦。
要算 varchar(n) 最大能允许存储的字节数,还要看数据库表的字符集,因为字符集代表着,1个字符要占用多少字节。(比如 ascii 字符集, 1 个字符占用 1 字节,那么 varchar(100) 意味着最大能允许存储 100 字节的数据)
单字段的情况 (一个字符占用一个字节)
前面我们知道了,一行记录最大只能存储 65535 字节的数据。
那假设数据库表只有一个 varchar(n) 类型的列且字符集是 ascii,在这种情况下, varchar(n) 中 n 最大取值是 65535 吗?
原因、计算分析
顺便复习一下之前的行记录组成:
- 记录的额外信息
- 变长字段长度列表 ( Byte,可无)
- NULL 值列表 ( Byte,可无)
- 记录头信息
- delete_mask
- next_record
- record_type
- 记录的真实数据
- row_id (6Byte,可无,隐藏主键)
- trx_id (6Byte),事务id
- roll_pointer (7Byte)
- [...列值] ( Byte)
在这个案例中,我们存储字段类型为 varchar(n) 的数据时,其实涉及了三个部分来存储:
NULL 标识 (1Byte)
- 一般规则:如果不允许为NULL,这部分不需要。否则则应该按位存储,向上取整到字节。
- 本次案例:前面我创建表的时候,字段是允许为 NULL 的,所以会用 1 字节来表示「NULL 值列表」。
真实数据占用的字节数 (2Byte)
一般规则:具体情况分为:
- 条件一:如果变长字段允许存储的最大字节数小于等于 255 字节,就会用 1 字节表示「变长字段长度」;
- 条件二:如果变长字段允许存储的最大字节数大于 255 字节,就会用 2 字节表示「变长字段长度」;
本次案例:
- 我们这里字段类型是 varchar(65535) ,字符集是 ascii,所以代表着变长字段允许存储的最大字节数是 65535,符合条件二,所以会用 2 字节来表示「变长字段长度」。
- 因为我们这个案例是只有 1 个变长字段,所以「变长字段长度列表」= 1 个「变长字段长度」占用的字节数,也就是 2 字节。
真实数据 (65533Byte)
一般规则:就是你这部分占了多少。在单字段的情况下,n就表示n个字节。在多字段的情况下,具体分析
本次案例:全部加起来最大值值是65535,前面两项已经被定下来了。因为我们在算 varchar(n) 中 n 最大值时,需要减去 「变长字段长度列表」和 「NULL 值列表」所占用的字节数的。
所以,在本次案例中(数据库表只有一个 varchar(n) 字段且字符集是 ascii 的情况下)
而后面的实验结果也能够说明我们的推论是正确的,在算 varchar(n) 中 n 最大值时,需要减去 「变长字段长度列表」和 「NULL 值列表」所占用的字节数的。
实验
可以做实验验证一下:
65535
我们定义一个 varchar(65535) 类型的字段,字符集为 ascii 的数据库表。
CREATE TABLE test (
`name` VARCHAR(65535) NULL
) ENGINE = InnoDB DEFAULT CHARACTER SET = ascii ROW_FORMAT = COMPACT;
看能不能成功创建一张表:
可以看到,创建失败了。
从报错信息就可以知道一行数据的最大字节数是 65535(不包含 TEXT、BLOBs 这种大对象类型),其中包含了 storage overhead。
问题来了,这个 storage overhead 是什么呢?其实就是「变长字段长度列表」和 「NULL 值列表」,也就是说一行数据的最大字节数 65535,其实是包含「变长字段长度列表」和 「NULL 值列表」所占用的字节数的。所以, 我们在算 varchar(n) 中 n 最大值时,需要减去 storage overhead 占用的字节数。
65533
我们先来测试看看 varchar(65533) 是否可行?
65532
可以看到,还是不行,接下来看看 varchar(65532) 是否可行?
可以看到,创建成功了。
多字段的情况 (一个字符占用多个字节)
当然,在这之前的例子是针对字符集为 ascii 情况,如果采用的是 UTF-8,varchar(n) 最多能存储的数据计算方式就不一样了:在 UTF-8 字符集下,一个字符最多需要三个字节,varchar(n) 的 n 最大取值就是 65532/3 = 21844。
如果有多个字段的话,要保证所有字段的长度 + 变长字段字节数列表所占用的字节数 + NULL值列表所占用的字节数 <= 65535。
这里举个多字段的情况的例子(感谢@Emoji同学提供的例子)
行溢出后,MySQL 是怎么处理的?
行溢出现象
MySQL 中磁盘和内存交互的基本单位是页。但有时一个页可能存不了一条记录,如:
- 一个页的大小:一般是
16KB
,也就是16384字节
- 一个 varchar(n) 类型的列:最多可以存储
65532字节
- 一些大对象:如 TEXT、BLOB 可能存储更多的数据
这个时候就会发生行溢出,多的数据就会存到另外的「溢出页」中。
解决方案 (「数据页」与「溢出页」)
如果一个数据页存不了一条记录,InnoDB 存储引擎会自动将溢出的数据存放到「溢出页」中。
- 在一般情况下,InnoDB 的数据都是存放在 **「数据页」**中。
- 但是当发生行溢出时,溢出的数据会存放到**「溢出页」**中。
对于 Compact 行格式
当发生行溢出时,在记录的真实数据处只会保存该列的一部分数据,而把剩余的数据放在「溢出页」中,然后真实数据处用 20 字节存储指向溢出页的地址,从而可以找到剩余数据所在的页。大致如下图所示。
对于 Compressed 和 Dynamic 行格式
这两个行格式和 Compact 非常类似,主要的区别在于处理行溢出数据时有些区别。这两种格式采用完全的行溢出方式,记录的真实数据处不会存储该列的一部分数据,只存储 20 个字节的指针来指向溢出页。而实际的数据都存储在溢出页中,看起来就像下面这样:
总结
MySQL 的 NULL 值是怎么存放的?
MySQL 的 Compact 行格式中会用「NULL值列表」来标记值为 NULL 的列,NULL 值并不会存储在行格式中的真实数据部分。
- NULL值列表会占用 1 字节空间。(可NULL列除以8并向上取整)
- 当表中所有字段都定义成 NOT NULL,行格式中就不会有 NULL值列表,这样可节省 1 字节的空间。
MySQL 怎么知道 varchar(n) 实际占用数据的大小?
MySQL 的 Compact 行格式中会用「变长字段长度列表」存储变长字段实际占用的数据大小。
varchar(n) 中 n 最大取值为多少?
一行记录最大能存储 65535 字节的数据,但是这个是包含「变长字段字节数列表所占用的字节数」和「NULL值列表所占用的字节数」。所以, 我们在算 varchar(n) 中 n 最大值时,需要减去这两个列表所占用的字节数。
如果一张表只有一个 varchar(n) 字段,且允许为 NULL,字符集为 ascii。varchar(n) 中 n 最大取值为 65532。
计算公式:65535 - 变长字段字节数列表所占用的字节数 - NULL值列表所占用的字节数 = 65535 - 2 - 1 = 65532。
如果有多个字段的话,要保证所有字段的长度 + 变长字段字节数列表所占用的字节数 + NULL值列表所占用的字节数 <= 65535。
行溢出后,MySQL 是怎么处理的?
如果一个数据页存不了一条记录,InnoDB 存储引擎会自动将溢出的数据存放到「溢出页」中。
Compact 行格式针对行溢出的处理是这样的:当发生行溢出时,在记录的真实数据处只会保存该列的一部分数据,而把剩余的数据放在「溢出页」中,然后真实数据处用 20 字节存储指向溢出页的地址,从而可以找到剩余数据所在的页。
Compressed 和 Dynamic 这两种格式采用完全的行溢出方式,记录的真实数据处不会存储该列的一部分数据,只存储 20 个字节的指针来指向溢出页。而实际的数据都存储在溢出页中。
参考资料
- 《MySQL 是怎样运行的》
- 《MySQL技术内幕 InnoDB存储引擎》