【MySQL 0338张图搞懂 MySQL 是如何实现事务的持久性和原子性的!

2023年 7月 24日 80.0k 0

本文主要是对MySQL事务的学习笔记,用了一些图总结相关知识。学习参考书籍:《从根上理解MySQL》

本文主要有以下内容:

  • 事务
  • redo 日志
  • undo 日志

事务

程序代码是对现实生活中的一种映射,而将现实生活中的一些操作以代码逻辑实现时,需要保证操作的一些特性,如要么成功,要么失败,不能有"如来"这种中间状态,比如银行账户 A 给 B 转账,要么A转了要么A没转,不会出现 A 转账给 B,A 的余额变少了 B 的余额没变,或者A的余额没变如转账撤销了但是B的余额又增加了!(如果存在这种:银行就亏麻了),当我们需要保证一些操作的原子性,对应的事件从一个状态变成另一个符合现实规则约束的状态,这时就提出了一个事务的概念。

事务:是一个抽象的概念,对应到数据库中是"一组"数据库操作的集合,这组操作要么同时成功,要么同时失败,不能够一部分操作成功,另一部分操作失败。"一组"操作可以是多个操作的集合,也可以是单个操作。

事务的特性

原子性(Atomicity):一组操作要么同时成功,要么同时失败。

一致性(Consistency):数据库中的数据要符合现实生活中的约束,如一个人的年龄不能为负数,一个人的身高不能为 0,也不可能超过3m(正常来说)。

隔离性(Isolation):状态与状态之间的转换不能受到其他状态的影响,应当保证其它的状态转换不会影响到本次状态转换。

持久性(Durability):一次状态的转换后,在没有其他状态转换时,这种状态应当永久保留。

上述的四个特性就是事务的ACID!

MySQL为了实现事务的这几个特性,就采用了"日志先行"的方法来记录用户操作,先记录用户操作,将用户操作的记录刷到磁盘上,这样即使断电或者奔溃也能保证将用户的操作操作完成!

事务的状态

  • 活动的(active)

    事务对应的数据库操作正在执行过程中时,我们就说该事务处在活动的状态。

  • 部分提交的(partially committed)

    当事务中的最后一个操作执行完成,但由于操作都在内存中执行,所造成的影响并没有刷新到磁盘时,我们就说该事务处在部分提交的状态。

  • 失败的(failed)

    当事务处在活动的或者部分提交的状态时,可能遇到了某些错误(数据库自身的错误、操作系统错误或者直接断电等)而无法继续执行,或者人为的停止当前事务的执行,我们就说该事务处在失败的状态。

  • 中止的(aborted)

    如果事务执行了半截而变为失败的状态,需要撤销失败事务对当前数据库造成的影响。我们把这个撤销的过程称之为回滚。当回滚操作执行完毕时,也就是数据库恢复到了执行事务之前的状态,我们就说该事务处在了中止的状态。

  • 提交的(committed)

    当一个处在部分提交的状态的事务将修改过的数据都同步到磁盘上之后,我们就可以说该事务处在了提交的状态。

状态转换如下图:

08_事务状态转换.png

Redo 日志

为了保证事务的持久性,MySQL 采用了记录操作的每一步的方式来实现,这样即使数据没有及时的刷到磁盘上,我们也可以根据 Redo 日志实现复原用户的操作,保证事务的持久性。

就比如我们上班过程中,从出门那一步开始我们就记录是那一个脚先迈出门,接着又是迈出了另一只脚,交叉前进直到进入公司,这样即使公司拿出"你左脚迈进公司,我们要开除你"的理由,我们也可以根据日志记录来反驳,我们是右脚进入公司从而逃过一劫!他如果不信我们就可以根据这个日志一步一步的还原过程,让他无话可说!

解决办法的思路有了,但是实现的方式采用何种方式呢?我们是走一步记一步还是走一个路口记一个路口还是怎么样?那如果是坐车坐地铁呢?亦或者自驾开车上班呢?

同样的,我们如何记录 Redo 日志来复原用户操作呢?我们先暂时把如何记录通勤这件事放下来,先来看看 Redo 日志的通用格式吧。

09_redo日志通用格式.png

  • Type:表示本条日志的类型,占据一个字节
  • Space ID:表空间ID,表示本条日志属于那个表
  • Page Number:页号
  • Data:本条日志的具体内容

简单的 redo 日志

简单的物理日志所针对的情况是在我们创建表时,如果没有定义显示的定义主键或者 Unique 键时,MySQL 会在隐藏信息为我们提供一个ROW_ID,当某个事务向某个包含row_id隐藏列的表插入一条记录,并且为该记录分配的row_id值为256的倍数时,就会向系统表空间页号为7的页面的相应偏移量处写入8个字节的值。这个写入实际上是在Buffer Pool中完成的,需要为这个页面的修改记录一条redo日志,以便在系统奔溃后能将已经提交的该事务对该页面所做的修改恢复出来。为什么是256的倍数呢?这是因为 row_id 的分配方式如下

  • 服务器会在内存中维护一个全局变量,每当向某个包含隐藏的row_id列的表中插入一条记录时,就会把该变量的值当作新记录的row_id列的值,并且把该变量自增1。
  • 每当这个变量的值为256的倍数时,就会将该变量的值刷新到系统表空间的页号为7的页面中一个称之为Max Row ID的属性处
  • 当系统启动时,会将上面提到的Max Row ID属性加载到内存中,将该值加上256之后赋值给我们前面提到的全局变量

这种方式就像我们在租借充电宝时,不满一小时按照一小时收费。采用这种方式是因为有可能断电时 Max_Row_Id大于 刚刚刷的值(后续新增了几条数据),为了避免出现相同的Max_Row_id 的情况,就加 256 咯。

针对上面这类简单的日志,有如下几种具体实现:

  • MLOG_1BYTE:表示在页面的某个偏移量处写入1个字节的redo日志类型。
  • MLOG_2BYTE:表示在页面的某个偏移量处写入2个字节的redo日志类型。
  • MLOG_4BYTE:表示在页面的某个偏移量处写入4个字节的redo日志类型。
  • MLOG_8BYTE:表示在页面的某个偏移量处写入8个字节的redo日志类型。
  • MLOG_WRITE_STRING:表示在页面的某个偏移量处写入一串数据。

所以要记录一个修改8个字节的值的一条redo日志,即类型为MLOG_8BYTE,其图示如下图所示:

10_MLOG_Bytes.png

MLOG_1BYTE、MLOG_2BYTE、MLOG_4BYTE的结构类似,MLOG_WRITE_STRING由于数据是可变长字符,因此需要一个长度字段来记录长度数据长度。

11_MLOG_STRING.png

复杂的 redo 日志

复杂类型的日志就不能这么简单,比如插入或者修改一条数据,就有可能导致响应的聚簇索引、二级索引、同一个页中的Page Header、Page Directory、next_record等这些内容发生改变,想一想都觉得复杂。而且以通勤为例,如果我们每走一步就记录一步,那么我们估计还没走到公司就到下班时间了,这就与我们的初衷相悖了。

同理如果我们在在每个修改的地方都记录一条redo日志,那么日志所占据的空间可能比我们整个页面占据的空间都大。那有没有其他办法记录?回到记录页上,因为插入一条数据可能导致的改变有很多,因此如果每一处改变都记录的话,显然不是很好的注意,那么你可能会想到我把整个页面第一个改变的字节和最后一个改变的字节以及他们中间的数据不管改没改都当作数据来处理,这样就只会产生一条 redo 日志,这样岂不是有效的解决这个问题?这样确实解决了日志过多的问题,但是产生了其他问题,假如我只修改开头的2个字节,末尾的4个字节,这样我却要把这两者中间的10kb的数据都当作数据刷入磁盘,这是不是就有点点奢侈浪费了?

由于上述两种方法都不能够很好的解决问题,MySQL 的大佬们就设计了针对不同用户操作的各种各样类型的 redo 日志:

  • MLOG_REC_INSERT:表示插入一条使用非紧凑行格式的记录时的redo日志类型。
  • MLOG_COMP_REC_INSERT:表示插入一条使用紧凑行格式的记录时的redo日志类型。
  • MLOG_COMP_PAGE_CREATE:表示创建一个存储紧凑行格式记录的页面的redo日志类型。
  • MLOG_COMP_REC_DELETE:表示删除一条使用紧凑行格式记录的redo日志类型。
  • MLOG_COMP_LIST_START_DELETE:表示从某条给定记录开始删除页面中的一系列使用紧凑行格式记录的redo日志类型。如删除id > 5的类似操作。
  • MLOG_COMP_LIST_END_DELETE:与MLOG_COMP_LIST_START_DELETE类型的redo日志呼应,表示删除一系列记录直到MLOG_COMP_LIST_END_DELETE类型的redo日志对应的记录为止。如删除id < 100的记录操作。
  • MLOG_ZIP_PAGE_COMPRESS:表示压缩一个数据页的redo日志类型。
  • ……more and more

上面所提到的内容只是为了告诉我们 MySQL 是如何根据各种类型的 redo 日志记录各种用户操作的。我们只需要知道他真的很牛掰就对了!

redo 日志的组织

又回到通勤的例子,如果我们一步一记,很显然效率不高且记的时间远大于我们步行的时间,同样的如果我们每在内存中形成一个 redo 日志就刷盘一个 redo 日志显然效率不高,而且有的操作会产生多条 redo 日志,如果在刷盘时没有保证刷盘的操作原子性形成了错误的结果,这就更不好了。如插入一条数据时,不仅要修改数据页,也要修改聚簇索引或者其他二级索引,如果日志在刷盘时没有保证修改索引的原子性,如果此时有其他事务来读取这棵错误的二叉树,这就出大问题了!因此为了解决上面的问题,redo 日志被划分成了若干不可分割的组:如

  • 更新Max Row ID属性时产生的redo日志是不可分割的。是一个组的!
  • 向聚簇索引对应B+树的页面中插入一条记录时产生的redo日志是不可分割的。
  • 向某个二级索引对应B+树的页面中插入一条记录时产生的redo日志是不可分割的。
  • 还有其他的一些对页面的访问操作时产生的redo日志是不可分割的...

为了保证多条 redo 日志的原子性,将他们划分到一个组里,设计了 MLOG_MULTI_REC_END类型的日志。以该日志类型结尾的前面的所有日志为一组。如下图:

12_redo日志组.png

