MySQL 存储引擎深度解析

一、InnoDB 架构

1.1 MySQL 的数据是存放在哪的

⭐ibdata1 共享表空间

需要注意的是表的数据可以存储在共享表空间里面 也可以存储在独占表空间里面,这个参数由 innodb_file_per_table 控制 默认是开启的。不过实际上这个 ibdata1 文件还是存在的。

原理说明

如果启用了 innodb_file_per_table 参数,需要注意的是每张表的表空间内存放的只是数据、索引和插入缓冲 Bitmap 页,其他数据如: 回滚信息、插入缓冲索引页、系统事物信息、二次写缓冲(Double write buffer)等还是放在原来的共享表空间内。同时说明了一个问题: 即使启用了 innodb_file_per_table 参数共享表空间 ibdata1 还是会不断的增加其大小的

数据库空间占用来源

数据库主要的空间占用来源于哪两部分。这里,我们还是针对 MySQL 中应用最广泛的 InnoDB 引擎展开讨论。

一个 InnoDB 表包含两部分,即:表结构定义和数据。

  • 在 MySQL 8.0 版本以前,表结构是存在以.frm 为后缀的文件里
  • 而 MySQL 8.0 版本,则已经允许把表结构定义放在系统数据表中了

因为表结构定义占用的空间很小,所以我们今天主要讨论的是表数据。

表数据存储方式

表数据既可以存在共享表空间里,也可以是单独的文件。这个行为是由参数 innodb_file_per_table 控制的:

  1. 这个参数设置为 OFF 表示的是,表的数据放在系统共享表空间,也就是跟数据字典放在一起
  2. 这个参数设置为 ON 表示的是,每个 InnoDB 表数据存储在一个以 .ibd 为后缀的文件中

从 MySQL 5.6.6 版本开始,它的默认值就是 ON 了。

推荐配置

我建议你不论使用 MySQL 的哪个版本,都将这个值设置为 ON。因为,一个表单独存储为一个文件更容易管理,而且在你不需要这个表的时候,通过 drop table 命令,系统就会直接删除这个文件。而如果是放在共享表空间中,即使表删掉了,空间也是不会回收的。

所以,将 innodb_file_per_table 设置为 ON,是推荐做法,我们接下来的讨论都是基于这个设置展开的。

MySQL 8 的存储文件

InnoDB 的存储主要是靠两个文件,分别是 .frm .ibd。不过注意的是,这个 .frm 在 MySQL 8 里不存在了,而是合并在了 .ibd 文件中的 SDI 里面了。

使用 ibd2sdi --dump-file=emp.txt emp.ibd 指令就可以看到详细的表信息了。

具体可以使用 show global variables like '%datadir%'; 得到数据实际存储的位置 C:\ProgramData\MySQL\MySQL Server 8.0\Data\


二、表空间、页结构、行格式

2.1 表空间的底层结构

表空间,表示一本书,段表示书中的章节,区表示每章节的小节,页表示书的每一页,行就是每页的每行数据。

结构关系

  • 表空间里有多个段
  • 一个段包含 256 个区
  • 一个区包含 64 个页
  • 一个页为 16K

段,表空间由多个段组成,段是由多个区组成的,具体分为三种段:

  1. 数据段 - 存放 B+ 树非叶子节点的区的集合
  2. 索引段 - 存放 B+ 树叶子节点的区的集合
  3. 回滚段 - 存放的是回滚数据区的集合,这里会配合 MVCC 机制实现多版本查询数据

⭐区

区,为了使用顺序 IO 避免磁盘随机 IO 的速度太慢,会按照区为单位划分空间。

每个区的大小为 1M 也就是 64 个页 (64 * 16),使 B+ 树每一层的双向链表的节点页,相邻的页的物理位置也相邻,从而使用顺序 IO

⭐页

页,记录数据是一行一行的记录的,但是为了提升 IO 的效率(局部性原理),我们会选择以页为单位读取数据,默认每个页的大小是 16KB。

重要特性

  • 记住是读和写都是以页为基本单位的
  • 内存 -> 磁盘,内存 <- 磁盘 都是以页为单位进行的
  • 在磁盘层面的话就是用 B+ 树来维护的

2.2 页的内部结构

页的组成部分

FileHeader(38byte)

  • 表示当前页的上一个数据页的位置和当前页的下一个数据页的位置(其实就是双向链表的上一个和下一个节点)

Infimun+SupremunRecord(下界,上界记录)

  • 便于我们后面进行二分操作
  • 下界是比该数据页中主键最小的值还小的值,上界同理

User Record

  • 存储实际的记录的内容,也就是一行行记录

FreeSpace

  • 其实就是空闲空间,记录被删除之后也会加入到FreeSpace中

FileTrailer(8byte)

  • 用于校验数据页是否完整用的,一般这种最小调用数据单元都有这种用于校验的部分,TCP就有

补充说明

  • FileHeader存储着两个指针分别指向pre和next形成双向链表
  • FileTrailer是用于校验我们读取到的数据页是不是完整的
  • PageHeader主要是记录各种元数据,存储各种状态信息,比如第一个标记为删除的记录的地址PAGE_FREE,页目录中槽的数量 PAGE_N_DIR_SLOTS,还未使用的空间最小地址PAGE_HEAP_TOP

2.3 User Record 中的存储细节

底层实现

虽然页与页之间是通过双向链表来连接的,但是页里面是按照 key 的顺序组成单向链表的。这里还有一个页目录 Page Directory 作为二分查找来使用,因为链表的查询效率并不高

一条记录的记录头信息

