30 事务怎么回滚?(上)
你好,我是俊达。
事务是关系型数据库的核心功能,具有ACID的特征。一个事务中的修改,要么全部生效,要么全部不生效,即使数据库异常崩溃,也不会违反事务的ACID属性。
上一讲中我们介绍了用来保证对数据库的修改都不丢的Redo机制。这一讲,我们来看一下,MySQL怎么做到“事务中的修改,要么都生效,要么都不生效”,实际上MySQL使用了Undo来实现事务的回滚。
事务简介
MySQL中,事务的行为受参数autocommit影响。如果autocommit设置为ON(这个参数默认就是ON),那么每个SQL语句会组成单独的事务,SQL语句执行完成时,InnoDB自动提交或回滚事务。
autocommit设置为ON时,你也可以使用BEGIN或START TRANSACTION语句开启事务,开启事务后,可以在一个事务中执行多条SQL语句,你需要执行COMMIT语句提交事务,或使用ROLLBACK语句回滚事务。
如果事务中的SQL执行时报错了,MySQL会怎么处理呢?如果是遇到了死锁,InnoDB会回滚整个事务。如果是遇到了锁等待超时,那么默认设置下,会回滚最后的那个SQL语句。如果把参数innodb_rollback_on_timeout设置为ON,那么锁等待超时后,会回滚整个事务。如果遇到其SQL报错,一般情况下都是回滚报错的那个SQL语句。
如果autocommit设置为OFF,那么不需要执行BEGIN或START TRANSACTION,MySQL也会自动开启事务,你需要使用COMMIT或ROLLBACK语句结束事务。一个事务结束后,会自动开启一个新的事务。
在执行DDL和一些管理语句时,会隐式提交当前的事务。你可以到 官方文档 查看哪些语句会隐式提交事务。
Undo简介
InnoDB在修改一行记录时,会把记录的当前状态保存在UNDO日志中。
-
对于INSERT语句,只需要保存新插入记录的主键值。
-
对于UPDATE语句,需要保存记录的主键值,以及发生变化的字段在修改前的值。
-
对于DELETE语句,需要保存被删除的记录的主键值。
DELETE数据时,InnoDB会先给记录加上删除标记,记录的真正数据并不会立刻被删除,所以在UNDO日志中,只需要保存被删除记录的主键值以及索引字段的值。
事务回滚时,要撤销事务所做的全部修改。
-
对于INSERT操作,回滚时要把插入的记录删除掉。
-
对于UPDATE操作,回滚时要把记录更新回之前的状态。
-
对于DELETE操作,回滚时要把记录的删除标记取消掉。
回滚时,要以相反的顺序撤销事务中的修改。一个事务生成的所有Undo记录组成一个(或多个)双向链表,逆序遍历Undo记录链表,并执行Undo操作,就可以将事务的所有修改都回滚掉。
下面是事务回滚一个简单的示意图。
事务回滚的过程中,也涉及到数据的修改,Insert的数据要删除掉,Delete的记录要取消删除标记,Update的记录,要更新回之前的状态,因此回滚操作本身也会生成Redo记录。那么回滚时会生成新的Undo记录吗?这里InnoDB做了处理,回滚时不生成新的Undo记录。
Undo记录保存在Undo表空间中。往Undo表空间中写入数据时,也需要先在InnoDB Buffer中修改,也会生成REDO日志。因此即使数据库或服务器崩溃了,Undo记录也不会丢失。
为了更好地观察Undo的实现,我们准备了一个测试案例。先创建一个表。
mysql> create table t_undo(
id varchar(10),
a varchar(30),
b varchar(30),
c varchar(30),
primary key(id),
key idx_a(a)
) engine=innodb;
在一个会话中开启一个一致性读取事务,这是为了让purge线程不要清理新生成的Undo记录。
然后开启另外一个会话,写入一些测试数据。
## 自动提交
insert into t_undo values
('ROW_01', rpad('',6,'A1'), rpad('',8,'B1'), rpad('',10,'C1'));
insert into t_undo values
('ROW_02', rpad('',6,'A2'), rpad('',8,'B2'), rpad('',10,'C2'));
## row-03 v1
insert into t_undo values
('ROW_03', rpad('',6,'A3'), rpad('',8,'B3'), rpad('',10,'C3'));
insert into t_undo values
('ROW_04', rpad('',6,'A4'), rpad('',8,'B4'), rpad('',10,'C4'));
insert into t_undo values
('ROW_05', rpad('',6,'A5'), rpad('',8,'B5'), rpad('',10,'C5'));
insert into t_undo values
('ROW_06', rpad('',6,'A6'), rpad('',8,'B6'), rpad('',10,'C6'));
## 开启事务
begin;
delete from t_undo where id = 'ROW_03';
## row-03 v2
insert into t_undo values
('ROW_03', rpad('',7,'X3'), rpad('',8,'Y3'), rpad('',10,'Z3'));
## row-03 v3
update t_undo
set a = rpad('', 8, 'R3'), b = rpad('', 8, 'S3'), c = rpad('', 10, 'T3')
where id = 'ROW_03';
## row-03 v4
update t_undo
set a = rpad('', 9, 'u3'), b = rpad('', 8, 'v3'), c = rpad('', 10, 'w3')
where id = 'ROW_03';
commit;
从binlog中可以找到最后一个事务的GITD和Xid。这个测试案例中,Xid为3598。后面你会看到,Undo日志中也记录了这些信息。
SET @@SESSION.GTID_NEXT= '7caa9a48-b325-11ed-8541-fab81f64ee00:16085'
# at 70670766
#240924 14:57:02 server id 119 end_log_pos 70670797 CRC32 0xe2263c4c Xid = 3598
COMMIT/*!*/;
上面这个测试案例中,生成了哪些Undo日志?Undo日志又存储在哪里呢?
先把t_undo表的数据页dump出来。前面提到过,每一行InnoDB的数据,都包含了db_trx_id和db_roll_ptr这两个隐藏字段。下面这张图中,标注了每一行记录的db_roll_ptr字段。通过db_roll_ptr就可以找到对应的Undo记录。
注:从上面的图里还可以观察到,ROW_03几个版本的数据都在页面中,这是因为更新的时候,每次记录长度都不一样,Update被转换成了Delete加Insert。
DB_ROLL_PTR的格式
DB_ROLL_PTR字段长度为7个字节,格式参考下面这张图。
第1个字节的最高1位是插入标记(Insert Flag),如果Undo类型为insert,那么这一位置为1。第1个字节的剩余7位为Undo表空间的编号,通过这个编号可以计算得到表空间ID。在8.0之前的版本中,这7位是回滚段ID。8.0引入了Undo表空间,这7位数字的含义有所变化。剩余的6个字节保存了Undo记录的页面编号和Undo记录在页面内的偏移。通过记录中的DB_ROLL_PTR,就可以找到构建上一个行版本需要的Undo记录。
我把测试案例中几行数据的db_roll_ptr整理到了下面这个表格中。
使用下面这个SQL,可以将Undo表空间编号转换成表空间ID。
mysql> select name, space, ( 0xFFFFFFEF - space ) % 128 + 1 as space_seq
from INNODB_TABLESPACES
where name like 'undo%' or name like 'innodb_undo%';
+-----------------+------------+-----------+
| name | space | space_seq |
+-----------------+------------+-----------+
| innodb_undo_001 | 4294967279 | 1 |
| innodb_undo_002 | 4294967278 | 2 |
| undo_x001 | 4294967277 | 3 |
+-----------------+------------+-----------+
编号3对应的表空间是undo_x001,这是我额外创建的一个Undo表空间。编号2对应的表空间是innodb_undo_002。
Undo物理存储
以前的版本中,Undo存储在系统表空间(ibdata)中。从8.0开始,InnoDB将Undo存储到了独立的Undo表空间中。Undo从系统表空间移出来之后,可以避免以前的版本中,因为大事务或Undo Purge速度慢导致的系统表空间膨胀的问题。
Undo表空间
MySQL 从8.0.14版本开始,在数据库初始化时默认会创建2个Undo表空间。Undo文件的位置由参数innodb_undo_directory指定,默认情况下Undo文件存储在DATADIR内。
使用create undo tablespace命令可以创建额外的undo表空间。一个实例中,最多可以有127个Undo表空间。大多数场景下,默认的2个undo表空间就够用了。如果你需要更多的Undo表空间,可以用create undo tablespace命令来创建。
创建Undo表空间有一些要求。
-
Undo文件名必须以.ibu结尾。
-
如果不指定文件路径,文件默认会放在innodb_undo_directory或datadir指定的路径下。
-
如果指定了路径,必须使用绝对路径,而且路径要在innodb_directories中设置。
-
使用drop undo tablespace命令删除undo表空间。系统默认的两个undo表空间不允许删除。
每个Undo表空间最多可容纳128个回滚段(Rollback Segment)。回滚段的数量可以通过参数innodb_rollback_segments设置,最大不能超过128。每个回滚段里面,可以创建多个Undo段,Undo段的数量跟数据页的大小有关,对于默认的16K数据页,一个回滚段里面最多可以创建1024个Undo段。
一个事务,如果修改了数据,就需要分配Undo段,用于存放Undo记录。InnoDB将所有修改数据的操作分为2大类,Insert操作和Update操作,这2类操作产生的Undo记录存放在两个单独的Undo段中。
如果事务中使用了临时表,那么修改临时表产生的Undo记录会存放到临时Undo段中,insert临时表产生的Undo记录存放到临时的insert undo段中,update临时表产生的undo记录存放到临时的update undo段中。所以一个事务最多可能会用到4个Undo段。
普通的Undo段存放到Undo表空间中,临时Undo段存放到临时表空间中。如果一个事务中没有insert操作,就不需要分配insert undo段。如果一个事务中没有Update操作,本来也不需要分配update Undo段,但如果使用了gtid,那么即使事务中只有insert操作,也要分配一个update undo段,用来存放事务的GTID。
Undo表空间文件格式
和普通的ibd文件类似,Undo文件分为多个数据页,前几个页面分别是文件头(File Header)、ibuf位图页、Inode页、回滚段数组(RSEG Array)页。
回滚段数组页(RSEG Array)
每个Undo表空间的第四个页面是回滚段数组页,页面类型为FIL_PAGE_TYPE_RSEG_ARRAY(0x15),这个页面中存储了当前表空间中所有回滚段的页面编号。这个页面的格式比较简单,参考下面这张图。
-
这个页面本身就组成了一个段,RSEG Array FSEG Header指向了Inode的地址。
-
RSEG Array存储了128个页面编号,每个编号对应一个回滚段。
下面的图里,就是一个回滚段数组页,里面存了128个回滚段的页面编号。
回滚段(Rollback Segment)
每个回滚段里最多分配1024个Undo段,回滚段的格式参考下面这张图。
回滚段的格式也比较简单。RSEG History Base Node是历史Undo日志链表的基节点,Purge线程就是从这个节点开始搜索要清理的Undo日志。RSEG Undo Slots中记录了Undo段头所在的页面编号,最多存1024个Undo段。
我们测试案例中最后那个事务的Undo(位于Undo页面D2),就存储在下面图里展示的回滚段中。
history链表中只有一个Undo日志,页面编号D2,偏移2300。
Undo段
Undo日志最终会存放在Undo段中。Undo段由Undo页头、Undo段头、Undo日志组成。Undo页面头、Undo段头的格式参考下面这张图。Undo段头存放在段内第一个Undo页的页头之后。
这些字段的含义我整理到了下面这个表格中。
测试案例中最后一个事务的Undo日志,就存放在下面这个Undo段中。从记录ROW_03的db_roll_ptr (03/000000D2/23D9)中可以找到这个Undo段。
-
Undo类型为2(TRX_UNDO_UPDATE)。
-
Latest Record(23D9)指向最新的Undo日志的记录起始处,地址为0x348000 + 0x23D9 = 0x34A3D9。
-
free(24F9)指向页面内的空闲空间起始处,地址为0x348000 + 0x24F9 = 0x34A4F9。
-
Latest Log(22DE)指向页面内最新的Undo日志头,地址为0x348000 + 0x22DE = 0x34A2DE。
-
Undo State为2(TRX_UNDO_CACHED),事务已完成,Undo段可重用。
-
Segment Page List Base Node为Undo页面链表,链表中只有一个页面,也就是当前页面,页面编号为D2。
Undo日志展示在下面这张图中。Undo页头的Latest Log、Latest Record、Free分别指向了Undo日志头、Undo记录、空闲空间。页面的Undo记录也组成了一个双向链表。
在Undo段中,Undo的数据由Undo头和Undo记录组成。Undo头的格式参考下面这张图。
这些字段的含义参考下面这个表格。
Undo日志头的一些信息,我在下面这张图中做了标注。
-
事务ID为01 E0 CD,和ibd文件中记录头的事务ID一样。
-
XID为0E0E,也就是3598,和binlog中看到的一样
-
GTID也和binlog中的一致(7caa9a48-b325-11ed-8541-fab81f64ee00:16085)
Undo记录
Undo记录也有一个固定的格式,每一条Undo记录的开始和结束的地方,保存了相邻记录的地址。
Undo记录中字段的描述信息参考下面这个表格。
总结
InnoDB使用Undo来实现事务的原子性,Undo日志中记录了回滚事务需要的所有信息。这一讲中,我们介绍了MySQL 8.0中Undo的物理存储格式。下一讲中还会继续介绍Undo在整个事务处理过程中的作用。
思考题
早期的版本中,Undo存储在系统表空间中,有时会遇到一个问题,就是History列表持续增长,导致系统表空间占用的文件很大。后续即使清理了Undo,但是系统表空间无法收缩。在8.0中,如果遇到类似的问题,Undo表空间增长得很大,有办法缩减Undo表空间吗?
期待你的思考,欢迎在留言区中与我交流。如果今天的课程让你有所收获,也欢迎转发给有需要的朋友。我们下节课再见!