这样就解决了多条日志原子性的问题,那么单条日志呢?毕竟有的单条 redo 日志也需要保证原子性。这就要回到日志的通用格式了,在日志的通用格式里,有一个 type 字段,它有一个字节,他的不同取值标识了不同类型的 redo 日志,因此恰恰由于这个我们就可以保证单条日志的原子性。具体的方法就是:由于定义了很多类型的日志,但是"很多"也有一个具体的值,他并没有超过100种,而一个字节可以表示256种取值,所以它将最高位的那一个bit用来表示这条日志是否需要保证原子性,取值为 1 则表示需要保证原子性的操作只产生了单一的一条redo日志,否则表示该需要保证原子性的操作产生了一系列的redo日志。或许你也想到了在单条日志后面加一个 MLOG_MULTI_REC_END类型的日志用来保证原子性,虽然可以,但是这样做可以节省空间!

Mini-Transaction

MySQL 对底层的一次访问称之为Mini-Transaction简称为mtr,即一个mtr对应一组redo日志,一个事务对应多条语句,一个语句可能对应多个mtr,一个mtr对应多条 redo 日志。如下图

13_mtr.png

redo 日志的写入[内存]

明白了逻辑上的一条条 redo 日志,接下来就要看看如何将内存中的日志刷到磁盘了!和用户数据类似,我们首先要把一条一条 redo 日志放在一个页面上,只不过这个页面和数据页不一样,它只有 512Kb,其结构如下所示:

14_redo_block.png

这就是一个存放redo日志的结构,称之为 Block。真正存放日志的区域为中间的 496 bytes

  • LOG_BLOCK_HDR_NO:每一个block都有一个大于0的唯一标号,本属性就表示该标号值。
  • LOG_BLOCK_HDR_DATA_LEN:表示block中已经使用了多少字节,初始值为12(因为log block body从第12个字节处开始)。如果被全部写满,则被设置为512
  • LOG_BLOCK_FIRST_REC_GROUP:一条redo日志也可以称之为一条redo日志记录,一个mtr会生产多条redo日志,这些redo日志记录组成了一个redo日志记录组。LOG_BLOCK_FIRST_REC_GROUP就代表该block中第一个mtr生成的redo日志记录组的偏移量,即这个block里第一个mtr生成的第一条redo日志的偏移量。
  • LOG_BLOCK_CHECKPOINT_NO:表示所谓的checkpoint的序号

log block trailer中属性的意思如下:

  • LOG_BLOCK_CHECKSUM:表示block的校验值。

Redo 日志缓冲区:

和 Buffer Pool 类似,MySQL 在启动时,申请了一个连续的内存空间 Redo Log Buffer,该区域就被划分为若干个 redo log block。如下图所示

15_redo_log_buffer.png

因为 log buffer 缓冲区是被一个一个block所划分的,因此在写入一条条的 redo 日志时,也是按照顺序写入的,即将一个 block 写满之后,再去下一个 block 写,为了能够找到写入的位置,设计了一个 buf_free 指针。该指针指向的位置就是下一条日志写入的位置。日志在写入时,是按照一组一组写入的,即是按照一个不可分割的组写入,并不是产生一条就写一条日志。

假如有AB两个事务,A事务由两个mtr,B事务有两个mtr则,redo日志写入顺序就有可能如下所示:

16_mtr_log_写入顺序.png

redo 日志的刷盘时机

redo 日志的刷盘时机就和我们何时清理手机里的"学习资料"一样,当新的学习资料来临时没有其他地方存储时,自然而然的我们就会去清理手机的存储空间,删除一些不必要的文件,毕竟学习资料来之不易,留着学习不香吗?

当然 redo 日志的刷盘时机不会单单的只有这种情况,在很多时候他都会刷盘

  • log buffer 内存不足时
  • 事务提交时
  • 正常关闭服务器时
  • checkpoint
  • 后台线程持续不停的帮我刷刷刷...
  • binlog...

刷盘的时机有很多,了解一下就好了,

我们明白了一条redo记录是什么样的?再然后搞明白了 redo 是如何逻辑分组的即一个mtr,再然后我们知道了一组一组日志是存在哪里的即 redo log block,这些都是在内存中的都还没有刷到磁盘,如果断电就没了,显然下一步我们关注的就应该是是 MySQL 如何将内存中的 redo 日志刷到磁盘上了!

redo 日志文件组

首先要明白这个存储 "redo文件" 在哪里,即"活要见人死要见尸",因此不管如何先要找到这个存储 redo 记录的文件。具体办法就是通过SHOW VARIABLES LIKE 'datadir'; 查看MySQL的数据目录,该目录下含有ib_logfile0ib_logfile1两个文件,这是默认的两个存储 redo 日志的文件,当然我们可以通过如下命令去调整:

  • innodb_log_group_home_dir:默认的数据目录,可通过此参数设置数据目录路径
  • innodb_log_file_sizelogfile文件大小,logfile文件会在记录日志的时候循环使用。(因此存在追尾覆盖的情况)
  • innodb_log_files_in_grouplogfile文件个数,默认是2,最大值100。

找到了这个文件在哪,接下来就看看这个文件到底是咋样的,是骡子是马,拉出来遛遛!

17_logfile文件格式.png

前四个块各占 512 字节,checkpoint1checkpoint2 结构我一样,第三个 block 没有使用。

header部分各属性含义如下表:主要描述 redo 日志文件的一些整体属性。

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

checkpoint部分各属性如下表:MySQL 做 checkpoint 时,会修改文件 checkpoint 部分的一些信息。

属性名 长度(单位:字节) 描述
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都有

各种各样的LSN