这里再分析一下一条记录的记录头信息(注意是 5 个字节,一共 40 位),这部分直接看图就行了,主要就是看图说话,面试的时候把图记到脑子里然后看图说话

插入记录

主要还是看图说话。这里稍微注意一下 User Records 就行了,一开始是没有这个部分的,是每当我们插入一条数据 Free Space 分配一块空间给我们用的。当 Free Space 的空间使用完之后,再有新的数据插入,就要去申请新的页了

删除记录

说完了查询,说说删除吧,比如我们删除了第二条记录,删除第 2 条记录前后主要发生了这些变化:

  1. 第 2 条记录并没有从存储空间中移除,而是把该条记录的 delete_mask 值设置为 1
  2. 第 2 条记录的 next_record 值变为了 0,意味着该记录没有下一条记录了
  3. 第 1 条记录的 next_record 指向了第 3 条记录
  4. 还有一点就是最大记录的 n_owned 值从 5 变成了 4

如果我们再把这条记录插入表中,会发现会复用原来被删除记录的存储空间

2.4 怎么创建页目录

页目录创建规则

页目录中都是一样的数据行,我们怎么创建页目录呢?

第一步:分组

  • 包括最小记录和最大记录,但不包括标记为已删除的记录

第二步:记录组内数量

  • 让每个组最后的一条记录的头信息存储该组一共有多少条记录,而且存储在 n_owned 字段中

第三步:创建槽

  • 页目录会存储每组最后一条记录的地址偏移量,这些地址偏移量会按照先后的顺序存储起来
  • 每组的地址偏移量也被称为槽 Slot

这里的槽不是 Redis 中存储数据的槽,而是相当于指针,分别指向了每组最后一个记录的地址。

查找过程

这里的页目录就是由多个槽组成的,槽是用来存储地址的,因为记录是按顺序排序的,所以我们可以使用二分快速定位到要查询的记录是在哪个槽中。

定位到具体的槽之后,我们再依次遍历槽内的记录即可。那啥二分其实二分的是槽的编号,比如就是从 0 开始编号的,然后二分槽的编号,再去取值对比,直到找到一个区间使得要找的 key 在这两个槽中间,然后再由较小的槽往后遍历即可(其实有点类似跳表 SkipList)。

分组规定

除此之外,我们分组是有规定的:

  1. 第一个分组中的记录只能有一条
  2. 最后一个分组的记录条数的范围只能在 1-8 条之间
  3. 剩下的分组中记录条数范围在 4-8 条之间

三、⭐行格式

3.1 InnoDB 行格式有哪些

MySQL 的行格式也是经历了一段时间的演变的,主要是让一个数据页中存储更多的行记录,所以由 Redundant 冗余的 变为了 Compact 紧凑的。

演变历程

  • Redundant (5.0 之前)
  • Compact (5.1 之后)
  • Dynamic (5.7 之后)
  • Compressed

我们这里主要分析一下 Compact 格式的,还是看图说话

3.2 行格式底层结构

行,想想平时 insert 一行数据,其实就是这里的行了

3.3 一行记录最多存储多少数据

基本限制

MySQL 其实是有规定的,除了大对象类型,比如 TEXT BLOB 这些主要是存储长字段的。其他所有的列 (不包括刚才说的那些记录头信息,还有隐藏列,也就是真实数据列 + 变长字段长度列表 + NULL 值列表) 加起来占用字节的长度不能超过 65535 个字节。

但是还有一点需要注意,这里 n 的单位是字符数量,不是字节数量。如果是单纯的 ascii 字符集,那就是 65535;但是如果有中文,那么 n 就会小于 65535。

分情况讨论

单字段的情况 & 多字段的情况

注意这里的 65535 个字节还包括了 storage overhead(存储开销 / 存储额外成本)的字节数,也就是 变长字段长度列表 和 NULL 值列表 占用的字节数,所以实际 n 的最大值是一定会小于 65535 的。

一般如果是建表的时候允许为 NULL 那么就至少会用一个字节来表示 NULL 值列表,然后再看 n 的值是多少。

  • 如果 n 的值 ≤ 255 那么只会用一个字节表示变长字段的长度
  • 如果是 > 255 那么就会用两个字节来存储这个列的变长字段长度

注意如果是 UTF-8 那么一个字符最多需要三个字节,n 的取值会大大减少。还有就是注意一点 n 本身也是要算入 65535 范围之内的,注意一下

单字段 vs 多字段

  • 单字段情况:整行里只有一个变长字段(如 VARCHAR)
  • 多字段情况:整行里有多个列(尤其多个变长列),它们要共享 65535 字节上限

InnoDB 中 65535 字节限制针对的是一行中所有变长字段的总长度,单字段时一个 VARCHAR 几乎可以独占该上限,而多字段时多个 VARCHAR 需要共享该上限,并且还要扣除变长字段长度列表和 NULL 位图等存储开销。

3.4 行溢出

行溢出产生原因

一般一个页的大小是 16KB 也就是 16384 个字节,而 varchar 最多会存储 65535 个字节,而且考虑到 TEXT BLOB,可能我们一个页都存储不下一条记录的数据了,这时候就会发生行溢出。

行溢出处理方式

多出来的数据,会被存储到到另外的溢出页中,然后用真实数据的 20 个字节存储指向溢出页的地址。

Dynamic 和 Compressed 格式

Compressed 和 Dynamic 这两种行格式和 Compact 格式非常类似,主要是处理行溢出问题有不同的解决方案。

这两者采用的是完全的行溢出,记录的真实数据不会存储该列的数据,而是只存储 20 个字节的指针来指向溢出页,实际的数据都存储在溢出页中