InnoDB引擎底层存储和缓存原理

本文从数据结构说明讲解MYSQL的InnoDB引擎的底层存储结构和缓存设计,进而帮助更深刻的理解InnoDB的原理与特性。

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

InnoDB是一个将表中的数据存储到磁盘上的存储引擎,所以即使关机后重启我们的数据还 是存在的。而真正处理数据的过程是发生在内存中的,所以需要把磁盘中的数据加载到内存中,如果是处理写入或修改请求的话,还需要把内存中的内容刷新到磁盘上。而读写磁盘的速度非常慢,和内存读写差了几个数量级,所以当我们想从表中获取某些记录时,存储引擎如果一条一条的把记录从磁盘上读出来,效率是极其差的,那么InnoDB采用的是什么呢?
InnoDB采取的方式是:
将数据划分为若干个页,以作为磁盘和内存之间交互的基本单 位,InnoDB中页的大小一般为16 KB。也就是在一般情况下,一次最少从磁盘中读取16KB 的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。 我们平时是以记录为单位来向表中插入数据的,这些记录在磁盘上的存放方式也被称为行格式或者记录格式。InnoDB存储引擎设计了4种不同类型的行格式,分别是Compact、 Redundant、Dynamic和Compressed行格式

行格式

可以在创建或修改表的语句中指定行格式:
CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称
MySQL5.7的默认行格式就是Dynamic

Compact:

innodb存储引擎架构 innodb存储引擎原理_表空间


变长字段列表:MySQL支持变长的数据类型,如VARCHAR(M)、VARBINARY(M)、各种TEXT类 型,各种BLOB类型;所以在存储真实数据的时候需要顺便把这些数据占用的字节数也存起来。如果该可变字段允许存储的最大字节数超过255字节并且真实存储的字节数超过127字节,则使用2个字节,否则使用1个字节。

NULL值列表:有时表中的某些列可能存储NULL值,如果把这些NULL值都放到记录的真实数据中存储会很占地 方,所以Compact行格式把这些值为NULL的列统一管理起来,存储到NULL值列表。每个允 许存储NULL的列对应一个二进制位,二进制位的值为1时,代表该列的值为NULL。二进制位的值为0时,代表该列的值不为NULL。

记录头信息:是由固定的5个字节组成。5个字节也就是40个二 进制位,不同的位代表不同的意思。

innodb存储引擎架构 innodb存储引擎原理_数据结构_02

预留位1 1 没有使用 
预留位2 1 没有使用 
delete_mask:标记该记录是否被删除 0/1
min_rec_mask:B+树的每层非叶子节点中的最小记录都会添加该标记 0/1
n_owned:表示当前记录拥有的记录数 
heap_no:表示当前记录在页的位置信息 
record_type:表示当前记录的类型,0表示普通记录,1表示B+树非叶子节点 记录,2表示最小记录,3表示最大记录 next_record:表示下一条记录的相对位置

记录真实数据:除了用户记录真实数据外还有一些默认信息

innodb存储引擎架构 innodb存储引擎原理_mysql_03


DB_ROW_ID(row_id):非必须,6字节,表示行ID,唯一标识一条记录

DB_TRX_ID:必须,6字节,表示事务ID

DB_ROLL_PTR:必须,7字节,表示回滚指针

关于DB_ROW_ID注意:

InnoDB表对主键的生成策略是:
1、优先使用用户自定义主键作为主键,
2、如果用户没有定义主 键,则选取一个Unique键作为主键,
3、如果表中连Unique键都没有定义的话,则InnoDB会为 表默认添加一个名为row_id的隐藏列作为主键。

Redundant行格式

Redundant行格式是MySQL5.0之前用的一种行格式

Dynamic和Compressed行格式

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

数据溢出

MySQL中磁盘和内存交互的基本单位是页,也就是说MySQL是以页为基本单位来 管理存储空间的,我们的记录都会被分配到某个页中存储。而一个页的大小一般是16KB, 也就是16384字节。
而如果定义一个表,表中只有一个VARCHAR字段,如下:
CREATE TABLE test_varchar( c VARCHAR(60000) )
一个VARCHAR(M)类型的列就最多可以存储65532个字节。当往这个字段插入60000个字符,这时就会数据溢出,一页装不下,那怎么处理?
不同行格式的溢出处理:
1、在Compact和Redundant行格式中,对于占用存储空间非常大的列,在记录的真实数据处只 会存储该列的该列的前768个字节的数据,然后把剩余的数据分散存储在几个其他的页中,记录的真实数据处用20个字节存储指向这些页的地址。这个过程也叫做行溢出,存储超出768字节的那些页面也被称为溢出页。
2、Dynamic和Compressed行格式,不会在记录的真实数据处存储字段真实数据的前768个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址。

