侧边栏壁纸
  • 累计撰写 244 篇文章
  • 累计创建 16 个标签
  • 累计收到 0 条评论
隐藏侧边栏

通过 undo 日志保证数据库事务的原子性

kaixindeken
2021-07-22 / 0 评论 / 0 点赞 / 73 阅读 / 5,714 字

通过 redo 日志保证数据库事务的持久性,简而言之,就是在数据库事务提交后,将对应修改记录持久化到 redo 日志,从而确保即使数据库服务器崩溃,重启后也能恢复相应的未持久化到磁盘的数据库记录。

undo 日志简介

在正式介绍 undo 日志之前,我们先简单回顾下数据库事务的原子性,所谓原子性是指通过数据库事务包裹的操作序列要么都执行成功,要么都执行失败,不允许部分执行成功,部分执行失败。如果执行到一半出现异常,需要通过 ROLLBACK 语句回滚前面已经执行的操作。

这里提到的执行都是针对数据库更新,数据库查询本身是不会对数据库产生任何副作用的。

以上基本知识只要你对数据库事务稍有了解,想必都是耳熟能详,而我们今天要探讨的问题就是 MySQL 底层是如何实现这种回滚的。

聪明如你,想必已经猜到是通过 undo 日志实现的。没错,MySQL 底层为了能够回滚事务里面的数据库更新操作,会将与之相对的撤销操作记录到 undo 日志里面,undo 本身就是撤销的意思。需要注意的是,由于查询操作对数据库没有副作用,无需回滚,所以 undo 日志只会记录针对更新操作的撤销。然后当我们执行 ROLLBACK 回滚时,就可以根据这些 undo 日志将对应的变更撤销(改回原来的状态)。

一般而言,每条 SQL 更新语句都会对应一个与之相对的 undo 日志来撤销更新(对于 UPDATE 语句可能是两条,下面我们会深入介绍),因此,一个事务会有多条 undo 日志。

那 undo 日志又是怎么将撤销记录与某个事务对应起来的呢?我们接着往下看。

undo 日志写入

原来,当我们开启一个事务后,执行到第一条增、删、改更新语句时,MySQL 就会为其分配一个全局唯一的自增事务 ID(如果一个事务里都是查询语句,意味着不会分配事务 ID,当然,这样的事务也没有啥意义)。在同一个事务中,所有记录的事务 ID 都是一样的,这样我们就可以通过事务 ID 将同一个事务的所有 undo 日志记录串联起来了。

这个事务 ID 同时也会被写入聚簇索引记录的 trx_id 字段中,这样数据表记录也和事务 ID 建立起了关联。

那么 undo 日志的数据结构是什么样的,MySQL 底层又是如何写入 undo 日志的呢?

undo 日志不同于 binlog 和 redo 日志,不是存放在独立的磁盘文件的,而是存放在数据库表空间的 FIL_PAGE_UNDO_LOG 页面中,这些 undo 日志会按照生成顺序分配编号,然后按照更新语句的类型存放到不同位置不同内存结构中:

INSERT 语句

对于 INSERT 语句来说,对应的回滚操作最简单,将该记录删除就好了。对应的 undo 日志数据结构如下(左侧):

1.jpeg

从上到下,依次是:

  • next:下一条 undo 日志开始的页面地址;
  • type:本条 undo 日志的类型,对于 INSERT 语句恶言,是 TRX_UNDO_INSERT_REC;
  • undo no:本条 undo 日志的编号;
  • table id:本条 undo 日志对应记录所在的数据表 ID(每张数据表会分配一个唯一的 table id);
  • Primary Key:对应记录的主键 ID,可能包含多个列,因此这里有一个箭头指向详细的主键信息(len 表示长度,col 表示列名);
  • start:表示本条 undo 日志开始的页面地址。

在聚簇索引记录中,还有一个 roll_pointer 指针指向与之对应的 undo 日志。

DELETE 语句

