MySQL 对于我们来说还是一个黑盒,我们只负责使用客户端发送请求并等待 服务器返回结果,表中的数据到底存到了哪里?以什么格式存放的?MySQL 是以 什么方式来访问的这些数据?这些问题我们统统不知道。要搞明白查询优化背后 的原理,就必须深入 MySQL 的底层去一探究竟,而且事务、锁等的原理也要求 我们必须深入底层。
InnoDB 是一个将表中的数据存储到磁盘上的存储引擎,所以即使关机后重 启我们的数据还是存在的。而真正处理数据的过程是发生在内存中的,所以需要 把磁盘中的数据加载到内存中,如果是处理写入或修改请求的话,还需要把内存 中的内容刷新到磁盘上。而我们知道读写磁盘的速度非常慢,和内存读写差了几 个数量级,所以当我们想从表中获取某些记录时,InnoDB 存储引擎需要一条一 条的把记录从磁盘上读出来么?
InnoDB 采取的方式是:将数据划分为若干个页,以页作为磁盘和内存之间 交互的基本单位,InnoDB 中页的大小一般为 16 KB。也就是在一般情况下,一次 最少从磁盘中读取 16KB 的内容到内存中,一次最少把内存中的 16KB 内容刷新 到磁盘中。
我们平时是以记录为单位来向表中插入数据的,这些记录在磁盘上的存放方 式也被称为行格式或者记录格式。InnoDB 存储引擎设计了 4 种不同类型的行格 式,分别是 Compact、Redundant、Dynamic 和 Compressed 行格式。
CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称
我们知道 MySQL 支持一些变长的数据类型,比如 VARCHAR(M)、 VARBINARY(M)、各种 TEXT 类型,各种 BLOB 类型,我们也可以把拥有这些数据 类型的列称为变长字段,变长字段中存储多少字节的数据是不固定的,所以我们 在存储真实数据的时候需要顺便把这些数据占用的字节数也存起来。如果该可变 字段允许存储的最大字节数(M×W)超过 255 字节并且真实存储的字节数(L) 超过 127 字节,则使用 2 个字节,否则使用 1 个字节。
表中的某些列可能存储 NULL 值,如果把这些 NULL 值都放到记录的真实数 据中存储会很占地方,所以 Compact 行格式把这些值为 NULL 的列统一管理起来,存储到 NULL 值列表。每个允许存储 NULL 的列对应一个二进制位,二进制位的 值为 1 时,代表该列的值为 NULL。二进制位的值为 0 时,代表该列的值不为 NULL。
还有一个用于描述记录的记录头信息,它是由固定的 5 个字节组成。5 个字 节也就是 40 个二进制位,不同的位代表不同的意思。
预留位 1 1 没有使用
预留位 2 1 没有使用
delete_mask 1 标记该记录是否被删除 删除标记位
min_rec_mask 1 B+树的每层非叶子节点中的最小记录都会添加该标记 最小记录
n_owned 4 表示当前记录拥有的记录数
heap_no 13 表示当前记录在页的位置信息 记录在也得位置
record_type3 表示当前记录的类型,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) 这两 个列是必有的,但是 row_id 是可选的(在没有自定义主键以及 Unique 键的情 况下才会添加该列)。
其他的行格式和 Compact 行格式差别不大。
Redundant 行格式是 MySQL5.0 之前用的一种行格式,不予深究。
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 行格式中,对于占用存储空间非常大的列,在记录 的真实数据处只会存储该列的该列的前 768 个字节的数据,然后把剩余的数据分 散存储在几个其他的页中,记录的真实数据处用 20 个字节存储指向这些页的地 址。这个过程也叫做行溢出,存储超出 768 字节的那些页面也被称为溢出页。
Dynamic 和 Compressed 行格式,不会在记录的真实数据处存储字段真实数 据的前 768 个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数 据处存储其他页面的地址。
那发生行溢出的临界点是什么呢?也就是说在列存储多少字节的数据时就 会发生行溢出?
MySQL 中规定一个页中至少存放两行记录,以上边的 test_varchar 表为例, 它只有一个列 c,我们往这个表中插入两条记录,每条记录最少插入多少字节的 数据才会行溢出的现象呢?这得分析一下页中的空间都是如何利用的。
每个页除了存放我们的记录以外,也需要存储一些页本身的信息,加起来需 要 132 个字节的空间,其他的空间都可以被用来存储记录。每个记录需要的额外 信息是 27 字节。
假设一个列中存储的数据字节数为 n,MySQL 规定如果该列不发生溢出的现 象,就需要满足下边这个式子:
132 + 2×(27 + n) < 16384
求解这个式子得出的解是:n < 8099。也就是说如果一个列中存储的数据小 于 8099 个字节,那么该列就不会成为溢出列,否则该列就需要成为溢出列。不 过这个 8099 个字节的结论只是针对只有一个列的 test_varchar 表来说的,如果 表中有多个列,那上边的式子和结论都需要改。所以重点就是:这个临界点具体 值无关紧要,只要知道如果我们一条记录的某个列中存储的数据占用的字节数非 常多时,该列就可能成为溢出列。
InnoDB 为了不同的目的而设计了许多种不同类型的页,存放我们表中记录 的那种类型的页自然也是其中的一员,官方称这种存放记录的页为索引(INDEX) 页,不过要理解成数据页也没问题,毕竟存在着聚簇索引这种索引和数据混合的东西
一个 InnoDB 数据页的存储空间大致被划分成了 7 个部分:
File Header 文件头部 38 字节 页的一些通用信息
Page Header 页面头部 56 字节 数据页专有的一些信息
Infimum + Supremum 最小记录和最大记录26 字节 两个虚拟的行记录
User Records 用户记录大小不确定 实际存储的行记录内容
Free Space 空闲空间 大小不确定 页中尚未使用的空间 页剩余空间
Page Directory 页面目录 大小不确定 页中的某些记录的相对位置 (max = 4?)
File Trailer 文件尾部 8 字节 校验页是否完整 (38 +56 + 26 + 8 = 128 + 4 = 132)
User Records
我们自己存储的记录会按照我们指定的行格式存储到 User Records 部分。但 是在一开始生成页的时候,其实并没有 User Records 这个部分,每当我们插入一 条记录,都会从 Free Space 部分,也就是尚未使用的存储空间中申请一个记录大 小的空间划分到 User Records 部分,当 Free Space 部分的空间全部被 User Records 部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就 需要去申请新的页了。
当前记录被删除时,则会修改记录头信息中的 delete_mask 为 1,也就是说 被删除的记录还在页中,还在真实的磁盘上。这些被删除的记录之所以不立即从 磁盘上移除,是因为移除它们之后把其他的记录在磁盘上重新排列需要性能消耗。(做标记是为了提高性能)
所以只是打一个删除标记而已,所有被删除掉的记录都会组成一个所谓的垃 圾链表,在这个链表中的记录占用的空间称之为所谓的可重用空间,之后如果有 新记录插入到表中的话,可能把这些被删除的记录占用的存储空间覆盖掉。
同时我们插入的记录在会记录自己在本页中的位置,写入了记录头信息中 heap_no 部分。heap_no 值为 0 和 1 的记录是 InnoDB 自动给每个页增加的两个 记录,称为伪记录或者虚拟记录。这两个伪记录一个代表最小记录,一个代表最 大记录,这两条存放在页的 User Records 部分,他们被单独放在一个称为 Infimum + Supremum 的部分。
记录头信息中 next_record 记录了从当前记录的真实数据到下一条记录的真 实数据的地址偏移量。这其实是个链表,可以通过一条记录找到它的下一条记录。 但是需要注意注意再注意的一点是,下一条记录指得并不是按照我们插入顺序的 下一条记录,而是按照主键值由小到大的顺序的下一条记录(叶子节点的索引链表)。而且规定 Infimum 记录(也就是最小记录) 的下一条记录就是本页中主键值最小的用户记录,而 本页中主键值最大的用户记录的下一条记录就是 Supremum 记录(也就是最大 记录)
我们的记录按照主键从小到大的顺序形成了一个单链表,记录被删除,则从 这个链表上摘除。
Page Directory
Page Directory 主要是解决记录链表的查找问题。如果我们想根据主键值查 找页中的某条记录该咋办?按链表查找的办法:从 Infimum 记录(最小记录)开 始,沿着链表一直往后找,总会找到或者找不到。
InnoDB 的改进是,为页中的记录再制作了一个目录,他们的制作过程是这 样的:
1、将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录) 划分为几个组。
2、每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的 n_owned 属性表示该记录拥有多少条记录,也就是该组内共有几条记录。
3、将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近 页的尾部的地方,这个地方就是所谓的 Page Directory,也就是页目录页面目录 中的这些地址偏移量被称为槽(英文名:Slot),所以这个页面目录就是由槽组 成的。
4、每个分组中的记录条数是有规定的:对于最小记录所在的分组只能有 1 条记录,最大记录所在的分组拥有的记录条数只能在 1~8 条之间,剩下的分组 中记录的条数范围只能在是 4~8 条之间。如下图:
这样,一个数据页中查找指定主键值的记录的过程分为两步:
通过二分法确定该记录所在的槽,并找到该槽所在分组中主键值最小的那条 记录。
通过记录的 next_record 属性遍历该槽所在的组中的各个记录。(槽外使用目录, 槽内直接遍历(最多8条,很快的))
Page Header
InnoDB 为了能得到一个数据页中存储的记录的状态信息,比如本页中已经 存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等, 特意在页中定义了一个叫 Page Header 的部分,它是页结构的第二部分,这个部 分占用固定的 56 个字节,专门存储各种状态信息。
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 Header 中的校验和就代表着已经修改过的页,而在 File Trailer 中的校验和代表着原先的页,二者不同则意味着同步中间出了错。
后 4 个字节代表页面被最后修改时对应的日志序列位置(LSN),这个也和 校验页的完整性有关。
这个 File Trailer 与 File Header 类似,都是所有类型的页通用的。
表空间是一个抽象的概念,对于系统表空间来说,对应着文件系统中一个或 多个实际文件;对于每个独立表空间来说,对应着文件系统中一个名为表名.ibd 的实际文件。大家可以把表空间想象成被切分为许许多多个页的池子,当我们想 为某个表插入一条记录的时候,就从池子中捞出一个对应的页来把数据写进去。
再回忆一次,InnoDB 是以页为单位管理存储空间的,我们的聚簇索引(也 就是完整的表数据)和其他的二级索引都是以 B+树的形式保存到表空间的,而 B+树的节点就是数据页。
任何类型的页都有 File Header 这个部分,File Header 中专门的地方 (FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID)保存页属于哪个表空间,同时表空间 中的每一个页都对应着一个页号(FIL_PAGE_OFFSET),这个页号由 4 个字节组 成,也就是 32 个比特位,所以一个表空间最多可以拥有 2³²个页,如果按照页 的默认大小 16KB 来算,一个表空间最多支持 64TB 的数据。
表空间中的页可以达到 2³²个页,实在是太多了,为了更好的管理这些页面, InnoDB 中还有一个区(英文名:extent)的概念。对于 16KB 的页来说,连续的 64 个页就是一个区,也就是说一个区默认占用 1MB 空间大小。
不论是系统表空间还是独立表空间,都可以看成是由若干个区组成的,每 256 个区又被划分成一个组。(表空间->区->组->页->记录)
第一个组最开始的 3 个页面的类型是固定的:用来登记整个表空间的一些整 体属性以及本组所有的区被称为 FSP_HDR,也就是 extent 0 ~ extent 255 这 256 个区,整个表空间只有一个 FSP_HDR。
其余各组最开始的 2 个页面的类型是固定的,一个 XDES 类型,用来登记本 组 256 个区的属性,FSP_HDR 类型的页面其实和 XDES 类型的页面的作用类似, 只不过 FSP_HDR 类型的页面还会额外存储一些表空间的属性。
引入区的主要目的是什么?我们每向表中插入一条记录,本质上就是向该表 的聚簇索引以及所有二级索引代表的 B+树的节点中插入数据。而 B+树的每一层 中的页都会形成一个双向链表,如果是以页为单位来分配存储空间的话,双向链 表相邻的两个页之间的物理位置可能离得非常远。(局部性原理)
我们介绍 B+树索引的适用场景的时候特别提到范围查询只需要定位到最左 边的记录和最右边的记录,然后沿着双向链表一直扫描就可以了,而如果链表中 相邻的两个页物理位置离得非常远,就是所谓的随机 I/O。再一次强调,磁盘的 速度和内存的速度差了好几个数量级,随机 I/O 是非常慢的,所以我们应该尽量 让链表中相邻的页的物理位置也相邻,这样进行范围查询的时候才可以使用所谓 的顺序 I/O。
一个区就是在物理位置上连续的 64 个页。在表中数据量大的时候,为某个 索引分配空间的时候就不再按照页为单位分配了,而是按照区为单位分配,甚至 在表中的数据十分非常特别多的时候,可以一次性分配多个连续的区,从性能角 度看,可以消除很多的随机 I/O。
我们提到的范围查询,其实是对 B+树叶子节点中的记录进行顺序扫描,而 如果不区分叶子节点和非叶子节点,统统把节点代表的页面放到申请到的区中的 话,进行范围扫描的效果就大打折扣了。所以 InnoDB 对 B+树的叶子节点和非叶 子节点进行了区别对待,也就是说叶子节点有自己独有的区,非叶子节点也有自 己独有的区。存放叶子节点的区的集合就算是一个段(segment),存放非叶子 节点的区的集合也算是一个段。也就是说一个索引会生成 2 个段,一个叶子节点 段,一个非叶子节点段。
段其实不对应表空间中某一个连续的物理区域,而是一个逻辑上的概念。
系统表空间的结构和独立表空间基本类似,只不过由于整个 MySQL 进程只 有一个系统表空间,在系统表空间中会额外记录一些有关整个系统信息的页面, 所以会比独立表空间多出一些记录这些信息的页面,相当于是表空间之首,所以它的表空间 ID(Space ID)是 0。
系统表空间和独立表空间的前三个页面的类型是一致的,只是页号为 3~7 的页面是系统表空间特有的。分别包括:
页号 页面类型 英文描述 描述
3 SYS Insert Buffer Header 存储 Insert Buffer 的头部信息
4 INDEX Insert Buffer Root 存储 Insert Buffer 的根页面
5 TRX_SYS Transction System 事务系统的相关信息
6 SYS First Rollback Segment 第一个回滚段的页面
7 SYS Data Dictionary Header 数据字典头部信息 7号
系统表空间的 extent 1 和 extent 2 这两个区,也就是页号从 64~191 这 128 个页面被称为 Doublewrite buffer,也就是双写缓冲区。
双写缓冲区/双写机制 (会通过顺序写同步到磁盘,临时存放,不仅仅是写缓存)
双写缓冲区/双写机制是 InnoDB 的三大特性之一,还有两个是 Buffer Pool、 自适应 Hash 索引。
它是一种特殊文件 flush 技术,带给 InnoDB 存储引擎的是数据页的可靠性。 它的作用是,在把页写到数据文件之前,InnoDB 先把它们写到一个叫 doublewrite buffer(双写缓冲区)的连续区域内,在写 doublewrite buffer 完成后,InnoDB 才 会把页写到数据文件的适当的位置。如果在写页的过程中发生意外崩溃,InnoDB 在稍后的恢复过程中在 doublewrite buffer 中找到完好的 page 副本用于恢复。
所以,虽然叫双写缓冲区,但是这个缓冲区不仅在内存中有,更多的是属于 MySQL 的系统表空间,属于磁盘文件的一部分。那为什么要引入一个双写机制 呢?
InnoDB 的页大小一般是 16KB,其数据校验也是针对这 16KB 来计算的,将 数据写入到磁盘是以页为单位进行操作的。而操作系统写文件是以 4KB 作为单位 的,那么每写一个 InnoDB 的页到磁盘上,操作系统需要写 4 个块。
而计算机硬件和操作系统,在极端情况下(比如断电)往往并不能保证这一 操作的原子性,16K 的数据,写入 4K 时,发生了系统断电或系统崩溃,只有一 部分写是成功的,这种情况下会产生 partial page write(部分页写入)问题。这 时页数据出现不一样的情形,从而形成一个"断裂"的页,使数据产生混乱。在 InnoDB 存储引擎未使用 doublewrite 技术前,曾经出现过因为部分写失效而导致 数据丢失的情况。
doublewrite buffer 是 InnoDB 在表空间上的 128 个页(128*16k = 2m)(2 个区,extend1 和 extend2),大小是 2MB。为了解决部分页写入问题,当 MySQL 将脏数据 flush 到数据文件的时候, 先使用 memcopy 将脏数据复制到内存中的一个区域(也是 2M),之后通过这个内存区域再分 2 次,每次写入 1MB 到系统表空间,然后马 上调用 fsync 函数,同步到磁盘上。在这个过程中是顺序写,开销并不大,在完 成 doublewrite 写入后,再将数据写入各数据文件文件,这时是离散写入。
所以在正常的情况下, MySQL 写数据页时,会写两遍到磁盘上,第一遍是写 到 doublewrite buffer,第二遍是写到真正的数据文件中。如果发生了极端情况(断 电),InnoDB 再次启动后,发现了一个页数据已经损坏,那么此时就可以从 doublewrite buffer 中进行数据恢复了。
前面说过,位于系统表空间上的 doublewrite buffer 实际上也是一个文件, 写系统表空间会导致系统有更多的 fsync 操作, 而硬盘的 fsync 性能因素会降低 MySQL 的整体性能。不过在存储上,doublewrite 是在一个连续的存储空间, 所以 硬盘在写数据的时候是顺序写,而不是随机写,这样性能影响不大,相比不双写, 降低了大概 5-10%左右。(rocketmq同步复制性能也是10%)
所以,在一些情况下可以关闭 doublewrite 以获取更高的性能。比如在 slave 上可以关闭,因为即使出现了 partial page write 问题,数据还是可以从中继日志 中恢复。比如某些文件系统 ZFS 本身有些文件系统本身就提供了部分写失效的防 范机制,也可以关闭。
在数据库异常关闭的情况下启动时,都会做数据库恢复(redo)操作,恢复 的过程中,数据库都会检查页面是不是合法(校验等等),如果发现一个页面校 验结果不一致,则此时会用到双写这个功能。
有经验的同学也许会想到,如果发生写失效,可以通过重做日志(Redo Log) 进行恢复啊!但是要注意,重做日志中记录的是对页的物理操作,如偏移量 800, 写’ aaaa’记录,而不是页面的全量记录,而如果发生 partial page write(部分页写入)问题时,出现问题的是未修改过的数据,此时重做日志(Redo Log)无能为力。 写 doublewrite buffer 成功了,这个问题就不用担心了。
如果是写 doublewrite buffer 本身失败,那么这些数据不会被写到磁盘, InnoDB 此时会从磁盘载入原始的数据,然后通过 InnoDB 的事务日志来计算出正 确的数据,重新写入到 doublewrite buffer,这个速度就比较慢了。如果 doublewrite buffer 写成功的话,但是写数据文件失败,innodb 就不用通过事务日志来计算了, 而是直接用 doublewrite buffer 的数据再写一遍,速度上会快很多。
总体来说,doublewrite buffer 的作用有两个: 提高 innodb 把缓存的数据写到 硬盘这个过程的安全性;间接的好处就是,innodb 的事务日志不需要包含所有数 据的前后映像,而是二进制变化量,这可以节省大量的 IO。
我们平时使用 INSERT 语句向表中插入的那些记录称之为用户数据,MySQL 只是作为一个软件来为我们来保管这些数据,提供方便的增删改查接口而已。但 是每当我们向一个表中插入一条记录的时候,MySQL 先要校验一下插入语句对应 的表存不存在,插入的列和表中的列是否符合,如果语法没有问题的话,还需要 知道该表的聚簇索引和所有二级索引对应的根页面是哪个表空间的哪个页面,然 后把记录插入对应索引的 B+树中。所以说,MySQL 除了保存着我们插入的用户 数据之外,还需要保存许多额外的信息,比方说:
某个表属于哪个表空间,表里边有多少列,表对应的每一个列的类型是什么, 该表有多少索引,每个索引对应哪几个字段,该索引对应的根页面在哪个表空间 的哪个页面,该表有哪些外键,外键对应哪个表的哪些列,某个表空间对应文件 系统上文件路径是什么。
上述这些数据并不是我们使用 INSERT 语句插入的用户数据,实际上是为了 更好的管理我们这些用户数据而不得已引入的一些额外数据,这些数据也称为元 数据。InnoDB 存储引擎特意定义了一些列的内部系统表(internal system table) 来记录这些这些元数据:
表名 描述
SYS_TABLES 整个 InnoDB 存储引擎中所有的表的信息
SYS_COLUMNS 整个 InnoDB 存储引擎中所有的列的信息
SYS_INDEXES 整个 InnoDB 存储引擎中所有的索引的信息
SYS_FIELDS 整个 InnoDB 存储引擎中所有的索引对应的列的信息
SYS_FOREIGN 整个 InnoDB 存储引擎中所有的外键的信息
SYS_FOREIGN_COLS 整个 InnoDB 存储引擎中所有的外键对应列的信息
SYS_TABLESPACES 整个 InnoDB 存储引擎中所有的表空间信息
SYS_DATAFILES 整个 InnoDB 存储引擎中所有的表空间对应文件系统的文件路径信息
SYS_VIRTUAL 整个 InnoDB 存储引擎中所有的虚拟生成列的信息
这些系统表也被称为数据字典,它们都是以 B+树的形式保存在系统表空间 的某些页面中,其中 SYS_TABLES、SYS_COLUMNS、SYS_INDEXES、SYS_FIELDS 这 四个表尤其重要,称之为基本系统表。
这 4 个表是表中之表,那这 4 个表的元数据去哪里获取呢?只能把这 4 个表 的元数据,就是它们有哪些列、哪些索引等信息硬编码到代码中,然后 InnoDB 的又拿出一个固定的页面来记录这 4 个表的聚簇索引和二级索引对应的 B+树位 置,这个页面就是页号为 7 的页面 Data Dictionary Header,类型为 SYS,记录了 数据字典的头部信息。除了这 4 个表的 5 个索引的根页面信息外,这个页号为 7 的页面还记录了整个 InnoDB 存储引擎的一些全局属性,比如 Row ID。
数据字典头部信息中有个 Max Row ID 字段,我们说过如果我们不显式的为 表定义主键,而且表中也没有 UNIQUE 索引,那么 InnoDB 存储引擎会默认为我 们生成一个名为 row_id 的列作为主键。因为它是主键,所以每条记录的 row_id 列的值不能重复。
原则上只要一个表中的 row_id 列不重复就可以了,也就是说表 a 和表 b 拥 有一样的 row_id 列也没啥关系,不过 InnoDB 只提供了这个 Max Row ID 字段, 不论哪个拥有 row_id 列的表插入一条记录时,该记录的 row_id 列的值就是 Max Row ID 对应的值,然后再把 Max Row ID 对应的值加 1,也就是说这个 Max Row ID 是全局共享的。
用户是不能直接访问 InnoDB 的这些内部系统表的,除非你直接去解析系统 表空间对应文件系统上的文件。不过 InnoDB 考虑到查看这些表的内容可能有助 于大家分析问题,所以在系统数据库 information_schema 中提供了一些以 innodb_sys 开头的表:
在 information_schema 数据库中的这些以 INNODB_SYS 开头的表并不是真正 的内部系统表(内部系统表就是我们上边唠叨的以 SYS 开头的那些表),而是在 存储引擎启动时读取这些以 SYS 开头的系统表,然后填充到这些以 INNODB_SYS 开头的表中。
我们知道,对于使用 InnoDB 作为存储引擎的表来说,不管是用于存储用户 数据的索引(包括聚簇索引和二级索引),还是各种系统数据,都是以页的形式 存放在表空间中的,而所谓的表空间只不过是 InnoDB 对文件系统上一个或几个 实际文件的抽象,也就是说我们的数据说到底还是存储在磁盘上的。
但是磁盘的速度慢,所以 InnoDB 存储引擎在处理客户端的请求时,当需要 访问某个页的数据时,就会把完整的页的数据全部加载到内存中,也就是说即使 我们只需要访问一个页的一条记录,那也需要先把整个页的数据加载到内存中。 将整个页加载到内存中后就可以进行读写访问了,在进行完读写访问之后并不着 急把该页对应的内存空间释放掉,而是将其缓存起来,这样将来有请求再次访问 该页面时,就可以省去磁盘 IO 的开销了
InnoDB 为了缓存磁盘中的页,在 MySQL 服务器启动的时候就向操作系统申 请了一片连续的内存,他们给这片内存起了个名,叫做 Buffer Pool(中文名是缓 冲池)。那它有多大呢?这个其实看我们机器的配置,默认情况下 Buffer Pool 只有 128M 大小,这个值其实是偏小的。
show variables like ‘innodb_buffer_pool_size’;
mysql> show variables like 'innodb_buffer_pool_size';
+-------------------------+-----------+
| Variable_name | Value |
+-------------------------+-----------+
| innodb_buffer_pool_size | 134217728 |
+-------------------------+-----------+
1 row in set (0.00 sec)
可以在启动服务器的时候配置 innodb_buffer_pool_size 参数的值,它表示 Buffer Pool 的大小,就像这样:
[server]
innodb_buffer_pool_size = 268435456
其中,268435456 的单位是字节,也就是指定 Buffer Pool 的大小为 256M。 需要注意的是,Buffer Pool 也不能太小,最小值为 5M(当小于该值时会自动设置 成 5M)。
Buffer Pool 内部组成
Buffer Pool 中默认的缓存页大小和在磁盘上默认的页大小是一样的,都是 16KB。为了更好的管理这些在 Buffer Pool 中的缓存页,InnoDB 为每一个缓存页 都创建了一些所谓的控制信息,这些控制信息包括该页所属的表空间编号、页号、 缓存页在 Buffer Pool 中的地址、链表节点信息、一些锁信息以及 LSN 信息,当然 还有一些别的控制信息。
每个缓存页对应的控制信息占用的内存大小是相同的,我们称为控制块。控 制块和缓存页是一一对应的,它们都被存放到 Buffer Pool 中,其中控制块被存 放到 Buffer Pool 的前边,缓存页被存放到 Buffer Pool 后边,所以整个 Buffer Pool 对应的内存空间看起来就是这样的:
每个控制块大约占用缓存页大小的 5%,而我们设置的 innodb_buffer_pool_size 并不包含这部分控制块占用的内存空间大小,也就是说 InnoDB 在为 Buffer Pool 向操作系统申请连续的内存空间时,这片连续的内存空 间一般会比 innodb_buffer_pool_size 的值大 5%左右。
最初启动 MySQL 服务器的时候,需要完成对 Buffer Pool 的初始化过程,就 是先向操作系统申请 Buffer Pool 的内存空间,然后把它划分成若干对控制块和缓 存页。但是此时并没有真实的磁盘页被缓存到 Buffer Pool 中(因为还没有用到), 之后随着程序的运行,会不断的有磁盘上的页被缓存到 Buffer Pool 中。
那么问题来了,从磁盘上读取一个页到 Buffer Pool 中的时候该放到哪个缓存 页的位置呢?或者说怎么区分 Buffer Pool 中哪些缓存页是空闲的,哪些已经被使 用了呢?最好在某个地方记录一下 Buffer Pool 中哪些缓存页是可用的,这个时候 缓存页对应的控制块就派上大用场了,我们可以把所有空闲的缓存页对应的控制 块作为一个节点放到一个链表中,这个链表也可以被称作 free 链表(或者说空 闲链表)。刚刚完成初始化的 Buffer Pool 中所有的缓存页都是空闲的,所以每一 个缓存页对应的控制块都会被加入到 free 链表中,假设该 Buffer Pool 中可容纳 的缓存页数量为 n,那增加了 free 链表的效果图就是这样的:
有了这个 free 链表之后,每当需要从磁盘中加载一个页到 Buffer Pool 中时, 就从 free 链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填 上(就是该页所在的表空间、页号之类的信息),然后把该缓存页对应的 free 链表节点从链表中移除,表示该缓存页已经被使用了。
我们前边说过,当我们需要访问某个页中的数据时,就会把该页从磁盘加载 到 Buffer Pool 中,如果该页已经在 Buffer Pool 中的话直接使用就可以了。那么问 题也就来了,我们怎么知道该页在不在 Buffer Pool 中呢?难不成需要依次遍历 Buffer Pool 中各个缓存页么?
我们其实是根据表空间号 + 页号来定位一个页的,也就相当于表空间号 + 页号是一个 key,缓存页就是对应的 value,怎么通过一个 key 来快速找着一个 value 呢?
所以我们可以用表空间号 + 页号作为 key,缓存页作为 value 创建一个哈希 表,在需要访问某个页的数据时,先从哈希表中根据表空间号 + 页号看看有没 有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从 free 链 表中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置。
如果我们修改了 Buffer Pool 中某个缓存页的数据,那它就和磁盘上的页不一 致了,这样的缓存页也被称为脏页(英文名:dirty page)。当然,最简单的做法 就是每发生一次修改就立即同步到磁盘上对应的页上,但是频繁的往磁盘中写数 据会严重的影响程序的性能。所以每次修改缓存页后,我们并不着急立即把修改 同步到磁盘上,而是在未来的某个时间点进行同步。
但是如果不立即同步到磁盘的话,那之后再同步的时候我们怎么知道 Buffer Pool 中哪些页是脏页,哪些页从来没被修改过呢?总不能把所有的缓存页都同步 到磁盘上吧,假如 Buffer Pool 被设置的很大,比方说 300G,那一次性同步会非 常慢。
所以,需要再创建一个存储脏页的链表,凡是修改过的缓存页对应的控制块 都会作为一个节点加入到一个链表中,因为这个链表节点对应的缓存页都是需要 被刷新到磁盘上的,所以也叫 flush 链表。链表的构造和 free 链表差不多。
缓存不够的窘境
Buffer Pool 对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大 小超过了 Buffer Pool 大小,也就是 free 链表中已经没有多余的空闲缓存页的时 候该咋办?当然是把某些旧的缓存页从 Buffer Pool 中移除,然后再把新的页放进 来,那么问题来了,移除哪些缓存页呢?
为了回答这个问题,我们还需要回到我们设立 Buffer Pool 的初衷,我们就是 想减少和磁盘的 IO 交互,最好每次在访问某个页的时候它都已经被缓存到 Buffer Pool 中了。假设我们一共访问了 n 次页,那么被访问的页已经在缓存中的次数除 以 n 就是所谓的缓存命中率,我们的期望就是让缓存命中率越高越好。
从这个角度出发,回想一下我们的微信聊天列表,排在前边的都是最近很 频繁使用的,排在后边的自然就是最近很少使用的,假如列表能容纳下的联系人 有限,你是会把最近很频繁使用的留下还是最近很少使用的留下呢?当然是留下 最近很频繁使用的了。
简单的 LRU 链表
管理 Buffer Pool 的缓存页其实也是这个道理,当 Buffer Pool 中不再有空闲的 缓存页时,就需要淘汰掉部分最近很少使用的缓存页。不过,我们怎么知道哪些 缓存页最近频繁使用,哪些最近很少使用呢?
再创建一个链表,由于这个链表是为了按照最近最少使用的原则去淘汰缓存 页的,所以这个链表可以被称为 LRU 链表(LRU 的英文全称:Least Recently Used)。 当我们需要访问某个页时,可以这样处理 LRU 链表:
如果该页不在 Buffer Pool 中,在把该页从磁盘加载到 Buffer Pool 中的缓存页 时,就把该缓存页对应的控制块作为节点塞到 LRU 链表的头部。
如果该页已经缓存在 Buffer Pool 中,则直接把该页对应的控制块移动到 LRU 链表的头部。
也就是说:只要我们使用到某个缓存页,就把该缓存页调整到 LRU 链表的头 部,这样 LRU 链表尾部就是最近最少使用的缓存页。所以当 Buffer Pool 中的空 闲缓存页使用完时,到 LRU 链表的尾部找些缓存页淘汰就行了。