3.数据存储
数据页
InnoDB 的数据是按**「数据页」** 为单位来读写的,默认大小是16KB,意味着数据库每次读写都是以 16KB 为单位的,一次最少从磁盘中读取 16K 的内容到内存中,一次最少把内存中的 16K 内容刷新到磁盘中。


在 File Header 中有两个指针,分别指向上一个数据页和下一个数据页,连接起来的页相当于一个双向的链表

页目录创建的过程如下:
-
将所有的记录划分成几个组,这些记录包括最小记录 和最大记录,但不包括标记为"已删除"的记录。
-
每个记录组的最后一条记录就是组内最大的那条记录 ,并且最后一条记录的头信息中会存储该组一共有多少条记录,作为 n_owned 字段(上图中粉红色字段)。
-
页目录用来存储每组最后一条记录的地址偏移量 ,这些地址偏移量会按照先后顺序存储起来,每组的地址偏移量也被称之为槽(slot),每个槽相当于指针指向了不同组的最后一个记录。
从图可以看到,页目录就是由多个槽组成的,槽相当于分组记录的索引 。然后,因为记录是按照「主键值」从小到大排序的,所以我们通过槽查找记录时,可以使用二分法快速定位要查询的记录在哪个槽(哪个记录分组),定位到槽后,再遍历槽内的所有记录,找到对应的记录,无需从最小记录开始遍历整个页中的记录链表。
以上面那张图举个例子,5 个槽的编号分别为 0,1,2,3,4,我想查找主键为 11 的用户记录:
-
先二分得出槽中间位是 (0+4)/2=2 ,2 号槽里最大的记录为 8。因为 11 > 8,所以需要从 2 号槽后继续搜索记录;
-
再使用二分搜索出 2 号和 4 槽的中间位置 (2+4)/2= 3,3 号槽里最大的记录为 12。因为 11 < 12,所以主键为 11 的记录在 3 号槽里;
-
这里有个问题,「槽对应的值都是这个组的主键最大的记录,如何找到组里最小的记录」?比如槽 3 对应最大主键是 12 的记录,那如何找到最小记录 9。解决办法是:通过槽 3 找到 槽 2 对应的记录,也就是主键为 8 的记录。主键为 8 的记录的下一条记录就是槽 3 当中主键最小的 9 记录,然后开始向下搜索 2 次,定位到主键为 11 的记录,取出该条记录的信息即为我们想要查找的内容。
如果某个槽内的记录很多,然后因为记录都是单向链表串起来的,那这样在槽内查找某个记录的时间复杂度不就是 O(n) 了吗?
InnoDB 对每个分组中的记录条数都是有规定的,槽内的记录就只有几条:
-
第一个分组中的记录只能有 1 条记录;
-
最后一个分组中的记录条数范围只能在 1-8 条之间;
-
剩下的分组中记录条数范围只能在 4-8 条之间。
B+Tree 的结构
InnoDB 里的 B+ 树中的每个节点都是一个数据页:

B+ 树的特点:
-
只有叶子节点(最底层的节点)才存放了数据,非叶子节点(其他上层节)仅用来存放目录项作为索引。
-
非叶子节点分为不同层次,通过分层来降低每一层的搜索量;
-
所有节点按照索引键大小排序,构成一个双向链表,便于范围查询;
我们再看看 B+ 树如何实现快速查找主键为 6 的记录,以上图为例子:
-
从根节点开始,通过二分法快速定位到符合页内范围包含查询值的页,因为查询的主键值为 6,在[1, 7)范围之间,所以到页 30 中查找更详细的目录项;
-
在非叶子节点(页 30)中,继续通过二分法快速定位到符合页内范围包含查询值的页,主键值大于 5,所以就到叶子节点(页 16)查找记录;
-
接着,在叶子节点(页 16)中,通过槽查找记录时,使用二分法快速定位要查询的记录在哪个槽(哪个记录分组),定位到槽后,再遍历槽内的所有记录,找到主键为 6 的记录。
可以看到,在定位记录所在哪一个页时,也是通过二分法快速定位到包含该记录的页 。定位到该页后,又会在该页内进行二分法快速定位记录所在的分组(槽号),最后在分组内进行遍历查找。
表空间结构
表空间由**段(segment)、区(extent)、页(page)、行(row)**组成,InnoDB 存储引擎的逻辑存储结构:

- 段(segment)
表空间是由各个段(segment)组成的,段是由多个区(extent)组成的。段一般分为数据段、索引段和回滚段等。
-
**索引段:**存放 B + 树的非叶子节点的区的集合;
-
**数据段:**存放 B + 树的叶子节点的区的集合;
-
回滚段: 存放的是回滚数据的区的集合,事务隔离 MVCC 利用了回滚段实现了多版本查询数据。
数据库表中的记录都是按行(row)进行存放的,每行记录根据不同的行格式,有不同的存储结构。
- 区(extent)
B+ 树中每一层都是通过双向链表连接起来的,如果是以页为单位来分配存储空间,那么链表中相邻的两个页之间的物理位置并不是连续的 ,可能离得非常远,那么磁盘查询时就会有大量的随机 I/O,随机 I/O 是非常慢的。
解决这个问题也很简单,就是让链表中相邻的页的物理位置也相邻 ,这样就可以使用顺序 I/O 了,那么在范围查询(扫描叶子节点)的时候性能就会很高。
那具体怎么解决呢?
在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区(extent)为单位分配 。每个区的大小为1MB ,对于 16KB 的页来说,连续的 64 个页会被划为一个区 ,这样就使得链表中相邻的页的物理位置也相邻,就能使用顺序 I/O 了。
- 页(page)
记录是按照行来存储的,但是数据库的读取并不以「行」为单位,否则一次读取(也就是一次 I/O 操作)只能处理一行数据,效率会非常低。
因此,InnoDB 的数据是按「页」为单位来读写的,也就是说,当需要读一条记录的时候,并不是将这个行记录从磁盘读出来,而是以页为单位,将其整体读入内存。
默认每个页的大小为 16KB,也就是最多能保证 16KB 的连续存储空间。
页是 InnoDB 存储引擎磁盘管理的最小单元,意味着数据库每次读写都是以 16KB 为单位的,一次最少从磁盘中读取 16K 的内容到内存中,一次最少把内存中的 16K 内容刷新到磁盘中。
页的类型有很多,常见的有数据页、undo 日志页、溢出页 等等。数据表中的行记录是用「数据页」来管理的。
行数据结构
行格式(row_format),就是一条记录的存储结构。
InnoDB 提供了 4 种行格式,分别是 Redundant、Compact(5.1 后默认)、Dynamic(5.7 后默认)和 Compressed 行格式。(后三种格式类似),以 Compact 格式讲解:

记录的额外信息
记录的额外信息包含 3 个部分:变长字段长度列表、NULL 值列表、记录头信息。
- 变长字段长度列表:
char 是定长的,varchar 是变长的。在存储数据的时候,也要把数据占用的大小存起来,存到「变长字段长度列表」里面,读取数据的时候才能根据这个「变长字段长度列表」去读取对应长度的数据。其他 TEXT、BLOB 等变长字段也是这么实现的。
一个例子:

第一条记录:
-
name 列的值为 a,真实数据占用的字节数是 1 字节,十六进制 0x01;
-
phone 列的值为 123,真实数据占用的字节数是 3 字节,十六进制 0x03;
-
age 列和 id 列不是变长字段,所以这里不用管。
这些变长字段的真实数据占用的字节数会按照列的顺序逆序存放,所以「变长字段长度列表」里的内容是「 03 01」,而不是 「01 03」。

