InnoDB引擎解析

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

InnoDB的内存结构和磁盘存储结构图总结

行格式

InnoDB存储引擎设计了4种不同类型的行格式,分别是Compact、Redundant、Dynamic和Compressed行格式。我们可以查看默认值:

show variables like 'innodb_default_row_format';
 

在创建或修改表的语句中指定行格式

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

COMPACT行记录格式

Compact设计目标是高效地存储数据,一个页中存放的行数据越多,其性能就越高。

Compact行记录由两部分组成: 记录放入额外信息 和 记录的真实数据。

COMPACT行格式示意图如下

记录额外信息部分

服务器为了描述一条记录而添加了一些额外信息(元数据信息),这些额外信息分为3类,分别是: 变长字段长度列表、NULL值列表和记录头信息

变长字段长度列表

我们知道MySQL支持一些变长的数据类型,比如VARCHAR(M)、VARBINARY(M)、各种TEXT类型,各种BLOB类型,我们也可以把拥有这些数据类型的列称为变长字段,变长字段中存储多少字节的数据是不固定的,所以我们在存储真实数据的时候需要顺便把这些数据占用的字节数也存起来。如果该可变字段允许存储的最大字节数(M×W)超过255字节并且真实存储的字节数(L)超过127字节,则使用2个字节,否则使用1个字节。

NULL值列表

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

记录头信息

记录头信息是由固定的5个字节组成,5个字节也就是40个二进制位,不同的位代表不同的意思,这些头信息会在后面的一些功能中看到。

名称大小(单位:bit)描述
预留位11没有使用
预留位21没有使用
delete_mask1标记着当前记录是否被删除,占用1个二进制位,值为0 的时候代表记录并没有被删除,为1 的时候代表记录被删除掉了
min_rec_mask1标记该记录是否是本层B+树的非叶子节点中的最小记录,B+树的每层非叶子节点中的最小记录都会添加该标记。
n_owned4表示当前分组中管理的记录数,代表每个分组里,所拥有的记录的数量,一般是分组里主键最大值才有的。
heap_no13表示当前记录在记录堆中的位置信息,在数据页的User Records中插入的记录是一条一条紧凑的排列的,这种紧凑排列的结构又被称为堆。为了便于管理这个堆,把记录在堆中的相对位置给定一个编号——heap_no。所以heap_no这个属性表示当前记录在本页中的位置。
record_type3表示当前记录的类型: 0 表示普通记录, 1 表示B+树非叶子节点记录,2 表示最小记录,3表示最大记录
next_record16表示下一条记录的相对位置。从当前记录的真实数据到下一条记录的真实数据的地址偏移量,可以理解为指向下一条记录地址的指针。值为正数说明下一条记录在当前记录后面,为负数说明下一条记录在当前记录的前面。
记录真实数据部分

记录的真实数据除了插入的那些列的数据,MySQL会为每个记录默认的添加一些列(也称为隐藏列),具体的列如下:

列名是否必须占用空间描述
row_id6字节行ID,唯一标识一条记录,
transaction_id6字节事务ID
roll_pointer7字节回滚指针

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

生成隐藏主键列的方式
  1. 服务器会在内存中维护一个全局变量,每当向某个包含隐藏的row_id列的表中插入一条记录时,就会把该变量的值当作新记录的row_id列的值,并且把该变量自增1。
  2. 每当这个变量的值为256的倍数时,就会将该变量的值刷新到系统表空间的页号为7的页面中一个Max Row ID的属性处。
  3. 当系统启动时,会将页中的Max Row ID属性加载到内存中,并将该值加上256之后赋值给全局变量,因为在上次关机时该全局变量的值可能大于页中Max Row ID属性值。

Redundant行格式

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

Dynamic和Compressed行格式

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

数据溢出(行溢出)

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

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

InnoDB 规定一页至少存储两条记录(B+树特点),如果页中只能存放下一条记录,InnoDB存储引擎会自动将行数据存放到溢出页中. 当发生行溢出时,数据页只保存了前768字节的前缀数据,接着是20个字节的偏移量,指向行溢出页。

索引页格式

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

数据页结构

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

Page 各部分说明

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

文件头是各种类型的页共有的组成部分,描述了页的类型、编号、上一个页和下一个页等信息,占用38个字节。其中文件头的FIL_PAGE_PREV和FIL_PAGE_NEXT字段非常重要,通过这两个字段,可以找到该页的上一页和下一页。实际上,所有的页可以通过这两个字段形成一条双向链表。

页的类型包括Undo日志页、段信息节点、InsertBuffer空闲列表、InsertBuffer位图、系统页、事务系统数据、表空间头部信息、扩展描述页、溢出页、索引页等。

通过上一个页和下一个页的属性,可以建立双向链表将所有的页连接起来,无需在物理上进行实际的连接。但并非所有类型的页都具有上一个和下一个页的属性,只有数据页才有。

File Trailer

InnoDB存储引擎将数据存储到磁盘上,但由于磁盘速度较慢,需要以页为单位将数据加载到内存中进行处理。如果内存中的页数据被修改,需要在某个时间将数据同步到磁盘。但如果在同步过程中断电怎么办呢?

为了检测页是否完整地同步到磁盘(避免只同步了一半的尴尬情况),InnoDB在每个页的尾部添加了File Trailer部分,由8个字节组成,可分为两个小部分:

前4个字节是页的校验和。

这个校验和与File Header中的校验和相对应。在内存中修改一个页面时,需要在同步之前计算出校验和。由于File Header位于页面的前面,校验和会首先被同步到磁盘。当完全写入时,校验和也会被写入页面的尾部。如果同步成功,页的首部和尾部的校验和应该是一致的。如果写了一半然后断电,File Header中的校验和表示已被修改的页,而File Trailer中的校验和表示原始页,如果两者不同,说明同步过程中出错了。

后4个字节是页面被最后修改时的日志序列位置(LSN),与检验页的完整性有关。

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

记录部分(User Records&Free Space)

我们的记录按照指定的行格式存储在"User Records"部分。每次插入记录时,我们会从"Free Space"申请与记录大小相同的空间,并将其划分到"User Records"部分。当"Free Space"被完全使用后,我们需要申请新的页面。

记录被删除时,我们会将其标记为删除,并保留在页面和磁盘中。不直接从磁盘中删除这些记录是因为重新排列其他记录会消耗性能。

被删除的记录构成了一个垃圾链表,占用了可重用空间。当有新的记录插入时,可能会使用到这些被删除记录的空间。

插入的记录会记录自己在页面中的位置,并写入记录头信息的heap_no部分。页面中heap_no为0和1的记录是自动生成的伪记录或虚拟记录。它们代表最小和最大记录,独立放置在Infimum + Supremum部分。

记录头信息的next_record字段存储了从当前记录到下一条记录的地址偏移量。这是一个链表,通过一条记录可以找到下一条记录。需要注意的是,链表的顺序是按照主键值从小到大的顺序排列,而不是按照插入顺序排列。规定Infimum记录的下一条是本页面中最小主键值的用户记录,而页面中最大主键值的用户记录的下一条是Supremum记录。

页的主要作用是存储记录,所以“最小和最大记录”和“用户记录”部分占了页结构的主要空间。另外空闲空间是个灵活的部分,当有新的记录插入时,会从空闲空间中进行分配用于存储新记录。

数据目录部分 (Page Directory) 

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

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

  1. 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。
  2. 每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的n_owned属性表示该记录拥有多少条记录,也就是该组内共有几条记录。
  3. 将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近页的尾部的地方,这个地方就是所谓的Page Directory,也就是页面目录中的这些地址偏移量被称为槽(英文名:Slot),所以这个页面目录就是由槽组成的。

数据页中行记录按照主键值由小到大顺序串联成一个单链表(页中记录是以单向链表的形式进行存储的),且单链表的链表头为最小记录,链表尾为最大记录。并且为了更快速地定位到指定的行记录,通过 Page Directory实现目录的功能,借助 Page Directory使用二分法快速找到需要查找的行记录。

Page Header

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

Infimum 和 Supremum

Infimum 和 Supremum两个伪行记录,Infimum(下确界)记录比该页中任何主键值都要小的值,Supremum (上确界)记录比该页中任何主键值都要大的值,这个伪记录分别构成了页中记录的边界。

InnoDB的表空间

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

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

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

独立表空间结构

区(extent)

表空间中的页可以达到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。

段(segment)

我们提到的范围查询,其实是对B+树叶子节点中的记录进行顺序扫描,而如果不区分叶子节点和非叶子节点,统统把节点代表的页面放到申请到的区中的话,进行范围扫描的效果就大打折扣了。所以InnoDB对B+树的叶子节点和非叶子节点进行了区别对待,也就是说叶子节点有自己独有的区,非叶子节点也有自己独有的区。

存放叶子节点的区的集合就算是一个段(segment),存放非叶子节点的区的集合也算是一个段。也就是说一个索引会生成2个段,一个叶子节点段,一个非叶子节点段。

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

系统表空间

整体结构

系统表空间的结构和独立表空间基本类似,只不过由于整个MySQL进程只有一个系统表空间,在系统表空间中会额外记录一些有关整个系统信息的页面,所以会比独立表空间多出一些记录这些信息的页面,相当于是表空间之首,所以它的表空间 ID(Space ID)是0。

系统表空间的extent 1和extent 2这两个区,也就是页号从64~191这128个页面被称为Doublewrite buffer,也就是双写缓冲区

InnoDB三大特性

Doublewrite Buffer(双写缓存区,双写机制)

InnoDB的页和操作系统的页大小不一致,InnoDB页大小一般为16K,操作系统页大小为4K,InnoDB的页写入到磁盘时,一个页需要分4次写。

而计算机硬件和操作系统,在极端情况下(比如断电)往往并不能保证这一操作的原子性,16K的数据,写入4K时,发生了系统断电或系统崩溃,只有一部分写是成功的,这种情况下会产生partial page write(部分页写入)问题。这时页数据出现不一样的情形,从而形成一个"断裂"的页,使数据产生混乱。在InnoDB存储引擎未使用doublewrite技术前,曾经出现过因为部分写失效而导致数据丢失的情况。

为了解决写失效问题,InnoDB实现了double write buffer Files, 它位于系统表空间,是一个存储区域。双写机制是一种特殊文件flush技术,带给InnoDB存储引擎的是数据页的可靠性。它的作用是,在把页写到数据文件之前,InnoDB先把它们写到一个叫doublewrite buffer(双写缓冲区)的连续区域内,在写doublewrite buffer完成后,InnoDB才会把页写到数据文件的适当的位置。如果在写页的过程中发生意外崩溃,InnoDB在稍后的恢复过程中在doublewrite buffer中找到完好的page副本用于恢复。

doublewrite buffer是InnoDB在系统表空间上的128个页(2个区,extend1和extend2),大小是2MB

doublewrite是在一个连续的存储空间, 所以硬盘在写数据的时候是顺序写,而不是随机写,这样性能影响不大,相比不双写,降低了大概5-10%左右。

所以,在一些情况下可以关闭doublewrite以获取更高的性能。比如在slave上可以关闭,因为即使出现了partial page write问题,数据还是可以从中继日志中恢复。比如某些文件系统ZFS本身有些文件系统本身就提供了部分写失效的防范机制,也可以关闭。

在数据库异常关闭的情况下启动时,都会做数据库恢复(redo)操作,恢复的过程中,数据库都会检查页面是不是合法(校验等等),如果发现一个页面校验结果不一致,则此时会用到双写这个功能。

MySQL写数据页时,会写两遍到磁盘上,第一遍是写到doublewrite buffer,第二遍是写到真正的数据文件中。如果发生了极端情况(断电),InnoDB再次启动后,发现了一个页数据已经损坏,那么此时就可以从doublewrite buffer中进行数据恢复了。

同样是恢复数据,为什么有了Redo Log还要用双写机制?

Redo Log是用来记录已经提交事务的数据(也就是已经写入磁盘并刷新到数据文件的数据),而对于已经写入磁盘但尚未刷新到数据文件的数据,Redo Log是无法起作用的。这时候就需要依赖其他机制(比如双写机制)来确保数据的完整性。

双写流程如图

  • step1:当进行缓冲池中的脏页刷新到磁盘的操作时,并不会直接写磁盘,每次脏页刷新必须要先写double write。

  • step2:通过memcpy函数将脏页复制到内存中的double write buffer。

  • step3: double write buffer再分两次、每次1MB, 顺序写入系统表空间的物理磁盘上, 第一次写

  • step4: 在完成double write页的写入后,再将double wirite buffer中的页写入各个表的独立表空间文件中(数据文件 .ibd), 第二次写

默认情况下启用双写缓冲区,如果要禁用Doublewrite 缓冲区,可以将 innodb_doublewrite设置为0。

show variables like '%innodb_doublewrite%';


Buffer Pool

缓存的重要性:InnoDB存储引擎将表数据以页的形式存放在表空间中,而表空间是InnoDB对文件系统上亿个或几个实际文件的抽象。由于磁盘速度慢,当需要访问某个页的数据时,InnoDB会将整个页的数据加载到内存中,即使只需要访问一条记录。这样做的好处是进行读写访问后,将该页缓存起来,以减少将来再次访问时的磁盘IO开销。

Buffer Pool:缓冲池,简称BP。其作用是用来缓存表数据与索引数据,减少磁盘IO操作,提升效率。

Buffer Pool由缓存数据页(Page) 和 对缓存数据页进行描述的控制块 组成, 控制块中存储着对应缓存页的所属的 表空间、数据页的编号、以及对应缓存页在Buffer Pool中的地址等信息。

Buffer Pool默认大小是128M, 以Page页为单位,Page页默认大小16K,而控制块的大小约为数据页的5%,大 概是800字节。

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

show variables like 'innodb_buffer_pool_size';

控制块和缓存页是一一对应的,它们都被存放到 Buffer Pool 中,其中控制块被存放到 Buffer Pool 的前边,缓存页被存放到 Buffer Pool 后边。

如何判断一个页是否在BufferPool中缓存 ?

缓存页的哈希处理:MySQl中有一个哈希表数据结构,它使用表空间号+数据页号,作为一个key,然后缓冲页对应的控制块作为value。

  • 当需要访问某个页的数据时,先从哈希表中根据表空间号+页号看看是否存在对应的缓冲页。

  • 如果有,则直接使用;如果没有,就从free链表中选出一个空闲的缓冲页,然后把磁盘中对应的页加载到该缓冲页的位置

InnoDB对Page页的管理

Page页分类

  • free page : 空闲page,未被使用

  • clean page:被使用page,数据没有被修改过

  • dirty page:脏页,被使用page,数据被修改过,Page页中数据和磁盘的数据产生了不一致

free链表的管理

最初启动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链表节点从链表中移除,表示该缓存页已经被使用了。

free list:表示空闲缓冲区,管理free page。

  • free链表是把所有空闲的缓冲页对应的控制块作为一个个的节点放到一个链表中,这个链表便称之为free链表

  • 基节点: free链表中只有一个基节点是不记录缓存页信息(单独申请空间),它里面就存放了free链表的头节点的地址,尾节点的地址,还有free链表里当前有多少个节点。

flush链表的管理

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

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

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

flush list: 表示需要刷新到磁盘的缓冲区,管理dirty page,内部page按修改时间排序。

  • InnoDB引擎为了提高处理效率,在每次修改缓冲页后,并不是立刻把修改刷新到磁盘上,而是在未来的某个时间点进行刷新操作. 所以需要使用到flush链表存储脏页,凡是被修改过的缓冲页对应的控制块都会作为节点加入到flush链表.

  • flush链表的结构与free链表的结构相似

LRU链表的管理

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

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

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

普通的LRU

Least Recently Used(最近最少使用): 就是末尾淘汰法,新数据从链表头部加入,释放空间时从末尾淘汰。

  1. 当要访问某个页时,如果不在Buffer Pool,需要把该页加载到缓冲池,并且把该缓冲页对应的控制块作为节点添加到LRU链表的头部。

  2. 当要访问某个页时,如果在Buffer Pool中,则直接把该页对应的控制块移动到LRU链表的头部

  3. 当需要释放空间时,从最末尾淘汰。

普通LRU时存在的问题1:预读

由于MySQL中存在预读机制,很多预读的页都会被放到LRU链表的表头。如果这些预读的页都没有用到的话,这样,会导致很多尾部的缓冲页很快就会被淘汰。

InnoDB提供了预读(英文名:readahead)。所谓预读,就是InnoDB认为执行当前的请求可能之后会读取某些页面,就预先把它们加载到Buffer Pool中。

线性预读

InnoDB提供了一个系统变量innodb_read_ahead_threshold,如果顺序访问了某个区(extent)的页面超过这个系统变量的值,就会触发一次异步读取下一个区中全部的页面到Buffer Pool的请求。

show variables like 'innodb_read_ahead_threshold'; 

这个innodb_read_ahead_threshold系统变量的值默认是56,我们可以在服务器启动时通过启动参数或者服务器运行过程中直接调整该系统变量的值。

随机预读

如果Buffer Pool中已经缓存了某个区的13个连续的页面,不论这些页面是不是顺序读取的,都会触发一次异步读取本区中所有其他的页面到Buffer Pool的请求。

如果Buffer Pool中的页成功被预读到并使用,可以极大地提高语句执行的效率。但如果这些页未被用到,它们会被放到LRU链表的头部。如果此时Buffer Pool的容量较小且很多预读的页面都没有用到,会导致LRU链表尾部的一些缓存页迅速被淘汰,也就是所谓的劣币驱逐良币现象,会大大降低缓存命中率。

所以InnoDB同时提供了innodb_random_read_ahead系统变量,它的默认值为OFF。

show variables like 'innodb_random_read_ahead';

普通LRU时存在的问题2:全表扫描

如果发生全表扫描(比如:没有建立合适的索引 or 查询时使用select * 等),则有很大可能将真正的热数据淘汰掉。

扫描全表意味着什么?意味着将访问到该表所在的所有页!假设这个表中记录非常多的话,那该表会占用特别多的页,当需要访问这些页时,会把它们统统都加载到Buffer Pool中,这也就意味着Buffer Pool中的所有页都被换了一次血,其他查询语句在执行时又得执行一次从磁盘加载到Buffer Pool的操作。而这种全表扫描的语句执行的频率也不高,每次执行都要把Buffer Pool中的缓存页换一次血,这严重的影响到其他查询对 Buffer Pool的使用,从而大大降低了缓存命中率。

改进型LRU算法(InnoDB使用的)

改进型LRU:将链表分为new和old两个部分,加入元素时并不是从表头插入,而是从中间midpoint位置插入(就是说从磁盘中新读出的数据会放在冷数据区的头部),如果数据很快被再次访问(默认一秒内再次被访问),那么page就会向new列表头部移动,如果数据没有被访问,会逐步向old尾部移动,等待淘汰。

lru list:表示正在使用的缓冲区,管理clean page和dirty page,缓冲区以midpoint为基点,前面链表称为new列表区,存放经常访问的数据,占63%;后面的链表称为old列表区,存放使用较少数据,占37%

冷数据区的数据页什么时候会被转到到热数据区呢 ?

  1. 如果该数据页在LRU链表中存在时间超过1s,就将其移动到链表头部 ( 链表指的是整个LRU链表)

  2. 如果该数据页在LRU链表中存在的时间短于1s,其位置不变(由于全表扫描有一个特点,就是它对某个页的频繁访问总耗时会很短)

  3. 1s这个时间是由参数 innodb_old_blocks_time 控制的

查看冷数据区转移到热数据区的阈值

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

show variables like 'innodb_old_blocks_time';

更进一步优化LRU链表

对于young区域的缓存页来说,我们每次访问一个缓存页就要把它移动到LRU链表的头部,这样开销是不是太大?

毕竟在young区域的缓存页都是热点数据,也就是可能被经常访问的,这样频繁的对LRU链表进行节点移动操作也会拖慢速度?为了解决这个问题,MySQL中还有一些优化策略,比如只有被访问的缓存页位于young区域的1/4的后边,才会被移动到LRU链表头部,这样就可以降低调整LRU链表的频率,从而提升性能。

多个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_size(默认128M)的值小于1G的时候设置多个实例是无效的,InnoDB会默认把innodb_buffer_pool_instances 的值修改为1。所以Buffer Pool大于或等于1G的时候设置应该多个Buffer Pool实例。

innodb_buffer_pool_chunk_size

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

正是因为发明了这个chunk的概念,我们在服务器运行期间调整Buffer Pool的大小时就是以chunk为单位增加或者删除内存空间,而不需要重新向操作系统申请一片大的内存,然后进行缓存页的复制。这个所谓的chunk的大小是我们在启动操作MySQL服务器时通过innodb_buffer_pool_chunk_size启动参数指定的,它的默认值是134217728,也就是128M。不过需要注意的是,innodb_buffer_pool_chunk_size的值只能在服务器启动时指定,在服务器运行过程中是不可以修改的。

Buffer Pool的缓存页除了用来缓存磁盘上的页面以外,还可以存储锁信息、自适应哈希索引等信息。

SHOW VARIABLES LIKE 'innodb_buffer_pool_chunk_size';

查看Buffer Pool的状态信息

SHOW ENGINE INNODB STATUS;

----------------------
BUFFER POOL AND MEMORY
----------------------
Total large memory allocated 0
Dictionary memory allocated 489041
Buffer pool size   8192
Free buffers       7151
Database pages     1038
Old database pages 403
Modified db pages  0
Pending reads      0
Pending writes: LRU 0, flush list 0, single page 0
Pages made young 0, not young 0
0.00 youngs/s, 0.00 non-youngs/s
Pages read 895, created 143, written 197
0.00 reads/s, 0.00 creates/s, 0.00 writes/s
No buffer pool page gets since the last printout
Pages read ahead 0.00/s, evicted without access 0.00/s, Random read ahead 0.00/s
LRU len: 1038, unzip_LRU len: 0
I/O sum[0]:cur[0], unzip sum[0]:cur[0]
--------------
ROW OPERATIONS
--------------
0 queries inside InnoDB, 0 queries in queue
0 read views open inside InnoDB
Process ID=1, Main thread ID=139732977698560 , state=sleeping
Number of rows inserted 0, updated 0, deleted 0, read 0
0.00 inserts/s, 0.00 updates/s, 0.00 deletes/s, 0.00 reads/s
Number of system rows inserted 8, updated 331, deleted 8, read 4794
0.00 inserts/s, 0.00 updates/s, 0.00 deletes/s, 0.00 reads/s
----------------------------
END OF INNODB MONITOR OUTPUT
============================

Total large 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:正在解压的页面数量。

Adaptive Hash Indexing(自适应Hash索引)

 哈希索引使用哈希算法,根据key计算的哈希值,映射到数组中的哈希槽,如果发生哈希冲突则使用链表连接。

在mysql中memory存储引擎支持哈希索引,在innodb中有一种自适应哈希功能(innodb的三大特性:buffer pool , adaptive_hash_index , double write buffer)

  • 自适应即我们不需要自己处理,当InnoDB引擎根据查询统计发现某一查询满足hash索引的数据结构特点,就会给其建立一个hash索引;
  • hash索引底层的数据结构是散列表(Hash表),其数据特点就是比较适合在内存中使用,自适应Hash索引存在于InnoDB架构中的缓存中(不存在于磁盘架构中),见下面的InnoDB架构图。
  • 自适应hash索引只适合搜索等值的查询,如select * from table where index_col='xxx',而对于其他查找类型,如范围查找,是不能使用的;

Adaptive Hash Index是针对B+树Search Path的优化,因此所有会涉及到Search Path的操作,均可使用此Hash索引进行优化.

根据索引键值(前缀)快速定位到叶子节点满足条件记录的Offset,减少了B+树Search Path的代价,将B+树从Root节点至Leaf节点的路径定位,优化为Hash Index的快速查询。

InnoDB的自适应Hash索引是默认开启的,可以通过配置下面的参数设置进行关闭

innodb_adaptive_hash_index = off

自适应Hash索引使用分片进行实现的,分片数可以使用配置参数设置

innodb_adaptive_hash_index_parts = 8

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值