给事务分配 id 的时机
- 对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、改操作时才会为这个事务分配一个
事务id。 - 对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执行增、删、改操作时才会为这个事务分配一个
事务id。
事务 id 是怎么生成的
- 服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个
事务id时,就会把该变量的值当作事务id分配给该事务,并且把该变量自增 1。 - 每当这个变量的值为
256的倍数时,就会将该变量的值刷新到系统表空间的页号为 5 的页面中一个称之为Max Trx ID的属性处,这个属性占用 8 个字节的存储空间。 - 当系统下一次重启时,会将上边提到的
Max Trx ID属性加载到内存中,将该值加上256之后赋值给我们前边提到全局变量(因为在上次关机时该全局变量的值可能大于Max Trx ID属性值)。
insert 操作对应的 undo 日志
undo no在一个事务中是从0开始递增的,也就是说只要事务没提交,没生成一条undo日志,那么该条日志的undo no就增加 1。- 如果记录中的主键只包含一个列,那么只需要把该列占用的存储空间和真实值记录下来;如果记录中的主键包含多个列,那么每个列占用的存储空间大小和对应的真实值都需要记录下来。
roll_pointer 隐藏列的含义
roll_pointer 本质上就是指向记录对应的 undo日志 的指针。如下所示:
delete 操作对应的 undo 日志
其实删除一条记录需要经历两个阶段:
- 阶段一:将记录的
delete_mask标识位设置为1,其他的不做修改(其实会修改记录的trx_id、roll_pointer这些隐藏列的值)。这个额阶段称为delete_mark。 - 阶段二:当该删除语句所在的事务提交之后,会有专门的线程来真正的把记录删除掉。所谓真正的删除就是把该记录从
正常记录链表中移除,加入到垃圾链表中。这个阶段称为purge。
事务提交之后我们就不用回滚了,所以只需要考虑对删除操作的 阶段一 做的影响进行回滚。下面是 delete 操作对应的 undo日志 结构图:
-
在对一条记录进行
delete mark操作前,需要把该记录的旧trx_id和roll_pointer隐藏列的值都记录到对应的undo日志中,就是上图中显示的old trx_id和old roll_pointer属性。这样就可以通过undo日志的old roll_pointer找到记录在修改之前对应的undo日志。比如说在一个十五中,先插入一条记录,然后又执行对该记录的删除操作,这个过程的示意图就是这样:
从图中可以看出来,执行完 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 日志结构如下:
大部分属性都在上边介绍过了,需要注意以下两点:
n_updated属性表示本条update语句执行后将有几个列被更新,后边跟着的<pos, old_len, old_value>分别表示被更新列在记录中的位置、更新前该列占用的存储空间大小、更新前该列的真实值。- 如果在
update语句中更新的列包含索引列,那么也会添加索引列各列信息部分,否则不会添加这个部分的。
更新主键的情况
在簇聚索引中,记录时按照主键值的大小连成了一个单向链表,如果我们更新某条记录的主键值,意味着这条记录在簇聚索引中的位置将会发生变化。针对 update 语句中更新了记录主键值的这种情况,InnoDB 在聚簇索引中分了两步处理:
- 将旧记录进行
delete_mark操作。 - 根据更新后各列的值创建一条新纪录,并把它插入到簇聚索引中。
针对 update 语句更新记录主键的这种情况,在对该记录进行 delete_mark 操作前,会记录一条类型为 TRX_UNDO_DEL_MARK_REC 的 undo 日志;之后插入新记录时,会记录一条类型为 TRX_UNDO_INSERT_REC 的 undo日志,也就是说每对一条记录的主键值做改动时,会记录 2 条 undo 日志。
通用链表结构
前面我们了解了为什么需要 undo 日志,以及 insert、delete、update 操作会产生什么类型的 undo 日志,还有不同类型的 undo 日志的具体格式。下面继续了解一下 undo 日志会被存储到什么地方,以及在存储过程中需要注意的一些问题。
在写入 undo 日志的过程中会使用到很多链表,很多链表有同样的节点结构,如图所示:
为了更好的管理链表,InnoDB 设计了一个基节点的结构,里边存储了这个链表的 头结点、尾节点 以及链表长度信息,基节点的结构示意图如下:
所以使用 List Base Node 和 List Node 这两个结构组成的链表的示意图就是这样:
FIL_PAGE_UNDO_LOG 页面
页是 InnoDB 管理存储空间的基本单位,一个页的大小一般是 16KB,InnoDB 为了不同的目的而设计了许多种不同类型的页。比如 FIL_PAGE_INDEX 类型的页面用于存储索引,类型为 FIL_PAGE_TYPE_FSP_HDR 的页面用于存储表空间头部信息的,还有其他各种类型的页面,其中有一种称之为 FIL_PAGE_UNDO_LOG 类型的页面是专门用来存储 undo 日志的,这种类型的页面的通用结构如下图所示:
上图中的 File Header 和 File Trailer 是所有页面都由的通用结构。Undo Page Header 是 Undo 页面所特有的,我们来看一下它的结构:
-
TRX_UNDO_PAGE_TYPE:本页面准备存储什么类型的undo日志。前边介绍的几种类型的
undo日志,可以被分为两个大类:-
TRX_UNDO_INSERT(使用十进制1表示):类型为TRX_UNDO_INSERT_REC的undo日志属于此大类,一般由insert语句,或者在update语句中有更新主键的情况产生此种日志。 -
TRX_UNDO_UPDATE(使用十进制2表示):除了类型为TRX_UNDO_INSERT_REC的undo日志,其他类型的undo日志都属于这个大类,比如我们前边说的TRX_UNDO_DEL_MARK_REC、TRX_UNDO_UPD_EXIST_REC,一般由delete、update语句产生的日志属于这个大类。
-
-
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 属性连成了链表:
在一个事务的执行过程中,可能混着执行 insert、delete、update 语句,也就意味会产生不同类型的 undo 日志。前边又强调过,同一个 Undo 页面只能存储一个类型的日志,所以一个事务执行过程中可能需要多个 Undo 页面的链表。
另外,InnoDB 规定对普通表和临时表的记录改动时产生的 undo 日志要分别记录。
当然,并不是在事务一开始就会为事务分配这么多链表,具体策略是:按需分配,什么时候需要什么时候再分配,不需要就不分配。
多个事务的 Undo 页面链表
为了尽可能提高 undo 日志的写入效率,不同事务执行过程中产生的 undo 日志会被写入到不同的 Undo 页面链表中。比如说 trx 1 和 trx 2 两个事务。
Undo Log Segment Header
InnoDB 规定,每一个 Undo 页面链表都对应着一个段,称之为 Undo Log Segment。也就是说链表中的页面都是从这个段里边申请的,所以在 Undo 页面链表的第一个页面,也就是上边提到的 first undo page 中设计了一个称之为 Undo Log Segment Header 的部分,这个部分中包含了该链表对应的段的 segment header 信息以及其他的一些关于这个段的信息,所以 Undo 页面链表的第一个页面其实长这样:
可以看到这个 Undo 链表的第一个页面比普通页面多了个 Undo Log Segment Header,我们来看一下它的结构:
-
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_LIST:Undo页面链表的基节点。