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

一、数据页 

平时执行CRUD的时候,都会从磁盘加载数据页到Buffer Pool的缓存页,更新缓存页后,由异步线程刷回磁盘的数据页,MySQL进行数据操作的最小单位是数据页。每个数据页默认16kb,数据页由多个部分组成,如图

 

在数据页还未写入数据时,是没有数据行的,只有空闲空间,写入就会占用空闲空间,直到空闲空间耗尽,数据页满了自然会开辟新的数据页来存储数据。数据页之间通过双向链表来链接。在文件头中存放了如当前页号、页类型、所属表空间、上一页号、下一页号等等。

      数据页内部会存储一行一行的数据,每一行数据都会按照主键大小进行排序存储,同时每一行数据都有指针指向下一行数据,组成单向链表

       为了提升查找效率,利用二分查找的特性,在数据页目录部分存储主键ID和行位置

 这样就可以通过数据页目录走二分查找,快速定位到数据页的数据行。但如果数据页成千上万个也会出现效率问题,此时就出现了索引。

二、页分裂

索引的核心基础要求后一个数据页的主键值都大于前面一个数据页的主键值,如果你的主键是自增的,可以保证这一点,但有时候主键并不是自增的,就会出现后一个数据页的主键值小于前一个数据页主键值的情况,为了保证索引的核心基础,就有一个交换行数据的过程,这个过程叫做页分裂。

 三、主键索引

以主键为例,创建一个主键索引,这个主键索引就是主键目录,它会维护数据页的最小主键值与对应的页号。有了主键目录后

1.二分查找主键目录,找到对应的数据页

2.进入数据页,二分查找数据页目录,知道对应的行数据

 如果有大量数据页,意味着主键目录要存储大量的数据页号和最小主键值,仅仅靠二分查找也会吃力,所以InnoDB实际上是把主键目录数据存储在多个数据页中,我们把这个数据页称为索引页

三、索引页

索引页,就是存储索引信息的数据页,在数据页的文件头部,有页类型来进行区分。索引页存储最小主键值与索引页号。

把大量的索引信息分散在多个索引页中,再将多个索引页组成B+树结构,方便二分查找,结构如下图(非真正的B+只是便于理解)

1.根据主键id二分查找索引页

2.找到对应的索引页,再二分查找数据页

3.进入数据页,二分查找数据页目录,找到对应的行数据


InnoDB存储引擎

        当使用MySQL的时候,从表中获取记录信息,InnoDB存储引擎并不需要一条一条的从磁盘上读取数据,InnoDB将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,一个页大小一般为16KB,也就是在一般情况下,一次最少要从磁盘读取16KB的内容到内存中,同时最少也是一次最少把内存中16KB内容刷新到磁盘中。

        记录在磁盘上的存放方式也被称之为行格式或者记录格式。一共4种不同类型的行格式,分别是Compact、Redundant、Dynamic、和Compressed行格式,以Compact为例。

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

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

        ① VARCHAR,VARBINARY, TEXT , BLOB等类型的列称为变长字段,变长字段中存储多少字节的数据是不固定的,所以我们存储真实数据的时候需要顺便把这些数据占用的字节数也存起来。

        ② 表中的某些列可能存储NULL值,Compact行格式把这些值为NULL的列统一管理,每个允许存储NULL的列对应一个二进制位,二进制位为1时,代表该列的值为NULL,为0时,代表该列的值不为NULL

        ③ 描述记录的记录头信息,固定的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 表示下一条记录的相对位

        ④MySQL会为每个记录默认的添加一些列(也称为隐藏列):

                DB_ROW_ID(row_id): 6字节,表示行ID(当无主键或者唯一索引的时候,才会有)

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

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

        数据溢出

        假设一个字段 VARCHAR(60000),VARCHAR类型可以存65535个字节,此时插入60000个字符,由于MySQL中磁盘和内存交互的基本单位是页,记录会被分配到某个页中存储,而一个页一般为16KB,也就是16384字节,此时可能造成一个页存放不了一条记录的情况。

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

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

        索引页(数据页)

        

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

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 记录了从当前记录的真实数据到下一条记录的真实 数据的地址偏移量。这其实是个链表,可以通过一条记录找到它的下一条记录。 但是需要注意注意再注意的一点是,下一条记录指得并不是按照我们插入顺序的 下一条记录,而是按照主键值由小到大的顺序的下一条记录。而且规定 Infimum 记录(也就是最小记录) 的下一条记录就是本页中主键值最小的用户记录,而 本页中主键值最大的用户记录的下一条记录就是 Supremum 记录(也就是最大记录)

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

Page Directory : 主要是解决记录链表的查找问题。将记录分组,单独再用槽来记录    

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

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

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

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

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

       这样可以通过二分法确定该记录所在的槽,并找到该槽所在分组中主键值最小的记录,通过记录的next_record 属性遍历该槽所在的组中的各个记录


