MySQL UNDO日志

960 阅读11分钟

给事务分配 id 的时机

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

事务 id 是怎么生成的

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

insert 操作对应的 undo 日志

undo01.png

  • undo no 在一个事务中是从 0 开始递增的,也就是说只要事务没提交,没生成一条 undo日志,那么该条日志的 undo no 就增加 1。
  • 如果记录中的主键只包含一个列,那么只需要把该列占用的存储空间和真实值记录下来;如果记录中的主键包含多个列,那么每个列占用的存储空间大小和对应的真实值都需要记录下来。

roll_pointer 隐藏列的含义

roll_pointer 本质上就是指向记录对应的 undo日志 的指针。如下所示:

undo02.png

delete 操作对应的 undo 日志

其实删除一条记录需要经历两个阶段:

  • 阶段一:将记录的 delete_mask 标识位设置为 1,其他的不做修改(其实会修改记录的 trx_idroll_pointer 这些隐藏列的值)。这个额阶段称为 delete_mark
  • 阶段二:当该删除语句所在的事务提交之后,会有专门的线程来真正的把记录删除掉。所谓真正的删除就是把该记录从 正常记录链表 中移除,加入到 垃圾链表 中。这个阶段称为 purge

事务提交之后我们就不用回滚了,所以只需要考虑对删除操作的 阶段一 做的影响进行回滚。下面是 delete 操作对应的 undo日志 结构图:

undo03.png

  • 在对一条记录进行 delete mark 操作前,需要把该记录的旧 trx_idroll_pointer 隐藏列的值都记录到对应的 undo日志 中,就是上图中显示的 old trx_idold roll_pointer 属性。这样就可以通过 undo日志old roll_pointer 找到记录在修改之前对应的 undo 日志。比如说在一个十五中,先插入一条记录,然后又执行对该记录的删除操作,这个过程的示意图就是这样:

undo04.png

从图中可以看出来,执行完 delete mark 操作后,它对应的 undo 日志和 insert 操作对应的 undo 日志就串成了一个链表,这个链表称为 版本链

  • insert 操作对应的 undo 日志不同,delete 操作对应的 undo 日志多了一项 索引列各列信息 的内容,也就是说如果某个列被包含在某个索引中,那么它的相关信息就应该被记录的 索引列各项信息 部分。所谓的相关信息包括该列在记录中的位置(用 pos 表示),该列占用的存储空间大小(用 len 表示),该列实际值(用 value 表示)。所以 索引列各列信息 存储的内容实质上就是 <pos, len, value> 的一个列表。这部分信息主要是用在事务提交后,对该 中间状态记录 做真正删除的阶段二,也就是 purge 阶段中使用的。

update 操作对应的 undo 日志

在执行 update 语句时,InnoDB 对更新主键和不更新主键这两种情况有截然不同的处理方案。

不更新主键的情况

在不更新主键的情况下,又可以细分为被更新的列占用的存储空间不发生变化和发生变化的情况。

  • 就地更新(in-place update)

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

  • 先删除掉旧记录,在插入新纪录

    在不更新主键的情况下,如果有任何一个被更新的列更新前和更新后占用的存储空间大小不一致,那么就需要先把这条旧的记录从簇聚索引页面中删除掉,然后再根据更新后列的值创建一条新的记录插入到页面中。

    我们这里所说的 删除 并不是 delete mark 操作,而是真正删除掉,也就是把这条记录从 正常记录链表 中移除并加入到 垃圾链表中。另外这里做真正删除操作的线程并不是 delete 语句中做 purge 操作时使用的另外专门的线程,而是由用户线程同步执行真正的删除操作,真正删除之后紧接着就要根据各个列更新后的值创建的新记录插入。

    这里如果新创建的记录占用的存储空间大小不超过旧记录占用的空间,那么可以直接重用被加入到 垃圾链表 中的旧记录所占用的存储空间,否则的话需要在页面中新申请一段空间以供新记录使用,如果本页面内已经没有可用的空间的话,那就需要进行页面分裂操作,然后再插入新记录。

update 不更新主键的情况下,对应的 undo 日志结构如下:

undo05.png

大部分属性都在上边介绍过了,需要注意以下两点:

  • n_updated 属性表示本条 update 语句执行后将有几个列被更新,后边跟着的 <pos, old_len, old_value> 分别表示被更新列在记录中的位置、更新前该列占用的存储空间大小、更新前该列的真实值。
  • 如果在 update 语句中更新的列包含索引列,那么也会添加 索引列各列信息 部分,否则不会添加这个部分的。

更新主键的情况

在簇聚索引中,记录时按照主键值的大小连成了一个单向链表,如果我们更新某条记录的主键值,意味着这条记录在簇聚索引中的位置将会发生变化。针对 update 语句中更新了记录主键值的这种情况,InnoDB 在聚簇索引中分了两步处理:

  • 将旧记录进行 delete_mark 操作。
  • 根据更新后各列的值创建一条新纪录,并把它插入到簇聚索引中。

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

