1、在一、二中已经介绍了InnoDB的数据结构相关,并且详述了数据插入的过程。但是似乎并没有讲述关于更新和删除相关的操作。我只知道记录删除的时候,并不是直接删除记录,而是将行的delete_mark置成了0。可是仅仅只是置成delete_mark为0却不回收物理空间,那么长此以往必定消耗完磁盘。那么肯定是要收回被删除的物理空间的。可是又是怎么做到的呢? 2、事务相关的知识点,在前面也没有提及到。那么会不会很疑惑InnoDB是怎么做到的事务隔离呢? 3、数据回滚在MySQL中已经屡见不鲜了,可是到底是怎么实现的回滚呢? 等等这些问题,在这一节当中,我们都要去探索。

Buffer Pool

介绍

  • Buffer Pool
  • Free链表
  • Flush链表
  • LRU链表

InnoDB的数据是存储在磁盘的,如果我们每次去查询某一条记录都需要从磁盘通过IO读取数据,无疑是浪费时间的。所以必须引入缓存(类似Redis/Memcache,我们在熟悉不过了)。缓存的好处不必多说了,可是InnoDB是怎么引入缓存的呢?
1、InnoDB在新建表的时候,也会申请128M(默认)的内存空间用作Buffer Pool。查询数据的时候,InnoDB会将数据先缓存到Buffer Pool中。当再次命中缓存的时候则直接从Buffer Pool中获取数据。
首先看看Buffer Pool的数据结构:
image.png
缓存页的大小和页的大小一致,也是16KB。但是InnoDB申请的128M物理空间指的就是缓存页实际分配的空间,而控制块则是额外分配的空间。因此在申请Buffer Pool的时候也会额外申请5%的空间作为控制块的物理空间。倘若控制块的物理空间没有被用完,则产生了无法被利用的碎片。

2、那么InnoDB是如何知道查找的页是否存在Buffer Pool呢?
通过hash表存储:哈希表的key:表空间ID+页号,哈希表的value:缓存页数据。如果命中了hash表的key即可命中缓存。

3、可是InnoDB怎么知道那个页已经写了数据,哪个页还是空闲的呢?再看看控制块是如何控制缓存页的
image.png
控制块之间形成双向链表,每个控制块对应一个缓存页。控制块中记录着缓存页的控制信息。初始化Buffer Pool的时候,将所有的空闲页面的控制块通过双向链表链接起来,就形成了Free链表。当查找的某一页不在缓存中需要被缓存的时候,则从Free链表中拿出一页缓存页中的内容。

4、MySQL数据页的修改是发生在内存的,待某个时机在刷到磁盘。如果缓存中的数据页被修改,那么将会导致缓存的数据和磁盘的数据不一致,我们称之为脏页。由脏页形成的链表我们称之为Flush链表。Flush链表和Free链表的数据结构是一样的。

5、毕竟Buffer Pool的内存是有限的,要在有限的内存中有效且高效的存储数据页,则需要一个数据结构:LRU链表。如此我们可知Buffer Pool内存中,存在着3种链表:Free链表、Flush链表、LRU链表。
**

LRU链表

第一种形式:从Page1开始不断的读取数据并缓存,然后直到Buffer Pool占满了之后在从头开始移除Page1。
image.png
可是如果一直这么循环的缓存,对于一些热数据来说,可能导致刚缓存不久的数据页就被更新了。而对于一些偶尔被访问到的冷数据来说,占着页其实又不再会被命中。

第二种形式:分冷热数据查询。这样就可以提高热区数据的查询效率,并且不会因为偶尔冷区被访问将热区覆盖。
image.png
热区的数据是经常被访问到的数据,而冷区数据则是偶尔被访问到的数据。可是这个图里面没有体现几个问题:

  • 加入热区的条件是什么?当前页被多次访问到,访问次数>=2。
  • 热区和冷区的比例是多少? 热区:冷区=5:3,即热区占5/8。

页被多次访问到则从冷区移到热区头节点,可是如果是全表扫描呢?因为全表扫描读取的是页里面的每个数据行,所以这一页会被持续访问以至于“持续发热”,导致热区的头节点被持续的替换。为了解决这个问题,InnoDB记录了页第一次被访问的时间,且设置了一个全局变量innodb_old_blocks_time表示在这个时间规定之内访问页面无效。

LRU调度算法(Last Recent Used:最近最少调用),其实很熟悉。在进程调度里面就有提到过LRU算法。

Buffer Pool中链表关系

Buffer Pool初始化阶段:由于Buffer Pool的默认大小是128M,所以可以计算出一共有1281024KB/16KB=8192个页面。还会额外申请5%的空间给控制块,即1280.05=6.4M。所以一共申请了134.4M的内存空间供Buffer Pool使用。开始阶段Free链表是将所有的控制块通过双向链表链接起来,Flush链表和LRU链表都是空的。

