MySQL数据存储详解

3.数据存储

数据页

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


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

页目录创建的过程如下:

  1. 将所有的记录划分成几个组,这些记录包括最小记录最大记录,但不包括标记为"已删除"的记录。

  2. 每个记录组的最后一条记录就是组内最大的那条记录 ,并且最后一条记录的头信息中会存储该组一共有多少条记录,作为 n_owned 字段(上图中粉红色字段)。

  3. 页目录用来存储每组最后一条记录的地址偏移量 ,这些地址偏移量会按照先后顺序存储起来,每组的地址偏移量也被称之为槽(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 存储引擎的逻辑存储结构:

  1. 段(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 值列表、记录头信息。

  1. 变长字段长度列表:

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。

  1. row_id: 如果我们建表的时候指定了主键或者唯一约束列,那么就没有 row_id 隐藏字段了 。如果既没有指定主键,又没有唯一约束,那么 InnoDB 就会为记录添加 row_id 隐藏字段。row_id 不是必需的,占用 6 个字节。

  2. trx_id: 事务(Transaction ) id,表示这个数据是由哪个事务生成的。 trx_id 是必需的,占用 6 个字节。

  3. **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 字节存储指向溢出页的地址,从而可以找到剩余数据所在的页。

相关推荐
D4c-lovetrain2 小时前
Linux个人心得25 (mysql⑤)
linux·运维·mysql
档案宝档案管理2 小时前
2026档案管理系统排名解析,易用性+安全性双维度对比
大数据·数据库·人工智能·档案管理
AllData公司负责人2 小时前
AllData数据中台集成开源项目Apache Doris建设实时数仓平台
java·大数据·数据库·数据仓库·apache doris·实时数仓平台·doris集群
Dream of maid2 小时前
Mysql(2)DML
android·数据库·mysql
菜程序2 小时前
2026年MySQL安装教程(超详细)
数据库·mysql
Milu_Jingyu2 小时前
sqlite3_prepare_v2 与 sqlite3_exec 在 SQLite 中的核心区别
java·数据库·sqlite
神の愛2 小时前
针对“单个功能操作数据库”要不要加 @Transactional,
数据库
fly spider2 小时前
MySQL执行流程详解
数据库·mysql
计算机学姐2 小时前
基于SpringBoot的充电桩预约管理系统【阶梯电费+个性化推荐+数据可视化】
java·vue.js·spring boot·后端·mysql·信息可视化·mybatis