索引页格式

页是InnoDB管理存储空间的基本单位,一个页的大小一 般是16KB。

InnoDB为了不同的目的而设计了许多种不同类型的页,存放我们表中记录的那种类型的页 自然也是其中的一员,官方称这种存放记录的页为索引(INDEX)页

innodb存储引擎架构 innodb存储引擎原理_表空间_04


一个InnoDB数据页的存储空间大致被划分成了7个部分:

File Header  文件头部38字节页的一些通用信息 
Page Header  页面头部56字节数据页专有的一些信息 
Infimum + Supremum  最小记录和最大记录26字节,两个虚拟的行记录 
User Records  用户记录大小不确定,实际存储的行记录内容 
Free Space  空闲空间大小不确定,页中尚未使用的空间 
Page Directory 页面目录大小不确定,页中的某些记录的相对位置File Trailer文件尾部8字节校验页是否完整

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记录了从当前记录的真实数据到下一条记录的真实数据的地址偏移量。这其实是个链表,可以通过一条记录找到它的下一条记录。
注意:

1、下一条记录指得并不是按照我们插入顺序的下一条记录,而是按照主键值由小到大的顺序的下一条记录。
2、规定 Infimum记录(也就是最小记录) 的下一条记 录就是本页中主键值最小的用户记录,
3、页中主键值最大的用户记录的下一条记录就是Supremum记录(也就是最大记录)

innodb存储引擎架构 innodb存储引擎原理_表空间_05

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

Page Directory

MYSQL在索引树定位数据时会定位到某个叶子节点,而一个节点即为一个页,当根据主键值查找页中的一条数据时,这就是链表扫描了,而当每页数据条数过多时,则平均查询复杂度就会上升,所以InnoDB就做了改进,给页中的记录做了一个目录这个就叫Page Directory。如下:

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

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

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

innodb存储引擎架构 innodb存储引擎原理_表空间_06


4、每个分组中的记录条数是有规定的:对于最小记录所在的分组只能有1条记录,最大 记录所在的分组拥有的记录条数只能在 1~8 条之间,剩下的分组中记录的条数范围只能 在是 4~8 条之间。

innodb存储引擎架构 innodb存储引擎原理_表空间_07


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

1、通过二分法确定该记录所在的槽,并找到该槽所在分组中主键值最小的那条记录。
2、通过记录的next_record属性遍历该槽所在的组中的各个记录。

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类似,都是所有类型的页通用的。

InnoDB的体系结构

上面,是从微观的角度了解了数据行记录和页面的存储格式,现在再从宏观的角度了解InnoDB的内存结构和磁盘存储结构。

MYSQL8.0官方文档

innodb存储引擎架构 innodb存储引擎原理_缓存_08

InnoDB的表空间

表空间是一个抽象的概念,对于系统表空间来说,对应着文件系统中一个或多个实际文 件,一般是(ibdata1);对于每个独立表空间(也就是上图的File-Per-Table Tablespaces)来说,对应着文件系统中一个名为表名.ibd的实际文件。
表空间中的每一个页都对应着 一个页号,这个页号由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+树的每一层中的页都会形成一个双向链表,如果是以页为单位来分配存储空间的话,
双向链表相邻的两个页之间的物理位置可能离得非常远。
当在范围查询时只需要定位到最左边的记录和最右边的记录,然后沿着双向链表一直扫描就可以了,
而如果链表中相邻的两个页物理位置离得非常远,就是所谓的随机I/O。
而磁盘的速度和内存的速度差了好几个数量级,随机I/O是非常慢的,所以我们应该尽量让链表中相邻的页的物理位置
也相邻,这样,进行范围查询的时候才可以使用所谓的顺序I/O。 一个区就是在物理位置上连续的64个页。在表中数
据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区为单位分配,
甚至在表中的数据十分非常特 别多的时候,可以一次性分配多个连续的区,
从性能角度看,可以消除很多的随机I/O。
总的一句话:减少数据查找时的随机I/O
段(segment)