1、从数据表中查找Page1中的某条记录后,发生的情况如下:
image.png
每个控制块对应一个缓存页,所以这里假设控制块1对应缓存页1,控制块2对应缓存页2,以此类推。

  • 从数据表的Page1中查找某条记录
  • 从Free链表中找到控制块1,将Page1缓存到对应的内存
  • 将控制块1链入到LRU链表的表头,由于现在数据比较少,所以还无需区分冷热数据。
  • 将当前的表空间ID+页号和Page1的内容记录到HashTable结构中。

起初我会很好奇HashTable和LRU链表之间的关系:HashTable也可以记录数据的内容,LRU也可以通过遍历控制块得到数据的内容,但LRU链表相比于HashTable的O(1)复杂度来说太慢了。那么LRU和HashTable的关系是什么呢?确切的来说,LRU更像是HashTable数据的控制器。在LRU中的数据如果被移除之后,那么也将从HashTable中删除对应的映射关系。在LRU中新增的数据也会在HashTable中增加新的Key和Value。

2、继续查找Page2、Page3、Page4中的记录,并且修改了Page3中的某条记录。发生的情况如下:
image.png

我们通过Buffer Pool的固定大小可以推断出LRU链表的最大长度可以是8192,那么HashTable也可以记录8192个映射关系啦!可是还是会好奇:

  • LRU是在什么时机开始区分冷热数据?
  • InnoDB是怎么找到这些链表的?
  • 这些控制块是怎么个数据结构可以将三种链表串在一起?

刷脏

总共有三种形式将脏页刷到磁盘
(1) BUF_FLUSH_LRU:后台线程定时从LRU链表的冷区尾部开始扫描,如果发现脏页即将脏页写入到磁盘。
(2) BUF_FLUSH_LIST:后台线程定时从Flush链表刷新一部分页面到磁盘,刷新的速率取决于当时系统是否繁忙。也可能出现系统繁忙的时候刷盘的行为,这会严重降低处理的速度。
(3) BUF_FLUSH_SINGLE_PAGE:有时候后台线程刷新脏页的进度比较慢,导致用户线程在准备加载一个磁盘页到Buffer Pool时没有可用的缓存页,这时就会尝试看看LRU链表尾部有没有可以直接释放掉的未修改页面,如果没有的话会不得不将LRU链表尾部的一个脏页同步刷新到磁盘(和磁盘交互是很慢的,这会降低处理用户请求的速度)。

注意

  1. 在多线程环境下,访问Buffer Pool的各个链表都需要加锁处理。因此可通过innodb_buffer_pool_instances配置项配置Buffer Pool的实例个数。但是如果innodb_buffer_pool_size的大小小于1G,innodb_buffer_pool_instances只能为1。
  2. Buffer Pool多个实例的好处在于:多进程同时访问的时候并发数可以更高,访问速度更快。但是并不是越多越好,因为对Buffer Pool实例的管理也是消耗资源的。
  3. 在MySQL5.7.5之前,Buffer Pool的大小只能在启动前配置,启动后不允许修改。MySQL5.7.5之后,可以在运行时配置Buffer Pool的大小,MySQL将申请新的内存空间并且将原Buffer Pool中的数据复制到新的内存空间。如若申请一个大的内存空间是及其浪费时间的,因此可以通过配置项innodb_buffer_pool_chunk_size每次从内存中申请连续内存空间。一个chunk中包含若干个控制块和缓存页,若干个chunk组成Buffer Pool。
    image.png
  4. innodb_buffer_pool_chunk_size只能在启动之前指定大小,默认是128M。
  5. 可以通过 show engine innodb status 命令查看InnoDB引擎的相关信息。

事务

事务在InnoDB中可谓是重中之重,也是我们经常在面试的时候被问到和MyISAM的主要区别之一了。MyISAM是不支持事务的,这也让很多的公司都会直接选择InnoDB作为他们DB的数据引擎。那么什么是事务呢?英文差的我经常记不住这几个英文单词,但是记住对记忆更有帮助。

四大特性

事务的四大特性:ACID(英文含义:硫酸)
A:atomic 原子性(英文含义:原子的,原子能的,微粒的)
C:consistency 一致性
I:isolation 隔离性
D:durable 持久性

分别解释下事务的这四大特性的意思是什么?
A:atomic 原子性:这个事务作为一个整体,要么都执行,要么都不执行。
C:consistency 一致性:客观事实上符合现实需求的一致性要求。
I:isolation 隔离性:事务之间的执行是隔离的,互不影响。
D:durable 持久性:事务执行后的结果是持久的。

五大状态

事务提交阶段,共有五种状态的转化:
活动的(active):事务对应的数据库操作正在执行阶段
部分提交的(partially committed):当事务的最后一个操作执行结束,但造成的影响仅在内存中并没刷盘。
失败的(failed):事务处在active或者partially committed状态由于其他原因导致事务执行失败。
中止的(aborted):如果事务是执行了半截变为失败,那么在中止前需要执行回滚操作。
提交的(committed):一个事务修改的数据都被同步到磁盘,那么这个事务的状态就是提交。
状态之间的转变如下图:
image.png

