【6. InnoDB 引擎底层解析】

InnoDB 引擎底层解析

? MySQL 对于我们来说还是一个黑盒,我们只负责使用客户端发送请求并等待 服务器返回结果,表中的数据到底存到了哪里什么格式存放的ySQL 是以 什么方式来访问的这些数据些问题我们统统不知道。要搞明白查询优化背后 的原理,就必须深入 MySQL 的底层去一探究竟,而且事务、锁等的原理也要求 我们必须深入底层。

InnoDB 记录存储结构和索引页结构

? InnoDB 是一个将表中的数据存储到磁盘上的存储引擎,所以即使关机后重 启我们的数据还是存在的。而真正处理数据的过程是发生在内存中的,所以需要 把磁盘中的数据加载到内存中,如果是处理写入或修改请求的话,还需要把内存 中的内容刷新到磁盘上。而我们知道读写磁盘的速度非常慢,和内存读写差了几 个数量级,所以当我们想从表中获取某些记录时,InnoDB 存储引擎需要一条一 条的把记录从磁盘上读出来么/p>

? InnoDB 采取的方式是:将数据划分为若干个页,以页作为磁盘和内存之间 交互的基本单位,InnoDB 中页的大小一般为 16 KB。也就是在一般情况下,一次 最少从磁盘中读取 16KB 的内容到内存中,一次最少把内存中的 16KB 内容刷新 到磁盘中。

? 我们平时是以记录为单位来向表中插入数据的,这些记录在磁盘上的存放方 式也被称为行格式或者记录格式。InnoDB 存储引擎设计了 4 种不同类型的行格 式,分别是 Compact、Redundant、Dynamic 和 Compressed 行格式

行格式

? CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称

COMPACT

【6. InnoDB 引擎底层解析】

? 记录的真实数据除了我们自己定义的列的数据以外,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 行格式

? Redundant 行格式是 MySQL5.0 之前用的一种行格式,不予深究。

Dynamic 和 Compressed 行格式

? MySQL5.7 的默认行格式就是 DynamicDynamic 和 Compressed 行格式和 Compact 行格式挺像,只不过在处理行溢出数据时有所不同。Compressed 行格式和 Dynamic 不同的一点是,Compressed 行格式会采用压缩算法对页面进行压 缩,以节省空间

数据溢出

如果我们定义一个表,表中只有一个 VARCHAR 字段,如下:

CREATE TABLE test_varchar( c VARCHAR(60000) )

然后往这个字段插入 60000 个字符,会发生什么/p>

? 前边说过,MySQL 中磁盘和内存交互的基本单位是页,也就是说 MySQL 是 以页为基本单位来管理存储空间的,我们的记录都会被分配到某个页中存储。而 一个页的大小一般是 16KB,也就是 16384 字节,而一个 VARCHAR(M)类型的列就 最多可以存储 65532 个字节,这样就可能造成一个页存放不了一条记录的情况。

? 在 Compact 和 Redundant 行格式中,对于占用存储空间非常大的列,在记录 的真实数据处只会存储该列的该列的前 768 个字节的数据,然后把剩余的数据分 散存储在几个其他的页中,记录的真实数据处用 20 个字节存储指向这些页的地 址。这个过程也叫做行溢出,存储超出 768 字节的那些页面也被称为溢出页。

? Dynamic 和 Compressed 行格式,不会在记录的真实数据处存储字段真实数 据的前 768 个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数 据处存储其他页面的地址。

? 那发生行溢出的临界点是什么呢就是说在列存储多少字节的数据时就 会发生行溢出/p>

? MySQL 中规定一个页中至少存放两行记录,以上边的 test_varchar 表为例, 它只有一个列 c,我们往这个表中插入两条记录,每条记录最少插入多少字节的 数据才会行溢出的现象呢得分析一下页中的空间都是如何利用的。

? 每个页除了存放我们的记录以外,也需要存储一些页本身的信息,加起来需 要 132 个字节的空间,其他的空间都可以被用来存储记录。每个记录需要的额外 信息是 27 字节

? 假设一个列中存储的数据字节数为 n,MySQL 规定如果该列不发生溢出的现 象,就需要满足下边这个式子:

? 132 + 2×(27 + n)