明白了记录 redo 日志的磁盘文件格式,接下来就要看看如何将内存中的文件以何种方式刷入磁盘了?你可能会说是通过IO操作啊你个笨蛋,这还需要想?如果是这样的话我就要问你茴香豆蔻的茴有几种写法了!!就像我们炒菜时,把菜炒好了,装菜的盘子也准备好了,那么该以什么顺序装这就是个学问了。你可以先把盘子端上去,然后把锅端过去当着客人面上菜,证明你是刚出锅的菜(新鲜的鸡汤来咯.jpg)!也可以先把菜装好到盘子里顺便摆个盘!

还记得前文提到的buf_free吗,其作用就是让 redo 日志正确的刷入到 log buffer 的 block 中,类似的,MySQL 为了能够统计已经写入多少 redo 日志,设置了全局的 Log Sequence Number(LSN) 来记录,毕竟不能够出现吃了一碗粉你却让我给两份钱的情况。LSN 默认值为 8704。每写入一次 redo 日志其增量就是 redo 日志的大小!如下图:

18_first_lsn.png

此时写入 100 bytes 的 redo 日志,如下图:

19_lsn_100bytes.png

这是在一个页面能够装完的情况下的增长变化,在此基础上,在加 600 byte的日志,如下图

20_lsn_600bytes.png

注意 LSN 值的变化,多加了 4 + 12 byte 的块信息!!

现在这一切都还是在内存里面!你可能会说就这就这?说了半天就为了引入LSN?也太啰嗦了吧!既然你这么心急那我就亮出下一个!!下一个LSN即Flushed_to_disk_lsn,见名知意,这个变量就是负责记录记录有多少 block 中的日志刷到logfile中的!除此之外还有一个buf_next_to_write,见下图:

21_flushed_to_disk_lsn.png

如图所示那样,flushed_to_disk_lsn 值也是从 8704 开始的。也是需要加入 header 和 trailer的哟!buf_next_to_write则表示下一次从哪里开始刷盘!此时对应的 logfile 文件如下图:假定这是第一次刷盘!

22_对应的logfile文件.png

除此之外,我们知道 redo 日志代表我们记录的操作,记录的操作有可能是会更改数据的,而我们的数据是以缓存页存在于 buffer pool 中的。在 buffer pool 中,存在LRU链表 Free链表以及一个Flush链表。当我们修改一个页面的数据时,是会把页面对应的控制块加入到 Flush 链表的,即在一次 mtr (代表一次对底层页面的访问)结束之后,修改 flush 链表!

23_flush_lsn.png

当第一次修改某个缓存在Buffer Pool中的页面时,就会把这个页面对应的控制块插入到flush链表的头部,之后再修改该页面时由于它已经在flush链表中,就不再次插入了。即flush链表中的脏页是按照页面的第一次修改时间从大到小(head到tail)进行排序的。在这个过程中会在缓存页对应的控制块中记录两个关于页面何时修改的属性:

  • oldest_modification:如果某个页面被加载到Buffer Pool后进行第一次修改,那么就将修改该页面的mtr开始时对应的lsn值写入这个属性。
  • newest_modification:每修改一次页面,都会将修改该页面的mtr结束时对应的lsn值写入这个属性。也就是说该属性表示页面最近一次修改后对应的系统lsn值。

即前者的值相当于是写入前buf_free所指向的地方的LSN,后者的值为写完之后buf_free的指向的地方的LSN,因此整个链表是按照oldest_modification的值从大到小从head到tail排序的,越靠近尾部的值越小,代表修改的越早,越靠近头部的越大代表修改的时间距离当前最近。

checkpoint

随着不断的写写写写,logfile的容量就会逐渐枯竭!当没有容量了怎么办?

回到 redo 日志的初衷,初衷就是为了保证事务的持久性。因此当我们把 flush 链表或者 LRU 链表中的脏页都刷到磁盘了,换句话说我们的改动已经修改到磁盘上去了,我们还有必要保留这一部分的redo文件吗?那当然是没必要了!因此我们的 logfile 文件实际上是循环使用的。如下图所示:

24_logfile文件组.png

由于我们可以循环覆盖这个文件,最需要解决的就是如何确定哪些日志是可以覆盖的?

我们当前已知的有 redo 日志写入时维护的变量即各种 LSN 的值。那么该如何计算出可以覆盖的位置呢?

回到问题的起点:日志可以被覆盖则证明对应的脏页已经被刷到磁盘,而 flush 链表中的节点是有顺序的!按照修改顺序排列的。

其次把日志刷到磁盘时,我们使用了 flush_to_disk_lsn 来记录,那么如果有一个相应的 LSN 来记录我把对应脏页刷到磁盘,则小于该值对应的 redo 日志是不是就可以覆盖了?答案确实如此,MySQL 提供了一个 checkpoint_lsn 来表示可以被覆盖的日志总量,该值的起始值也是8704。除此之外还维护了一个checkpoint_no 用来记录一共做了多少次checkpoint

具体步骤:

  • 计算出当前系统中被最早修改的脏页对应的oldest_modification值,那凡是在系统lsn值小于该节点的oldest_modification值时产生的redo日志都是可以被覆盖掉的,我们就把该脏页的oldest_modification赋值给checkpoint_lsn
  • checkpoint_lsn和对应的redo日志文件组偏移量以及此次checkpint的编号写到日志文件的管理信息(checkpoint1checkpoint2)中。设计InnoDB的大佬规定,当checkpoint_no的值是偶数时,就写到checkpoint1中,是奇数时,就写到checkpoint2中。