InnoDB的体系结构

        

        其中的 Insert/Change Buffer 主要是用于对二级索引的写入优化,Undo 空间则是 undo 日志一般放在系统表空间,但是通过参数配置后,也可以用独立表空间存放,所以用虚线表示。通用表空间和独立表空间不同,通用表空间是允许多个表存储数据的共享表空间。

 InnoDB的表空间 

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

        

 

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

        独立表空间:(为了更好的提升性能,对数据进行组织维护)

        区(extent)

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

       

        不论是系统表空间还是独立表空间,都可以看成是由若干个区组成的,每 256 个区又被划分成一个。 第一个组最开始的 3 个页面的类型是固定的:用来登记整个表空间的一些整体属性以及本组所有的区被称为 FSP_HDR,也就是 extent 0 ~ extent 255 256个区,整个表空间只有一个 FSP_HDR。其余各组最开始的 2 个页面的类型是固定的,一个 XDES 类型,用来登记本组256 个区的属性,FSP_HDR 类型的页面其实和 XDES 类型的页面的作用类似,只不过 FSP_HDR 类型的页面还会额外存储一些表空间的属性。

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

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

        段(segment)  并不是某一个连续的物理区域,而是一个逻辑上的概念。

        InnoDB 对 B+树的叶子节点和非叶子节点进行了区别对待,也就是说叶子节点有自己独有的区,非叶子节点也有自己独有的区。存放叶子节点的区的集合就算是一个段(segment),存放非叶子节点的区的集合也算是一个段。也就是说一个索引会生成 2 个段,一个叶子节点段,一个非叶子节点段。同时还额外有一个回滚段。(用自增id减少页面的分裂与合并),

                  系统表空间:

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

        双写缓冲区/双写机制

        是一种特殊文件 flush 技术,带给 InnoDB 存储引擎的是数据页的可靠性。在把页写到数据文件之前,InnoDB 先把它们写到一个叫 doublewrite buffer(双写缓冲区)的连续区域内,在写 doublewrite buffer 完成后,InnoDB 才会把页写到数据文件的适当的位置。如果在写页的过程中发生意外崩溃,InnoDB在稍后的恢复过程中在 doublewrite buffer 中找到完好的 page 副本用于恢复。  

        原因:

        InnoDB 的页大小一般是 16KB,其数据校验也是针对这 16KB 来计算的,将数据写入到磁盘是以页为单位进行操作的。而操作系统写文件是以 4KB 作为单位的,那么每写一个 InnoDB 的页到磁盘上,操作系统需要写 4 个块。而计算机硬件和操作系统,在极端情况下(比如断电)往往并不能保证这一操作的原子性,16K 的数据,写入 4K 时,发生了系统断电或系统崩溃,只有一部分写是成功的,这种情况下会产生 partial page write(部分页写入)问题。这时页数据出现不一样的情形,从而形成一个"断裂"的页,使数据产生混乱。

        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%左右。

        在读写分离的情况下,因为主从复制,从库上的双写机制可以关掉,来提升性能。   

        正常情况下如果发生写失效,可以通过重做日志(Redo Log)进行恢复!但是要注意,重做日志中记录的是对页的物理操作,如偏移量 800, ' aaaa'记录,而不是页面的全量记录,而如果发生 partial page write(部分页写入)问题时,出现问题的是未修改过的数据,此时重做日志(Redo Log)无能为力。

        写 doublewrite buffer 成功了,这个问题就不用担心了。如果是写 doublewrite buffer 本身失败,那么这些数据不会被写到磁盘,InnoDB 此时会从磁盘载入原始的数据,然后通过 InnoDB 的事务日志来计算出正确的数据,重新写入到 doublewrite buffer,这个速度就比较慢了。如果 doublewrite buffer 写成功的话,但是写数据文件失败,innodb 就不用通过事务日志来计算了,而是直接用 doublewrite buffer 的数据再写一遍,速度上会快很多。

        作用:

        1.提高 innodb 把缓存的数据写到硬盘这个过程的安全性;

        2.innodb 的事务日志不需要包含所有数据的前后映像,而是二进制变化量,这可以节省大量的 IO

        InnoDB数据字典(Data Dictionary Header) 

        我们平时使用 INSERT 语句向表中插入的那些记录称之为用户数据,MySQL 只是作为一个软件来为我们来保管这些数据,提供方便的增删改查接口而已。但是每当我们向一个表中插入一条记录的时候,MySQL 先要校验一下插入语句对应的表存不存在,插入的列和表中的列是否符合,如果语法没有问题的话,还需要知道该表的聚簇索引和所有二级索引对应的根页面是哪个表空间的哪个页面,然后把记录插入对应索引的 B+树中。所以说,MySQL 除了保存着我们插入的用户数据之外,还需要保存许多额外的信息,比方说:某个表属于哪个表空间,表里边有多少列,表对应的每一个列的类型是什么,该表有多少索引,每个索引对应哪几个字段,该索引对应的根页面在哪个表空间 的哪个页面,该表有哪些外键,外键对应哪个表的哪些列,某个表空间对应文件系统上文件路径是什么。

        上述这些数据并不是我们使用 INSERT 语句插入的用户数据,实际上是为了更好的管理我们这些用户数据而不得已引入的一些额外数据,这些数据也称为元数据。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 存储引擎中所有的虚拟生成列的信息

        InnoDB 又拿出一个固定的页面来记录这 4 个基本系统表的聚簇索引和二级索引对应的 B+树位 置,这个页面就是页号为 7 的页面 Data Dictionary Header,类型为 SYS,记录了数据字典的头部信息。除了这 4 个表的 5 个索引的根页面信息外,这个页号为 7 的页面还记录了整个 InnoDB 存储引擎的一些全局属性,比如 Row ID。数据字典头部信息中有个 Max Row ID 字段,我们说过如果我们不显式的为表定义主键,而且表中也没有 UNIQUE 索引,那么 InnoDB 存储引擎会默认为我们 生成一个名为 row_id的列作为主键。因为它是主键,所以每条记录的 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 开头的表中。


         Buffer Pool

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

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