InnoDB对B+树的叶子节点和非叶子节点进行了区别对待,也就是说叶子节点有自己独有的区,非叶子节点也有自己独有的区。存放叶子节点的区的集合就算是 一个段(segment),存放非叶子节点的区的集合也算是一个段。也就是说一个索引会生成2个段,一个叶子节点段,一个非叶子节点段。 段其实不对应表空间中某一个连续的物理区域,而是一个逻辑上的概念。

innodb存储引擎架构 innodb存储引擎原理_数据结构_09


段的作用?

InnoDB的聚簇索引结构是B+树,叶子点的存储的所有页数据,非叶子点则存的的是冗余的有序索引的数据。
非叶子节点数据是为了辅助查找最终的叶子节点数据的,所以将其分开可以减少判断节点类型的处理,
尤其是范围查询时,更能提升查询效率

系统表空间

系统表空间的结构和独立表空间基本类似,只不过由于整个MySQL进程只有一个系统表空 间,在系统表空间中会额外记录一些有关整个系统信息的页面,所以会比独立表空间多出 一些记录这些信息的页面,相当于是表空间之首,所以它的表空间 ID(Space ID)是0。
以前的 MySQL 版本中,系统表空间还包含双写缓冲区存储区域。有extent 1和extent两个区,也就是页号从64~191这128个页面被称为 Doublewrite buffer,也就是双写缓冲区。
自 MySQL 8.0.20 起,此存储区域位于单独的双写文件中。

双写缓冲区/双写机制