通用链表结构

前面我们了解了为什么需要 undo 日志,以及 insertdeleteupdate 操作会产生什么类型的 undo 日志,还有不同类型的 undo 日志的具体格式。下面继续了解一下 undo 日志会被存储到什么地方,以及在存储过程中需要注意的一些问题。

在写入 undo 日志的过程中会使用到很多链表,很多链表有同样的节点结构,如图所示:

undo06.png

为了更好的管理链表,InnoDB 设计了一个基节点的结构,里边存储了这个链表的 头结点尾节点 以及链表长度信息,基节点的结构示意图如下:

undo07.png

所以使用 List Base NodeList Node 这两个结构组成的链表的示意图就是这样:

undo08.png

FIL_PAGE_UNDO_LOG 页面

页是 InnoDB 管理存储空间的基本单位,一个页的大小一般是 16KB,InnoDB 为了不同的目的而设计了许多种不同类型的页。比如 FIL_PAGE_INDEX 类型的页面用于存储索引,类型为 FIL_PAGE_TYPE_FSP_HDR 的页面用于存储表空间头部信息的,还有其他各种类型的页面,其中有一种称之为 FIL_PAGE_UNDO_LOG 类型的页面是专门用来存储 undo 日志的,这种类型的页面的通用结构如下图所示:

undo09.png

上图中的 File HeaderFile Trailer 是所有页面都由的通用结构。Undo Page HeaderUndo 页面所特有的,我们来看一下它的结构:

undo10.png

  • TRX_UNDO_PAGE_TYPE:本页面准备存储什么类型的 undo 日志。

    前边介绍的几种类型的 undo 日志,可以被分为两个大类:

    • TRX_UNDO_INSERT(使用十进制 1 表示):类型为 TRX_UNDO_INSERT_RECundo 日志属于此大类,一般由 insert 语句,或者在 update 语句中有更新主键的情况产生此种日志。

    • TRX_UNDO_UPDATE(使用十进制2表示):除了类型为 TRX_UNDO_INSERT_RECundo 日志,其他类型的 undo 日志都属于这个大类,比如我们前边说的 TRX_UNDO_DEL_MARK_RECTRX_UNDO_UPD_EXIST_REC,一般由 deleteupdate 语句产生的日志属于这个大类。

  • TRX_UNDO_PAGE_START:表示第一条 undo 日志在本页面中的其实偏移量。

  • TRX_UNDO_PAGE_FREE:表示当前页面中存储的最后一条 undo 日志结束时的偏移量,或者说从这个位置开始,可以继续写入新的 undo 日志。

  • TRX_UNDO_PAGE_NODE:代表一个 List Node 结构,链表的普通节点。

Undo 页面链表

因为一个事务可能包含多个语句,而且一个语句可能对若干条记录进行改动,而对每条记录进行改动前,都需要记录 1 条或 2 条的 undo 日志,所以在一个事务执行过程中可能产生很多 undo 日志,这些日志可能一个页面放不下,需要放到多个页面中,这些页面就通过我们上边介绍的 TRX_UNDO_PAGE_NODE 属性连成了链表:

undo11.png

在一个事务的执行过程中,可能混着执行 insertdeleteupdate 语句,也就意味会产生不同类型的 undo 日志。前边又强调过,同一个 Undo 页面只能存储一个类型的日志,所以一个事务执行过程中可能需要多个 Undo 页面的链表。

undo12.png

另外,InnoDB 规定对普通表和临时表的记录改动时产生的 undo 日志要分别记录。

undo13.png

当然,并不是在事务一开始就会为事务分配这么多链表,具体策略是:按需分配,什么时候需要什么时候再分配,不需要就不分配。

多个事务的 Undo 页面链表

为了尽可能提高 undo 日志的写入效率,不同事务执行过程中产生的 undo 日志会被写入到不同的 Undo 页面链表中。比如说 trx 1trx 2 两个事务。

undo14.png

Undo Log Segment Header

InnoDB 规定,每一个 Undo 页面链表都对应着一个,称之为 Undo Log Segment。也就是说链表中的页面都是从这个段里边申请的,所以在 Undo 页面链表的第一个页面,也就是上边提到的 first undo page 中设计了一个称之为 Undo Log Segment Header 的部分,这个部分中包含了该链表对应的段的 segment header 信息以及其他的一些关于这个段的信息,所以 Undo 页面链表的第一个页面其实长这样:

undo15.png

可以看到这个 Undo 链表的第一个页面比普通页面多了个 Undo Log Segment Header,我们来看一下它的结构:

undo16.png

  • TRX_UNDO_STATE:本页面链表处在什么状态。

    • 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 Log Header 的位置。

  • TRX_UNDO_FSEG_HEADER:本页面链表对应的段的 Segment Header 信息,通过这个信息可以找到该段对应的 INODE Entry

  • TRX_UNDO_PAGE_LISTUndo 页面链表的基节点。