? 求解这个式子得出的解是:n 。也就是说如果一个列中存储的数据小 于 8099 个字节,那么该列就不会成为溢出列,否则该列就需要成为溢出列。不 过这个 8099 个字节的结论只是针对只有一个列的 test_varchar 表来说的,如果 表中有多个列,那上边的式子和结论都需要改。所以重点就是:这个临界点具体 值无关紧要,只要知道如果我们一条记录的某个列中存储的数据占用的字节数非 常多时,该列就可能成为溢出列

索引页格式

? InnoDB 为了不同的目的而设计了许多种不同类型的页,存放我们表中记录 的那种类型的页自然也是其中的一员,官方称这种存放记录的页为索引(INDEX) 页,不过要理解成数据页也没问题,毕竟存在着聚簇索引这种索引和数据混合的东西

数据页结构

【6. InnoDB 引擎底层解析】

? 我们的记录按照主键从小到大的顺序形成了一个单链表,记录被删除,则从 这个链表上摘除。

Page Directory

? Page Directory 主要是解决记录链表的查找问题。如果我们想根据主键值查 找页中的某条记录该咋办链表查找的办法:从 Infimum 记录(最小记录)开 始,沿着链表一直往后找,总会找到或者找不到。

? InnoDB 的改进是,为页中的记录再制作了一个目录,他们的制作过程是这 样的:

? 1、将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录) 划分为几个组。

? 2、每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的 n_owned 属性表示该记录拥有多少条记录,也就是该组内共有几条记录。

? 3、将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近 页的尾部的地方,这个地方就是所谓的 Page Directory,也就是页目录页面目录 中的这些地址偏移量被称为槽(英文名:Slot),所以这个页面目录就是由槽组 成的。

?

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AGcy4Dy4-1639409183849)(img/my_note/image-.png)]

? 这样,一个数据页中查找指定主键值的记录的过程分为两步:

? 通过二分法确定该记录所在的槽,并找到该槽所在分组中主键值最小的那条 记录。

? 通过记录的 next_record 属性遍历该槽所在的组中的各个记录。(槽外使用目录, 槽内直接遍历(最多8条,很快的))

Page Header

? InnoDB 为了能得到一个数据页中存储的记录的状态信息,比如本页中已经 存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等, 特意在页中定义了一个叫 Page Header 的部分,它是页结构的第二部分,这个部 分占用固定的 56 个字节,专门存储各种状态信息。

File Header

? File Header 针对各种类型的页都通用,也就是说不同类型的页都会以 File Header 作为第一个组成部分,它描述了一些针对各种页都通用的一些信息,比 方说页的类型,这个页的编号是多少,它的上一个页、下一个页是谁,页的校验 和等等,这个部分占用固定的 38 个字节。

? 页的类型,包括 Undo 日志页、段信息节点、Insert Buffer 空闲列表、Insert Buffer 位图、系统页、事务系统数据、表空间头部信息、扩展描述页、溢出页、 索引页,有些页会在后面的课程看到。

? 同时通过上一个页、下一个页建立一个双向链表把许许多多的页就串联起来, 而无需这些页在物理上真正连着。但是并不是所有类型的页都有上一个和下一个 页的属性,数据页是有这两个属性的,所以所有的数据页其实是一个双向链表。

File Trailer [防断电]

? 我们知道 InnoDB 存储引擎会把数据存储到磁盘上,但是磁盘速度太慢,需 要以页为单位把数据加载到内存中处理,如果该页中的数据在内存中被修改了,那么在修改后的某个时间需要把数据同步到磁盘中。但是在同步了一半的时候中 断电了咋办/p>

? 为了检测一个页是否完整(也就是在同步的时候有没有发生只同步一半的尴 尬情况),InnoDB 每个页的尾部都加了一个 File Trailer 部分,这个部分由 8 个字 节组成,可以分成 2 个小部分:

? 前 4 个字节代表页的校验和

? 这个部分是和 File Header 中的校验和相对应的。每当一个页面在内存中修 改了,在同步之前就要把它的校验和算出来,因为 File Header 在页面的前边, 所以校验和会被首先同步到磁盘,当完全写完时,校验和也会被写到页的尾部, 如果完全同步成功,则页的首部和尾部的校验和应该是一致的。如果写了一半儿 断电了,那么在 File Header 中的校验和就代表着已经修改过的页,而在 File Trailer 中的校验和代表着原先的页,二者不同则意味着同步中间出了错。

? 后 4 个字节代表页面被最后修改时对应的日志序列位置(LSN),这个也和 校验页的完整性有关。

? 这个 File Trailer 与 File Header 类似,都是所有类型的页通用的。

InnoDB 的表空间

? 表空间是一个抽象的概念,对于系统表空间来说,对应着文件系统中一个或 多个实际文件对于每个独立表空间来说,对应着文件系统中一个名为表名.ibd 的实际文件。大家可以把表空间想象成被切分为许许多多个页的池子,当我们想 为某个表插入一条记录的时候,就从池子中捞出一个对应的页来把数据写进去。

? 再回忆一次,InnoDB 是以页为单位管理存储空间的,我们的聚簇索引(也 就是完整的表数据)和其他的二级索引都是以 B+树的形式保存到表空间的,而 B+树的节点就是数据页

? 任何类型的页都有 File Header 这个部分,File Header 中专门的地方 (FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID)保存页属于哪个表空间,同时表空间 中的每一个页都对应着一个页号(FIL_PAGE_OFFSET)这个页号由 4 个字节组 成,也就是 32 个比特位,所以一个表空间最多可以拥有 232个页,如果按照页 的默认大小 16KB 来算,一个表空间最多支持 64TB 的数据

独立表空间结构

区(extent)

? 表空间中的页可以达到 232个页,实在是太多了,为了更好的管理这些页面, 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。

段(segment)

? 我们提到的范围查询,其实是对 B+树叶子节点中的记录进行顺序扫描,而 如果不区分叶子节点和非叶子节点,统统把节点代表的页面放到申请到的区中的 话,进行范围扫描的效果就大打折扣了。所以 InnoDB 对 B+树的叶子节点和非叶 子节点进行了区别对待,也就是说叶子节点有自己独有的区,非叶子节点也有自 己独有的区。存放叶子节点的区的集合就算是一个段(segment)存放非叶子 节点的区的集合也算是一个段。也就是说一个索引会生成 2 个段,一个叶子节点 段,一个非叶子节点段

? 段其实不对应表空间中某一个连续的物理区域,而是一个逻辑上的概念。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ldKDqHTd-1639409183850)(img/my_note/image-.png)]

? 在 information_schema 数据库中的这些以 INNODB_SYS 开头的表并不是真正 的内部系统表(内部系统表就是我们上边唠叨的以 SYS 开头的那些表),而是在 存储引擎启动时读取这些以 SYS 开头的系统表,然后填充到这些以 INNODB_SYS 开头的表中。

InnoDB 的 Buffer Pool

缓存的重要性

? 我们知道,对于使用 InnoDB 作为存储引擎的表来说,不管是用于存储用户 数据的索引(包括聚簇索引和二级索引),还是各种系统数据,都是以页的形式 存放在表空间中的,而所谓的表空间只不过是 InnoDB 对文件系统上一个或几个 实际文件的抽象,也就是说我们的数据说到底还是存储在磁盘上的。

? 但是磁盘的速度慢,所以 InnoDB 存储引擎在处理客户端的请求时,当需要 访问某个页的数据时,就会把完整的页的数据全部加载到内存中,也就是说即使 我们只需要访问一个页的一条记录,那也需要先把整个页的数据加载到内存中。 将整个页加载到内存中后就可以进行读写访问了,在进行完读写访问之后并不着 急把该页对应的内存空间释放掉,而是将其缓存起来,这样将来有请求再次访问 该页面时,就可以省去磁盘 IO 的开销了

Buffer Pool

? InnoDB 为了缓存磁盘中的页,在 MySQL 服务器启动的时候就向操作系统申 请了一片连续的内存,他们给这片内存起了个名,叫做 Buffer Pool(中文名是缓 冲池)。那它有多大呢个其实看我们机器的配置,默认情况下 Buffer Pool 只有 128M 大小,这个值其实是偏小的。

show variables like ‘innodb_buffer_pool_size’;

? 可以在启动服务器的时候配置 innodb_buffer_pool_size 参数的值,它表示 Buffer Pool 的大小,就像这样:

? 其中,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 对应的内存空间看起来就是这样的:

【6. InnoDB 引擎底层解析】

? 有了这个 free 链表之后,每当需要从磁盘中加载一个页到 Buffer Pool 中时, 就从 free 链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填 上(就是该页所在的表空间、页号之类的信息),然后把该缓存页对应的 free 链表节点从链表中移除,表示该缓存页已经被使用了。

缓存页的哈希处理

? 我们前边说过,当我们需要访问某个页中的数据时,就会把该页从磁盘加载 到 Buffer Pool 中,如果该页已经在 Buffer Pool 中的话直接使用就可以了。那么问 题也就来了,我们怎么知道该页在不在 Buffer Pool 中呢不成需要依次遍历 Buffer Pool 中各个缓存页么/p>

? 我们其实是根据表空间号 + 页号来定位一个页的,也就相当于表空间号 + 页号是一个 key,缓存页就是对应的 value,怎么通过一个 key 来快速找着一个 value 呢/p>

? 所以我们可以用表空间号 + 页号作为 key,缓存页作为 value 创建一个哈希 表,在需要访问某个页的数据时,先从哈希表中根据表空间号 + 页号看看有没 有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从 free 链 表中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置。

flush 链表的管理

? 如果我们修改了 Buffer Pool 中某个缓存页的数据,那它就和磁盘上的页不一 致了,这样的缓存页也被称为脏页(英文名:dirty page)。当然,最简单的做法 就是每发生一次修改就立即同步到磁盘上对应的页上,但是频繁的往磁盘中写数 据会严重的影响程序的性能。所以每次修改缓存页后,我们并不着急立即把修改 同步到磁盘上,而是在未来的某个时间点进行同步。

? 但是如果不立即同步到磁盘的话,那之后再同步的时候我们怎么知道 Buffer Pool 中哪些页是脏页,哪些页从来没被修改过呢不能把所有的缓存页都同步 到磁盘上吧,假如 Buffer Pool 被设置的很大,比方说 300G,那一次性同步会非 常慢。

? 所以,需要再创建一个存储脏页的链表,凡是修改过的缓存页对应的控制块 都会作为一个节点加入到一个链表中,因为这个链表节点对应的缓存页都是需要 被刷新到磁盘上的,所以也叫 flush 链表。链表的构造和 free 链表差不多。

【6. InnoDB 引擎底层解析】

LRU 链表的管理

缓存不够的窘境

? Buffer Pool 对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大 小超过了 Buffer Pool 大小,也就是 free 链表中已经没有多余的空闲缓存页的时 候该咋办然是把某些旧的缓存页从 Buffer Pool 中移除,然后再把新的页放进 来,那么问题来了,移除哪些缓存页呢/p>

? 为了回答这个问题,我们还需要回到我们设立 Buffer Pool 的初衷,我们就是 想减少和磁盘的 IO 交互,最好每次在访问某个页的时候它都已经被缓存到 Buffer Pool 中了。假设我们一共访问了 n 次页,那么被访问的页已经在缓存中的次数除 以 n 就是所谓的缓存命中率,我们的期望就是让缓存命中率越高越好。

? 从这个角度出发,回想一下我们的微信聊天列表,排在前边的都是最近很 频繁使用的,排在后边的自然就是最近很少使用的,假如列表能容纳下的联系人 有限,你是会把最近很频繁使用的留下还是最近很少使用的留下呢然是留下 最近很频繁使用的了。

简单的 LRU 链表

? 管理 Buffer Pool 的缓存页其实也是这个道理,当 Buffer Pool 中不再有空闲的 缓存页时,就需要淘汰掉部分最近很少使用的缓存页。不过,我们怎么知道哪些 缓存页最近频繁使用,哪些最近很少使用呢/p>

? 再创建一个链表,由于这个链表是为了按照最近最少使用的原则去淘汰缓存 页的,所以这个链表可以被称为 LRU 链表(LRU 的英文全称:Least Recently Used)。 当我们需要访问某个页时,可以这样处理 LRU 链表:

  • 如果该页不在 Buffer Pool 中,在把该页从磁盘加载到 Buffer Pool 中的缓存页 时,就把该缓存页对应的控制块作为节点塞到 LRU 链表的头部。

  • 如果该页已经缓存在 Buffer Pool 中,则直接把该页对应的控制块移动到 LRU 链表的头部。

? 也就是说:只要我们使用到某个缓存页,就把该缓存页调整到 LRU 链表的头 部,这样 LRU 链表尾部就是最近最少使用的缓存页。所以当 Buffer Pool 中的空 闲缓存页使用完时,到 LRU 链表的尾部找些缓存页淘汰就行了。

文章知识点与官方知识档案匹配,可进一步学习相关知识Java技能树使用JDBC操作数据库数据库操作91437 人正在系统学习中

来源:岁月玲珑

声明:本站部分文章及图片转载于互联网,内容版权归原作者所有,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!

上一篇 2021年11月11日
下一篇 2021年11月11日

相关推荐