show variables like 'innodb_buffer_pool_size';

         比较合理的设置方法是按照比例设置,一般的最好是给buffer pool设置的机器内存的70%~75%,当然最好是在DBA的监控下根据繁忙情况按照Buffer Pool的命中率来设置

 

         Buffer Pool 内部组成

        Buffer Pool中默认的缓存页大小和在磁盘上默认的页大小是一样的,都是16KB。为了更好的管理这些在 Buffer Pool 中的缓存页,InnoDB 为每一个缓存页都创建了一些所谓的控制信息,这些控制信息包括该页所属的表空间编号、页号、缓存页在 Buffer Pool 中的地址、链表节点信息、一些锁信息以及 LSN 信息,当然还有一些别的控制信息。

        每个缓存页对应的控制信息占用的内存大小是相同的,我们称为控制块。控制块和缓存页是一一对应的,它们都被存放到 Buffer Pool 中,其中控制块被存放到 Buffer Pool 的前边,缓存页被存放到 Buffer Pool 后边,所以整个 Buffer Pool 对应的内存空间看起来就是这样

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

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

        缓存页的哈希处理        

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

        我们其实是根据表空间号 + 页号来定位一个页的,也就相当于表空间号 + 页号是一个 key,缓存页就是对应的 value,怎么通过一个 key 来快速找着一个 value 呢? 所以我们可以用表空间号 + 页号作为 key,缓存页作为 value 创建一个哈希表,在需要访问某个页的数据时,先从哈希表中根据表空间号 + 页号看看有没有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从 free 链表中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置。

        flush 链表的管理      

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

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

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

         LRU链表的管理 (Least Recently Used)

        1> 缓存不够的窘境:

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

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

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

        2>简单的LRU链表   

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

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

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

        也就是说:只要我们使用到某个缓存页,就把该缓存页调整到 LRU 链表的头 部,这样 LRU 链表尾部就是最近最少使用的缓存页。所以当 Buffer Pool 中的空闲缓存页使用完时,到 LRU 链表的尾部找些缓存页淘汰就行了。 缓存机制一直是各种系统提高性能的首选手段,使用了缓存就必然有缓存淘汰的需求,LRU 就是常见的算法,再比如 Redis 中的缓存淘汰算法,其中就有 volatile-lruallkeys-lru 等淘汰策略。

        3>划分区域的LRU链表

        情况1:InnoDB的预读(read ahead),就是 InnoDB 认为执行当前的请求可能之后会读取某些页面,就预先把它们加载到 Buffer Pool中。

        ①线性预读:InnoDB 提供了一个系统变量 innodb_read_ahead_threshold,如果顺序访问了 某个区(extent)的页面超过这个系统变量的值,就会触发一次异步读取下一个区中全部的页面到 Buffer Pool 的请求。 这个 innodb_read_ahead_threshold 系统变量的值默认是 56,我们可以在服务器启动时通过启动参数或者服务器运行过程中直接调整该系统变量的值,取值范围是 0~64

        ②随机预读:如果 Buffer Pool 中已经缓存了某个区的 13 个连续的页面,不论这些页面是不 是顺序读取的,都会触发一次异步读取本区中所有其他的页面到 Buffer Pool 的请求。InnoDB同时提供了innodb_random_read_ahead 系统变量,它的默认值为OFF

show variables like '%_read_ahead%';

 

         预读和LRU产生的问题:如果预读到 Buffer Pool 中的页成功的被使用到,那就可以极大的提高语句执行的效率。可是如果用不到呢?这些预读的页都会放到 LRU 链表的头部,但是如果此时 Buffer Pool 的容量不太大而且很多预读的页面都没有用到的话,这就会导致处在 LRU 链表尾部的一些缓存页会很快的被淘汰掉,也就是所谓的劣币驱逐良币,会大大降低缓存命中率。

        情况2应用程序可能会写一些需要扫描全表的查询语句(比如没有建立合适的索引或者压根儿没有 WHERE 子句的查询)。    

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

        总结: 以上两种情况可能造成加载到Buffer Pool中的页不一定被用到,也可能非常多的但使用频率低的页被同时加载到Buffer Pool时,会把那些使用频率高的页从Buffer Pool中淘汰掉。

        所以InnoDB 把这个 LRU 链表按照一定比例分成两截,分别是:

        一部分存储使用频率非常高的缓存页,这一部分链表也叫做热数据,或者称 young 区域。

        另一部分存储使用频率不是很高的缓存页,所以这一部分链表也叫做冷数据,或者称 old 区域

        我们是按照某个比例将 LRU 链表分成两半的,不是某些节点固定是 young 区域的,某些节点固定是 old 区域的,随着程序的运行,某个节点所属的区域也可能发生变化。那这个划分成两截的比例怎么确定呢?对于 InnoDB 存储引擎来说,我们可以通过查看系统变量innodb_old_blocks_pct 的值来确定 old 区域在 LRU 链表中所占的比例

SHOW VARIABLES LIKE 'innodb_old_blocks_pct';

        有了这个被划分成young 和 old区域的LRU链表之后,InnoDB就可以针对上门提到的两种情况,进行优化

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

        针对全表扫描时,因为每访问一条记录,相当于访问了一次页面,这样即使最开始这个页放在old区域,由于逐条扫描,会导致页访问频率变高,又会将页放到yong区域的头部,这样仍然会把那些实际使用频率比较高的页面给顶下去。

        全表扫描有一个特点,执行频率非常低,这类语句也是我们应该尽快优化的。而且在执行全表扫描的过程中,即使某个页面中有很多条记录,多次访问这个页面所花费的时间也是非常少的

        所以,在对某个处在old区域的缓存页面进行第一次访问时,就在它对应的控制块中记录下来这个访问的时间,如果后续的访问时间与第一次访问的时间在某个时间间隔内,那么该页面就不会被从old区域移动到young区域的头部,否则将它移动到young区域的头部。间隔时间:由系统变量 innodb_old_blocks_time控制的。

# 显示系统变量 buffer pool 控制块间隔时间
SHOW VARIABLES LIKE 'innodb_old_blocks_time';

