一、事务回滚的需求
我们说过事务需要保证原子性 ,也就是事务中的操作要么全部完成,要么什么也不做。但是偏偏有时候事务执行到一半会出现一些情况,比如:
- 情况一:事务执行过程中可能遇到各种错误,比如服务器本身的错误,操作系统错误,甚至是突然断电导致的错误。
- 情况二:程序员可以在事务执行过程中手动输入 ROLLBACK 语句结束当前的事务的执行。
这两种情况都会导致事务执行到一半就结束,但是事务执行过程中可能已经修改了很多东西,为了保证事务的原子性,我们需要把东西改回原先的样子,这个过程就称之为 回滚 (英文名: rollback )
二、事务 id
给事务分配id的时机:
一个事务可以是一个只读事务,或者是一个读写事务:
- 我们可以通过 START TRANSACTION READ ONLY 语句开启一个只读事务。
在只读事务中不可以对普通的表(其他事务也能访问到的表)进行增、删、改操作,但可以对临时表做增、删、改操作。 - 我们可以通过 START TRANSACTION READ WRITE 语句开启一个读写事务,或者使用 BEGIN 、 START TRANSACTION 语句开启的事务默认也算是读写事务。在读写事务中可以对表执行增删改查操作
如果某个事务执行过程中对某个表执行了增、删、改操作,那么 InnoDB 存储引擎就会给它分配一个独一无二的事务id ,分配方式如下:
对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、改操作时才会为这个事务分配一个 事务id ,否则的话是不分配 事务id 的
对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执行增、删、改操作时才会为这个事务分配一个 事务id ,否则的话也是不分配 事务id 的。
有的时候虽然我们开启了一个读写事务,但是在这个事务中全是查询语句,并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个 事务id
小贴士:事务对表中的记录做改动时才会为这个事务分配一个唯一的 事务id
以上我们讲了在哪种情况下事务才有事务id,那这个事务id是如何生成的呢?🤭🤭🤭
事务id是怎么生成的:
本质就是一个数字。
服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个 事务id 时,就会把该变量的值当作事务id 分配给该事务,并且把该变量自增1。
每当这个变量的值为 256 的倍数时,就会将该变量的值刷新到系统表空间的页号为 5 的页面中一个称之为
Max Trx ID 的属性处,这个属性占用 8 个字节的存储空间。
当系统下一次重新启动时,会将上边提到的 Max Trx ID 属性加载到内存中,将该值加上256之后赋值给我们前边提到的全局变量
这样就可以保证整个系统中分配的事务id值是一个递增的数字。先被分配 id 的事务得到的是较小的事务id,后被分配 id 的事务得到的是较大的 事务id 。
trx_id隐藏列:
聚簇索引的记录除了会保存完整的用户数据以外,而且还会自动添加名为 trx_id
、roll_pointer
的隐藏列,如果用户没有在表中定义主键以及 UNIQUE键,还会自动添加一个名为 row_id 的隐藏列。所以一条记录在页面中的真实结构看起来就是这样的:
三、undo日志的格式
为了实现事务的 原子性 ,InnoDB 存储引擎在实际进行增、删、改一条记录时,都需要先把对应的 undo日志 记下来,我们会为这些日志编号,也叫 undo no
undo 日志是存储在系统表空间中或一种专门存放 undo日志 的表空间(undo tablespace)
1、INSERT操作对应的undo日志
插入一条记录到一个数据页中。如果希望回滚这个插入操作,那么把这条记录删除就好了,也就是说在写对应的 undo 日志时,主要是把这条记录的主键信息记上。插入操作对应类型为 TRX_UNDO_INSERT_REC
的 undo日志 ,它的完整结构如下图所示:
- undo no 在一个事务中是从 0 开始递增的,也就是说只要事务没提交,每生成一条 undo日志 ,那么该条日志的 undo no 就增1
- 如果记录中的主键只包含一个列,那么在类型为
TRX_UNDO_INSERT_REC
的 undo日志 中只需要把该列占用的存储空间大小和真实值记录下来,如果记录中的主键包含多个列,那么每个列占用的存储空间大小和对应的真实值都需要记录下来(图中的 len 就代表列占用的存储空间大小, value 就代表列的真实值)
roll pointer隐藏列的含义
rool pointer 隐藏列本质上就是一个指向记录对应的 undo日志 的一个指针 占用 7 个字节,存储到了类型为 FIL_PAGE_INDEX
的页面中(数据页),我们画一个图康康吧:
2、DELETE操作对应的undo日志
在讲解 undo日志之前,我们先来看看啥是「垃圾链表」,还是老习惯,上图:
左侧为插入到页面中的记录会根据记录头信息中的 next_record 属性组成一个单向链表,我们把这个链表称之为「正常记录链表」;右侧是被删除的记录其实也会根据记录头信息中的 next_record 属性组成一个链表,只不过这个链表中的记录占用的存储空间可以被重新利用,所以也称这个链表为「垃圾链表」
Page Header 部分有一个称之为 PAGE_FREE
的属性,它指向由被删除记录组成的垃圾链表中的头节点
当我们把正常记录链表中的记录删除时,经过下面两个阶段:
阶段一:仅仅将记录的 delete_mask 标识位设置为 1 ,其他的不做修改,称之为 「delete mark」
注意:被删除的记录并没有被加入到垃圾链表,处于一个中间状态,如图:
阶段二:当该删除语句所在的事务提交之后,会有专门的线程后来真正的把记录删除掉。所谓真正的删除就
是把该记录从 正常记录链表 中移除,并且加入到 垃圾链表 中,然后还要调整一些页面的其他信息,比如页面中的用户记录数量 PAGE_N_RECS
、上次插入记录的位置 PAGE_LAST_INSERT
、垃圾链表头节点的指针 PAGE_FREE
、页面中可重用的字节数量 PAGE_GARBAGE
、还有页目录的一些信息等等。这个阶段称之为 purge ,如图:
将被删除记录加入到垃圾链表时,实际上加入到链表的头节点处,会跟着修改 PAGE_FREE
属性的值,只有阶段二执行完后这条记录就算是真正的被删除掉了
在删除语句所在的事务提交之前,只会经历 阶段一,因此 InnoDB 设计了一种称之为TRX_UNDO_DEL_MARK_REC
类型的 undo日志,如图:
现在我只想说 「what’s up」🙄🙄🙄
在对一条记录进行 delete mark 操作前,需要把该记录的旧的 trx_id 和 roll_pointer 隐藏列的值都给记
到对应的 undo日志 中来,就是我们图中显示的 old trx_id 和 old roll_pointer 属性,可以形成版本链
TRX_UNDO_DEL_MARK_REC 的 undo 日志有一个 索引列各列信息 的内容,包括 pos,len,value,
pos是该列在记录中的位置,len 是该列在记录中的位置,value 该列实际值
其他的参数以后再介绍哈
3、UPDATE 操作对应的 undo日志
1、不更新主键的情况
就地更新(in-place update):更新记录时,对于被更新的「每个列」来说,如果更新后的列和更新前的列占用的存储空间都一样大,那么就可以进行就地更新
先删除掉旧记录,再插入新记录:在不更新主键的情况下,如果有任何一个被更新的列更新前和更新后占用的存储空间大小不一致,那么就需要先把这条旧的记录从聚簇索引页面中删除掉,然后再根据更新后列的值创建一条新的记录插入到页面中
注意:这个删除并不是 delete mark 操作,而是真正的删除掉,也就是把这条记录从 正常记录链表 中移除并加入到 垃圾链表 中
这里如果新创建的记录占用的存储空间大小不超过旧记录占用的空间,那么可以直接重用被加入到 垃圾链
表 中的旧记录所占用的存储空间,否则的话需要在页面中新申请一段空间以供新记录使用,如果本页面内已经没有可用的空间的话,那就需要进行页面分裂操作,然后再插入新记录
UPDATE 不更新主键的情况下undo日志类型为 TRX_UNDO_UPD_EXIST_REC
,如图:
n_updated 属性表示本条 UPDATE 语句执行后将有几个列被更新,后边跟着的 <pos, old_len, old_value>
分别表示被更新列在记录中的位置、更新前该列占用的存储空间大小、更新前该列的真实值。
如果在 UPDATE 语句中更新的列包含索引列,那么也会添加 索引列各列信息 这个部分,否则的话是不会添加这个部分的。
2、更新主键的情况
分为两步:
将旧记录进行 delete mark 操作
小贴士:这里是 delete mark 操作,对旧记录只做一个 delete mark 操作,在事务提交后才由专门的线程做purge 操作,把它加入到垃圾链表中
根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中,由于更新后的记录主键值发生了改变,所以需要重新从聚簇索引中定位这条记录所在的位置,然后把它插进去。
四、通用链表结构
List Node 结构示意图:
List Base Node 结构示意图:
- List Length 表明该链表一共有多少节点
List Node 与 List Base Node 的关系为:
五、FIL_PAGE_UNDO_LOG页面
FIL_PAGE_UNDO_LOG 类型的页面是专门用来存储 undo日志 的,结构如下:
这里重点说下一下 Uodo Page Header,结构如下:
TRX_UNDO_PAGE_TYPE
:本页面准备存储什么种类的 undo日志 。-
RX_UNDO_INSERT
:TRX_UNDO_INSERT_REC
属于它 RX_UNDO_INSERT
:,除了类型为TRX_UNDO_INSERT_REC
的 undo日志 ,其他类型
的 undo日志 都属于这个大类,比如我们前边说的TRX_UNDO_DEL_MARK_REC
、
TRX_UNDO_UPD_EXIST_REC
啥的,一般由 DELETE 、 UPDATE 语句产生的 undo日志 属于这个大类。
-
TRX_UNDO_PAGE_START
:表示在当前页面中是从什么位置开始存储 undo日志 的,或者说表示第一条 undo日志 在本页面中的起始偏移量TRX_UNDO_PAGE_FREE
:表示当前页面中存储的最后一条 undo 日志结束时的偏移量,或者说从这个位置开始,可以继续写入新的 undo日志 。RX_UNDO_PAGE_NODE
:代表一个 List Node 结构
六、Undo页面链表
1、多个事务中的Undo页面链表
因为一个事务可能包含多个语句,可能产生很多 undo日志 ,这些日志可能一个页面放不下,需要放到多个页面中,这些页面就通 TRX_UNDO_PAGE_NODE 属性连成了链表:
我们之前说过,Undo 页面分为不同的类型,但是一个事务可能产生 2 种类型的 Undo页面,一个称之为 insert undo链表 ,另一个称之为 update undo链表 ,画个示意图吧:
2、多个事务中的Undo页面链表
为了尽可能提高 undo日志 的写入效率,不同事务执行过程中产生的 undo日志需要被写入到不同的 Undo页面链表中,还是画个图吧:画图画吐了(谁叫我这么喜欢你们呢😘😘😘)
七、undo日志具体写入过程
1、Undo Log Segment Header
InnoDB 的规定,每一个 Undo页面链表都对应着一个段 ,称之为 Undo Log Segment 。也就是说链表中的页面都是从这个段里边申请的,所以他们在 Undo页面 链表的第一个页面,也就是上边提到的 first undo page 中设计了一个称之为 Undo Log Segment Header 的部分,这个部分中包含了该链表对应的段的 segment header 信息以及其他的一些关于这个段的信息,所以 Undo 页面链表的第一个页面其实长这样:
Undo Log Segment Header的结构如下:
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 信息TRX_UNDO_PAGE_LIST
: Undo页面 链表的基节点
2、Undo Log Header
讲了这么多页面的信息,大家可能会说,这有啥用啊,看了也记不住,好,听你的,以后少讲一点
那 Undo Log Header 有啥用呢,用来存储组的一些信息,那啥是组呢?同一个事务向一个 Undo页面 链表
中写入的 undo日志 算是一个组
如图:
Undo Log Header 具体的属性我实在是不想画了,太多了,感兴趣的同学自行去康康,老夫无能 🤣🤣🤣
小总结:对于没有被重用的 Undo页面 链表来说,链表的第一个页面,也就是 first undo page 在真正写入 undo日志前,会填充 Undo Page Header 、 Undo Log Segment Header 、 Undo Log Header 这3个部分,之后才开始正式写入 undo日志 。对于其他的页面来说,也就是 normal undo page 在真正写入 undo日志 前,只会填充 Undo Page Header 。链表的 List Base Node 存放到 first undo page 的 Undo Log Segment Header 部分, List Node 信息存放到每一个 Undo页面 的 undo Page Header 部分,所以画一个 Undo页面 链表的示意图就是这样:
八、回滚段
1、回滚段的概念
我们现在知道一个事务在执行过程中最多可以分配 4个 Undo页面 链表,在同一时刻不同事务拥有的 Undo页面 链表是不一样的,所以在同一时刻系统里其实可以有许许多多个 Undo页面 链表存在。为了更好的管理这些链表,InnoDB 设计了一个称之为 Rollback Segment Header 的页面,在这个页面中存放了各个 Undo页面 链表的 frist undo page 的页号 ,他们把这些页号称之为 undo slot,如图:
每一个 Rollback Segment Header 页面都对应着一个段,这个段就称为 Rollback Segment ,翻译过来就是 回滚段 。与我们之前介绍的各种段不同的是,这个 Rollback Segment 里其实只有一个页面
又到了解释这些字段是啥意思的时候了,相信你们肯定也不想看了,其实我更加不想写了 😒😒😒
TRX_RSEG_MAX_SIZE
:本 Rollback Segment 中管理的所有 Undo页面 链表中的 Undo页面 数量之和的最大值。换句话说,本 Rollback Segment 中所有 Undo页面 链表中的 Undo页面 数量之和不能超过TRX_RSEG_MAX_SIZE
代表的值。该属性的值默认为无限大,也就是我们想写多少 Undo页面 都可以TRX_RSEG_HISTORY_SIZE
: History 链表占用的页面数量。TRX_RSEG_HISTORY
: History 链表的基节点。TRX_RSEG_FSEG_HEADER
:本 Rollback Segment 对应的10字节大小的 Segment Header 结构,通过它可以找到本段对应的 INODE Entry 。TRX_RSEG_UNDO_SLOTS
:各个Undo页面链表的 first undo page 的页号集合,也就是 undo slot 集合
2、从回滚段中申请Undo页面链表
初始情况下,由于未向任何事务分配任何 Undo页面 链表,所以对于一个 Rollback Segment Header 页面来说,它的各个 undo slot 都被设置成了一个特殊的值: FIL_NULL (对应的十六进制就是 0xFFFFFFFF),表示该undo slot 不指向任何页面。
随着时间的流逝,开始有事务需要分配 Undo页面 链表了,就从回滚段的第一个 undo slot 开始,看看该 undo slot 的值是不是 FIL_NULL
- 如果是 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 ,重复上边的步骤。
一个 Rollback Segment Header 页面中包含 1024 个 undo slot ,如果这 1024 个 undo slot 的值都不为
FIL_NULL ,这就意味着这 1024 个 undo slot 都已经名花有主(被分配给了某个事务),此时由于新事务无法再获得新的 Undo页面 链表,就会回滚这个事务并且给用户报错
Too many active concurrent transactions
当一个事务提交时,它所占用的 undo slot 有两种命运:
如果该 undo slot 指向的 Undo页面 链表符合被重用的条件,该 undo slot 就处于被缓存的状态,改 Undo页面 链表的 TRX_UNDO_STATE 属性会设置为 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页面 链表是 insert undo链表 ,则该 Undo页面 链表的
3、多个回滚段
我们说一个事务执行过程中最多分配 4 个 Undo页面 链表,而一个回滚段里只有 1024 个 undo slot ,很显然 undo slot 的数量有点不能够啊,于是 InnoDB 定义了 128 个回滚段,这些回滚段的地址存储在第 5 号页面的某个区域包含了128个8字节大小的格子:
每个8字节的格子的构造就像这样:
- 4字节大小的 Space ID ,代表一个表空间的ID。
- 4字节大小的 Page number ,代表一个页号
每个8字节大小的 格子 相当于一个指针,指向某个表空间中的某个页面,这些页面就是 Rollback Segment Header 。这里需要注意的一点事,要定位一个 Rollback Segment Header 还需要知道对应的表空间ID,这也就意味着不同的回滚段可能分布在不同的表空间中。
如图:
4、为事务分配Undo页面链表详细过程
事务执行过程中分配 Undo页面 链表时的完整过程:
事务在执行过程中对普通表的记录首次做改动之前,首先会到系统表空间的第 5 号页面中分配一个回滚段(其实就是获取一个 Rollback Segment Header 页面的地址)。一旦某个回滚段被分配给了这个事务,那么之后该事务中再对普通表的记录做改动时,就不会重复分配了。
在分配到回滚段后,首先看一下这个回滚段的两个 cached链表 有没有已经缓存了的 undo slot ,比如如果事务做的是 INSERT 操作,就去回滚段对应的 insert undo cached 链表 中看看有没有缓存的 undo slot ;如果事务做的是 DELETE 操作,就去回滚段对应的 update undo cached链表 中看看有没有缓存的 undo slot 。如果有缓存的 undo slot ,那么就把这个缓存的 undo slot 分配给该事务
如果没有缓存的 undo slot 可供分配,那么就要到 Rollback Segment Header 页面中找一个可用的 undo slot 分配给当前事务。
找到可用的 undo slot 后,如果该 undo slot 是从 cached链表 中获取的,那么它对应的 Undo Log
Segment 已经分配了,否则的话需要重新分配一个 Undo Log Segment ,然后从该 Undo Log Segment 中申请一个页面作为 Undo页面 链表的 first undo page然后事务就可以把 undo日志 写入到上边申请的 Undo页面 链表了!
小贴士:如果一个事务在执行过程中既对普通表的记录做了改动,又对临时表的记录做了改动,那么需要为这个记录分配2个回滚段。并发执行的不同事务其实也可以被分配相同的回滚段,只要分配不同的 undo slot 就可以了
到这里我们的 undo log 就讲完了,内容还是比较多的,希望大家慢慢消化吧 😝😝😝
巨人的肩膀: