InnoDB底层原理看这篇就够了

目录

一、体系架构

mysql 5.7

mysql 8.0

二、InnoDB记录的存储结构

InnoDB是如何把记录从磁盘上读出来的?

行格式

类型

三、InnoDB的索引页结构

user Records

插入操作

删除操作

Infinum + Supremum

Page Directory

Page Header

File Header

File Trailer

前 4 个字节

后 4 个字节

四、InnoDB的表空间

表空间概述

独立表空间

区(extent)

系统表空间

整体结构

双写缓冲区/双写机制

InnoDB数据字典


一、体系架构

        站在宏观的角度看看 InnoDB 的内存结构和磁盘存储结构。下面是参考MySQL官方文档。

mysql 5.7

https://dev.mysql.com/doc/refman/5.7/en/innodb-architecture.html

mysql 8.0

https://dev.mysql.com/doc/refman/8.0/en/innodb-architecture.html

二、InnoDB记录的存储结构

        InnoDB 是一个将表中的数据存储到磁盘上的存储引擎,所以即使关机后重启我们的数据还是存在的。而真正处理数据的过程是发生在内存中的,所以需要把磁盘中的数据加载到内存中,如果是处理写入或修改请求的话,还需要把内存中的内容刷新到磁盘上。

InnoDB是如何把记录从磁盘上读出来的?

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

行格式

        我们平时是以记录为单位来向表中插入数据的,这些记录在磁盘上的存放方式也被称为行格式或者记录格式

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

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

类型

Compact

  • 变长字段列表: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 个二进制位,不同的位代表不同的意思。
  • DB_ROW_ID(row_id):非必须,6 字节,表示行 ID,唯一标识一条记录,在没有自定义主键以及 Unique 键的情况下才会添加该列。
  • DB_TRX_ID:必须,6 字节,表示事务 ID。
  • DB_ROLL_PTR:必须,7 字节,表示回滚指针。

在其中记录头信息的详情如下:

  • 预留位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 个二进制位,表示下一条记录的相对位置。

Redundant

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

Dynamic

        MySQL 5.7 以后默认的行格式,格式和Compact行格式差不多。

Compressed

        格式和Compact行格式差不多,会采用压缩算法对页面进行压缩,以节省空间。

数据溢出:

        MySQL 中磁盘和内存交互的基本单位是页,也就是说 MySQL 是以页为基本单位来管理存储空间的,我们的记录都会被分配到某个页中存储。而一个页的大小一般是 16KB,也就是 16384 字节,而一个 VARCHAR(M)类型的列就最多可以存储 65532 个字节,这样就可能造成一个页存放不了一条记录的情况。
        在 Compact 和 Redundant 行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的该列的前 768 个字节的数据,然后把剩余的数据分散存储在几个其他的页中,记录的真实数据处用 20 个字节存储指向这些页的地址。这个过程也叫做行溢出,存储超出 768 字节的那些页面也被称为溢出页。
        Dynamic 和 Compressed 行格式,不会在记录的真实数据处存储字段真实数据的前 768 个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址。

三、InnoDB的索引页结构

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

user Records

        存储的记录会按照指定的行格式存储到 User Records 部分。

插入操作

        但是在一开始生成页的时候,其实并没有 User Records 这个部分,每当我们插入一条记录,都会从 Free Space 部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到 User Records 部分,当 Free Space 部分的空间全部被 User Records部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了。

删除操作

        当前记录被删除时,则会修改记录头信息中的 delete_mask 为 1,也就是说被删除的记录还在页中,还在真实的磁盘上。

        这些被删除的记录之所以不立即从磁盘上移除,是因为移除它们之后把其他的记录在磁盘上重新排列需要性能消耗。
        所以只是打一个删除标记而已,所有被删除掉的记录都会组成一个所谓的垃圾链表,在这个链表中的记录占用的空间称之为所谓的可重用空间,之后如果有新记录插入到表中的话,可能把这些被删除的记录占用的存储空间覆盖掉。

Infinum + Supremum

        插入的记录为了记录自己在本页中的位置,会写入记录头信息 heap_no 中。

        heap_no 值为 0 和 1 的记录是 InnoDB 自动给每个页增加的两个记录,称为伪记录或者虚拟记录。

        这两个伪记录一个代表最小记录,一个代表最大记录,这两条存放在页的 User Records 部分,他们被单独放在一个称为 Infimum + Supremum 的部分。

        记录头信息中 next_record 记录了从当前记录的真实数据到下一条记录的真实数据的地址偏移量。

        这其实是个 链表,可以通过一条记录找到它的下一条记录。但是需要注意注意再注意的一点是, 下一条记录指得并不是按照我们插入顺序的下一条记录,而是按照主键值由小到大的顺序的下一条记录。

        规定 Infimum记录(也就是最小记录) 的下一条记录就是本页中主键值最小的用户记录,而本页中主键值最大的用户记录的下一条记录就是 Supremum 记录(也就是最大记录)。

