前言
到现在为止, MySQL 还是一个黑盒,只知道使用客户端发送请求并等待服务器返回结果
那么表中的数据到底存到了哪里?以什么格式存放的? MySQL 是以什么方式来访问的这些数据?
相应的知识储备我只知道MySQL 服务器上负责对表中数据的读取和写入工作的部分是存储引擎 ,而服务器又支持不同类型的存储引擎,比如 InnoDB 、 MyISAM 、 Memory
那么就从这里开始使用 InnoDB 作为存储引擎来理解数据存储结构!
InnoDB
初学者の总览
InnoDB 是一个将表中的数据存储到磁盘上的存储引擎(关机保存)
真正处理数据的过程是发生在内存中的,所以需要把磁盘中的数据加载到内存中,如果是处理写入或修改请求的话,还需要把内存中的内容刷新到磁盘上。
我们知道读写磁盘的速度非常慢,和内存读写差了几个数量级,所以当我们想从表中获取某些记录时,InnoDB 存储引擎不需要一条一条的把记录从磁盘上读出来,而是将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,InnoDB中页的大小一般为 16 KB。也就是在一般情况下,一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB 内容刷新到磁盘中。
InnoDB行格式
总共四种:Compact(存储小型数据和索引,因为它将NULL和0值存储为一个位来节省空间) 、 Redundant(弃用了) 、 Dynamic(默认,能够根据数据的需要动态地调整存储空间) 和 Compressed(使用了压缩算法来减小数据的存储空间)
Compact行格式
从图中可以看出来,一条完整的记录其实可以被分为 记录的额外信息 和 记录的真实数据 两部分
记录的额外信息是服务器为了描述这条记录而不得不额外添加的一些信息,分别是 变长字段长度列表 、 NULL值列表 和 记录头信息
变长字段长度列表
MySQL 支持一些变长的数据类型,比如 VARCHAR(M) 、 VARBINARY(M) 、各种 TEXT 类型,各种 BLOB 类型,我们可以把拥有这些数据类型的列称为 变长字段 ,变长字段中存储多少字节的数据是不固定的,所以我们在存储真实数据的时候需要顺便把这些数据占用的字节数也存起来
所以这些变长字段占用的存储空间分为两部分: 真正的数据内容 + 占用的字节数
在 Compact 行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表,各变长字段数据占用的字节数按照列的顺序逆序存放
比如一条记录中有三列是变长字段,细节如下图:
那么实际效果是:
至于每个内容长度的编码,如果该可变字段允许存储的最大字节数( M×W )超过255字节并且真实存储的字节数( L ) 超过127字节,则使用2个字节,否则使用1个字节
此外,变长字段长度列表中只存储值为 NULL 的列内容占用的长度,值为 NULL 的列的长度是不储存的。如果这条记录没有变长字段, 这一行就没有变长字段长度列这部分。
NULL值列表
我们知道表中的某些列可能存储 NULL 值,如果把这些 NULL 值都放到 记录的真实数据 中存储会很占地方,所以 Compact 行格式把这些值为 NULL 的列统一管理起来,存储到 NULL 值列表中,它的处理过程是这样的:
1. 首先统计表中允许存储 NULL 的列有哪些(如果表中没有允许存储 NULL 的列,那么 NULL 也不存在了)
2. 将每个允许存储 NULL 的列对应一个二进制位,二进制位按照列的顺序逆序排列,值为1表示NULL,0表示非NULL
同时 NULL 值列表长度必须是字节长度的整数倍(8的倍数),位数不够的话高位补0
记录头信息
记录头信息由固定的 5 个字节组成,如图:
真实案例
现在实例表如下,设置了 ROW_FORMAT=COMPACT
对于 该表来说, 记录的真实数据 除了 c1 、 c2 、 c3 、 c4 这几个我们自己定义的列的数据以外, MySQL 会为每个记录默认的添加一些列(也称为隐藏列 ),具体的列如下:
这是因为InnoDB 表对主键的生成策略:
优先使用用户自定义主键作为主键,如果用户没有定义主键,则选取一个 Unique 键作为主键,如果表中连 Unique 键都没有定义的话,则 InnoDB 会为表默认添加一个名为row_id 的隐藏列作为主键。所以我们从上表中可以看出:InnoDB存储引擎会为每条记录都添加 transaction_id 和 roll_pointer 这两个列,但是 row_id 是可选的(在没有自定义主键以及Unique键的情况下才会添加该列)
于是这两条记录的真实存储为:
Redundant行格式
该格式在现行的MySQL中已弃用,简单看看
刚才的案例在该格式下:
Redundant 行格式的开头是字段长度偏移列表 ,与Compact的变长字段长度列表有两处不同:
1. Redundant 行格式会把该条记录中所有列(包括隐藏列 )的长度信息都按照逆序存储到字段长度偏移列表 。
2. Redundant 行格式计算列值长度的方式不像 Compact 行格式那么直观,而是采用两个相邻数 值的差值来计算各个列值的长度。
比如第一条记录的字段长度偏移列表是: 25 24 1A 17 13 0C 06,因为它是逆序排放的,所以按照列的顺序排列为:06 0C 13 17 1A 24 25
按照偏移量差值计算长度:
第一列(`row_id`)的长度就是 0x06个字节,也就是6个字节。
第二列(`transaction_id`)的长度就是 (0x0C - 0x06)个字节,也就是6个字节。
第三列(`roll_pointer`)的长度就是 (0x13 - 0x0C)个字节,也就是7个字节。
第四列(`c1`)的长度就是 (0x17 - 0x13)个字节,也就是4个字节。
第五列(`c2`)的长度就是 (0x1A - 0x17)个字节,也就是3个字节。
第六列(`c3`)的长度就是 (0x24 - 0x1A)个字节,也就是10个字节。
第七列(`c4`)的长度就是 (0x25 - 0x24)个字节,也就是1个字节。
Redundant的记录头信息跟 Compact 的差距不大,就只对接了字段长度偏移列表
因为 Redundant 行格式并没有 NULL值列表 ,所以在 字段长度偏移列表中的各个列对应的偏移量处做了一些特殊处理 —— 将列对应的偏移量值的第一个比特位作为是否为 NULL 的依据,该比特位也可以被称之为 NULL比特位 。也就是说在解析一条记录的某个列时,首先看一下该列对应的 偏移量的 NULL比特位 是不是为 1 ,如果为 1 ,那么该列的值就是 NULL ,否则不是 NULL 。
行溢出数据
我们知道对于 VARCHAR(M) 类型的列最多可以占用 65535 (每个2字节==16位)个字节。其中的 M 代表该类型最多存储的字符数量,如果我们使用 ascii 字符集的话,一个字符就代表一个字节
如果该 VARCHAR 类型的列没有 NOT NULL 属性,那最多只能存储 65532 个字节的数据,因为真实数据的长度可能占用2个字节, NULL 值标识需要占用1个字节。
由于MySQL 是以页为基本单位来管理存储空间的,我们的记录都会被分配到某个 页 中存储。而一个页的大小一般是 16KB ,也就是 16384 字节,而一个 VARCHAR(M) 类型的列就最多可以存储 65532 个字节,这样就可能造成一个页存放不了一条记录的情况。
对于 Compact 和 Reduntant 行格式来说,如果某一列中的数据非常多的话,在本记录的真实数据处只会存储该列的前 768 个字节的数据和一个指向其他页的地址,然后把剩下的数据存放到其他页中,这个 过程也叫做行溢出 ,存储超出 768 字节的那些页面也被称为溢出页 。
每个页除了存放我们的记录以外,也需要存储一些额外的信息,需要 136 个字节的空间,其他的空间都可以被用来存储记录。
而每个记录需要的额外信息是 27 字节,包括
2个字节用于存储真实数据的长度
1个字节用于存储列是否是NULL值
5个字节大小的头信息
6个字节的 row_id 列
6个字节的 transaction_id 列
7个字节的 roll_pointer 列
假设一个列中存储的数据字节数为n,那么发生行溢出现象时需要满足这个式子:
136 + 2×(27 + n) > 16384,求解这个式子得出的解是: n > 8098 。也就是说如果一个列中存储的数据不大于 8098 个字节,那就不会发生行溢出 ,否则就会发生行溢出 。
Dynamic和Compressed行格式
这两个行格式和 Compact 行格式挺像,只不过在处理行溢出数据时有点儿分歧,它们不会在记 录的真实数据处存储字段真实数据的前 768 个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址
InnoDB页结构
数据页结构的快速浏览
数据页代表的这块 16KB 大小的存储空间可以被划分为多个部分,各个部分如图所示:
记录在页中的存储
在页的7个组成部分中,用户存储的记录会按照我们指定的行格式存储到 User Records 部分。
但是在一开始生成页的时候,其实并没有 User Records 这个部分,每当我们插入一条记录,都会从 Free Space 部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到 User Records 部分。
当 Free Space 部分的空间全部被 User Records 部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了,这个过程的图示如下:
管理User Records的案例
为了更好的管理在 User Records 中的这些记录,InnoDB 有哪些机制呢?
下面创建一个表,有3个列,其中 c1 和 c2 列是用来存储整数的, c3 列是用来存储字符串的。
由于把 c1 列指定为主键,所以在具体的行格式中InnoDB就没必要为我们去创建row_id 隐藏列了
表中记录的行格式示意图:
记录头信息
下面我们试着向表中插入几条记录:
这些记录的示意图:
1. delete_mask标记着当前记录是否被删除,占用1个二进制位,值为 0 的时候代表记录并没有被删除,为 1 的时候代表记录被删除掉了(将这个delete_mask位设置为1和将被删除的记录加入到垃圾链表中是两个阶段)
2. min_rec_mask: B+树的每层非叶子节点中的最小记录都会添加该标记
3. n_owned:在页目录分组时使用,每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的n_owned属性表示该记录拥有多少条记录,也就是该组内共有几条记录
4. heap_no:表示当前记录在本页中的位置,从图中可以看出来,我们插入的4条记录在本页中的位置分别是 2 、 3 、 4 、 5 ,为什么没有1呢?
首先对于一条完整的记录来说,比较记录的大小就是比较主键的大小。InnoDB 自动给每个页里加了两个伪记录,一个代表最小记录 ,一 个代表最大记录
实际存储时这两部分是隔开的,从图中我们可以看出来,最小记录和最大记录的 heap_no 值分别是 0 和 1 ,也就是说它们的位置最靠前
5. record_type:表示当前记录的类型,一共有4种类型的记录, 0 表示普通记录, 1 表示B+树非叶节点记录, 2 表示最小记录, 3 表示最大记录。从图中我们也可以看出来,我们自己插入的记录就是普通记录,它们的record_type 值都是 0 ,而最小记录和最大记录的 record_type 值分别为 2 和 3 。
6. next_record:表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量(这样就可以用链表的方式处理)
但下一条记录指得并不是按照我们插入顺序的下一条记录,而是按照主键值由小到大的顺序的下一条记录。而且规定 Infimum 的下一条记录就是本页中主键值最小的用户记录,而本页中主键值最大的用户记录的下一条记录就是 Supremum
可以发现记录按照主键从小到大的顺序形成了一个单链表
如果从中删除掉一条记录,这个链表也是会跟着变化的,比如我们把第2条记录删掉:
这之后存储结构变成这样,可以发现就是链表的删除方式:
从图中可以看出来,删除第2条记录前后主要发生了这些变化:
1. 第2条记录并没有从存储空间中移除,而是把该条记录的 delete_mask 值设置为 1 。
2. 第2条记录的 next_record 值变为了0,意味着该记录没有下一条记录了。
3. 第1条记录的 next_record 指向了第3条记录。
4. 最大记录的 n_owned 值从 5 变成了 4
如果这以后再插入刚刚删除的那条记录,就会复用删除的记录。当数据页中存在多条被删除掉的记录时,这些记录的next_record属性将会把这些被删除掉的记录组成一个垃圾链表,以备之后重用这部分存储空间。
Page Directory
现在我们了解了记录在页中按照主键值由小到大顺序串联成一个单链表,那如果我们想根据主键值查找页中的某条记录该咋办呢?比如说这样的查询语句:
暴力做法:从 Infimum 记录(最小记录)开始,沿着链表一直往后找,总会找到,或者找不到
因为链表中各个记录的值是按照从小到大顺序排列的,所以当链表的某个节点代表的记录的主键值大于想要查找的主键值时,就可以停止查找了,因为该节点后边的节点的主键值依次递增。
优化后的算法:制作页目录
1. 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。
2. 每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的 n_owned 属性表示该记录拥有多少条记 录,也就是该组内共有几条记录。
3. 将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近页的尾部的地方,这个就是页目录。页目录中的这些地址偏移量被称为槽 (Slot ),所以这个页目录就是由槽组成的。
示意图如下:
对每个分组中的记录条数是有规定的:对于最小记录所在的分组只能有 1 条记录, 最大记录所在的分组拥有的记录条数只能在 1~8 条之间,剩下的分组中记录的条数范围只能在是 4~8 条之间
分组是按照下边的步骤进行的:
1. 初始情况下一个数据页里只有最小记录和最大记录两条记录,它们分属于两个分组。
2. 之后每插入一条记录,都会从页目录中找到主键值比本记录的主键值大并且差值最小的槽,然后把该槽对应的记录的 n_owned 值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8个。
3. 在一个组中的记录数等于8个后再插入一条记录时,会将组中的记录拆分成两个组,一个组中4条记录,另一 个5条记录。这个过程会在页目录中新增一个槽来记录这个新增分组中最大的那条记录的偏移量。
下面往表中添加了12条记录,现在页里边就一共有18条记录了(包括最小和最大记录),这些记录被分成了5个组,如图所示:
现在看怎么从页目录中查找记录。
因为各个槽代表的记录的主键值都是从小到大排序的,所以我们可以使用所谓的 二分法 来进行 快速查找。4个槽的编号分别是: 0 、 1 、 2 、 3 、 4 ,所以初始情况下最低的槽就是 low=0 ,最高的槽就是high=4 。比方说我们想找主键值为 6 的记录,过程是这样的:
1. 计算中间槽的位置: (0+4)/2=2 ,所以查看 槽2 对应记录的主键值为 8 ,又因为 8 > 6 ,所以设置 high=2 , low 保持不变。
2. 重新计算中间槽的位置: (0+2)/2=1 ,所以查看 槽1 对应的主键值为 4 ,又因为 4 < 6 ,所以设置 low=1 , high 保持不变。
3. 因为 high - low 的值为1,所以确定主键值为 5 的记录在槽2对应的组中。此刻我们需要找到 槽2 中主键值最小的那条记录,然后沿着单向链表遍历槽2 中的记录。但是我们前边又说过,每个槽对应的记录都是该 组中主键值最大的记录,这里槽2对应的记录是主键值为 8 的记录,怎么定位一个组中最小的记录呢?别忘了各个槽都是挨着的,我们可以很轻易的拿到 槽1 对应的记录(主键值为 4 ),该条记录的下一条记录就是 槽2 中主键值最小的记录,该记录的主键值为 5 。所以我们可以从这条主键值为 5 的记录出发,遍历槽 2 中的各条记录,直到找到主键值为 6 的那条记录即可。由于一个组中包含的记录条数只能是1~8条,所以 遍历一个组中的记录的代价是很小的。
所以在一个数据页中查找指定主键值的记录的过程分为两步:
1. 通过二分法确定该记录所在的槽,并找到该槽中主键值最小的那条记录。
2. 通过记录的 next_record 属性遍历该槽所在的组中的各个记录。
Page Header
为了能得到一个数据页中存储的记录的状态信息,比如本页中已经存储了多少条记录,第 一条记录的地址是什么,页目录中存储了多少个槽等等,特意在页中定义了一个叫 Page Header 的部分,它是页结构的第二部分,这个部分占用固定的 56 个字节,专门存储各种状态信息
File Header
Page Header 是专门针对数据页记录的各种状态信息,比如页里有多少记录,有多少个槽。
我们现在描述的 File Header 针对各种类型的页都通用,也就是说不同类型的页都会以 File Header 作为第一个组成部分,它描述了一些针对各种页都通用的一些信息,比方说这个页的编号是多少,它的上一个页、 下一个页是哪个。这个部分占用固定的 38 个字节,是由下边这些内容组成的:
File Trailer
我们知道 InnoDB 存储引擎会把数据存储到磁盘上,但是磁盘速度太慢,需要以页为单位把数据加载到内存中处理,如果该页中的数据在内存中被修改了,那么在修改后的某个时间需要把数据同步到磁盘中。但是在同步了一 半的时候中断电了就寄了。为了检测一个页是否完整, InnoDB 在每个页的尾部都加了一个 File Trailer 部分,这个部分由 8 个字节组成,可以分成2个小部分:
1. 前4个字节代表页的校验和
这个部分是和 File Header 中的校验和相对应的。每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来,因为 File Header 在页面的前边,所以校验和会被首先同步到磁盘,当完全写完时,校验和也会被写到页的尾部,如果完全同步成功,则页的首部和尾部的校验和应该是一致的。如果写了一半儿断电 了,那么在 File Header 中的校验和就代表着已经修改过的页,而在 File Trialer 中的校验和代表着原先的页,二者不同则意味着同步中间出了错。
2. 后4个字节代表页面被最后修改时对应的日志序列位置(LSN)