注意:虽然每一个 logfile 都有管理信息,但上述关于checkpoint的信息只会被写到日志文件组的第一个日志文件的管理信息中。

奔溃恢复

redo 日志的作用就是为了预防系统崩溃时,运行中的脏页没有刷到磁盘,因此如果系统奔溃了该如何恢复到崩溃前的状态呢?

确定恢复的起点:redo日志文件组的第一个文件的管理信息中有两个block都存储了checkpoint_lsn的信息,我们当然是要选取最近发生的那次checkpoint的信息。只要把checkpoint1checkpoint2这两个block中的checkpoint_no值读出来比一下大小,谁大就表明谁是最近存储了 checkpoint 的相关信息的。

确定恢复的终点: 这就需要回到存储 redo log 的 block来确定了。在log block header中有一个记录 block 使用了多少字节的属性即LOG_BLOCK_HDR_DATA_LEN,对于被填满的block来说,该值永远为512。如果该属性的值不为512,则说明了这个 block 没有被填满换言之。这就是 redo log 的终点。

恢复的方式: 你可能会说,起点确定了,终点也确定了,我们把起点到终点的 redo 日志记录的内容依次执行一遍不就可以了吗?确实可以!但是效率不高。我们有更好的方式,回到 redo log 的通用格式,如下图:

25_行格式额外信息.png

我们知道 space id 代表表空间id 是唯一的,我们可以通过该属性确定是哪个表,而 Page Number 代表表空间中的页,那么我们就可以通过 space id + page number 定位对一个表中的某一个页,那么我们把相同的space id + page number 哈希化,这样就可以快速的完成对一个页面的修改操作!只要我们保证恢复的顺序是按照redo日志生成的时间顺序就可!

注意:在最近的一次 checkpoint 之后,有可能flush链表中的一些脏页已经刷到磁盘了,毕竟后台线程一直在哪里刷刷刷。如何跳过这部分已经刷盘的页面,这就需要提到在数据页的 FileHeader, File Header里有一个称之为FIL_PAGE_LSN的属性,该属性记载了最近一次修改页面时对应的lsn值(其实就是页面控制块中的newest_modification值)。如果在做了某次checkpoint之后有脏页被刷新到磁盘中,那么该页对应的FIL_PAGE_LSN代表的lsn值肯定大于checkpoint_lsn的值,因此就不用恢复了!你可能会想到如果这个页的刷到一半出现故障了怎么办?那我就要搬出这篇文章让你好好看数据页的 file header 和 file trailer 这两部分的结构属性了。

到此我们搞明白了事务的持久性是如何保证的了,持久性是依赖于 redo 日志正常记录的情况下,那么如果 redo 日志还没来的急刷盘就出现异常了?那么我们该如何应对?

Undo 日志

Redo 日志保证了事务的持久性,而 Undo 日志保证了事务的原子性,这是 Undo 日志的作用之一!在弄明白 Undo 日志之前,首先要考虑一下为什么需要 Undo 日志。

我们在上面提到了如果 redo 日志还没来得急刷盘就出现异常了,我们该如何恢复数据?这是一种情况,另一种情况是在事务执行的过程中,如果我们手动 rollback 事务,那么我们该如何恢复之前软件的运行状态?

在一个事务中,我们知道一组操作要么同时成功,要么同时失败,不可能存在中间状态。正是由于上面这几种情况,Undo 日志的作用才得以体现。保证事务的原子性!再说一遍这只是作用之一,另一个比较重要的作用——MVCC,在我们介绍完之后他是如何保证事务的原子性之后,将介绍这个!

我们知道在插入一条记录时,数据库会给这条数据添加额外的信息如下图所示:

26_insert_undolog.png

我们这里需要关注一下事务ID这一数据。

trx_id:就是指的把这条数据插入数据库时的事务的Id,即代表的是执行插入语句的事务的id。

roll_point:本质上就是一个指针,指向记录对应的 undo log

事务Id的分配时机

事务可分为读写事务或者只读事务,我们可以通过如下方式开启事务:

  • 通过START TRANSACTION READ ONLY语句开启一个只读事务。

      在只读事务中不可以对普通的表(其他事务也能访问到的表)进行增、删、改操作,但可以对临时表做增、删、改操作。

  • 通过START TRANSACTION READ WRITE语句开启一个读写事务,或者使用BEGINSTART TRANSACTION语句开启的事务默认也算是读写事务。

      在读写事务中可以对表执行增删改查操作。

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

  • 对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、改操作时才会为这个事务分配一个事务id,否则的话是不分配事务id的。
  • 对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执行增、删、改操作时才会为这个事务分配一个事务id,否则的话也是不分配事务id的。

注意:有的时候虽然我们开启了一个读写事务,但是在这个事务中全是查询语句,并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个事务id

总的来说只有一个事务对表(包括临时表)进行增删改时,才会给事务分配一个id。

Undo 日志的格式

在上面提到的分配事务id的时机时,包含增删改这三个操作,这三种类型的名称分别为:

  • TRX_UNDO_INSERT_REC:代表新增操作的undolog
  • TRX_UNDO_DEL_MARK_REC:代表删除操作的undolog
  • TRX_UNDO_UPD_EXIST_REC:代表更新操作的undolog

我们看了这三种类型的日志结构示意图:接下来就要看看这几种日志生成的过程!

Insert undo log

Insert 操作对应的undo log 比较简单(相对于删除和更新而言)。

首先我们就来看看记录 insert 操作的 undo 日志。

这就是 insert 操作对应的 undo log日志类型。其中undo no在一个事务中从0开始递增。主键各列信息记录了形成主键的各个列的实际空间大小和真实值,这样在回滚操作时,只需要根据主键从聚簇索引和二级索引中把对应的记录删掉即可。

Delelte undo log

在上文中提到了 roll_point 指针,在这里我们就会看到它的实际用处!我们知道 roll_point 指向了一条 undo log,而 undo log 记录实际上放在了 FIL_PAGE_UNDO_LOG的页面中,可用如下的图来表示:

29_rollepoint指针示意图.png

从这个简图中我们可以知道通过roll_point可以找到该条记录对应的undo log。而 delete 操作对应的日志如下图所示:

27_delete_uodolog.png

在这幅图里面我们看到了一个old_roll_pointer,这个指针指向了之前的undo log。也就是说我们可以通过当前记录的roll_pointer 通过不断的old_roll_pointer遍历出来当前记录所有的undo log!!!这也就是版本链!后面会详细讲讲。先用简图示意一下:

30_mvcc版本链.png

接下来我们看一下 MySQL 的删除过程!

删除分为两个阶段:

  • delete_mask:仅仅将记录的delete_mask标识位设置为 1 。
  • purge:当该删除语句所在的事务提交之后,会有专门的线程后来真正的把记录删除掉。

第一阶段进行 delete_mask 后,此时记录还处于正常的链表,

  • 在对一条记录进行delete mark操作前,会把该记录的旧的trx_idroll_pointer隐藏列的值都给记到对应的undo日志中来,即上面图中显示的old trx_idold roll_pointer属性。这样有一个好处,那就是可以通过undo日志old roll_pointer找到记录在修改之前对应的undo日志。

31_delete_mask.png

第二阶段:purge阶段,该阶段会把记录从正常链表中移动到删除链表的头部!删除过程会维护一些统计信息的变化!

32_delete_purge.png

Update undo log

最后是 update 操作对应的Undo log图如下所示,和delete操作的日志类似,多了一个更新前的信息。

28_update_uodolog.png

update操作的情况可分为以下几种:

  • 更新主键

  • 没有更新主键

    • 就地更新
    • 先删除,在插入新纪录

更新主键:

先将记录进行 delete_mask 操作,即在UPDATE语句所在的事务提交前,对旧记录只做一个delete mark操作,在事务提交后才由专门的线程做 purge 操作,把它加入到垃圾链表中。在根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中(需重新定位插入的位置)。因为更新后的记录主键值发生了改变,所以需要重新从聚簇索引中定位这条记录所在的位置,然后把它插进去。这种情况会生成两条undo日志:一个是对应的删除操作,一个是对应的插入操作!

不更新主键—就地更新: 在更新记录时,对于被更新的每个列来说,如果更新后的列和更新前的列占用的存储空间都一样大,就可原地更新!

不更新主键—先删除再插入: 在不更新主键的情况下,如果有任何一个被更新的列更新前和更新后占用的存储空间大小不一致,那么就需要先把这条旧的记录从聚簇索引页面中删除掉,再根据更新后列的值创建一条新的记录插入到页面中。这里的删除不是说得 delete_mask,而是真正的删除即从正常记录链表移动到删除链表并维护相关的统计信息!

Undo 日志的存储

我们在上文了解了增删改对应的undo 日志,接下来就要考虑一个问题,这些日志存在哪里?通存储用户记录的方式一样,如果我们把undo log 看着一条条的记录,我们就可以把这些 undo 记录放在一个页面中,这个页面类型就是FIL_PAGE_UNDO_LOG,其结构如下图所示

33_undo_page.png

就如图中所示一样,File HeaderFile trailer 就不做介绍了,我们就介绍以下 undo page header这一结构,该结构占据18个字节

  • trx_undo_page_type:指的是这个页面存储的 undo log 类型。占据两个字节,undo log 分为两大类,第一类是TRX_UNDO_INSERT,前文介绍的insert操作对应的undolog就属于这一类,另一类是TRX_UNDO_UPDATE,不属于第一类的就是属于这一类的如前文介绍的删除操作和更新操作对应的undo log
  • TRX_UNDO_PAGE_STARTTRX_UNDO_PAGE_FREE:二者相互呼应,一个表示 undo log开始的位置,一个表示结束的位置,各占据2
  • trx_undo_page_node:表示一个 List Node 结构,占据 12 个字节

一个list node 结构如下图:

34_list_node.png

为了快速定位由List Node 形成的链表,提供了一个List Base Node结构帮助我们快速定位!其结构如下图:

35_list_base_node.png

其中List Length 代表有多少个节点,其他的就不做介绍了!

假定存储了3条redo 日志,其结构大概就如下图所示:

36_undo日志存储.png

Undo 页面链表

同存储数据的数据页一样,存储 undo log 的日志也会形成双向链表,只不过是存储同一种类型的才会在一个链表里。

在一个事务里,可以会对数据库存在增删改等操作,且可能存在很多的操作语句,这些操作形成的 undo log都需要得到记录,因此为了更好的管理和复用 undo 页面,通常在一个事务中会形成如下链表。

  • Insert undo 链表:存储 insert 操作类型的 undo 页面形成的链表。
  • update undo 链表:存储 update 操作类型的 undo 页面的链表。

