InnoDB存储结构

InnoDB存储引擎是为数据页为操作的基本单位,默认大小为16KB,而这些数据页是存储在磁盘中,当需要查询数据时,InnoDB怎么知道每条记录放在磁盘的哪个位置,这里面就涉及到了InnoDB记录的存储存储结构、索引页结构以及表空间等,这篇文章主要就是介绍记录是怎么存储在磁盘中,除了记录业务数据外,还需要记录哪些内容。

一、InnoDB记录存储结构

我们平时在使用数据库时,是以记录为单位读取或修改数据,这些记录在磁盘上的存放方式称为行格式或记录格式。InnoDB存储引擎设计了四种不同类型的行格式,分别是:Compact、Redundant、Dynamic和Compressed

通常可以在创建或修改表的语句中指定行格式:

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

注:mysql5.7以上的版本,默认的行格式为Dynamic

下面以COMPACT格式为例,介绍行格式的具体内容:

1.1 Compact

对于每个记录来说,我们通常看到的都是表结构中定义的字段值,而在实际的存储中,如果只存储表结构定义的这些字段值显然是不够的。因为行记录是在磁盘存储的,读取每个记录中的属性值是通过计算偏移量来完成,但对于每一个记录来说,它的属性值可能为空,也可能数据长度不固定,这样就没法精确计算偏移量,所以除了存储表结构中的基本属性值外,每个行记录还包含了一些额外信息。

通常一条记录的基本结构如下图:

MySQL支持一些变长的数据类型,比如VARCHAR(M)、VARBINARY(M)、TEXT和BLOB类型等,拥有这些变长数据类型的列称为变长字段,变长字段中存储真实数据的数据长度是不固定的,那么在数据读取的时候就不知道读到什么位置结束,所以就需要在存储真实数据的时候把这些数据占用的字节数也存起来,这就是额外信息中变长字段长度列表所要干的事情。

如果变长字段允许存储的最大字节数超过255字节并且真实存储的字节数超过127字节时,使用2个字节来存储数据大小,否则使用1个字节

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

除了上面的变长字段列表和NULL值列表外,还有一个用于描述记录的的记录头信息以及一些隐藏列。其中记录头的详细信息如下:

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

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

注:n_owned属性会在后面索引页格式中详细介绍

除了记录头信息外,MySQL还会为每个记录添加一些隐藏列,包括下面三个:

列名 是否必须 所占字节 说明
DB_ROW_ID(row_id) 6 表示行ID,唯一标识一条记录
DB_TRX_ID 6 表示事务ID
DB_ROLL_PTR 7 表示事务回滚指针

注:对于DB_ROW_ID,表中没有可以作为主键的列时,MySQL才会默认生成一个这样列,作为记录的唯一标识

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

另外两个与事务相关的列,在前面的文章《MVCC机制》中已经介绍过它们的作用。

1.2 Redundant

Redundant行格式是MySQL5.0之前用的一种行格式,现在基本已经不用了。

1.3 Dynamic和Compressed

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

1.4 数据溢出

数据库表的VARCHAR(M)类型的列,最多可以存放65532个字节的数据。而MySQL中磁盘和内存交互的基本单位是页,也就是说MySQL是以页为基本单位来管理存储空间的,表中记录都会被分配到某个页中存储。而MySQL中页的大小默认为16KB,也就是16384字节,这样就导致可能会存在一个页存放不了一条记录的情况,就会造成数据溢出的现象。

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

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

二、索引页格式

前面介绍了一条记录的存储结构,InnoDB是以页为单位操作数据的,为了不同的目的设计了许多类型的页,存放表记录的页只是其中一种,官方称这种存放记录的页为索引(INDEX)页,也可以理解为数据页。

其大概结构如下:

索引页的存储空间大概分为七部分:

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

2.1 User Records

数据库表的记录会按照执行的行格式存储到User Records部分。但在刚开始生成的索引页中,其实并没有User Records这个部分,而是每次插入一条记录时,都会从Free Space部分也就是尚未使用的存储空间中申请一个记录大小的空间划分到User Record部分,当Free Space的空间全部划分到User Records后,意味着当前页的数据存储空间使用完了,如果后续还有新的记录插入的话,就需要去申请新的索引页。

当行记录被删除时,则会修改记录头信息中的delete_mask为1,也就是说被删除的记录还在页中,还在真实的磁盘中。这些被删除的记录之所以不立即从磁盘移除,一方面是MVCC机制需要保证数据可重复读,另一方面移除被删除的记录后其他记录在磁盘上重新排列需要性能消耗。

所以对于记录的删除,只是打一个标记而已,所有被删除的记录会组成一个所谓的垃圾链表,在这个链表中的记录所占用的空间称之谓可重用空间,之后有新的记录插入进来时,可能把这些被删除的记录占用空间覆盖掉。