DELETE 操作的底层实现实际上是分两个阶段进行的:

  • delete mark:将要删除的记录进行 delete_mark 标记为 1;
  • purge:事务提交之后,通过专门的线程将这些待删除记录真正删除掉。
    可以看到,在删除语句所在的事务提交之前,只会经历 delete mark 阶段,所以只需要对这个阶段做回滚即可。删除操作的 redo 日志格式如下:

1.jpeg

和 insert undo log 相同的字段就不多做介绍了,注意对于删除操作而言,对应 undo 日志类型是 TRX_UNDO_DEL_MARK_REC,由于要恢复到旧的记录状态,所以这里提供了旧的事务 ID old trx_id 和旧的 roll_pointer 指针,指向旧的 undo log。除此之外,这里还提供了除主键外的二级索引信息,对于 insert 操作而言,只需要提供主键即可删除簇拥索引和二级索引,对于 delete 操作而言,则需要提供所有的索引信息以便恢复。

UPDATE 语句

UPDATE 语句比 DELETE 语句还要复杂,需要根据是否更新主键区别对待。

不更新主键

对于不更新主键的情况,又可以进步细分以下两种方案:

  • 如果更新前后存储空间没有任何变化,则使用原地更新。
  • 否则要先删除旧的记录,再插入新的记录,这里的删除也不同于上面介绍的两阶段删除,而是真正删除。

不过这两种方案对应的 undo 日志数据格式是一样的:

1.jpeg

这里的 undo 日志类型是 TRX_UNDO_UPD_EXIST_REC,由于要恢复到更新前的状态,所以结构和删除日志类似,多了一个 n_updated 字段表示多少条记录被更新,以及被更新前的列信息。

更新主键

对于更新主键的情况,会通过先删除后插入的方式记录两条 undo 日志:

将旧记录进行 delete mark 操作:这里又不同于上面的删除,是 delete mark 操作,因为别的事务可能也在访问这条记录,不能真正删除它;
根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中。
所以这两个 undo 日志的类型分别是 TRX_UNDO_DEL_MARK_REC 和 TRX_UNDO_INSERT_REC。

undo 页面链表

TRX_UNDO_PAGE_TYPE

我们前面提到 undo 日志都是存储在 FIL_PAGE_UNDO_LOG 页中的(为了方便介绍,姑且将之简称为 undo 页面),每条 undo 日志又按照 SQL 语句类型分别存储在上面列举的数据结构中,此外,undo 页的 Page Header 中还包含了一个 TRX_UNDO_PAGE_TYPE 字段,表明该数据页中存储 undo 日志的类型,所有的 TRX_UNDO_INSERT_REC 类型 undo 日志都会记录到 TRX_UNDO_PAGE_TYPE 值为 TRX_UNDO_INSERT 的 undo 页面中,而其他类型的 undo 日志则会记录到 TRX_UNDO_PAGE_TYPE 值为 TRX_UNDO_UPDATE 的 undo 页面中,这么分类的依据是更新和删除撤销需要考虑 MVVC,而插入则不需要。

单个事务中的 undo 页面链表

一个事务可能包含多条 SQL 更新语句,一条 SQL 更新语句可能对若干条记录进行改动,而对每条记录进行改动前,都需要记录 1~2 条 undo 日志,所以在一个事务执行过程中可能产生很多 undo 日志,这些日志可能一个 undo 页面放不下(MySQL 数据页大小默认都是 16 KB),需要放到多个页面中,这些页面最终通过 undo 页 Undo Page Header 中的 TRX_UNDO_PAGE_NODE 属性连成了链表,这个页面链表中的所有 undo 日志都归属于同一个事务 ID。

另外,根据上面介绍的 TRX_UNDO_PAGE_TYPE,如果一个事务中混合着插入、更新、删除语句,则该事务会生成两个 undo 页面链表,一个是 TRX_UNDO_PAGE_TYPE 值为 TRX_UNDO_INSERT 的 insert undo 页面链表,一个是 TRX_UNDO_PAGE_TYPE 值为 TRX_UNDO_UPDATE 的 update undo 页面链表。

