MySQL 存储引擎深度解析
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 控制的:
- 这个参数设置为 OFF 表示的是,表的数据放在系统共享表空间,也就是跟数据字典放在一起
- 这个参数设置为 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
段
段,表空间由多个段组成,段是由多个区组成的,具体分为三种段:
- 数据段 - 存放 B+ 树非叶子节点的区的集合
- 索引段 - 存放 B+ 树叶子节点的区的集合
- 回滚段 - 存放的是回滚数据区的集合,这里会配合 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 条记录前后主要发生了这些变化:
- 第 2 条记录并没有从存储空间中移除,而是把该条记录的 delete_mask 值设置为 1
- 第 2 条记录的 next_record 值变为了 0,意味着该记录没有下一条记录了
- 第 1 条记录的 next_record 指向了第 3 条记录
- 还有一点就是最大记录的 n_owned 值从 5 变成了 4
如果我们再把这条记录插入表中,会发现会复用原来被删除记录的存储空间
2.4 怎么创建页目录
页目录创建规则
页目录中都是一样的数据行,我们怎么创建页目录呢?
第一步:分组
- 包括最小记录和最大记录,但不包括标记为已删除的记录
第二步:记录组内数量
- 让每个组最后的一条记录的头信息存储该组一共有多少条记录,而且存储在 n_owned 字段中
第三步:创建槽
- 页目录会存储每组最后一条记录的地址偏移量,这些地址偏移量会按照先后的顺序存储起来
- 每组的地址偏移量也被称为槽 Slot
这里的槽不是 Redis 中存储数据的槽,而是相当于指针,分别指向了每组最后一个记录的地址。
查找过程
这里的页目录就是由多个槽组成的,槽是用来存储地址的,因为记录是按顺序排序的,所以我们可以使用二分快速定位到要查询的记录是在哪个槽中。
定位到具体的槽之后,我们再依次遍历槽内的记录即可。那啥二分其实二分的是槽的编号,比如就是从 0 开始编号的,然后二分槽的编号,再去取值对比,直到找到一个区间使得要找的 key 在这两个槽中间,然后再由较小的槽往后遍历即可(其实有点类似跳表 SkipList)。
分组规定
除此之外,我们分组是有规定的:
- 第一个分组中的记录只能有一条
- 最后一个分组的记录条数的范围只能在 1-8 条之间
- 剩下的分组中记录条数范围在 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 个字节的指针来指向溢出页,实际的数据都存储在溢出页中