双写缓冲区/双写机制是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个页(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%左右。
所以,在一些情况下可以关闭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的作用有两个:
1、提高innodb把缓存的数据写到硬盘这个过 程的安全性;
2、间接的好处就是,innodb的事务日志不需要包含所有数据的前后映像,而是二进制变化量,这可以节省大量的IO。

Change Buffer

Change Buffer有的也叫Insert Buffer ,主要是用于对二级索引的写入优化和doublewrite buffer功能类似

其他表空间

参考:官方文档Undo空间则是undo日志 一般放在系统表空间,但是通过参数配置后,也可以用独立表空间存放。
通用表空间和独立表空间不同,通用表空间是允许多个表存储数据的共享表空间。
会话临时表空间存储用户创建的临时表和优化器在InnoDB配置为磁盘内部临时表的存储引擎时创建的内部临时表。从 MySQL 8.0.16 开始,用于磁盘内部临时表的存储引擎是InnoDB. (以前,存储引擎由 的值决定 internal_tmp_disk_storage_engine。)
会话临时表空间在第一次请求创建磁盘临时表时从临时表空间池中分配给会话。一个会话最多分配两个表空间,一个用于用户创建的临时表,另一个用于优化器创建的内部临时表。分配给会话的临时表空间用于会话创建的所有磁盘临时表。当会话断开连接时,其临时表空间将被截断并释放回池中。服务器启动时会创建一个包含 10 个临时表空间的池。池的大小永远不会缩小,并且表空间会根据需要自动添加到池中。临时表空间池在正常关闭或中止初始化时被删除。会话临时表空间文件在创建时大小为 5 页,并且具有.ibt 文件扩展名。
为会话临时表空间保留了 40 万个空间 ID。因为每次启动服务器时都会重新创建会话临时表空间池,所以会话临时表空间的空间 ID 在服务器关闭时不会保留,并且可以重复使用。
该innodb_temp_tablespaces_dir 变量定义了创建会话临时表空间的位置。默认位置是 #innodb_temp数据目录中的目录。如果无法创建临时表空间池,则会拒绝启动。

InnoDB数据字典(Data Dictionary)

当向一个表中插 入一条记录的时候,MySQL先要校验一下插入语句对应的表存不存在,插入的列和表中的 列是否符合,如果语法没有问题的话,还需要知道该表的聚簇索引和所有二级索引对应的根页面是哪个表空间的哪个页面,然后把记录插入对应索引的B+树中。MySQL除 了保存着我们插入的用户数据之外,还需要保存许多额外的信息比如:

表的所属表空间,
表的列数,表中每一个列的类型是什么,
表的索引个数,表中索引对应哪些字段,该索引对应的根页面在哪个表空间的哪个页面,
表的外键,外键对应哪个表的哪些列,
某个表空间对应文件系统上文件路径是什么等等。

这些数据也称为元数据。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这四个表尤其重要,称之为基本系统表。

但是用户是不能直接访问InnoDB的这些内部系统表的,除非你直接去解析系统表空间对应文件系统上的文件。不过InnoDB考虑到查看这些表的内容可能有助于大家分析问题,所以在系统数据库information_schema中提供了一些以innodb_sys开头的表,8.0后以innodb开头的,以下为8.0的

innodb存储引擎架构 innodb存储引擎原理_innodb存储引擎架构_10


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

其实,information_schema还有一些表,比如columns、tables等,一般的代码生成器也就是利用这两个表获取每个表的元数据信息和字段的元数据信息进行逆向生成orm模型。

InnoDB 的 Buffer Pool

InnoDB为了缓存磁盘中的页,在MySQL服务器启动的时候就向操作系统申请了一片连续的内存,他们给这片内存起了个名,叫做Buffer Pool(中文名是缓冲池)。默认情况下Buffer Pool只有128M。
命令查看:
show variables like ‘innodb_buffer_pool_size’;
可以在启动服务器的时候配置innodb_buffer_pool_size参数的值,它表示Buffer Pool的 大小,就像这样:
[server]
innodb_buffer_pool_size = 268435456 ## 以字节为单位,这里为256M
Buffer Pool也不能太小,最小值为5M(当小于该值时会自动设置成5M)。

Buffer Pool的缺省值其实是偏小的,一个比较合理的设置方法是按比例设置,一般的网上惯例是给buffer pool设置的机器内存的60%左右,当然这个值偏保守MYSQL官网中(https://dev.mysql.com/doc/refman/8.0/en/innodb-buffer-pool.html)建议:更大的缓冲池只需更少的磁盘 I/O 来多次访问相同的表数据。在专用数 据库服务器上,您可以将缓冲池大小设置为机器物理内存大小的 80%。
InnoDB 为缓冲区和控制结构保留了额外的内存,因此分配的总空间比指定的缓冲池大小 大约大 10%。
也就是说其实按照官方的分配最终Buffer Pool占据的空间可能达到机器物理内存的90%, 这个内存占用还是有点冒险的,因为即使是专用数据库服务器,还需要考虑:

  • 每个查询至少需要几K的内存(有时候是几M)
  • 有各种其它内部的MySQL结构和缓存
  • InnoDB有一些结构是不用缓冲池的内存的(字典缓存,文件系统等)
  • 也有一些MySQL文件是在OS缓存里的(binary日志,relay日志,innodb事务日志等)
  • 此外也必须为操作系统留出些内存
    所以比较权衡的值是70%~75%之间,但是需要监控好服务器的内存使用情况。当然最好的 情况是在DBA的监控下根据业务的繁忙情况按照Buffer Pool的命中率来设置:
    show engine innodb status\G
    对于读取多的情况,如果没达到98%以上,都说明buffer不够,可以扩,如果给命中都能达 到98%~100%了,而且还有大量的free page那说明够用了。
    总的来说,没有专人管理和实时监控的情况下,可以设置为60%较为稳妥,有专人管理和 实时监控的情况下,可以设置为75%,并根据业务情况适度增大或者缩小。
Buffer Pool内部组成

Buffer Pool中默认的缓存页大小和在磁盘上默认的页大小是一样的,都是16KB。为了更 好的管理这些在Buffer Pool中的缓存页,InnoDB为每一个缓存页都创建了一些所谓的控 制信息,这些控制信息包括该页所属的表空间编号、页号、缓存页在Buffer Pool中的地 址、链表节点信息、一些锁信息以及LSN信息,当然还有一些别的控制信息。 每个缓存页对应的控制信息占用的内存大小是相同的,我们称为控制块。控制块和缓存页是一一对应的,它们都被存放到 Buffer Pool 中,其中控制块被存放到 Buffer Pool 的前边,缓存页被存放到 Buffer Pool 后边,所以整个Buffer Pool对应的内存空间看起来 就是这样的

innodb存储引擎架构 innodb存储引擎原理_mysql_11


每个控制块大约占用缓存页大小的5%,而我们设置的innodb_buffer_pool_size并不包含这部分控制块占用的内存空间大小,也就是说InnoDB在为Buffer Pool向操作系统申请连 续的内存空间时,这片连续的内存空间一般会比innodb_buffer_pool_size的值大5%左 右。

free链表的管理

刚启动MySQL服务器的时候,需要完成对Buffer Pool的初始化过程,就是先向操作系统 申请Buffer Pool的内存空间,然后把它划分成若干对控制块和缓存页。但是此时并没有 真实的磁盘页被缓存到Buffer Pool中(因为还没有用到),之后随着程序的运行,会不 断的有磁盘上的页被缓存到Buffer Pool中。

为了更好的区分已缓存页与空闲缓存页,InnoDB把所有空闲的缓存页对应的控制块作为一个节点放到一个链表中,这个链表也可以被称作free链表(或者说空闲链表)。刚刚完成初始化的Buffer Pool中所有的缓存页都是空闲的,所以每一个缓存页对应的控制块都会被加入到free链表中,假设该Buffer Pool中可容纳的缓存页数量为n,那增加了free链表的效果图就是这样:

innodb存储引擎架构 innodb存储引擎原理_缓存_12


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

缓存页的哈希处理

为了区分和定位缓存页是否存在,InnoDB采用表空间号 + 页号作为key,缓存页作为value创建一个哈希表,在需要访问某个页的数据时,先从哈希表中根据表空间号 + 页号看看有没有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从free链表中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置,这样避免每次扫描缓存页链表做判断而降低效率。

flush链表的管理

修改了Buffer Pool中某个缓存页的数据,那它就和磁盘上的页不一致了,这样的缓存页也被称为脏页(英文名:dirty page)。InnoDB采用的异步的刷新策略,这就会出现脏页和非脏页,是需要区分的,所以InnoDB再创建一个存储脏页的链表,凡是修改过的缓存页对应的控制块都会作为一个 节点加入到一个链表中,因为这个链表节点对应的缓存页都是需要被刷新到磁盘上的,所 以也叫flush链表。链表的构造和free链表相差不大:

innodb存储引擎架构 innodb存储引擎原理_数据结构_13

LRU链表的管理

Buffer Pool对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大小超过了 Buffer Pool大小,这时就需要释放掉部分缓存页,InnoDB采用的是LRU的英文全称:Least Recently Used,最近最少使用淘汰策略。

默认策略如下:

innodb存储引擎架构 innodb存储引擎原理_mysql_14


InnoDB把LRU链表按照一定比例分成两截,分别是: 一部分存储使用频率非常高的缓存页,所以这一部分链表也叫做热数据,或者称young 区域(new)。另一部分存储使用频率不是很高的缓存页,所以这一部分链表也叫做冷数据,或者称old 区域

通过如下命令:

SHOW VARIABLES LIKE ‘innodb_old_blocks_pct’;

默认情况下,old区域在LRU链表中所占的比例是37%,也就是说old区 域大约占LRU链表的3/8。这个比例我们是可以设置的,我们可以在启动时修改 innodb_old_blocks_pct参数来控制old区域在LRU链表中所占的比例。在服务器运行期间,我们也可以修改这个系统变量的值,不过需要注意的是,这个系统变量属于全局变量。

针对预读的页面可能不进行后续访问情况的优化:

InnoDB规定,当磁盘上的某个页面在初次加载到Buffer Pool中的某个缓存页时,该缓存页对应的控制块会被放到old区域的头部。这样针对预读到Buffer Pool却不进行后续访问 的页面就会被逐渐从old区域逐出,而不会影响young区域中被使用比较频繁的缓存页。

针对全表扫描时,短时间内访问大量使用频率非常低的页面情况的优化:

在进行全表扫描时,虽然首次被加载到Buffer Pool的页被放到了old区域的头部,但是后续(间隔时间内)会被马上访问到,每次进行访问的时候又会把该页放到young区域的头部,这样仍然会 把那些使用频率比较高的页面给顶下去。

间隔时间是由系统变量innodb_old_blocks_time控制的:

SHOW VARIABLES LIKE ‘innodb_old_blocks_time’;

默认值是1000,它的单位是毫秒

对于从磁盘 上被加载到LRU链表的old区域的某个页来说,如果第一次和最后一次访问该页面的时间间隔小于1s(很明显在一次全表扫描的过程中,多次访问一个页面中的时间不会超过1s), 那么该页是不会被加入到young区域的, 当然,像innodb_old_blocks_pct一样,我们也 可以在服务器启动或运行时设置innodb_old_blocks_time的值,这里需要注意的是,如果 我们把innodb_old_blocks_time的值设置为0,那么每次我们访问一个页面时就会把该页面放到young区域的头部。

内部小优化:

对于young区域的缓存页来说,我们每次访问一个缓存页就要把它移动到LRU链表的头部, 这样开销是不是太大? 毕竟在young区域的缓存页都是热点数据,也就是可能被经常访问的,这样频繁的对LRU链 表进行节点移动操作也会拖慢速度?为了解决这个问题,MySQL中还有一些优化策略,比如只有被访问的缓存页位于young区域的1/4的后边,才会被移动到LRU链表头部,这样就可以降低调整LRU链表的频率,从而提升性能。其实还有很多策略,但是不论怎么优化,出发点就是:尽量高效的提高 Buffer Pool 的 缓存命中率。

补充其他链表:
为了更好的管理Buffer Pool中的缓存页,除了我们上边提到的一些措施,InnoDB们还引 进了其他的一些链表,比如unzip LRU链表用于管理解压页,zip clean链表用于管理没有 被解压的压缩页,zip free数组中每一个元素都代表一个链表,它们组成所谓的伙伴系统 来为压缩页提供内存空间等等。

刷新脏页到磁盘

后台有专门的线程每隔一段时间负责把脏页刷新到磁盘,这样可以不影响用户线程处理正 常的请求。主要有两种刷新路径:
1、从LRU链表的冷数据中刷新一部分页面到磁盘。 后台线程会定时从LRU链表尾部开始扫描一些页面,扫描的页面数量可以通过系统变量 innodb_lru_scan_depth来指定,如果从里边儿发现脏页,会把它们刷新到磁盘。这种刷 新页面的方式被称之为BUF_FLUSH_LRU
2、从flush链表中刷新一部分页面到磁盘。 后台线程也会定时从flush链表中刷新一部分页面到磁盘,刷新的速率取决于当时系统是不是很繁忙。这种刷新页面的方式被称之为BUF_FLUSH_LIST
3、有时候后台线程刷新脏页的进度比较慢,导致用户线程在准备加载一个磁盘页到Buffer Pool时没有可用的缓存页,这时就会尝试看看LRU链表尾部有没有可以直接释放掉的未修改页面,如果没有的话会不得不将LRU链表尾部的一个脏页同步刷新到磁盘(和磁盘交互是很慢的,这会降低处理用户请求的速度)。这种刷新单个页面到磁盘中的刷新方式被称之为BUF_FLUSH_SINGLE_PAGE
当然,有时候系统特别繁忙时,也可能出现用户线程批量的从flush链表中刷新脏页的情 况,很显然在处理用户请求过程中去刷新脏页是一种严重降低处理速度的行为,这属于一 种迫不得已的情况。

多个Buffer Pool实例

Buffer Pool本质是InnoDB向操作系统申请的一块连续的内存空间,在多线程环境下,访问Buffer Pool中的各种链表都需要加锁处理,在Buffer Pool特别大而且 多线程并发访问特别高的情况下,单一的Buffer Pool可能会影响请求的处理速度。所以在Buffer Pool特别大的时候,我们可以把它们拆分成若干个小的Buffer Pool,每个 Buffer Pool都称为一个实例,它们都是独立的,独立的去申请内存空间,独立的管理各种链表,所以在多线程并发访问时并不会相互影响,从而提高并发处理能力。
可以在服务器启动的时候通过设置innodb_buffer_pool_instances的值来修改Buffer Pool实例的个数。那每个Buffer Pool实例实际占多内存空间为: innodb_buffer_pool_size/innodb_buffer_pool_instances
也就是总共的大小除以实例的个数,结果就是每个Buffer Pool实例占用的大小。 不过也不是说Buffer Pool实例创建的越多越好,分别管理各个Buffer Pool也是需要性能开销的,InnoDB规定:innodb_buffer_pool_instances能设置的最大值是64,而且当 innodb_buffer_pool_size(默认128M)的值小于1G的时候设置多个实例是无效的, InnoDB会默认把innodb_buffer_pool_instances 的值修改为1。

按照官方的说明,最佳的innodb_buffer_pool_instances的数量是, innodb_buffer_pool_size除以innodb_buffer_pool_instances,可以让每个Buffer Pool 实例达到1个G
官方文档地址

innodb_buffer_pool_chunk_size

MySQL在5.7.5以及之后的版本中支持了在服务器运行过程中调整Buffer Pool大小的功能, 但是有一个问题,就是每次当我们要重新调整Buffer Pool大小时,都需要重新向操作系统申请一块连续的内存空间,然后将旧的Buffer Pool中的内容复制到这一块新空间,这是极其耗时的。所以MySQL决定不再一次性为某个Buffer Pool实例向操作系统申请一大片连续的内存空间,而是以一个所谓的chunk为单位向操作系统申请空间。也就是说一个 Buffer Pool实例其实是由若干个chunk组成的,一个chunk就代表一片连续的内存空间, 里边儿包含了若干缓存页与其对应的控制块:

innodb存储引擎架构 innodb存储引擎原理_缓存_15


在服务器运行期间调整Buffer Pool的大小时就是 以chunk为单位增加或者删除内存空间,而不需要重新向操作系统申请一片大的内存,然后进行缓存页的复制。这个所谓的chunk的大小是我们在启动操作MySQL服务器时通过 innodb_buffer_pool_chunk_size启动参数指定的,它的默认值是134217728,也就是 128M。不过需要注意的是,innodb_buffer_pool_chunk_size的值只能在服务器启动时指定,在服务器运行过程中是不可以修改的。 Buffer Pool的缓存页除了用来缓存磁盘上的页面以外,还可以存储锁信息、自适应哈希 索引等信息。

查看Buffer Pool的状态信息

:SHOW ENGINE INNODB STATUS

Total memory allocated:代表Buffer Pool向操作系统申请的连续内存空间大小,包括 全部控制块、缓存页、以及碎片的大小。
Dictionary memory allocated:为数据字典信息分配的内存空间大小,注意这个内存空 间和Buffer Pool没啥关系,不包括在Total memory allocated中。
Buffer pool size:代表该Buffer Pool可以容纳多少缓存页,注意,单位是页!
Free buffers:代表当前Buffer Pool还有多少空闲缓存页,也就是free链表中还有多少 个节点
Database pages:代表LRU链表中的页的数量,包含young和old两个区域的节点数量。
Old database pages:代表LRU链表old区域的节点数量。
Modified db pages:代表脏页数量,也就是flush链表中节点的数量。
Pending reads:正在等待从磁盘上加载到Buffer Pool中的页面数量。 当准备从磁盘中加载某个页面时,会先为这个页面在Buffer Pool中分配一个缓存页以及 它对应的控制块,然后把这个控制块添加到LRU的old区域的头部,但是这个时候真正的磁 盘页并没有被加载进来,Pending reads的值会跟着加1.
Pending writes LRU:即将从LRU链表中刷新到磁盘中的页面数量。
Pending writes flush list:即将从flush链表中刷新到磁盘中的页面数量。
Pending writes single page:即将以单个页面的形式刷新到磁盘中的页面数量。
Pages made young:代表LRU链表中曾经从old区域移动到young区域头部的节点数量。
Page made not young:在将innodb_old_blocks_time设置的值大于0时,首次访问或者后 续访问某个处在old区域的节点时由于不符合时间间隔的限制而不能将其移动到young区域 头部时,Page made not young的值会加1。
youngs/s:代表每秒从old区域被移动到young区域头部的节点数量。
non-youngs/s:代表每秒由于不满足时间限制而不能从old区域移动到young区域头部的节 点数量
Pages read、created、written:代表读取,创建,写入了多少页。后边跟着读取、创 建、写入的速率。
Buffer pool hit rate:表示在过去某段时间,平均访问1000次页面,有多少次该页面已 经被缓存到Buffer Pool了。
young-making rate:表示在过去某段时间,平均访问1000次页面,有多少次访问使页面 移动到young区域的头部了。
not (young-making rate):表示在过去某段时间,平均访问1000次页面,有多少次访问 没有使页面移动到young区域的头部。
LRU len:代表LRU链表中节点的数量。
unzip_LRU:代表unzip_LRU链表中节点的数量。
I/O sum:最近50s读取磁盘页的总数。
I/O cur:现在正在读取的磁盘页数量。
I/O unzip sum:最近50s解压的页面数量。
I/O unzip cur:正在解压的页面数量。

MYSQL8.0—data目录:

innodb存储引擎架构 innodb存储引擎原理_数据结构_16


innodb存储引擎架构 innodb存储引擎原理_数据结构_17