MySQL 对于我们来说还是一个黑盒,我们只负责使用客户端发送请求并等待服务器返回结果,表中的数据到底存到了哪里?以什么格式存放的?MySQL 是以 什么方式来访问的这些数据?这些问题我们统统不知道。要搞明白查询优化背后 的原理,就必须深入 MySQL 的底层去一探究竟,而且事务、锁等的原理也要求 我们必须深入底层。
InnoDB存储数据的实际流程
InnoDB 是一个将表中的数据存储到磁盘上的存储引擎,所以即使关机后重启我们的数据还是存在的。
那么,很多新手理所当然的会理解成——每次我们存储,查询数据都是直接与磁盘交互的。然而实际上,真正的数据处理过程是在InnoDB层内存中进行的。也就是说我们的修改,更新操作都是直接写入一个InnoDB的内部缓存中就算完事儿了。而缓存什么时候刷入磁盘?以及这种内存中的数据尚未刷新到磁盘前宕机时,数据如何恢复问题,我们之后再聊。
除了内存写入磁盘,我们想要获取表中的某条记录时,InnoDB存储引擎是以条为单位的将磁盘数据取出到内存中的么?
回忆我们的B+树存储结构可知,InnoDB 采取的方式是:将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,InnoDB 中页的大小一般为 16 KB。也就是在一般情况下,一次 最少从磁盘中读取 16KB 的内容到内存中,一次最少把内存中的 16KB 内容刷新到磁盘中。
我们知道,往往操作即使一行数据,在InnoDB的角度来说,相当于直接操作该行所在的整个磁盘页。当以我们用户的角度,往往是以行记录为单位进行数据插入的。
这些记录在磁盘上的存放方 式也被称为行格式或者记录格式。InnoDB 存储引擎设计了 4 种不同类型的行格式,分别是 Compact、Redundant、Dynamic 和 Compressed 行格式。
行格式
你是不是以为,磁盘文件中的行数据,就和你select * 查出来的一样,就一大堆列所在的行对应的信息就完事儿了。可惜,事情没那么简单。
以表为单位设置行格式
我们可以在创建或修改表的语句中指定行格式:
CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称
行格式类别
Compact
变长字段列表
我们知道 MySQL 支持一些变长的数据类型,比如 VARCHAR(M)、 VARBINARY(M)、各种 TEXT 类型,各种 BLOB 类型,我们也可以把拥有这些数据 类型的列称为变长字段,变长字段中存储多少字节的数据是不固定的,所以我们在存储真实数据的时候需要顺便把这些数据占用的字节数也存起来。如果该可变字段允许存储的最大字节数(M×W)超过 255 字节并且真实存储的字节数(L) 超过 127 字节,则使用 2 个字节,否则使用 1 个字节。(MySQL数据库varcahr类型的最大长度限制为65535)
NULL值列表
表中的某些列可能存储 NULL 值,如果把这些 NULL 值都放到记录的真实数 据中存储会很占地方,所以 Compact 行格式把这些值为 NULL 的列统一管理起来,存储到 NULL 值列表。
每个允许存储 NULL 的列对应一个二进制位,二进制位的值为 1 时,代表该列的值为 NULL。二进制位的值为 0 时,代表该列的值不为 NULL。
这也是为什么推荐我们尽量不使用Null列的原因。在InnoDB层的行数据中,总需要开辟那么一点空间去维护Null值列表。
你看看,假如一张表1亿条数据,某个列你设为可以为NULL,平白无故的浪费了一大堆空间存储去记录NULL值列表。此时你不服了。那我设置不为null,我这个列还不是必须得存点东西占用内存?但即使这样,我们在未来管理行的时候也会更加的方便统一,因为MySQL不同的运用场景中,可为NULL值的意义是在区别太大了。
记录头信息
- 预留位 1 没有使用
- 预留位 2 没有使用
- delete_mask 标记该记录是否被删除
- min_rec_mask B+树的每层非叶子节点中的最小记录都会添加该标记
- n_owned 表示当前记录拥有的记录数
- heap_no 表示当前记录在页的位置信息
- record_type 表示当前记录的类型,0 表示普通记录,1 表示 B+树非叶子 节点记录,2 表示最小记录,3 表示最大记录
- next_record16 表示下一条记录的相对位置
隐藏列
记录的真实数据除了我们自己定义的列的数据以外,MySQL 会为每个记录默 认的添加一些列(也称为隐藏列),包括:
- DB_ROW_ID(row_id):非必须,6 字节,表示行 ID,唯一标识一条记录
- DB_TRX_ID:必须,6 字节,表示事务 ID
- DB_ROLL_PTR:必须,7 字节,表示回滚指针
InnoDB 表对主键的生成策略是:优先使用用户自定义主键作为主键,如果用户没有定义主键,则选取一个 Unique 键作为主键。如果表中连 Unique 键都没有定义的话,则 InnoDB 会为表默认添加一个名为 row_id 的隐藏列作为主键。
DB_TRX_ID(也可以称为 trx_id) 和 DB_ROLL_PTR(也可以称为 roll_ptr) 这两个列是必有的,MVCC事务的隔离性,undo log回滚数据都靠他了。
Redundant 行格式
Redundant 行格式是 MySQL5.0 之前用的一种行格式,不予深究
Dynamic 和 Compressed 行格式
MySQL5.7 的默认行格式就是 Dynamic,Dynamic 和 Compressed 行格式和 Compact 行格式挺像,只不过在处理行溢出数据时有所不同。Compressed 行格式和 Dynamic 不同的一点是,Compressed 行格式会采用压缩算法对页面进行压缩,以节省空间。
数据溢出
如果我们定义一个表,表中只有一个 VARCHAR 字段,如下:
CREATE TABLE test_varchar( c VARCHAR(60000) )
然后往这个字段插入 60000 个字符,会发生什么?
前边说过,MySQL 中磁盘和内存交互的基本单位是页,也就是说 MySQL 是 以页为基本单位来管理存储空间的,我们的记录都会被分配到某个页中存储。而 一个页的大小一般是 16KB,也就是 16384 字节,而一个 VARCHAR(M)类型的列就 最多可以存储 65532 个字节,这样就可能造成一个页存放不了一条记录的情况。
Compact 和 Redundant处理页溢出
在 Compact 和 Redundant 行格式中,对于占用存储空间非常大的列,该行对应的列数据只会存储该列的前 768 个字节的数据,然后把剩余的数据分散存储在几个其他的页中,这768个字节中包含 20 个字节专门用来存储指向这些溢出页的地址。这个过程也叫做行溢出,存储超出 768 字节的那些页面也被称为溢出页。
Dynamic 和 Compressed处理页溢出
这俩行格式更直接,对于占用存储空间非常大的列,该行对应的列数据仅存储溢出页地址,刚列的所有实际数据都全部分散存储到其他页中。
数据溢出的发生条件
之前我们说的VARCHAR(60000)的例子往往也大可能出现,那么具体InnoDB是如何判断一个行数据大到需要数据溢出的呢?
假设现在表中只有一个字段。再次回顾,我们MySQL一个页的大小为16384 字节,并且MySQL 中规定一个页中至少存放两行记录,每个页除了存放我们的记录以外,也需要存储一些页本身的信息,加起来需要 132 个字节的空间,其他的空间都可以被用来存储记录。每个记录需要的额外信息是 27 字节。
假设一个列中存储的数据字节数为 n,MySQL 规定如果该列不发生溢出的现象,就需要满足下边这个式子:
132 + 2×(27 + n) < 16384
求解这个式子得出的解是:n < 8099。也就是说如果一个列中存储的数据小 于 8099 个字节,那么该列就不会成为溢出列,否则该列就需要成为溢出列。不 过这个 8099 个字节的结论只是针对只有一个列的 test_varchar 表来说的,如果 表中有多个列,那上边的式子和结论都需要改。所以重点就是:这个临界点具体 值无关紧要,只要知道如果我们一条记录的某个列中存储的数据占用的字节数非常多时,该列就可能成为溢出列。
索引页
前边我们简单提了一下页的概念,它是 InnoDB 管理存储空间的基本单位, 一个页的大小一般是 16KB。
InnoDB 为了不同的目的而设计了许多种不同类型的页,存放我们表中记录 的那种类型的页自然也是其中的一员,官方称这种存放记录的页为索引(INDEX) 页,不过要理解成数据页也没问题。对应我们的B+树中的叶子节点。既存着索引,也存着实际的行数据。
索引页格式
一个 InnoDB 数据页的存储空间大致被划分成了 7 个部分:
- File Header 文件头部 38 字节 页的一些通用信息
- Page Header 页面头部 56 字节 数据页专有的一些信息
- Infimum + Supremum 最小记录和最大记录 26 字节,两个虚拟的行记录
- User Records 用户记录 大小不确定 实际存储的行记录内容
- Free Space 空闲空间 大小不确定 页中尚未使用的空间
- Page Directory 页面目录 大小不确定 页中的某些记录的相对位置
- File Trailer 文件尾部 8 字节 校验页是否完整
File Header + File Trailer
File Header
File Header 针对各种类型的页都通用,也就是说不同类型的页都会以 File Header 作为第一个组成部分,它描述了一些针对各种页都通用的一些信息,比 方说页的类型,这个页的编号是多少,它的上一个页、下一个页是谁,页的校验和等等,这个部分占用固定的 38 个字节。
页的类型,包括 Undo 日志页、段信息节点、Insert Buffer 空闲列表、Insert Buffer 位图、系统页、事务系统数据、表空间头部信息、扩展描述页、溢出页、 索引页。
通过上一个页、下一个页建立一个双向链表把许许多多的页就串联起来, 而无需这些页在物理上真正连着。但是并不是所有类型的页都有上一个和下一个 页的属性,数据页是有这两个属性的,所以所有的数据页其实是一个双向链表。
File Trailer
我们知道 InnoDB 存储引擎会把数据存储到磁盘上,但是磁盘速度太慢,需要以页为单位把数据加载到内存中处理,如果该页中的数据在内存中被修改了,那么在修改后的某个时间需要把数据同步到磁盘中。但是在同步了一半的时候中断电了咋办?
为了检测一个页是否完整(也就是在同步的时候有没有发生只同步一半的尴尬情况),InnoDB 在每个页中加入了 File Trailer 部分,这个部分由 8 个字 节组成,可以分成 2 个小部分:
- 第一部分:前 4 个字节代表页的校验和。
这个部分是和 File Header 中的校验和相对应的。每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来,存入 File Header 中。当此页的所有数据被同步完毕后,File Trailer中的值也会更新。此时的首部File Heade和尾部 File Trailer的校验和应该是一致的,若不一致,则代表同步过程中发生了问题。
- 第二部分:后 4 个字节代表页面被最后修改时对应的日志序列位置(LSN)
这个也和校验页的完整性有关。
Page Header
InnoDB 为了能得到一个数据页中存储的记录的状态信息,比如本页中已经 存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等, 特意在页中定义了一个叫 Page Header 的部分,它是页结构的第二部分,这个部 分占用固定的 56 个字节,专门存储各种状态信息。
User Records + Infimum + Supremum +Free Space(行记录)
我们自己存储的记录会按照我们指定的行格式存储到 User Records 部分。但 是在一开始生成页的时候,其实并没有 User Records 这个部分,每当我们插入一 条记录,都会从 Free Space 部分,也就是尚未使用的存储空间中申请一个记录大 小的空间划分到 User Records 部分,当 Free Space 部分的空间全部被 User Records 部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了。
delete_mask
当前记录被删除时,则会修改记录头信息中的 delete_mask 为 1,也就是说 被删除的记录还在页中,还在真实的磁盘上。这些被删除的记录之所以不立即从磁盘上移除,是因为移除它们之后把其他的记录在磁盘上重新排列需要性能消耗。 所以只是打一个删除标记而已,所有被删除掉的记录都会组成一个所谓的垃 圾链表,在这个链表中的记录占用的空间称之为所谓的可重用空间,之后如果有新记录插入到表中的话,可能把这些被删除的记录占用的存储空间覆盖掉。
heap_no 和 Infimum + Supremum的关系
我们插入的记录在会记录自己在本页中的位置,插入该行数据的 heap_no 中。heap_no 值为 0 和 1 的记录是 InnoDB 自动给每个页增加的两个记录,称为伪记录或者虚拟记录。这两个伪记录一个代表最小记录,一个代表最大记录,这两条存放在页的 User Records 部分,他们被单独放在一个称为 Infimum + Supremum 的部分。
next_record
记录头信息中 next_record 记录了从当前记录的真实数据到下一条记录的真实数据的地址偏移量。这其实是个链表,可以通过一条记录找到它的下一条记录。
但是需要注意注意再注意的一点是,下一条记录指得并不是按照我们插入顺序的 下一条记录,而是按照主键值由小到大的顺序的下一条记录。
而且规定 Infimum 记录(也就是最小记录) 的下一条记录就是本页中主键值最小的用户记录,而页中主键值最大的用户记录的下一条记录就是 Supremum 记录(也就是最大 记录)
我们的记录按照主键从小到大的顺序形成了一个单链表,记录被删除,则从这个链表上摘除。
Page Directory与n_owned
Page Directory 主要是提供了一个磁盘页中的记录链表的查找问题。我们最容易,最简单的定位一个磁盘页中某条数据的方式,就是从头到尾挨个遍历一遍。时间复杂度是O(1)。
而InnoDB并不满足与这种缓慢的遍历方式。它的改进是:为一个页中的记录再制作了一个目录,制作过程是这样的:
InnoDB数据页,行记录,槽位的概念
- 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录) 划分为几个组。
- 每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的 n_owned 属性表示该记录拥有多少条记录,也就是该组内共有几条记录。
- 将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储起来,就是所谓的 Page Directory,也就是页目录页面目录中的这些地址偏移量被称为槽(英文名:Slot),所以这个页面目录就是由槽组成的。槽0指向第一个行数组的Infimum,后面的槽都指向上行数组的Supremum。当然这个只是开发者为了方便规定的。我们通过这些记录可以简单的获取到每个槽的起始地址和终止地址,在查询某个行数据时,可以快速定位到该行数据哪一个槽,再在槽中进行遍历,以提高遍历速度。
InnoDB如何快速定位一个页的行记录
一个数据页中查找指定主键值的记录的过程分为两步:
- 通过二分法确定该记录所在的槽,并找到该槽所在分组中主键值最小的那条记录。
- 通过记录的 next_record 属性遍历该槽所在的组中的各个记录。
我们看到,有了槽的概念InnoDB还不满足,在定位槽的时候还是用了二分查找,真的是榨干了查询性能。
不同槽的槽数限制
每个分组中的记录条数是有规定的:对于最小记录所在的分组只能有 1 条记录,最大记录所在的分组拥有的记录条数只能在 1~8 条之间,剩下的分组 中记录的条数范围只能在是 4~8 条之间。如下图: