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表空间吗?
期待你的思考,欢迎在留言区中与我交流。如果今天的课程让你有所收获,也欢迎转发给有需要的朋友。我们下节课再见!
- binzhang 👍(1) 💬(1)
can multiple active transactions share same undo segment? btw, i ask gpt4o, it says """3. **No New Undo Records**: The rollback operation does not create new undo records because it is directly using the existing undo log to revert changes. The goal is to clean up and restore the database without creating additional overhead."""
2024-11-02