同时插入的记录会记录自己在本页中的位置,写入记录头信息中的head_no部分。

注:head_no值为0和1的记录是Innodb自动给每个页增加的两条记录,称为伪记录或虚拟记录。这两个伪记录一个代表当前页的最小记录,一个代表当前页的最大记录,这两条记录并不存放在User Record中,而是被单独存放在Infimum+Supremum部分。User Record中记录的记录头中的head_no是从2开始计算。

数据记录链如下图所示:

记录头中的next_record记录了从当前记录的真实数据到下一条记录的真实数据的地址偏移量。这是一条链表,可以通过一条记录找到它的下一条记录。但是下一条记录并不是插入顺序的下一条记录,而是按照主键由小到大的顺序的下一条记录。而且规定Infimum记录的下一条记录就是本页中主键最小的用户记录,而本页中主键值最大的用户记录的下一条记录就是Supremum记录。
注:记录按照主键从小到大的顺序形成了一个单链表,如果记录被删除,则从这个链表上移除即可

2.2 Page Directory

Page Directory主要解决记录链表的查找问题。

链表最大的问题就是查找时只能从链表头依次向链表尾遍历,这样的时间复杂度为O(N)。

InnoDB对记录查找的改进方法是,为页中的记录再制作一个目录,大概过程如下:

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

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

3、将每个组的最后一条记录的地址偏移量单独提取出来存储在Page Directory中,页目录中的这些地址偏移量被称为槽(solt),所以页目录是由槽组成的。

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

这样在记录链表中查找指定主键值记录的过程就分为两步:

1、通过二分法确定记录所在的槽,并找到该槽对应分组中主键值最小的记录

2、根据记录头中的next_record属性遍历该槽对应分组中的各个记录

Page Directory的结构大概如下图:

按照记录分组的规则,记录划分完分组后,结构大概是下面这样子:

2.3 Page Header

InnoDB中,对于索引页中存储记录的状态信息,比如该页已经存储了多少条记录,第一条记录地址,页目录中有多少槽等等,设置了一个Page Header部分,该部分固定占用56个字节,用于存储各种状态信息。

2.4 File Header

File Header是InnoDB中所有类型页都有的一部分内容,且作为页的第一个组成部分。

文件头记录了页的各种通用信息,比如页类型、页编号、上下页、页的检验和等,这部分占用固定38个字节。
页的类型,包括Undo日志页、段信息节点、Insert Buffer空闲列表、Insert Buffer位图、系统页、事务系统数据、表空间头部信息、扩展描述页、溢出页、索引页等。

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

2.5 File Trailer

在前面讲过,InnoDB以页为单位进行数据的操作,即从磁盘读取到内存以及从从内存刷新到磁盘,但是操作系统操作磁盘时是以磁盘块为基本单位的,通常一个磁盘块的大小为4KB,也就是InnoDB中一个页包含了四个磁盘块,所以对页数据从内存刷新到磁盘并不是一个原子操作。

如果中途出现了断电的情况,可能导致一个页中的数据,只有部分数据被写入了磁盘,因此InnoDB为了检验页的完整性,在每个页的尾部都添加了一个File Trailer部分,该部分由8个字节组成,分为两个部分:

  • 前四个字节代表页的检验和

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

  • 后四个字节代表页面被修改时对应的日志序列位置(LSN)

    关于日志序列位置的作用在后面文章中介绍redo log时会详细介绍

三、InnoDB体系结构

InnoDB存储引擎主要就包含两部分存储空间,内存存储和磁盘存储,而这两者之间的数据流转就体现了InnoDB的体系结构。
前面较深入地了解了行记录结构和索引页结构,下面通过官网文档来了解一下InnoDB的整体体系结构。

MySQL5.7

MySQL8.0:

MySQL5.7和MySQL8.0的体系结构有微小的改变,但整体结构是一样的,通过下面的简略图可以看的更清晰一些。

从图中可以看到,InnoDB内部是由各种各样的缓存和表空间构成的。后面将会重点介绍这些缓冲区和表空间都是干什么使用的。

四、InnoDB的表空间

表空间是一个抽象的概念,但这些表空间都是与文件系统中的文件相对应的。对于系统表空间来说,它对应文件系统中一个或多个实际文件(一般是ibdata1文件),在mysql-5.7.23-winx64\data目录下可以看到:

而对于每个独立表空间(即File-Per-Table Tablespace)来说,对应文件系统中一个表名.idb的实际文件,同样在MySQL的data目录下,每个数据库对应有一个文件夹,而文件夹下面存储了该数据库中每个表的结构和数据,以idb后缀的文件就是每个表的独立表空间,它存储了该表的索引和记录数据。

