《MySQL是怎样运行的 —— 从跟上理解MySQL》—— 第二十章
一、事务回滚的需求
事务
需要保证原子性
,也就是事务中的操作要么全部完成,要么什么也不做。
事务执行到一半会出现一些情况,比如:
- 事务执行过程中可能遇到各种错误,比如服务器本身的错误,操作系统错误,甚至是突然断电导致的错误。
- 开发者可以在事务执行过程中手动输入
ROLLBACK
语句结束当前的事务的执行。
这两种情况都会导致事务执行到一半就结束,但是事务执行过程中可能已经修改了很多东西,为了保证事务的原子性,此时需要把东西改回原先的样子,这个过程就称之为回滚
(rollback
)。
每当要对一条记录做改动时,把回滚时所需的东西都给记下来。这些为了回滚而做的记录称之为撤销日志(undo log
)。
⚠️注意:需要注意的一点是,由于查询操作(SELECT
)并不会修改任何用户记录,所以在查询操作执行时,并不需要记录相应的undo日志
。
二、事务ID
2.1 给事务分配ID的时机
一个事务可以是一个只读事务,或者是一个读写事务:
- 通过
START TRANSACTION READ ONLY
语句开启一个只读事务。在只读事务中不可以对普通的表(其他事务也能访问到的表)进行增、删、改操作,但可以对临时表做增、删、改操作。 - 通过
START TRANSACTION READ WRITE
语句开启一个读写事务,或者使用BEGIN
、START TRANSACTION
语句开启的事务默认也算是读写事务。在读写事务中可以对表执行增删改查操作。
如果某个事务执行过程中对某个表执行了增、删、改操作,那么InnoDB
存储引擎就会给它分配一个唯一的事务id
,分配方式如下:
对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、改操作时才会为这个事务分配一个
事务id
,否则不分配事务id
。⚠️注意:对某个查询语句执行EXPLAIN分析它的查询计划时,有时候在Extra列会看到Using temporary的提示,这个表明在执行该查询语句时会用到内部临时表。这个所谓的内部临时表和手动用CREATE TEMPORARY TABLE创建的用户临时表并不一样,在事务回滚时并不需要把执行SELECT语句过程中用到的内部临时表也回滚,在执行SELECT语句用到内部临时表时并不会为它分配事务id。
对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执行增、删、改操作时才会为这个事务分配一个
事务id
,否则的话也是不分配事务id
的。有时虽然开启了一个读写事务,但是在这个事务中全是查询语句,并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个事务id
。
2.2 如何生成事务ID
事务id
本质上就是一个数字,它的分配策略和对隐藏列row_id
(当用户没有为表创建主键和UNIQUE
键时InnoDB
自动创建的列)的分配策略大抵相同,具体策略如下:
- 服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个
事务id
时,就会把该变量的值当作事务id
分配给该事务,并且把该变量自增1。 - 每当这个变量的值为
256
的倍数时,就会将该变量的值刷新到系统表空间的页号为5
的页面中一个称之为Max Trx ID
的属性处,这个属性占用8
个字节的存储空间。 - 当系统下一次重新启动时,会将上面提到的
Max Trx ID
属性加载到内存中,将该值加上256之后赋值给全局变量(因为在上次关机时该全局变量的值可能大于Max Trx ID
属性值)。
这样就可以保证整个系统中分配的事务id
值是一个递增的数字。先被分配id
的事务得到的是较小的事务id
,后被分配id
的事务得到的是较大的事务id
。
2.3 trx_id隐藏列
聚簇索引的记录除了会保存完整的用户数据以外,而且还会自动添加名为trx_id、roll_pointer的隐藏列,如果用户没有在表中定义主键以及UNIQUE键,还会自动添加一个名为row_id的隐藏列。
trx_id
列其实就是某个对这个聚簇索引记录做改动的语句所在的事务对应的事务id
而已(改动可以是INSERT
、DELETE
、UPDATE
操作)。
三、undo log 的格式
详情见书
四、通用链表结构
在写入undo日志
的过程中会使用到多个链表,很多链表都有同样的节点结构:
为了更好的管理链表,InnoDB
提出了一个基节点的结构,里边存储了这个链表的头节点
、尾节点
以及链表长度信息,
使用List Base Node
和List Node
这两个结构组成的链表的示意图:
五、FIL_PAGE_UNDO_LOG页
FIL_PAGE_UNDO_LOG
类型的页面是专门用来存储undo日志
的,这种类型的页面的通用结构:
详细结构见书
六、Undo页面链表
6.1 单个事务中的Undo页面链表
因为一个事务可能包含多个语句,而且一个语句可能对若干条记录进行改动,而对每条记录进行改动前,都需要记录1条或2条的undo日志
,所以在一个事务执行过程中可能产生很多undo日志
,这些日志可能一个页面放不下,需要放到多个页面中:
在一个事务执行过程中就可能需要2个Undo页面
的链表,一个称之为insert undo链表
,另一个称之为update undo链表
:
InnoDB
对普通表和临时表的记录改动时产生的undo日志
是分别记录的,所以在一个事务中最多有4个以Undo页面
为节点组成的链表:
并不是在事务一开始就会为这个事务分配这4个链表,具体分配策略如下:
- 刚刚开启事务时,一个
Undo页面
链表也不分配。 - 当事务执行过程中向普通表中插入记录或者执行更新记录主键的操作之后,就会为其分配一个
普通表的insert undo链表
。 - 当事务执行过程中删除或者更新了普通表中的记录之后,就会为其分配一个
普通表的update undo链表
。 - 当事务执行过程中向临时表中插入记录或者执行更新记录主键的操作之后,就会为其分配一个
临时表的insert undo链表
。 - 当事务执行过程中删除或者更新了临时表中的记录之后,就会为其分配一个
临时表的update undo链表
。
总结:按需分配
6.2 多个事务中的Undo页面链表
为了尽可能提高undo日志
的写入效率,不同事务执行过程中产生的undo日志需要被写入到不同的Undo页面链表中。比方说现在有事务id
分别为1
、2
的两个事务,分别称之为trx 1
和trx 2
,假设在这两个事务执行过程中:
trx 1
对普通表做了DELETE
操作,对临时表做了INSERT
和UPDATE
操作。InnoDB
会为trx 1
分配3个链表,分别是:- 针对普通表的
update undo链表
- 针对临时表的
insert undo链表
- 针对临时表的
update undo链表
- 针对普通表的
trx 2
对普通表做了INSERT
、UPDATE
和DELETE
操作,没有对临时表做改动。InnoDB
会为trx 2
分配2个链表,分别是:- 针对普通表的
insert undo链表
- 针对普通表的
update undo链表
- 针对普通表的
在trx 1
和trx 2
执行过程中,InnoDB
共需为这两个事务分配5个Undo页面
链表:
七、Undo Log写入过程
详情见书
对于没有被重用的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页面
一个Undo页面
链表是否可以被重用的条件:
- 该链表中只包含一个
Undo页面
- 该
Undo页面
已经使用的空间小于整个页面空间的3/4
详情见书
九、回滚段
详情见书
9.6 为事务分配 Undo 页链表的详细过程
以事务对普通表的记录做改动为例,梳理一下事务执行过程中分配Undo页面
链表时的完整过程,
- 事务在执行过程中对普通表的记录首次做改动之前,首先会到系统表空间的第
5
号页面中分配一个回滚段(其实就是获取一个Rollback Segment Header
页面的地址)。一旦某个回滚段被分配给了这个事务,那么之后该事务中再对普通表的记录做改动时,就不会重复分配了。使用round-robin
(循环使用)方式来分配回滚段。 - 在分配到回滚段后,首先看一下这个回滚段的两个
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就可以了。
十、回滚段相关配置
10.1 配置回滚段数量
系统中默认一共有128
个回滚段。可以通过启动参数innodb_rollback_segments
来配置回滚段的数量,可配置的范围是1~128
。但是这个参数并不会影响针对临时表的回滚段数量,针对临时表的回滚段数量一直是32
,也就是说:
- 如果把
innodb_rollback_segments
的值设置为1
,那么只会有1个针对普通表的可用回滚段,但是仍然有32个针对临时表的可用回滚段。 - 如果把
innodb_rollback_segments
的值设置为2~33
之间的数,效果和将其设置为1
是一样的。 - 如果把
innodb_rollback_segments
设置为大于33
的数,那么针对普通表的可用回滚段数量就是该值减去32。
10.2 配置 Undo 表空间
默认情况下,针对普通表设立的回滚段(第0
号以及第33~127
号回滚段)都是被分配到系统表空间的。其中的第0
号回滚段是一直在系统表空间的,但是第33~127
号回滚段可以通过配置放到自定义的undo表空间
中。但是这种配置只能在系统初始化(创建数据目录时)的时候使用,一旦初始化完成,之后就不能再次更改了。
相关启动参数:
- 通过
innodb_undo_directory
指定undo表空间
所在的目录,如果没有指定该参数,则默认undo表空间
所在的目录就是数据目录。 - 通过
innodb_undo_tablespaces
定义undo表空间
的数量。该参数的默认值为0
,表明不创建任何undo表空间
。第33~127
号回滚段可以平均分布到不同的undo表空间
中。
设立undo表空间
的一个好处就是在undo表空间
中的文件大到一定程度时,可以自动的将该undo表空间
截断(truncate)成一个小文件。而系统表空间的大小只能不断的增大,却不能截断。
十一、Undo log在崩溃恢复时的作用
在服务器因为崩溃而恢复的过程中,首先需要按照redo log将各个页面的数据恢复到崩溃之前的状态,这样可以保证已经提交的事务的持久性。但是这里仍然存在一个问题,就是那些没有提交的事务写的redo log可能也已经刷盘,那么这些未提交的事务修改过的页面在MySQL服务器重启尚可能也被恢复了。
为了保证事务的原子性,有必要在服务器重启时将这些未提交的事务回滚掉。那么,怎么找到这些未提交的事务呢?这个工作又落到了 undo log头上。
可以通过系统表空间中第 5 号页面定位到128个回滚段的位置,在每一个回滚段的1024个undo slot中找到那些值不为FIL_NULL的undo slot,每一个 undo slot 对应着一个 Undo 页面链表。然后从 Undo 页面链表第一个页面的 Undo Segment Header 中找到 TRX_UNDO_STATE 属性,该属性标识当前 Undo页面链表所处的状态。如果该属性的值为 TRX_UNDO_ACTIVE ,则意味着有一个活跃的事务正在向这个 Undo 页面链表中写入 undo log。然后再在 Undo Segment Header 中找到 TRX_UNDO_LAST_LOG 属性,通过该属性可以找到本 Undo 页面链表最后一个 Undo Log Header 的位置。从该 Undo Log Header 中可以找到对应事务的事务id以及一些其他信息,则该事务id对应的事务就是未提交的事务。通过 undo log中记录的信息将该事务对页面所做的更改全部回滚掉,这样就保证了事务的原子性。