多个事务中的 undo 页面链表

为了提高 undo 日志的写入效率,MySQL 底层会将不同事务产生的 undo 日志写入到不同的 undo 页面链表中。

first undo 页面

既然是页面链表,就有第一个页面,我们将 undo 页面链表的第一个页面称为 first undo 页面,将其他页面称之为 normal undo 页面,first undo 页面和 normal undo 页面的区别是多了两个 Header 管理区块:

  • Undo Log Segment Header:其中存放了本 undo 页面链表状态(活跃、缓存、是否可重用)、页面链表中最后一个 Undo Log Header 的位置、页面链表基节点等信息;
  • Undo Log Header:本组 undo 日志事务 ID、第一条 undo 日志的偏移量、是否包含 delete mark 标记日志等。

介绍到这里,想必你的脑海中已经对 undo 日志底层存储的数据结构有了初步的轮廓,我们画个图进行演示,就是下面这样:

1.jpeg

不过,到目前为止,我们还没有涉及到数据库事务回滚是怎么做的,接下来就要开始介绍了。

如何基于 undo 日志回滚数据库操作

回滚段

通过上面的介绍,我们知道每个数据库事务会创建多个 undo 页面链表,系统中同时可能存在多个数据库事务,所以系统中也会同时存在非常多的 undo 页面链表,为了管理这些页面链表,MySQL 底层提供了一个名为 Rollback Segment Header 的页面,在这个页面中存放了各个 undo 页面链表的 frist undo 页面页号,并且把这些页号称之为 undo slot。这样一来,我们就可以通过 Rollback Segment Header 页面找到系统中的所有 undo 页面链表。

与此同时,MySQL 规定每个 Rollback Segment Header 页面都对应着一个段,这个段就称为 Rollback Segment,中文译作回滚段,与其他段不同的是,回滚段中只有一个 Rollback Segment Header 页面。

通过回滚段申请 undo 页面链表

在数据库系统初始状态下,没有向任何事务分配 undo 页面链表,因此,此时 Rollback Segment Header 中存放的 undo slot 不指向任何页面。当开始有事务需要分配 undo 页面链表时,就从回滚段的第一个 undo slot 开始,看看该 undo slot 的值是不是 FIL_NULL(默认值):

  • 如果是 FIL_NULL,那么在表空间中新创建一个段(也就是 Undo Log Segment),然后从段里申请一个页面作为 undo 页面链表的 first undo 页面,然后把该 undo slot 的值设置为刚刚申请的这个页面的页号,这样也就意味着这个 undo slot 被分配给了这个事务。
  • 如果不是 FIL_NULL,说明该 undo slot 已经指向了一个 undo 页面链表,也就是说这个 undo slot 已经被别的事务占用了,那就跳到下一个 undo slot,判断该 undo slot 的值是不是 FIL_NULL,重复上面的步骤。
    一个回滚段支持的 undo slot 数量是有限的,默认最多是 1024 个,如果都被占用,则新事务将因无法获取新的 undo 页面链表而报错,事务本身也会自动回滚,为了解决这个限制,提高系统并发量,MySQL 底层现在支持多达 128 个回滚段,所以支持的总 undo slot 数量就是 128 * 1024 = 131072,这样一来,单机至多可以同时支持几万个事务,对一般系统绰绰有余了。

为事务申请好对应的 undo 页面链表后,就可以在事务执行过程中将 undo 日志写入对应的页面链表了。

事务回滚&提交

事务回滚时,只需要根据事务 ID 获取到对应的 undo 页面链表,读取里面的 undo 日志进行撤销操作即可。

事务提交后,相应的 undo slot 会根据条件判断是被其他事务重用还是释放掉,从而腾出空间给新的事务。

Rollback Segment Header 页面存放在系统表空间第 5 号页面,所以我们可以结合上面的介绍画出回滚段与 undo 页面链表的整体示意图:

1.jpeg

0

评论区