InnoDB是以页为单位管理存储空间的,而表空间就可以看作是所有页的集合,这些页包含了所有索引的叶子节点和非叶子节点的页。

同时表空间中的每一个页都对应一个页号,页号由4个字节组成,也就是32个比特位,所以一个表空间最多可以拥有2^32(4294967296)个页,按照页默认16KB的大小,一个表空间最多可以存储64TB的数据。

按照InnoDB体系结构展示的表空间,大概分为四类:

4.1 独立表空间

对于一个独立表空间而言,它的整体结构如下图所示:

对于一个独立表空间而言,除了行记录和索引页之外,还有区、组、段等概念。

4.1.1 区(extent)

一个表空间有很多个页,如果直接对这些页进行管理,是一个很复杂的工程,并且页只有16KB,如果每次加载数据都只是把一个页的数据加载进内存(随机IO),对于性能是一个很大的损耗。

所以为了更好的管理这些页,InnoDB引擎提出了区、组、段的概念。

对于16KB的页来说,连续的64个页就是一个区,也就是一个区默认占用1MB空间大小(物理连续)。
系统表空间和独立表空间都可以看作是由若干个区组成的。而每256个区又被划分为一个组。组内的区存储地址也是物理连续,这些都是物理空间上的概念。

引入分区的目的简单来说就是为了减少随机IO的次数,B+树种同一层的所有页构成了一个双向链表,如果以页为基本单位进行内存分配,就可能导致两个逻辑上相邻的页,在实际物理磁盘上却相距很远,这样在范围查询时,就要不停的进行随机IO,所以InnoDB尽量让链表中相邻的页在物理空间也是相邻的,这样范围查询时就可以使用顺序IO。

一个区就是在物理位置上连续的64个页。在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区为单位分配,甚至在表中的数据十分非常特别多的时候,可以一次性分配多个连续的区,从性能角度看,可以消除很多的随机I/O。

4.1.2 段(segment)

同样在范围查询中时,其实是对B+树叶子节点的记录进行顺序扫描,如果不区分叶子节点和非叶子节点,把所有节点的页都放到申请的区中,范围扫描的效果就大打折扣。

所以InnoDB为叶子节点和非叶子节点分配了独有的区。

存放叶子节点的区构成的集合称为一个段,存放非叶子节点的区构成的集合称为另一个段。

也就是任何一个索引都会生成两个段,一个叶子节点段,两一个是非叶子节点段。

段只是一个逻辑概念,并不是连续的物理空间。

4.2 系统表空间

整个MySQL进程只有一个系统表空间,在系统表空间中会额外记录一些有关整个系统信息的页面,所以会比独立表空间多一些记录系统信息的页面。系统表空间在所有表空间最前面,所以它的表空间ID(Spance ID)是0。

对于undo log默认也是在系统表空间中,但是可以通过配置,为它分配单独的表空间。

系统表空间有extent 1extent 2两个区,页号从64~191这128个页面被称为Doublewrite buffer,也就是双写缓冲区,这是InnoDB保证数据持久性的关键。

在MySQL5.7中,系统表空间中最重要的两部分内容就是Change BufferDouble Write Buffer,在MySQL8.0中,Double Write Buffer从系统表空间移除,单独存放了。磁盘上的这两部分内容,在内存中也有对应的内存区域。

Change BufferDouble Write BufferAdaptive Hash Index构成了InnoDB引擎的三大特性。自适应Hash索引在前面的文章中已经介绍过了,这里将重点这介绍Change BufferDouble Write Buffer

4.2.1 Change Buffer(写缓冲区)

Change Buffer的主要目的是为了减少磁盘IO,以向表A插入一条数据为例,新插入的数据会分配到某个页中,如果此时该页并不在Buffer Pool中,就需要从磁盘中将整个页的数据全部加载进内存,而这个操作的目的却只是为了插入一条数据,这样的开销实在太大了。

所以MySQL就采用写缓冲区的策略来解决,如果数据要插入的页刚好在Buffer Pool中,那就直接更新内存中的页即可,如果页不在Buffer Pool中,也不需要大费周章地把整个页加载进内存,而只需要把记录先缓存在Change Buffer中,然后在适当的时机,再将数据插入到原来的页中。

Change Buffer虽然在内存中,但也可以进行持久化,系统表空间中可以看到持久化Change Buffer的空间。触发写缓冲持久化的操作有下面几种:

  • 数据库空闲时,后台有线程定时持久化
  • 内存中的Change Buffer不够用时
  • 数据库正常关闭时
  • redo log 写满时