如果事务中还对临时表有操作,相应的也会形成这两个链表即如果一个事务对普通的表以及临时表都有进行相应的操作,则分别会形成记录普通表的undo日志的insert 和 update 链表,记录临时表undo日志的2种链表!

注:这两个链表是按需生成,不是一开启就会生成的,而是当我们进行相应操作的才会生成的,如当事务执行过程中向普通表中插入记录或者执行更新记录主键的操作之后,就会为其分配一个普通表的insert undo链表

注:为了提高写入的速度,不同事务执行过程中产生的undo日志需要被写入到不同的Undo页面链表中。即每个事务会自己生成相应的链表,不存在不同的事务写在同一个页面上的情况。

37_undo页面链表.png

链表的第一个页面称之为First_Page_Undo_Log页。

Undo 日志的写入过程

在【MySQL 01】我用40张图总结了MySQL是如何组织数据的!中,我们介绍了段相关的知识,同样的,为了更好的管理undo页面,设计InnoDB的大佬规定,每一个Undo页面链表都对应着一个,称之为Undo Log Segment。链表中用到的页面都是从这个段里边申请的,为了更好的表示当前这个链表处于何种状态。他们在Undo页面链表的第一个页面。也就是上面提到的first undo page中设计了一个称之为Undo Log Segment Header的部分。

38_first_undo_page.png

undo log segment header占据30个字节,其结构如下所示:

39_undo_log_seg_header.png

  • TRX_UNDO_STATE:本Undo页面链表处于何种状态。取值有如下:
  • TRX_UNDO_ACTIVE:活跃状态,也就是一个活跃的事务正在往这个段里边写入undo日志
  • TRX_UNDO_CACHED:被缓存的状态。处在该状态的Undo页面链表等待着之后被其他事务重用。
  • TRX_UNDO_TO_FREE:对于insert undo链表来说,如果在它对应的事务提交之后,该链表不能被重用,那么就会处于这种状态。
  • TRX_UNDO_TO_PURGE:对于update undo链表来说,如果在它对应的事务提交之后,该链表不能被重用,那么就会处于这种状态。
  • TRX_UNDO_PREPARED:包含处于PREPARE阶段的事务产生的undo日志
  • TRX_UNDO_LAST_LOG:本Undo页面链表中最后一个Undo Log Header的位置。
  • TRX_UNDO_FSEG_HEADER:本Undo页面链表对应的段的Segment Header信息(就是我们上一节介绍的那个10字节结构,通过这个信息可以找到该段对应的INODE Entry)。
  • TRX_UNDO_PAGE_LISTUndo页面链表的基节点。

  我们上面说Undo页面Undo Page Header部分有一个12字节大小的TRX_UNDO_PAGE_NODE属性,这个属性代表一个List Node结构。每一个Undo页面都包含Undo Page Header结构,这些页面就可以通过这个属性连成一个链表。这个TRX_UNDO_PAGE_LIST属性代表着这个链表的基节点,当然这个基节点只存在于Undo页面链表的第一个页面,也就是first undo page中。

上面的所有行为都是为了管理由undo页面形成的链表。为了更好的管理这个页面中的内容,如生成当前事务的id啊,上一组undo日志或者下一组undo日志开始的偏移量等数据时,设计了一个186字节的数据结构Undo Log Header , Undo log Header 结构如下图所示:

40_undo_log_header.png

具体的每一项就不做介绍了,需要的请参考书籍《从根上理解MySQL》。在这里我们只需要知道有这么个东西,以及他的作用是干什么的就可以了!就稍微注意一下最后的Trx_undo_history_node结构,代表一个History List Node 。

Undo 页面的重用

在介绍 Undo 页面链表时,提到了设计InnoDB的大佬决定为每个事务单独分配相应的Undo页面链表(最多可能单独分配4个链表),这样能够有效的提高事务的并发效率。但是这种分配方式也存在资源空间浪费的问题,比如一个事务进行了很少的操作,只生成了几条 undo 日志(虽然此链表只有一个页面)。如果不对此 undo 页面链表进行复用的话,则就造成了资源空间的浪费。因此在如下几种场景情况下,就会复用。

  • 该链表中只包含一个Undo页面

  • Undo页面已经使用的空间小于整个页面空间的3/4。

    • insert undo链表中只存储类型为TRX_UNDO_INSERT_REC的 undo 日志,这种日志在事务提交之后就没用了,可以直接把之前事务写入的一组undo日志覆盖掉,从头写入(该链表只有一个页面的情况下)。
    • update undo 链表:在一个事务提交后,update undo链表中的undo日志不能立即删除,在实现MVCC会用。因此就不能覆盖掉。

insert 覆盖如下所示

41_insert覆盖.png

update 复用如下所示:

42_update覆盖.png

回滚段

为了保证事务的原子性,我们学习undo日志,为了更好的管理 undo 日志,我们把日志放入了undo页面,然后undo页面之间形成了undo 页面链表,这样就能找到当前页面的下一个页面,且在该链表中第一个页面维护了这些链表节点的一些信息,那么我们管理这些链表头节点?这就是回滚段(Rollback Segment Header)的作用,先看其结构:

43_回滚段结构.png

  • TRX_RSEG_MAX_SIZE:本Rollback Segment中管理的所有Undo页面链表中的Undo页面数量之和的最大值。换句话说,本Rollback Segment中所有Undo页面链表中的Undo页面数量之和不能超过TRX_RSEG_MAX_SIZE代表的值。
  • TRX_RSEG_HISTORY_SIZEHistory链表占用的页面数量。
  • TRX_RSEG_HISTORYHistory链表的基节点。
  • TRX_RSEG_HISTORY_SIZEHistory链表占用的页面数量。
  • TRX_RSEG_HISTORYHistory链表的基节点。
  • TRX_RSEG_UNDO_SLOTS:各个Undo页面链表的first undo page页号集合,也就是undo slot集合。

Undo_SLOTS的分配:

初始情况下:于未向任何事务分配任何Undo页面链表,所以对于一个Rollback Segment Header页面来说,它的各个undo slot都被设置成了一个特殊的值:FIL_NULL(对应的十六进制就是0xFFFFFFFF),表示该undo slot不指向任何页面。

  • 如果是FIL_NULL,那么在表空间中新创建一个段(也就是Undo Log Segment),然后从段里申请一个页面作为Undo页面链表的first undo page,然后把该undo slot的值设置为刚刚申请的这个页面的地址,这样也就意味着这个undo slot被分配给了这个事务。
  • 如果不是FIL_NULL,说明该undo slot已经指向了一个undo链表,也就是说这个undo slot已经被别的事务占用了,那就跳到下一个undo slot,判断该undo slot的值是不是FIL_NULL,重复上面的步骤。

事务提交之后,UNDO_SLOTS的变化:

  • 如果该undo slot指向的Undo页面链表符合被重用的条件(Undo页面`链表只占用一个页面并且已使用空间小于整个页面的3/4)

    undo slot就处于被缓存的状态,设计InnoDB的大佬规定这时该Undo页面链表的TRX_UNDO_STATE属性(该属性在first undo pageUndo Log Segment Header部分)会被设置为TRX_UNDO_CACHED

      被缓存的undo slot都会被加入到一个链表,根据对应的Undo页面链表的类型不同,也会被加入到不同的链表:

    • 如果对应的Undo页面链表是insert undo链表,则该undo slot会被加入insert undo cached链表

    • 如果对应的Undo页面链表是update undo链表,则该undo slot会被加入update undo cached链表

      因此一个回滚段就对应着上述两个cached链表,如果有新事务要分配undo slot时,先从对应的cached链表中找。如果没有被缓存的undo slot,才会到回滚段的Rollback Segment Header页面中再去找。

如果该undo slot指向的Undo页面链表不符合被重用的条件,那么针对该undo slot对应的Undo页面链表类型不同,也会有不同的处理:

  • 如果对应的Undo页面链表是insert undo链表,则该Undo页面链表的TRX_UNDO_STATE属性会被设置为TRX_UNDO_TO_FREE,之后该Undo页面链表对应的段会被释放掉(也就意味着段中的页面可以被挪作他用),然后把该undo slot的值设置为FIL_NULL
  • 如果对应的Undo页面链表是update undo链表,则该Undo页面链表的TRX_UNDO_STATE属性会被设置为TRX_UNDO_TO_PRUGE,则会将该undo slot的值设置为FIL_NULL,然后将本次事务写入的一组undo日志放到所谓的History链表中(需要注意的是,这里并不会将Undo页面链表对应的段给释放掉)。

多个回滚段

前在面我们说一个事务执行过程中最多分配4Undo页面链表,而一个回滚段里只有1024(4096 / 4 = 1024)个undo slot。我们假设一个读写事务执行过程中只分配1Undo页面链表,那1024undo slot也只能支持1024个读写事务同时执行。因此InnoDB的大佬一口气定义了128个回滚段,也就相当于有了128 × 1024 = 131072undo slot

每个回滚段都对应着一个Rollback Segment Header页面,有128个回滚段,自然就要有128个Rollback Segment Header页面,这些页面的地址总得找个地方存一下吧!于是设计InnoDB的大佬在系统表空间的第5号页面的某个区域包含了128个8字节大小的格子:

44_回滚段.png

  • 4字节大小的Space ID,代表一个表空间的ID。
  • 4字节大小的Page number,代表一个页号。

128个回滚段分为两类

  • 0号、第33~127号回滚段属于一类。其中第0号回滚段必须在系统表空间中(就是说第0号回滚段对应的Rollback Segment Header页面必须在系统表空间中),第33~127号回滚段既可以在系统表空间中,也可以在自己配置的undo表空间中。如果一个事务在执行过程中由于对普通表的记录做了改动需要分配Undo页面链表时,必须从这一类的段中分配相应的undo slot
  • 1~32号回滚段属于一类。这些回滚段必须在临时表空间(对应着数据目录中的ibtmp1文件)中。如果一个事务在执行过程中由于对临时表的记录做了改动需要分配Undo页面链表时,必须从这一类的段中分配相应的undo slot

接下来用一张图总结一下 undo 日志相关的概念:

45_总结.png

至此我们就弄明白了 redo 日志和 undo 日志是如何保证事务的持久性和原子性的,更多细节请参考图书《从根上理解MySQL》

参考资料:

  • 《从根上理解MySQL》

相关文章

Oracle如何使用授予和撤销权限的语法和示例
Awesome Project: 探索 MatrixOrigin 云原生分布式数据库
下载丨66页PDF,云和恩墨技术通讯(2024年7月刊)
社区版oceanbase安装
Oracle 导出CSV工具-sqluldr2
ETL数据集成丨快速将MySQL数据迁移至Doris数据库

发布评论