NULL 是不会存放在行格式中记录的真实数据部分里的,所以「变长字段长度列表」里不需要保存值为 NULL 的变长字段的长度。
为什么「变长字段长度列表」的信息要按照逆序存放?
主要是因为**「记录头信息」中指向下一个记录的指针,指向的是下一条记录的「记录头信息」和「真实数据」之间的位置** ,这样的好处是向左读就是记录头信息,向右读就是真实数据,比较方便。
「变长字段长度列表」中的信息之所以要逆序存放,是因为这样可以使得位置靠前的记录的真实数据 和数据对应的字段长度信息 可以同时在一个 CPU Cache Line 中,这样就可以提高 CPU Cache 的命中率。
同样的道理,NULL 值列表的信息也需要逆序存放。
每个数据库表的行格式都有「变长字段字节数列表」吗?
当数据表没有变长字段的时候,比如全部都是 int 类型的字段,这时候表里的行格式就不会有「变长字段长度列表」了,节省空间。
- NULL 值列表
表中的某些列可能会存储 NULL 值,如果把这些 NULL 值都放到记录的真实数据中会比较浪费空间,所以 Compact 行格式把这些值为 NULL 的列存储到 NULL 值列表中。
如果存在允许 NULL 值的列,则每个列对应一个二进制位(bit),二进制位按照列的顺序逆序排列。
-
二进制位的值为
1时,代表该列的值为 NULL。 -
二进制位的值为
0时,代表该列的值不为 NULL。
另外,NULL 值列表必须用整数个字节的位表示 (1 字节 8 位),如果使用的二进制位个数不足整数个字节,则在字节的高位补 0。
第三条记录 phone 列 和 age 列是 NULL 值,所以,对于第三条数据,NULL 值列表用十六进制表示是 0x06:

每个数据库表的行格式都有「NULL 值列表」吗?
当数据表的字段都定义成 NOT NULL 的时候,这时候表里的行格式就不会有 NULL 值列表了。
所以在设计数据库表的时候,通常都是建议将字段设置为 NOT NULL,这样可以至少节省 1 字节的空间(NULL 值列表至少占用 1 字节空间)。
「NULL 值列表」是固定 1 字节空间吗?如果这样的话,一条记录有 9 个字段值都是 NULL,这时候怎么表示?
当一条记录有 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: 如果我们建表的时候指定了主键或者唯一约束列,那么就没有 row_id 隐藏字段了 。如果既没有指定主键,又没有唯一约束,那么 InnoDB 就会为记录添加 row_id 隐藏字段。row_id 不是必需的,占用 6 个字节。
-
trx_id: 事务(Transaction ) id,表示这个数据是由哪个事务生成的。 trx_id 是必需的,占用 6 个字节。
-
**roll_pointer:**这条记录上一个版本的指针。roll_pointer 是必需的,占用 7 个字节。
VARCHAR 最大是多少
MySQL 规定一行记录除了 TEXT、BLOBs 类型的列,限制最大为 65535 字节。(防止过大的行导致 B+Tree 索引深度增加,影响查询性能)
要算 varchar(n) 最大能允许存储的字节数,还要看数据库表的字符集 ,因为字符集代表着,1 个字符要占用多少字节,比如 ascii 字符集, 1 个字符占用 1 字节,那么 varchar(100) 意味着最大能允许存储 100 字节的数据。
保证所有字段的长度 + 变长字段字节数列表所占用的字节数 + NULL 值列表所占用的字节数 <= 65535
行溢出
行溢出时,MySQL 是怎么处理的?
MySQL 中磁盘和内存交互的基本单位是页,一个页的大小一般是 16KB,也就是 16384字节,而一个 varchar(n) 类型的列最多可以存储 65532字节,一些大对象如 TEXT、BLOB 可能存储更多的数据,这时一个页可能就存不了一条记录。这个时候就会发生行溢出,多的数据就会存到另外的「溢出页」中。
当发生行溢出时,在记录的真实数据处只会保存该列的一部分数据,而把剩余的数据放在「溢出页」中,然后真实数据处用 20 字节存储指向溢出页的地址,从而可以找到剩余数据所在的页。