Change Buffer中的数据最终还是会刷回到数据所在的原始数据页中,Change Buffer数据应用到原始数据页,得到新的数据页的过程称之为 mergemerge 过程中只会将Change Buffer中与原始数据页有关的数据应用到原始数据页,以下三种情况会发生 merge 操作:

  • 原始数据页加载到Buffer Pool
  • 系统后台定时触发merge操作
  • MySQL 数据库正常关闭时

写缓存(Change Buffer)可以使用命令参数来控制,InnoDB提供了两个对写缓存(Change Buffer)的参数:

innodb_change_buffer_max_size

表示Change Buffer最大占Buffer Pool的百分比,默认为 25%,最大可以设置为 50%。

innodb_change_buffering

用来控制对哪些操作启用 Change Buffer功能,默认是:all,还有下面几种参数可以选择。

--all:      默认值。开启buffer inserts、delete-marking operations、purges
--none:     不开启change buffer
--inserts:  只是开启buffer insert操作
--deletes:   只是开delete-marking操作
--changes:  开启buffer insert操作和delete-marking操作
--purges:   对只是在后台执行的物理删除操作开启buffer功能

4.2.2 Double Write Buffer(双写缓冲区/双写机制)

双写机制保证了InnoDB存储引擎数据页的可靠性。

它的作用是:在把页从内存写入到数据文件之前,InnoDB先把它们写入到一个叫做doublewrite buffer(双写缓冲区)的连续区域内,这个区域是系统表空间的一部分,在写完doublewrite buffer后,InnoDB才会把页写到数据文件中对应的位置。

如果在写数据文件时发生了意外奔溃,InnoDB可以在doublewrite buffer中找到完好的页副本用于恢复。

注:这个双写缓冲区不仅在表空间有这么一块区域,在内存中也有同样一块对应大小的内存空间,写双写缓冲区的时候,直接将内存中的双写缓冲区内容写入到磁盘的双写缓冲区即可。

双写缓冲区的主要作用就是为了极端情况下MySQL内部可以进行数据恢复

InnoDB的页一般是16KB,它的数据校验也是针对这16KB来计算的,将数据写入到磁盘是以页为单位进行操作的。而操作系统写文件是以4KB作为单位的,那么每写一个InnoDB的页到磁盘上,操作系统需要写4个块。
操作系统在写着四个快时,不是一个原子操作,在极端情况下,可能刚写完一个块的数据,就断电或系统奔溃了,整个页只有一部分写是成功的,这种情况下会产生partial page write(部分页写入)问题。这时页数据出现不一样的情形,从而形成一个"断裂"的页,使数据产生混乱。

doublewrite buffer是InnoDB在表空间上的128个页(2个区,extend1和extend2),大小是2MB。为了解决部分页写入问题,当MySQL将脏数据(内存中发生了修改的数据)flush到数据文件的时候, 先使用memcopy将脏数据复制到内存中的一个区域(也是2M),之后通过这个内存区域再分2次,每次写入1MB到系统表空间,然后马上调用fsync函数,同步到磁盘上。在这个过程中是顺序写,开销并不大,在完成doublewrite写入后,再将数据写入各数据文件文件,这时是离散写入(随机IO)。

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

性能损耗

系统表空间上的doublewrite buffer实际上也是一个文件,写系统表空间会导致系统有更多的fsync操作, 而硬盘的fsync性能因素会降低MySQL的整体性能。不过在存储上,doublewrite是在一个连续的存储空间, 所以硬盘在写数据的时候是顺序写,而不是随机写,这样性能影响不大,相比不双写,降低了大概5-10%左右。

优化策略

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

redo log与双写机制

redo log也可以用于数据恢复,为什么还需要双写机制呢?
这个地方一定要搞清楚redo log记录的是发生了修改的数据,在写页到数据文件时发生奔溃,有可能未修改的数据发生了错误(异常覆盖),对于这样的数据,redo log是没法进行恢复的。

但写doublewrite buffer成功了,直接去覆盖数据文件中的页即可。

如果是写doublewrite buffer本身失败,那么这些数据不会被写到磁盘,InnoDB此时会从磁盘载入原始的数据,然后通过InnoDB的事务日志(redo log)来计算出正确的数据,重新写入到doublewrite buffer,这个速度就比较慢了。

doublewrite buffer的两个作用:

  • 提高Innodb把内存中的数据写到硬盘这个过程的可靠性。
  • redo log不需要包含所有数据的前后映像,而是二进制变化量(这部分工作由双写机制完成),这可以节省大量的IO

4.3 通用表空间

通用表空间可以把多个表的数据存放在一个表空间(文件)中

4.4 临时表空间

临时表空间存放子查询生成的临时表以及表记录

猜你喜欢

转载自blog.csdn.net/sermonlizhi/article/details/124544864