我们在之前说到过InnoDB刷盘的行为是定时执行的,应该尽量避免在数据修改时同步刷盘。可是这里却指到事务提交的状态必须是事务从部分提交状态将数据刷盘后才能变成提交的状态,这岂不是自相矛盾了?这里的刷盘其实并不是直接将数据的修改同步刷盘,而是指将记录的事务修改的redo日志刷盘。这也将是后面即将讲到的内容。

事务提交

事务提交的几种写法:
第一种写法:

  1. BEGIN [WORK];
  2. -- SQL语句
  3. -- 期间执行任何DDL语句都会使事务隐式提交,如 DROP TABLECREATE TABLEALTER TABLE|USER
  4. COMMIT [WORK]; --提交事务
  5. -- ROLLBACK [WORK] --或者回滚事务
  6. -- 关闭系统参数autocommit,事务则不再自动提交,需要手动提交事务。
  7. -- set autocommit=off|0;
  8. //////////////////////////////////////////////
  9. --事务保存点,有点类似git的保存点
  10. BEGIN [WORK]
  11. -- SQL语句
  12. SAVEPOINT POINT_NAME; --保存当前的节点
  13. -- 返回保存点,然后继续编辑事务
  14. ROLLBACK [WORK] TO [SAVEPOINT] POINT_NAME;
  15. -- 或者删除保存点
  16. RELEASE SAVEPOINT POINT_NAME;
  17. /////////////////////////////////////////////

第二种写法:

  1. START TRANSACTION [READ ONLY || READ WRITE(默认)] [,WITH CONSISTENT SNAPSHOT]
  2. -- SQL语句
  3. COMMIT;

从图上可知,START TRANSACTION比BEGIN的功能多点,还可以支持多个参数选择:
READ ONLY:开启只读事务,不能在事务中进行修改。
READ WRITE:开始读写事务,可以在事务中进行修改。
WITH CONSITTENT SNAPSHOT:一致性快照,仅InnoDB支持。

这里值得注意的一点是:BEGIN和START TRANSACTION在执行的过程中,如果执行了DDL语句,那么事务会被隐式提交。

记住:哪怕我们执行的是单条SQL语句,也是事务!

redo日志

引导

  • redo日志是什么?
    redo日志是对数据操作的一句描述,如我执行了一句UPDATE语句,那么redo日志就会记录:将0号表空间的第10号页面的偏移量为0xc110处的值更改为2;
  • redo日志的作用?
    前面提到过,事务提交的状态是以最终事务已刷盘为标志。这里的刷盘不是指将修改的数据进行刷盘,指的就是将redo日志刷盘。即将redo日志的内容刷盘。
  • redo日志的好处?
    数据刷盘行为可能有几个问题:
    • 数据同步刷盘影响MySQL性能
    • 数据的修改可能是随机IO

但是使用redo日志就可以很好的解决这个问题。redo的好处则在于:

  • redo日志占用的磁盘更小,写入速度更快
  • redo日志记录是顺序IO写入写入磁盘

那么就开始学习redo日志的工作方式吧!

redo日志结构

image.png
type:该条日志的类型
space ID:表空间ID
page number:页号
data:该条redo日志的具体内容

redo日志类型:type
MLOG_1BYTE:type标志为1,表示在redo日志某个偏移量处写入1个字节的redo日志类型。
MLOG_2BYTE:type标志为2,表示在redo日志某个偏移量处写入2个字节的redo日志类型。
MLOG_4BYTE:type标志为4,表示在redo日志某个偏移量处写入4个字节的redo日志类型。
MLOG_8BYTE:type标志为8,表示在redo日志某个偏移量处写入8个字节的redo日志类型。
MLOG_WRITE_STRING:type标志为30,表示在redo日志某个偏移量处写入一串字符。

1、MLOG_1BYTE、2BYTE、4BYTE、8BYTE的数据类型都是类似的,只是具体数据中的内容字节数不一样。如图:

image.png
2、MLOG_WRITE_STRING的结构图稍有区别,如图:
image.png
可见比1、2、4、8BYTE多了个len表示具体数据的长度。

疑问:除了上述的几种类型之外,InnoDB有50多种的type类型。InnoDB设置这么多的type的用意是什么呢?

redo日志记录方式

以Insert语句为例,假设是多个索引的情况下一个Insert语句就涉及到多个B+树的修改,而且在最坏的情况下B+树还可能出现页分裂。除了用户数据页修改之外,还可能出现系统页的修改等等。所以任何一个语句,都可能带来很多的redo日志的记录。犹如下图:
image.png

被修改的内容是不连续的,甚至不在一个页面。可是这种情况应该怎么记录redo日志呢?
每个红点处都可以作为一个redo日志记录,如果这样的话可能redo日志记录的内容比实际修改页面的内容还多。
从第一个被修改的地方到最后一个修改的地方作为一个redo日志记录,可是中间很多没有被修改的数据也要被记录,浪费空间。