默认值是 1000 ms, 从磁盘上被加载到LRU链表的old区域的某个页来说,如果第一次和最后一次访问该页面的时间间隔小于1s,那么该页是不会被加入到young区域的。

        4>更进一步优化LRU链表

        对于young区域的缓存页来说,每次访问一个缓存页就要移动到LRU链表的头部,开销比较大,毕竟young区域的缓存页都是热点数据,可能被经常访问,频繁对LRU链表进行节点移动操作会影响性能,MySQL中还有一些优化策略,比如只有被访问的缓存页位于young区域的1/4的后边,才会被移动到LRU链表的头部,这样就可以降低调整LRU链表的频率,从而提升性能。出发点就是尽量高效的提高Buffer Pool的缓存命中率。

        刷新脏页到磁盘

        对于Buffer Pool的数据,后台有专门的线程每隔一段时间负责把脏页(有更新的数据)刷新到磁盘,这样可以不影响用户线程处理正常的请求,有两种刷新路径:

        1、从LRU链表的冷数据中刷新一部分页面到磁盘。

        后台线程会定时从LRU链表尾部开始扫描一些页面,扫描的页面数量可以通过系统变量innodb_lru_scan_depth来指定,如果从里面发现脏页,会把他们刷新到磁盘。这种刷新页面的方式称之为BUF_FLUSH_LRU 

        2、从flush链表中刷新一部分页面到磁盘。

        后台线程也会定时从flush链表中刷新一部分页面到磁盘,刷新的速率取决于当时系统是不是很忙。这种方式称之为BUF_FLUSH_LIST。      

        有时候后台线程刷新脏页的进度比较慢,导致用户线程在准备加载一个磁盘 页到 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,这个公式在 8.0 5.7 中都适用。

        innodb_buffer_pool_chunk_size

        MySQL5.75及之后的版本中支持了在服务器运行过程中调整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

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

        查看Buffer Pool的状态信息

        查看关于InnoDB存储引擎运行过程中的一些状态信息,其中包括Buffer Pool的一些信息

SHOW ENGINE INNODB STATUS\G

        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、createdwritten:代表读取,创建,写入了多少页。后边跟着读取、创建、写入的速率。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:正在解压的页面数量

        其中的Insert/Change Buffer主要是用于对二级索引的写入优化,Undo空间则是undo日志一般存放在系统表空间,但是通过参数配置后,也可以用独立表空间存放。通用表空间和独立空间不同,通用表空间是允许多个表存储数据的共享表空间


        redo 日志   

        事务具有原子性、一致性、隔离性、持久性4个属性,原子性通过undo日志实现隔离性是通过读写锁+mvcc实现的,一致性最重要,是目的,AID是手段,需要应用程序去保证,持久性通过redo日志实现

        在事务的具体实现机制上,MySQL采用的是WAL(Write-ahead logging,预写式日志)机制来实现的,这也是当今的主流方案。所有的修改都先被写入到日志中,然后再被应用到系统中,通常包含redo 和 undo两部分信息。【事务的日志类型的实现除了 WAL(Write-ahead logging,预写式日志)外,还 有“Commit Logging”(提交日志),这种方式只有在日志记录全部都安全落盘,数据库在日志中看到代表事务成功提交的“提交记录”(Commit Record)后,才会根据日志上的信息对真正的数据进行修改,修改完成后,再在日志中加入一条“结束记录”(End Record)表示事务已完成持久化。两者的区别是,WAL 允许在事务提交之前,提前写入变动数据,而 Commit Logging 则不行;WAL 中有 undo 日志,Commit Logging 没有。阿里的 OceanBase 则是使用的 Commit Logging 来实现事务。实现事务的原子性和持久性除日志外,还有另外一种称为“Shadow Paging” (有中文资料翻译为“影子分页”)的事务实现机制,常用的轻量级数据库 SQLite Version 3 采用的事务机制就是 Shadow Paging。 Shadow Paging 的大体思路是对数据的变动会写到硬盘的数据中,但并不是直接就地修改原先的数据,而是先将数据复制一份副本,保留原数据,修改副本数 据。在事务过程中,被修改的数据会同时存在两份,一份是修改前的数据,一份 是修改后的数据,这也是“影子”(Shadow)这个名字的由来。当事务成功提交,所有数据的修改都成功持久化之后,最后一步是去修改数据的引用指针,将引用从原数据改为新复制出来修改后的副本,最后的“修改指针”这个操作将被认为是原子操作,现代磁盘的写操作可以认为在硬件上保证了不会出现“改了半个值”的现象。所以 Shadow Paging 也可以保证原子性和持久性。Shadow Paging实现事务要比 Commit Logging 更加简单,但涉及隔离性与并发锁时,Shadow Paging 实现的事务并发能力就相对有限,因此在高性能的数据库中应用不多

        redo 日志的作用       

        InnoDB 存储引擎是以页为单位来管理存储空间的,我们进行的增删改查操作其实本质上都是在访问页面(包括读页面、写页面、创建新页面等操作)。在Buffer Pool 的时候说过,在真正访问页面之前,需要把在磁盘上的页缓存到内存中的 Buffer Pool 之后才可以访问。但是在事务的时候又强调过一个称之为持久性的特性,就是说对于一个已经提交的事务,在事务提交后即使系统发生了崩溃,这个事务对数据库中所做的更改也不能丢失。如果我们只在内存的 Buffer Pool 中修改了页面,假设在事务提交后突然发生 了某个故障,导致内存中的数据都失效了,那么这个已经提交了的事务对数据库 中所做的更改也就跟着丢失了,这是我们所不能忍受的。那么如何保证这个持久性呢?一个很简单的做法就是在事务提交完成之前把该事务所修改的所有页面都刷新到磁盘,但是这个简单粗暴的做法有些问题:        

        1、刷新一个完整的数据页太浪费了

        有时候我们仅仅修改了某个页面中的一个字节,但是我们知道在 InnoDB 中是以页为单位来进行磁盘 IO 的,也就是说我们在该事务提交时不得不将一个完整的页面从内存中刷新到磁盘,我们又知道一个页面默认是 16KB 大小,只修改一个字节就要刷新 16KB 的数据到磁盘上显然是太浪费了。

        2、随机 IO 刷起来比较慢

        一个事务可能包含很多语句,即使是一条语句也可能修改许多页面,该事务修改的这些页面可能并不相邻,这就意味着在将某个事务修改的 Buffer Pool 中的页面刷新到磁盘时,需要进行很多的随机 IO,随机 IO 比顺序 IO 要慢,尤其对于传统的机械硬盘来说

        解决办法:

          redo log 称为重做日志,每当有操作时,在数据变更之前将操作写入redo log,这样当发生掉电之类的情况时系统可以在重启后继续操作。记录的是哪个页面改了什么东西,把这个页面相关的变动要素(页总条数,槽,nextrecord等)形成一条日志,一共53种type全部记录了下来,顺序追加日志,作用是在事务执行过程中对数据的修改全部记录下来,专门有个log buffer,缺省值16M,并且分了块512B为一个块。 例:某个事务将系统表空间中的第 100 号页面中偏移量为 1000 处的那个字节的值 1 改成 2 我们只需要记录一下: 将第 0 号表空间的 100 号页面的偏移量为 1000 处的值更新为 2。

        好处:

        1.redo 日志占用的空间非常小,存储表空间ID、页号、偏移量以及需要更新的值所需要的存储空间很小。

        2.redo日志是顺序写入磁盘,执行事务的过程中,每执行一条语句,就可能产生若干条redo日志,这些日志是按照产生的顺序写入磁盘的,顺序IO,如果满了继续写,往前覆盖(可理解为一个环形)

        redo日志格式

        本质上redo日志只是记录了一下事务对数据库做了哪些修改,大部分类型的redo用这种

        通用的结构

 type: 该条redo日志的类型,约53中不同类型的日志