Page Directory

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

如果我们想根据主键值查找页中的某条记录该咋办?

        InnoDB中的是,为页中的记录再制作了一个目录。

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

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

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

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

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

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 个字节组成。

前 4 个字节

        代表页的校验和

        这个部分是和 File Header 中的校验和相对应的。

        每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来,因为 File Header 在页面的前边,所以校验和会被首先同步到磁盘,当完全写完时,校验和也会被写到页的尾部,如果完全同步成功,则页的首部和尾部的校验和应该是一致的。

        如果写了一半儿断电了,那么在 File Header 中的校验和就代表着已经修改过的页,而在 File Trailer 中的校验和代表着原先的页,二者不同则意味着同步中间出了错。

后 4 个字节

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

四、InnoDB的表空间

表空间概述

        表空间是一个抽象的概念,对于系统表空间来说,对应着文件系统中一个或多个实际文件,一般是(ibdata1);
        对于每个独立表空间(也就是上图的File-Per-Table Tablespaces)来说,对应着文件系统中一个名为表名.ibd 的实际文件。

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

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

独立表空间

区(extent)

        对于 16KB 的页来说,连续的64 个页就是一个区,也就是说一个区默认占用 1MB 空间大小。

引入区的主要目的是什么?

        我们每向表中插入一条记录,本质上就是向该表的聚簇索引以及所有二级索引代表的 B+树的节点中插入数据。而 B+树的每一层中的页都会形成一个双向链表,如果是以页为单位来分配存储空间的话,双向链表相邻的两个页之间的物理位置可能离得非常远。

        B+树索引的适用场景中范围查询只需要定位到最左边的记录和最右边的记录,然后沿着双向链表一直扫描就可以了,而如果链表中相邻的两个页物理位置离得非常远,就是所谓的随机 I/O。磁盘的速度和内存的速度差了好几个数量级,随机 I/O 是非常慢的,所以我们应该尽量让链表中相邻的页的物理位置也相邻,这样进行范围查询的时候才可以使用所谓的顺序 I/O。

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

        不论是系统表空间还是独立表空间,都可以看成是由若干个区组成的,每 256个区又被划分成一个组。

        第一个组最开始的 3 个页面的类型是固定的:用来登记整个表空间的一些整体属性以及本组所有的区被称为 FSP_HDR,也就是 extent 0 ~ extent 255 这 256个区,整个表空间只有一个 FSP_HDR。

        其余各组最开始的 2 个页面的类型是固定的,一个 XDES 类型用来登记本组256 个区的属性,FSP_HDR 类型的页面其实和 XDES 类型的页面的作用类似,只不过 FSP_HDR 类型的页面还会额外存储一些表空间的属性。

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

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

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

系统表空间

整体结构

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

        系统表空间有 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 个页(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。

InnoDB数据字典

        我们平时使用 INSERT 语句向表中插入的那些记录称之为用户数据,MySQL 只是作为一个软件来为我们来保管这些数据,提供方便的增删改查接口而已。但是每当我们向一个表中插入一条记录的时候,MySQL 先要校验一下插入语句对应的表存不存在,插入的列和表中的列是否符合,如果语法没有问题的话,还需要知道该表的聚簇索引和所有二级索引对应的根页面是哪个表空间的哪个页面,然后把记录插入对应索引的 B+树中。

        所以说,MySQL 除了保存着我们插入的用户数据之外,还需要保存许多额外的信息,比方说:

        某个表属于哪个表空间,表里边有多少列,表对应的每一个列的类型是什么,该表有多少索引,每个索引对应哪几个字段,该索引对应的根页面在哪个表空间的哪个页面,该表有哪些外键,外键对应哪个表的哪些列,某个表空间对应文件系统上文件路径是什么。

        上述这些数据并不是我们使用 INSERT 语句插入的用户数据,实际上是为了更好的管理我们这些用户数据而不得已引入的一些额外数据,这些数据也称为元数据。InnoDB 存储引擎特意定义了一些列的内部系统表(internal system table)来记录这些这些元数据:

        这些系统表也被称为数据字典,它们都是以 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。
        原则上只要一个表中的 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 开头的表中。
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值