那么到底该如何记录redo日志呢?为了解决这种问题,InnoDB引入了很多其他的type类型。看几个例子:

  1. MLOG_REC_INSERT(type枚举值为9):表示插入一条使用非紧凑行格式的记录时的redo日志类型。
  2. MLOG_COMP_REC_INSERT(枚举值为38):表示插入一条使用紧凑行格式的记录时的redo日志类型。 [1] Redundant是一种比较原始的行格式,它就是非紧凑的; [2] CompactDynamic以及Compressed行格式是较新的行格式,它们是紧凑的(占用更小的存储空间)。
  3. MLOG_COMP_PAGE_CREATE(枚举值为58):表示创建一个存储紧凑行格式记录的页面的redo日志类型。
  4. MLOG_COMP_REC_DELETE(枚举值为42):表示删除一条使用紧凑行格式记录的redo日志类型。
  5. MLOG_COMP_LIST_START_DELETE(枚举值为44):表示从某条给定记录开始删除页面中的一系列使用紧凑 行格式记录的redo日志类型。
  6. MLOG_COMP_LIST_END_DELETE(枚举值为43):与MLOG_COMP_LIST_START_DELETE类型的redo日志呼应,表示删除一系列记录直到MLOG_COMP_LIST_END_DELETE类型的redo日志对应的记录为止。 [1] InnoDB数据页中的记录是按照索引列大小的顺序组成单向链表的; [2] 有时候删除索引列的值在某个区间范围内的所有记录,如果每删除一条记录就写一条redo日志,效率可能有点低,所以提出MLOG_COMP_LIST_START_DELETE和MLOG_COMP_LIST_END_DELETE类型的redo日志,可以很大程度上减少redo日志的条数。
  7. MLOG_ZIP_PAGE_COMPRESS(type字段对应的十进制数字为51):表示压缩一个数据页的redo日志类型。

MLOG_COMP_REC_INSERT为例,它的数据结构类型是:
image.png
结合刚刚Insert语句的例子,Insert语句提到了可能会修改数据页,系统页等信息。可是在MLOG_COMP_REC_INSERT中却只是记录了必要的相关字段的修改信息。为什么呢?其实是因为只要记录这些必要的信息,InnoDB就可以根据一些内部函数还原其他的修改的信息。这就是redo日志的物理层面逻辑层面

物理层面:redo日志指明了那个表空间ID那个页号进行修改。
逻辑层面:在系统崩溃重启时,并不能直接根据这些日志里的记载,将页面内的某个偏移量处恢复成某个数据,而是需要调用一些事先准备好的函数,执行完这些函数后才可以将页面恢复成系统崩溃前的样子。

mini-transaction

说了这么多,但有一点必须知道的就是:在同一个事务发生的所有redo日志,必须是一个完整的。在崩溃重启或者其他异常情况重启时,要么一条redo日志都不执行,要么就是将所有的redo日志都执行。

可是怎么做呢?如何保证redo日志的原子性?

1、在执行这些需要保证原子性的操作时必须以的形式来记录的redo日志,在进行系统崩溃重启恢复时,针对某个组中的redo日志,要么把全部的日志都恢复掉,要么一条也不恢复
2、在该组中的最后一条redo日志后边加上一条类型为MLOG_MULTI_REC_END的redo日志:type枚举值为31。所以某个需要保证原子性的操作产生的一系列redo日志必须要以一个类型为MLOG_MULTI_REC_END结尾,否则就认为是不完整的redo日志丢弃。

MLOG_MULTI_REC_END类型和以MLOG_MULTI_REC_END类型结束的redo日志组: image.png image.png

type类型占1个字节,8个bit位。为了充分利用这8个bit位,第一个bit位表示redo日志是否是一条单一的日志。后七个表示type的枚举值。 image.png

对底层页面的一次原子访问过程就称为一个mini-transactionmtr),一个mtr可以包含一组redo日志,在进行崩溃恢复时,一个mtr是一个不可分割的组,或者说整体。
image.png
所以显而易见,一个事务的执行通过多个语句组成(1个语句也是事务),每个语句由多个mtr组成,每个mtr由多个redo日志组成。那么redo日志的恢复则通过一个个mtr组恢复。

疑问:为什么一个事务,要分这么多的组呢?

Redo Log Buffer

那么redo日志到底是写到哪里去了?

即使redo日志比较简小,但也不能直接写入磁盘,对吧?因此实际上在服务器启动时就向操作系统申请了一大片称之为redo log buffer的连续内存空间,并划分成若干个连续的redo log block。通过启动参数innodb_log_buffer_size来指定log buffer的大小(5.7版本中默认值为16M)

image.png

redo log block的结构里,大小为512字节(去头尾后,数据体为496字节)。
image.png


log block header:

  1. LOG_BLOCK_HDR_NO:每一个block都有一个大于0的唯一标号
  2. LOG_BLOCK_HDR_DATA_LEN: [1] 表示block中已经使用了多少字节,初始值为12(因为log block body从第12个字节处开始); [2] 随着往block中写入的redo日志越来也多,本属性值也跟着增长; [3] 如果log block body已经被全部写满,那么本属性的值被设置为512;
  3. LOG_BLOCK_FIRST_REC_GROUP: [1] 一个mtr会生产多条redo日志记录,这些redo日志记录被称之为一个redo日志记录组(redo log record group); [2] LOG_BLOCK_FIRST_REC_GROUP就代表该block中第一个mtr生成的redo日志记录组的偏移量(其实也就是这个block里第一个mtr生成的第一条redo日志的偏移量)。
  4. LOG_BLOCK_CHECKPOINT_NO: 表示所谓的checkpoint的序号,后边会详细说。 log block trailer中属性的意思如下:
  5. LOG_BLOCK_CHECKSUM:表示block的校验值,用于正确性校验,我们暂时不关心它。

redo日志从哪里开始写?
为了解决这个问题,引入了一个全局变量buf_free,该变量指明后续的redo日志应该从这里开始写,如下图:
image.png

多个事务同时执行呢?
事务之间的隔离性,所以多个事务是可以同时执行的。但是多个事务之间的mtr是如何记录的呢?
image.png


image.png
**

ib_logfile文件格式

犹如Buffer Pool的脏页,最终也是要写入到磁盘才不会丢失。那么Redo Log Buffer中的redo日志,也是要最终写到磁盘的。写到哪里去了呢?

在我本地MySQL的默认数据目录中,有文件名如ib_logfile,如图:
image.png

很有意思,Buffer Pool是写在了系统表和独立表中,即ibdata1和表名.ibd。并且Buffer Pool中的内容为了防止数据丢失,需要将数据刷到磁盘。可是又为了防止服务崩溃或者其他突发事件导致Buffer Pool中的数据还没有来得及刷盘结果就丢失了,因此采用了redo日志的方式将每个修改的语句产生的内容描述都记录起来。可是redo日志也有个自己的Redo Log Buffer,并且为了防止系统崩溃引起的redo日志丢失,也要将redo日志的内容刷盘。可是该怎么刷盘呢?

刷盘的时机
》Log Buffer空间不足时
》事务提交时
》后台线程刷新时
》正常关闭服务器时
》做checkpoint时
》其他一些情况

除此之外,我们可以通过系统配置项innodb_flush_log_at_trx_commit配置redo刷盘的时机。
0:当该系统变量值为0时,表示在事务提交时不立即向磁盘中同步redo日志,这个任务是交给后台线程做的。
这样很明显会加快请求处理速度,但是如果事务提交后服务器挂了,后台线程没有及时将redo日志刷新到磁盘,那么该事务对页面的修改会丢失。
1:当该系统变量值为1时,表示在事务提交时需要将redo日志同步到磁盘,可以保证事务的持久性。1也是innodb_flush_log_at_trx_commit的默认值
2:当该系统变量值为2时,表示在事务提交时需要将redo日志写到操作系统的缓冲区中,但并不需要保证将日志真正的刷新到磁盘。
这种情况下如果数据库挂了,操作系统没挂的话,事务的持久性还是可以保证的,但是操作系统也挂了的话,那就不能保证持久性了。

ib_logfile的数据结构
Redo Log Buffer的block大小是512B,ib_logfile的每个block大小也是512B。一个ib_logfile分为多个block,如下:
image.png
可以通过一下配置修改ib_logfile的属性:

  • innodb_log_group_home_dir:指定redo log的文件所在目录。
  • innodb_log_file_size:每个redo日志文件的大小,在MySQL 5.7.21这个版本中的默认值为48MB。
  • innodb_log_files_in_group:该参数指定redo日志文件的个数,默认值为2,最大值为100。

ib_logfile的前2048个字节是管理信息,512B一个redo log block,所以占用了4个block。那么这4个block的树结构又是什么样的呢?如图:
image.png

1、log file header
image.png

属性名 长度(单位:字节) 描述
LOG_HEADER_FORMAT 4 redo日志的版本,在MySQL 5.7中该值永远为1
LOG_HEADER_PAD1 4 做字节填充用的,没什么实际意义,忽略
LOG_HEADER_START_LSN 8 标记本redo日志文件开始的LSN值,也就是文件偏移量为2048字节初对应的LSN值
LOG_HEADER_CREATOR 32 一个字符串,标记本redo日志文件的创建者是谁。正常运行时该值为MySQL的版本号,比如:”MySQL 5.7.21”,使用mysqlbackup命令创建的redo日志文件的该值为”ibbackup”和创建时间。
LOG_BLOCK_CHECKSUM 4 本block的校验值,所有block都有,我们不关心

2、chekcpoint1、checkpoint2
image.png

属性名 长度(单位:字节) 描述
LOG_CHECKPOINT_NO 8 服务器做checkpoint的编号,每做一次checkpoint,该值就加1。
LOG_CHECKPOINT_LSN 8 服务器做checkpoint结束时对应的LSN值,系统崩溃恢复时将从该值开始。
LOG_CHECKPOINT_OFFSET 8 上个属性中的LSN值在redo日志文件组中的偏移量
LOG_CHECKPOINT_LOG_BUF_SIZE 8 服务器在做checkpoint操作时对应的log buffer的大小
LOG_BLOCK_CHECKSUM 4 本block的校验值,所有block都有,我们不关心

Log Sequeue Number(LSN)

InnoDB为记录已经写入的redo日志量,设计了一个称之为Log Sequeue Number的全局变量:LSN(日志序列号),初始值为8704(也就是一条redo日志也没写入时,LSN的值为8704)

系统第一次启动后初始化log buffer时,buf_free(就是标记下一条redo日志应该写入到log buffer的位置的变量)就会指向第一个block的偏移量为12字节(log block header的大小)的地方,那么lsn值也会跟着增加12。

redo日志的写入过程

1、初始化阶段如图下:

image.png

2、当写入第一个mtr时,假设mtr_1占用200B。log buffer就会如图下:
image.png

3、当写入第二个mtr时,假设mtr_2占用1000B。log buffer就会如图下:
image.png
因为mtr_2跨了1个block,所以LSN需要额外加上2个log block header和log block tailer。

于是,我们现在可以得知仅凭两个全局变量:buf_free和LSN就可以开始将redo日志存入log Buffer中。可是redo日志记录到log buffer之后,怎么刷新到磁盘呢?这里需要在用到2个全局变量:buf_next_to_write和flushed_to_disk_lsn

redo日志刷盘过程

继上面的redo日志继续写入100B的mtr3,结合buf_next_to_write和flushed_to_disk_lsn变量,如下图:

image.png

(10048在后续的讲解中出现了10000,统一看待即可,数字不是说明问题的重点)

此时由于还没有开始刷盘,所以buf_next_to_write和flushed_to_disk_lsn都指向了block的头部起始位置。flushed_to_disk_lsn起始值和LSN的起始值一样也是8704。为了更好的介绍刷盘的动作,将标出每个mtr的LSN和buf_free。如图下:
image.png
然后buf_next_to_write和flushed_to_disk_lsn就沿着mtr1、mtr2、mtr3的位置开始将mtr刷盘。
这里需要说明的是:
应用程序向磁盘写入文件时其实是先写到操作系统的缓冲区中去,如果某个写入操作要等到操作系统确认已经写到磁盘时才返回,那需要调用一下操作系统提供的fsync函数。
其实只有当系统执行了fsync函数后,flushed_to_disk_lsn的值才会跟着增长,当仅仅把log buffer中的日志写入到操作系统缓冲区却没有显式的刷新到磁盘时,另外的一个称之为write_lsn的值跟着增长。

所以,最终是否写入磁盘以flushed_to_disk_lsn为准。只有当flushed_to_disk_lsn和buf_next_to_write指向同一个位置时,才表示前面的所有mtr刷盘成功!

疑问:redo日志的写入用了LSN和buf_free两个全局变量,redo日志的刷盘用了3个全局变量,为什么非要用这么多的变量来表示同一件事情呢?

flush链表的LSN

之前将Buffer Pool的时候,没有明确的讲过控制头的数据结构。但是redo日志记录时的mtr的LSN会记录到控制头的start和end。如图所例,mtr1对应页a;mtr2对应页b和页c。页a修改的时间是最早的,页b修改的时间是最晚的。flush链表是按修改时间的先后连接的。

o_m:oldest_modification(最早修改LSN),n_m:newest_modification(最新修改LSN)
mtr1:o_m-8716 ~ n_m-8916(修改了页a)
mtr2:o_m-8916 ~ n_m-9948(修改了页b和页c)

image.png

mtr3:o_m-9948 ~ n_m-10000(修改了页b和页d)
因为mtr3比较特殊,修改了页b,因此将页b的n_m修改成最新的10000(部分数字是10048,统一看待即可,数字不是说明问题的重点)。
image.png
注意:
1、flush链表按照修改的时间先后顺序排序,这里可以对应的o_m的大小排序。
2、重复修改的页面不会重复插入,只会修改n_m值。

redo日志的刷盘和flush脏页的刷盘是不一样的。redo日志的刷盘是为了崩溃恢复,当脏页还未刷盘的时候可以根据已刷盘的redo日志恢复。但脏页的刷盘是为了将用户数据同步到磁盘持久化。ib_logfile的大小事有限的,但是redo日志的记录是无限的,为了能够记录无限的redo日志,所以ib_logfile要能够重复利用或者说循环利用。可是怎么重复利用呢?

checkpoint