space ID: 表空间 ID   ;  page Number :页号  ; data: 该条redo日志的具体内容

        简单的redo日志类型

 offset 代表在页面中的偏移量

         复杂一些的redo日志类型     

        有时候执行一条语句会修改非常多的页面,包括系统数据页面和用户数据页面(用户数据指的就是聚簇索引和二级索引对应的 B+树)。以一条 INSERT 语句为例,它除了要向 B+树的页面中插入数据,也可能更新系统数据 Max Row ID 的值,不过对于我们用户来说,平时更关心的是语句对 B+树所做更新:表中包含多少个索引,一条 INSERT 语句就可能更新多少棵 B+树。

        针对某一棵 B+树来说,既可能更新叶子节点页面,也可能更新非叶子节点页面,也可能创建新的页面(在该记录插入的叶子节点的剩余空间比较少,不足以存放该记录时,会进行页面的分裂,在非叶子节点页面中添加目录项记录)。 一个数据页中除了存储实际的记录之后,还有什么 File Header、Page Header、Page Directory 等等部分,所以每往叶子节点代表的数据页里插入一条记录时,还有其他很多地方会跟着更新,比如说: 可能更新 Page Directory 中的槽信息、Page Header 中的各种页面统计信息,比如槽数量可能会更改,还未使用的空间最小地址可能会更改,本页面中的记录数量可能会更改,各种信息都可能会被修改,同时数据页里的记录是按照索引列从小到大的顺序组成一个单向链表的,每插入一条记录,还需要更新上一条记录的记录头信息中的 next_record 属性来维护这个单向链表。

         一条记录插入到一个页面时需要更改的地方非常多,可以在每个修改的地方都记录一条redo日志,或者将整个页面的第一个被修改的字节到最后一个修改的字节之间所有的数据当成是一条物理redo日志中的具体数据        

        简单来说,一个 redo 日志类型而只是把在本页面中变动(比如插入、修改)一条记录所有必备的要素记了下来,之后系统崩溃重启时,服务器会调用相关向某个页面变动(比如插入、修改)一条记录的那个函数,而 redo 日志中的那些数据就可以被当成是调用这个函数所需的参数,在调用完该函数后,页面中的相关值也就都被恢复到系统崩溃前的样子了。redo 日志会把事务在执行过程中对数据库所做的所有修改都记录下来。

        写入过程

        redo log block 和日志缓冲区 

        InnoDB 为了更好的进行系统崩溃恢复,把 redo 日志都放在了大小为 512 字节的块(block中。 我们前边说过,为了解决磁盘速度过慢的问题而引入了 Buffer Pool。同理,写入 redo 日志时也不能直接直接写到磁盘上,实际上在服务器启动时就向操作系统申请了一大片称之为 redo log buffer 的连续内存空间,翻译成中文就是 redo 日志缓冲区,我们也可以简称为 log buffer这片内存空间被划分成若干个连续的redo log block,我们可以通过启动参数innodb_log_buffer_size 来指定 log buffer 的大小,该启动参数的默认值为 16MB。 向log buffer中写入redo日志的过程是顺序的,也就是先往前边的block中写,当该 block 的空闲空间用完之后再往下一个 block 中写。

        redo 日志刷盘时机(4种情况)

         1.log buffer 空间不足时,如果当前写入log buffer 的redo日志量已经占满了log buffer 总容量的大约一半左右,就需要把这些日志刷新到磁盘上。

         2.事务提交时,可以不把修改过的Buffer Pool页面刷新到磁盘,但是必须把这些页面对应的redo日志更新到磁盘。

         3.后台有一个线程,大约每秒钟会刷新一次log buffer中的redo 日志到磁盘。

         4.正常关闭服务器时。

        日志文件组       

        MySQL 的数据目录(使用 SHOW VARIABLES LIKE 'datadir'查看)下默认有两个名为 ib_logfile0 ib_logfile1 的文件,log buffer 中的日志默认情况下就是刷新到这两个磁盘文件中。如果我们对默认的 redo 日志文件不满意,可以通过下边几个启动参数来调节: innodb_log_group_home_dir,该参数指定了 redo 日志文件所在的目录,默认值就是当前的数据目录。 innodb_log_file_size,该参数指定了每个 redo 日志文件的大小,默认值为 48MBinnodb_log_files_in_group,该参数指定 redo 日志文件的个数,默认值为 2,最大值为 100。 所以磁盘上的 redo 日志文件可以不只一个,而是以一个日志文件组的形式出现的。这些文件以 ib_logfile[数字](数字可以是 012...)的形式进行命名。

        在将 redo 日志写入日志文件组时,是从 ib_logfile0 开始写,如果ib_logfile0 写满了,就接着 ib_logfile1 写,同理,ib_logfile1 写满了就去写 ib_logfile2,依此类推。如果写到最后一个文件该咋办?那就重新转到 ib_logfile0 继续写。 既然 Redo log 文件是循环写入的,在覆盖写之前,总是要保证对应的脏页已经刷到了磁盘。在非常大的负载下,为避免错误的覆盖,InnoDB 会强制的 flush脏页。

        redo日志文件格式        

        log buffer本质上是一片连续的内存空间,被划分成了若干个512 字节大小的 block。将 log buffer 中的 redo 日志刷新到磁盘的本质就是把 block 的 镜像写入日志文件中,所以 redo 日志文件其实也是由若干个 512 字节大小的 block 组成。 redo 日志文件组中的每个文件大小都一样,格式也一样,都是由两部分组成: 前 2048 个字节,也就是前 4 block 是用来存储一些管理信息的。 从第 2048 字节往后是用来存储 log buffer 中的 block 镜像的。

        Log Sequence Number (LSN日志序列号)

        自系统开始运行,就不断的在修改页面,也就意味着会不断的生成 redo 日志。redo 日志的量在不断的递增,就像人的年龄一样,自打出生起就不断递增,永远不可能缩减了。InnoDB 为记录已经写入的 redo 日志量,设计了一个称之为 Log Sequence Number 的全局变量,翻译过来就是:日志序列号,简称 LSN。规定初始的 lsn 值为 8704(也就是一条 redo 日志也没写入时,LSN 的值为 8704)。redo 日志都有一个唯一的 LSN 值与其对应,LSN 值越小,说明 redo 日志产生的越早。

SHOW ENGINE INNODB STATUS\G

Log sequence number:代表系统中的 lsn 值,也就是当前系统已经写入的 redo 日志量,包括写入 log buffer 中的日志。

Log flushed up to代表 flushed_to_disk_lsn 的值,也就是当前系统已经写入磁盘的 redo 日志量。

Pages flushed up to代表 flush 链表中被最早修改的那个页面对应的 oldest_modification 属性值。

Last checkpoint at当前系统的 checkpoint_lsn 值。

        innodb_flush_log_at_trx_commit 的用法

        如果对事务的持久性要求不是那么强烈的话,可以选择修改一个称为innodb_flush_log_at_trx_commit 的系统变量的值,该变量有 3 个可选的值:

                

0:当该系统变量值为0时,表示在事务提交时不立即向磁盘中同步redo日志,这个任务是交给后台线程做的。这样很明显会加快请求处理速度,但是如果事务提交后服务器挂了,后台线程没有及时将 redo 日志刷新到磁盘,那么该事务对页面的修改会丢失。

1当该系统变量值为 1 时,表示在事务提交时需要将 redo 日志同步到磁盘,可以保证事务的持久性。1 也是 innodb_flush_log_at_trx_commit 的默认值。

2:当该系统变量值为 2 时,表示在事务提交时需要将 redo 日志写到操作系统的缓冲区中,但并不需要保证将日志真正的刷新到磁盘。这种情况下如果数据库挂了,操作系统没挂的话,事务的持久性还是可以保证的,但是操作系统也挂了的话,那就不能保证持久性了。

        undo 日志  

        事务回滚的需求:事务是需要保证原子性的,事务中的操作要么全部完成,要么什么也不做,但是事务执行到一半可能遇到错误或者人为的ROLLBACK,但事务的执行过程中可能已经修改了很多东西,我们需要把东西改回原来的样子。至少要把这条记录的原信息记录下来。

        这些为了回滚而记录的这些日志称为撤销日志,当一些变更执行到一半无法完成时,可以根据撤销日志恢复到变更之间的状态。查询操作不会修改任何用户记录,所以查询操作执行时,不需要记录相应的undo日志。

        事务id   

        如果某个事务执行过程中对某个表执行了增、删、改操作,那么 InnoDB 存储引擎就会给它分配一个独一无二的事务 id,分配方式如下:

        对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、改操作时才会为这个事务分配一个事务 id,否则的话是不分配事务 id 的。我们前边说过对某个查询语句执行 EXPLAIN 分析它的查询计划时,有时候在Extra 列会看到 Using temporary 的提示,这个表明在执行该查询语句时会用到内部临时表。这个所谓的内部临时表和我们手动用 CREATE TEMPORARY TABLE 创建的用户临时表并不一样,在事务回滚时并不需要把执行 SELECT 语句过程中用到的内部临时表也回滚,在执行 SELECT 语句用到内部临时表时并不会为它分配事务id。对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执行增、删、改操作时才会为这个事务分配一个事务 id,否则的话也是不分配事务id 的。有的时候虽然我们开启了一个读写事务,但是在这个事务中全是查询语句,意味着这个事务并不会被分配一个事务id.        

        服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个事务 id 时,就会把该变量的值当作事务 id 分配给该事务,并且把该变量自增 1。每当这个变量的值为 256 的倍数时,就会将该变量的值刷新到系统表空间的页号为 5 的页面中一个称之为 Max Trx ID 的属性处,这个属性占用 8 个字节的存储空间。 当系统下一次重新启动时,会将上边提到的 Max Trx ID 属性加载到内存中,将该值加上 256 之后赋值给我们前边提到的全局变量(因为在上次关机时该全局变量的值可能大于 Max Trx ID 属性值)。 这样就可以保证整个系统中分配的事务 id 值是一个递增的数字。先被分配 id的事务得到的是较小的事务 id,后被分配 id 的事务得到的是较大的事务 id

        undo 日志的格式    

        为了实现事务的原子性,InnoDB 存储引擎在实际进行增、删、改一条记录时, 都需要先把对应的 undo 日志记下来。一般每对一条记录做一次改动,就对应着一条 undo 日志,但在某些更新记录的操作中,也可能会对应着 2 undo 日志。

        一个事务在执行过程中可能新增、删除、更新若干条记录,也就是说需要记录很多条对应的 undo 日志,这些 undo 日志会被从 0 开始编号,也就是说根据生成的顺序分别被称为第 0 undo 日志、第 1 undo 日志、...、第 n undo 日志等,这个编号也被称之为 undo NO

        表空间其实是由许许多多的页面构成的,页面默认大小为 16KB。这些页面有不同的类型,其中有一种称之为 FIL_PAGE_UNDO_LOG 类型的页面是专门用来存储 undo 日志的。也就是说 Undo page 跟储存的数据和索引的页等是类似的。 FIL_PAGE_UNDO_LOG 页面可以从系统表空间中分配,也可以从一种专门存放 undo 日志的表空间,也就是所谓的 undo tablespace 中分配。先来看看不同操作 都会产生什么样子的 undo 日志

        INSERT操作对应的undo日志

        当我们向表中插入一条记录时最终导致的结果就是这条记录被放到了一个数据页中。如果希望回滚这个插入操作,那么就把这条记录删除就好了,也就是写对应的undo日志时,主要把这条记录的主键信息记录,InnoDB设计了一个类型为TRX_UNDO_INSERT_REC的undo日志。

        当我们向表中插入一条记录时,实际上需要向聚簇索引和所有的二级索引都插入一条记录。不过记录undo日志的时候,我们只需要考虑向聚簇索引插入记录时的情况就好了,因为其实聚簇索引记录和二级索引记录时一一对应的,我们在回滚插入操作时,只需要知道这条记录的主键信息,然后根据主键信息坐对应的删除操作,删除操作时就会顺带把所有二级索引中相应的记录也删除掉。delete 操作和update操作同理!

         roll_pointer的作用

        本质上就是一个指向记录对应的undo日志的一个指针。

        DELETE操作对应的undo日志

        插入页面中的记录会根据记录头信息中的next_record属性组成一个单向链表,为正常的记录链表;而删除的记录其实也会根据记录头信息中的next_record属性组成一个链表,只不过这个链表中的记录占用的存储空间可以被重新利用,垃圾链表。Page Header部分有一个PAGE_FREE属性,指向由被删除记录组成的垃圾链表的头节点,也就是free 链表,

       删除的时候,我们只把记录的delete_mask标志位展示出来,阶段一,先将记录的delete_mask标识位设置为 1,这个阶段称为 delete mark;此时并没有被加入到垃圾链表,记录处于一个中间状态。删除语句的事务提交之前都是这个状态。

         阶段二,删除语句所在的事务提交之后,会有专门的线程来把真正的记录删除掉,就是把该记录从正常记录链表中移除,并且加入到垃圾链表中。当次阶段执行完,这条记录算是真正被删除,这条记录占用的存储空间也可以被重新利用。InnoDB中会产生一种称之为TRX_UNDO_DEL_MARK_REC类型的undo日志。

 

         版本链

        在对一条记录进行 delete mark 操作前,需要把该记录的旧的 trx_id 和 roll_pointer 隐藏列的值都给记到对应的 undo 日志中来,就是我们图中显示的 old trx_id 和 old roll_pointer 属性。这样有一个好处,那就是可以通过 undo 日志的 old roll_pointer 找到记录在修改之前对应的 undo 日志。比方说在一个事务中, 我们先插入了一条记录,然后又执行对该记录的删除操作,这个过程的示意图就是这样:

         执行完delete mark操作后,它对应的undo日志和INSERT操作对应的undo 日志就串成了一个链表,这个链表就称之为版本链。

        UPDATE操作对应的undo日志(两种处理方式)

         1.不更新主键的情况

                a.就地更新(in-place update)               

                更新记录时,对于被更新的每个列来说,如果更新后的列和更新前的列占用的存储空间都一样大,那么就可以进行就地更新也就是直接在原记录的基础上修改对应列的值。再次强调一遍,是每个列在更新前后占用的存储空间一样大,有任何一个被更新的列更新前比更新后占用的存储空间大,或者更新前比更新后占用的存储空间小都不能进行就地更新。

                b.先删除旧记录,再插入新记录

                 如果有任何一个被更新的列更新前和更新后占用的存储空间大小不一致,那么就需要先把这条旧的记录从聚簇索引页面中删除掉(并不是deelte mark操作,而是真正的删除,直接从正常记录链表中移除并加入到垃圾链表中),然后再根据更新后列的值创建一条新的记录插入到页面中。这里如果新创建的记录占用的存储空间大小不超过旧记录占用的空间,那么可以直接重用被加入到垃圾链表中的旧记录所占用的存储空间,否则的话需要在页面中新申请一段空间以供新记录使用,如果本页面内已经没有可用的空间的话,那就需要进行页面分裂操作,然后再插入新记录。

        针对 UPDATE 不更新主键的情况(包括上边所说的就地更新和先删除旧记录再插入新记录),InnoDB 设计了一种类型为 TRX_UNDO_UPD_EXIST_REC undo 日志。

        2.更新主键的情况:【在聚簇索引中,记录是按照主键值的大小连成了一个单向链表的,如果我们更新了某条记录的主键值,意味着这条记录在聚簇索引中的位置将会发生改变,比如你将记录的主键值从 1 更新为 10000,如果还有非常多的记录的主键值分布 在 1 ~ 10000 之间的话,那么这两条记录在聚簇索引中就有可能离得非常远,甚至中间隔了好多个页面。针对 UPDATE 语句中更新了记录主键值的这种情况,InnoDB 在聚簇索引中分了两步处理:

          第一步:将旧记录进行delete mark操作

          在事务提交后才由专门的线程做 purge 操作,把它加入到垃圾链表中。这里一定要和我们上边所说的在不更新记录主键值时,先真正删除旧记录,再插入新记录的方式区分开! 之所以只对旧记录做 delete mark 操作,是因为别的事务同时也可能访问这条记录,如果把它真正的删除加入到垃圾链表后,别的事务就访问不到了。这个功能就是所谓的 MVCC

          第二步:创建一条新记录          

          根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中(需重新定位插入的位置)。 由于更新后的记录主键值发生了改变,所以需要重新从聚簇索引中定位这条记录所在的位置,然后把它插进去。

        针对 UPDATE 语句更新记录主键值的这种情况,在对该记录进行 delete mark 操作前,会记录一条类型为 TRX_UNDO_DEL_MARK_REC undo 日志;之后插入新记录时,会记录一条类型为 TRX_UNDO_INSERT_REC undo 日志,也就是说 每对一条记录的主键值做改动时,会记录 2 undo 日志。

          总结事务的流程

          1.事务执行流程

                   MySQL 在事务执行的过程中,会记录相应 SQL 语句的 UndoLog 和 Redo Log,然后在内存中更新数据并形成数据脏页。接下来 RedoLog 会根据一定规则触发刷盘操作Undo Log 和数据脏页则通过刷盘机制刷盘。事务提交时,会将当前事务相关的所有 Redo Log 刷盘,只有当前事务相关的所有 Redo Log 刷 盘成功,事务才算提交成功。

          2.事务恢复流程

          如果MySQL由于某种原因崩溃或者宕机,需要进行数据的恢复或者回滚操作。如果事务提交之前(执行第8步之前),MySQL出现崩溃或者宕机,此时会先使用Redo log恢复,然后使用Undo Log回滚数据;如果再事务提交之后,会使用Redo Log恢复数据。

        

        MySQL 崩溃恢复后,首先会获取日志检查点信息,随后根据日志检查点信息使用 Redo Log 进行恢复。MySQL 崩溃或者宕机时事务未提交,则接下来使用 Undo Log 回滚数据。如果在 MySQL 崩溃或者宕机时事务已经提交,则用 Redo Log 恢复数据即可。

        恢复机制

         在服务器不挂的情况下,redo 日志简直就是个大累赘,不仅没用,反而让性能变得更差。但是万一数据库挂了,就可以在重启时根据 redo 日志中的记录就可以将页面恢复到系统崩溃前的状态。 MySQL 可以根据 redo 日志中的各种 LSN(日志序列号) 值,来确定恢复的起点和终点。然后 将 redo 日志中的数据,以哈希表的形式,将一个页面下的放到哈希表的一个槽中。之后就可以遍历哈希表,因为对同一个页面进行修改的 redo 日志都放在了 一个槽里,所以可以一次性将一个页面修复好(避免了很多读取页面的随机 IO)。并且通过各种机制,避免无谓的页面修复,比如已经刷新的页面,进而提升崩溃恢复的速度。

         崩溃后恢复为什么不用binlog?  

        1.使用方式不同

         binglog会记录表中所有更改操作,包括更新删除数据,更改表结构等,主要是用作人工恢复数据,redo log是MySQL自己家使用,用于保证再数据库崩溃时的事务持久性。

        2.redo log是InnoDB引擎特有的,binlog是MySQL的Server层实现的,所有引擎都可以使用。

        3.redo log 是物理日志,记录的是每个页面上的哪个数据页在什么位置上做了修改,恢复的速度更快;binlog 是逻辑日志,记录的是sql语句的原始逻辑。

        4.redo log 是“循环写”的日志文件,redo log 只会记录未刷盘的日志,已经刷入磁盘的数据都会从 redo log 这个有限大小的日志文件里删除。binlog 是追加日志,保存的是全量的日志

        5.最重要的原因,虽然bin-log拥有数据变化的全量日志,但是bin-log无法判断哪些数据已经落盘,哪些是没有落盘的,比如 id =2 c+1  ,id=2 c+1 无法判定哪条语句是否真的落了盘,redo可以识别的。但 redo log 不一样,只要刷入磁盘的数据,都会从 redo log 中抹掉,数据库重启后,直接把 redo log 中的数据都恢复至内存就可以了所以崩溃的时候一般用redo-log。如何保证redo-log 和bin-log 的一致性?两阶段提交。

        Redo日志和Undo日志的关系

         undo日志也需要redo日志来保证他的持久化,因为 redo log 是物理日志,记录的是数据库页的物理修改操作。所以 undo log (可以看成数据库的数据)的写入也会伴随着 redo log 的产生,这是因为 undo log 也需要持久化的保护。 undo保证原子性,redo保证事务的持久性。

        同时写Redo 和 Binlog怎么保持一致        

        当我们开启了 MySQL BinLog 日志,很明显需要保证 BinLog 和事务日志的一 致性,为了保证二者的一致性,使用了两阶段事务 2PC(所谓的两个阶段是指:第一阶段:准备阶段第二阶段:提交阶段,具体的内容请参考分布式事务)。步骤如下:

        1)当事务提交时 InnoDB 存储引擎进行 prepare 操作。

        2)MySQL 上层会将数据库、数据表和数据表中的数据的更新操作写入 Bin Log文件。

       3)InnoDB 存储引擎将事务日志写入 Redo Log 文件中,提交的时候,binlog会告知redolog,给一个标识给redo log

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Laughing_Xie

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值