ib_logfile的重复利用就是依赖于checkpoint,怎么用的呢?
如下图如果mtr1对应的flush链表的脏页已经被刷盘,那么理论上mtr1的空间就可以被重复利用。于是InnoDB就提出了新的全局变量checkpoint_lsn,用来记录已被刷盘的最新的LSN(即n_m)。checkpoint_lsn的初始值也是8704。(从图中可以看出redo日志的mtr2也已经刷盘了,mtr3还未刷盘。)
image.png
对应的flush链表:
image.png
ib_logfile的前4个block结构中有checkpoint1和checkpoint2,在InnoDB规定中,有个全局变量checkpoint_no用来记录当前系统checkpoint的次数,为奇数时将checkpoint_lsn写到checkpoint2,偶数写到checkpoint1。除了checkpoint_lsn,还有checkpoint_offset(即当前checkpoint_lsn在redo日志的偏移量,很容易计算得到)也会被记录。可是为什么要有2个checkpoint呢?
image.png

checkpoint_lsn的值其实就是flush链表的链尾的n_m的值。对应的在buf_free和checkpoint_lsn之间的空间都可以被利用。如此flush链表和redo log buffer以及ib_logfile就可以串联起来发挥作用了。说到这里还是的强调一下:flush链表的存在是为了避免用户数据页同步写入磁盘带来的IO性能问题二引入的。而redo日志的存在是为了防止内存中的flush脏页由于突发情况而丢失用户数据。可是为了解决redo日志同步写入磁盘带来的IO性能问题,也提出了类似Buffer Pool的概念Redo Log Buffer,可以将redo日志先写入Redo Log Buffer,然后在定时将redo日志刷盘。 当然,redo日志在刷盘的时候也可能存在异常导致刷盘失败,而事务的成功与否和redo日志是否刷盘成功却息息相关。如果redo日志刷盘失败,那么则认为这条事务执行失败!可redo日志的刷盘行为却不是同步执行的,那么这个事务就要一直等待这个redo日志刷盘成功为止。而这个等待带来的代价即redo日志刷盘的时机相关。前面有提到过:事务的提交也是redo日志刷盘的一个时机。 现在redo日志如何写入磁盘的过程我们已经探讨完毕了,可是redo日志真正的目的崩溃恢复功能我们却还没提及。那么redo日志到底是如何崩溃恢复的呢?

redo日志崩溃恢复

要通过redo日志恢复崩溃时的数据,那么就要知道redo日志应该从何开始执行。对于已经刷脏的页面来说就没有必要在执行了,如上例子的mtr1对应的页a已经刷盘了,那么就无需通过redo日志再次执行了。因此checkpoint_lsn之前的mtr都无需执行了。可是对于checkpoint_lsn之后的mtr来说,可能被刷盘也可能还没刷盘,因此需要校验block中的redo日志。

是否还记得一个事务分为多个语句,每个语句再分为多个mtr,每个mtr再分为多个redo日志。于是在不同的语句中可能存在修改同一个页的可能。redo日志是按顺序记录的,也就意味着同一个页面可能会被多次“崩溃恢复”。而来回的IO要是能够尽可能减少是再好不过的了,因此可以值得优化!

InnoDB通过spaceID和pageNo计算出散列值,把相同的散列值的redo日志放在一个哈希链表连接起来。如图:
image.png
redo日志通过相同的散列值链接在同一个链表,表示修改的是一个相同的页面。一个链表的redo日志也是按照时间顺序排序的。在恢复的过程中可以通过遍历哈希表,而且可以一次性恢复一个页面的内容,从而避免多次IO。

可是觉得很奇怪:不是说好的按照redo日志的顺序吗?虽然一个链表的redo日志是按序了,但是对于其他散列值的mtr来说就乱序了啊!但是这里注意了:因为redo日志是将恢复所有崩溃的页面,因此对于一个页面来说早晚恢复都是需要恢复的,所以无所谓了。一个页面的按序恢复和所有的redo日志按序恢复是没有区别的。

所以分为以下几个步骤:

  1. 重启:系统崩溃,重启MySQL。开始通过redo日志恢复崩溃前数据。
  2. 找起始:比较checkpoint1和checkpoint2取其中最大的checkpoint_lsn。
  3. 找终点:从checkpoint_lsn之后的mtr开始分析block,找到最后一个位置的mtr。即这之间的block都需要分析是否有已经刷盘的redo日志,如果有则不再处理这条redo日志。
    1. 在block的log block header中的字段LOG_BLOCK_HDR_DATA_LEN记录了当前block的使用大小。如果为512B那么则代表当前的block已经写满了,可以直接跳过。因为不可能是最后一个block。如果当前的block没有写满则表示这就是终点了。
    2. flush链表的控制头记录了o_m和n_m,并且同一个页面不会重复插入只会修改n_m。因此如果当某个页面在崩溃前已经刷盘了那么会将n_m记录到Page.FILE_HEADER.FIL_PAGE_LSN(详见:InnoDB从内分析一)字段。flush链表是按照时间顺序插入的,于是可以理解为在这个页面的n_m之前的都已经刷盘了,在这个n_m之后的还未来得及刷盘却丢失了数据。所以理论上可以认为:如果当前页面的FIL_PAGE_LSN大于checkpoint_lsn,那么则已经刷盘了。
  4. 写入哈希表:将block内的redo日志都写到哈希表中。
  5. 遍历哈希表:遍历哈希表,然后查看当前页面的FIL_PAGE_LSN,并且和checkpoint_lsn比较大小。如果大于checkpoint_lsn则直接跳过说明已经在崩溃前刷脏了。
  6. 恢复:否则执行redo日志进行恢复!

我想根据以上的步骤已经知道redo日志是怎么工作的了!前面讲了很多东西就是为了让大家先对redo日志格式、ib_logfile和Redo Log Buffer有所了解,然后才能水到渠成的讲解redo日志的工作方式!

Undo日志

事务是如何回滚的呢?你是否也猜想过!

undo日志的作用通俗来讲:

  • insert语句需要记录插入的主键id,回滚的时候只要删除这个id的记录即可。
  • update语句需要记录修改前的值和主键id,回滚的时候将数据还原到修改前的值。
  • delete语句需要记录删除的所有字段,回滚的时候则恢复所有的数据。

因为select语句不会修改任何数据,所以无需记录undo日志。那么接下来就是要分析下undo日志如何起作用了。

首先undo日志记录到类型为FIL_PAGE_UNDO_LOG的页面中,这些页面可以从系统表空间分配,也可以从undo tablespace(一种专门存放undo日志的表空间)中分配。

说到数据页的结构的时候,有提到过rowId和trxId。那么这两个字段是如何写入数据页的呢? 》rowId:对于rowId来说,如果没有主键或者不为null的唯一键,那么系统就会自己生成rowId(存在系统表空间的第七页)。将rowId变量存在内存中,并且rowId++。每当rowId为256的倍数时,则将rowId刷到磁盘。倘若重启,则下一次的rowId从磁盘的rowId+256。 》trxId:对于trxId来说和rowId差不多,也是每次trxId++,存在系统表空间第五页。只要发生数据修改则肯定会生成新的trxId。但是注意:修改了内存表也是会生成trxId的,比如memory引擎的数据表。 》roll_pointer:roll_pointer链接的就是undo页面,用于控制事务回退。

insert undo日志

InnoDB设计了一种TRX_UNDO_INSERT_REC类型的undo日志:
image.png
1、在一个事务还未提交之前,每产生一个undo日志则undo no增1。
2、主键如果是一列则记录这个主键占用的大小和真实值。如主键是多列组成则记录多个主键的占用大小和真实值。
3、其他字段注释已说明,不在详述。

当如下插入数据: image.png 发生的动作则如: image.pngimage.png

在数据页中的结构则如: image.png

update undo日志

不更新主键update分为两种:就地更新和先删除再插入更新;还有就是更新主键更新
就地更新:更新的字段的大小和原来的字段的占用大小一样,则直接更新即可!
先删除在插入:如果更新的字段大小不一,那么则需要先删除原来的记录在重新插入。这里的删除是真正的删除,而不是将delete_mark置成1。真正的删除意味着被删除的记录同步的被链接在垃圾链。主键不更新,但是记录的长度会发生变化。
更新主键更新:这种情况先将原先的delete_mark置成1,然后新记录找到位置之后在做插入操作。

InnoDB设计了一种类型为TRX_UNDO_UPD_EXIST_REC的undo日志:
image.png

假设发生如下操作:
image.png

则发生的行为如下:
image.png
可见undo1是插入的undo日志,undo3是针对这条记录更新的undo日志。由于这里更新的字段长度一样,所以就地更新。

delete undo日志

在之前有提到过删除数据的时候,将数据页的delete_mark先置成1,但是并未从数据页中剔除。但是如果事务都执行了,那么也就没有回滚一说了,这会则真正的删除了。如下:

image.png image.png

可是删除记录的undo日志是什么样的呢?

InnoDB设计了一种TRX_UNDO_DEL_MARK_REC类型的undo日志:
image.png
info bits:
old trx_id:这里记录的是旧的trx_id,但是注意:在数据页中记录的是当前delete的事务ID。
old roll_pointer:记录旧的roll_pointer值,即指向原来的undo日志,从而形成版本链。

如下:
image.png

左边的是第一次插入数据的时候的数据页格式,右边是执行删除操作(事务并未提交)后的数据页格式。因为存在着版本链,所以误删的数据才有可能被恢复!可是如何恢复呢?被置成delete_mark的记录会被一个purge进程异步的维护链接在垃圾链。

再举个例子:先执行插入操作,然后在执行删除操作。
image.png
发生的行为如下:右边的是insert undo,左边的是delete undo。
image.png

可是如果事务提交了之后呢?