Session 22 undo日志(上)

为了保证事务的原子性,当导致事务执行到一半就结束时,我们需要把情况改回原先的样子。这个过程就称之为==回滚==(英文名:rollback),这样就可以造成一个假象:这个事务看起来什么都没做,所以符合原子性要求。

每当我们要对一条记录做改动时(这里的改动可以指INSERTDELETEUPDATE),都需要留一手 —— 把回滚时所需的东西都给记下来。比方说:

  • 你插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉
  • 你删除了一条记录,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中
  • 你修改了一条记录,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值

这些为了回滚而记录的内容称为撤销日志(undo日志),SELECT不需要相应的undo日志。

本章辅助表:

1
2
3
4
5
6
7
CREATE TABLE undo_demo (
id INT NOT NULL,
key1 VARCHAR(100),
col VARCHAR(100),
PRIMARY KEY (id),
KEY idx_key1 (key1)
)Engine=InnoDB CHARSET=utf8;

有的时候虽然我们开启了一个事务,但是在这个事务中全是查询语句,并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个事务id

我们前面介绍InnoDB记录行格式的时候介绍过,一条记录在页面中的真实结构看起来是这样的:

image-20230908211044056

其中的trx_id列,就是某个对这个聚簇索引记录做改动的语句所在的事务对应的事务id而已(此处的改动可以是INSERTDELETEUPDATE操作)。至于roll_pointer隐藏列我们后边分析。

undo日志的格式

为了实现事务的原子性InnoDB存储引擎在实际进行增、删、改一条记录时,都需要==先==把对应的undo日志记下来。一般每对一条记录做一次改动,就对应着一条undo日志,但在某些更新记录的操作中,也可能会对应着2条undo日志,这个我们后边会仔细介绍。一个事务在执行过程中可能新增、删除、更新若干条记录,也就是说需要记录很多条对应的undo日志,这些undo日志会被从0开始编号,也就是说根据生成的顺序分别被称为第0号undo日志第1号undo日志、…、第n号undo日志等,这个编号也被称之为undo no

INSERT操作对应的undo日志

如果希望回滚插入操作,那么把这条记录删除就好了,也就是说在写对应的undo日志时,主要是把这条记录的主键信息记上。所以设计InnoDB的大佬设计了一个类型为TRX_UNDO_INSERT_RECundo日志,它的完整结构如下图所示:

image-20230908215549691

小贴士:当我们向某个表中插入一条记录时,实际上需要向聚簇索引和所有的二级索引都插入一条记录。不过记录undo日志时,我们只需要考虑向聚簇索引插入记录时的情况就好了,因为其实聚簇索引记录和二级索引记录是一一对应的,我们在回滚插入操作时,只需要知道这条记录的主键信息,然后根据主键信息做对应的删除操作,做删除操作时就会顺带着把所有二级索引中相应的记录也删除掉。后边说到的DELETE操作和UPDATE操作对应的undo日志也都是针对聚簇索引记录而言的,我们之后就不强调了。

例子(该记录的主键为一个INT值1)

image-20230908215725650

rolL_pointer隐藏列的含义

roll_pointer本质就是一个指针,指向记录对应的undo日志。

比方说我们上面向undo_demo表里插入了2条记录,每条记录都有与其对应的一条undo日志undo日志被存放到了类型为FIL_PAGE_UNDO_LOG的页面中。(事务id均为100,表明两次操作属于同一事务)

image-20230908220029245

DELETE操作对应的undo日志

我们在前面介绍数据页结构的时候说过,被删除的记录其实也会根据记录头信息中的next_record属性组成一个链表,只不过这个链表中的记录占用的存储空间可以被重新利用,所以也称这个链表为垃圾链表Page Header部分有一个称之为PAGE_FREE的属性,它指向由被删除记录组成的垃圾链表中的头节点。

image-20230908230113293

为了突出主题,在这个简化版的示意图中,我们只把记录的delete_mask标志位展示了出来。

假设现在我们准备使用DELETE语句把正常记录链表中的最后一条记录给删除掉,其实这个删除的过程需要经历两个阶段:

阶段一:仅仅将记录的delete_mask标识位设置为1其他的不做修改(其实会修改记录的trx_idroll_pointer这些隐藏列的值)。设计InnoDB的大佬把这个阶段称之为==delete mark==。

image-20230908230316068

在删除语句所在的事务提交之前,被删除的记录一直都处于这种所谓的中间状态

阶段二当该删除语句所在的事务提交之后,会有专门的线程来真正的把记录删除掉。所谓真正的删除就是把该记录从正常记录链表中移除,并且加入到垃圾链表(的头部)中,然后还要调整一些页面的其他信息,比如页面中的用户记录数量PAGE_N_RECS、上次插入记录的位置PAGE_LAST_INSERT、还有页目录的一些信息等等。设计InnoDB的大佬把这个阶段称之为==purge==(清理)。

**在删除语句所在的事务提交之前,只会经历阶段一**,也就是delete mark阶段(提交之后我们就不用回滚了,所以只需考虑对删除操作的阶段一做的影响进行回滚)。设计InnoDB的大佬为此设计了一种称之为TRX_UNDO_DEL_MARK_REC类型的undo日志,它的完整结构如下图所示:

image-20230908230658911

在对一条记录进行delete mark操作前,需要把该记录的旧的trx_idroll_pointer隐藏列的值都给记到对应的undo日志中来,就是我们图中显示的old trx_idold roll_pointer属性。

image-20230908230814685

可以看出来,执行完delete mark操作后,它对应的undo日志和INSERT操作对应的undo日志就串成了一个链表。这个链表就称之为==版本链==。

例子:

1
2
3
4
5
6
7
8
BEGIN;  # 显式开启一个事务,假设该事务的id为100

# 插入两条记录
INSERT INTO undo_demo(id, key1, col)
VALUES (1, 'AWM', '狙击枪'), (2, 'M416', '步枪');

# 删除一条记录
DELETE FROM undo_demo WHERE id = 1;

image-20230908231018070

(注意"狙击枪"不属于任何索引列,因此不在此日志中)

与类型为TRX_UNDO_INSERT_RECundo日志不同,类型为TRX_UNDO_DEL_MARK_RECundo日志还多了一个索引列各列信息的内容,也就是说如果某个列被包含在某个索引中,那么它的相关信息就应该被记录到这个索引列各列信息部分。这部分信息主要是用在事务提交后,对该中间状态记录做真正删除的阶段二,也就是purge阶段中使用的,具体如何使用现在我们可以忽略。

UPDATE操作对应的undo日志

在执行UPDATE语句时,InnoDB对更新主键和不更新主键这两种情况有截然不同的处理方案。

(1)不更新主键的情况

  1. 就地更新

​ 对于被更新的每个列来说,如果更新后的列和更新前的列占用的存储空间都一样大,那么就可以进行就地更新,也就是直接在原记录的基础上修改对应列的值。

  1. 先删除掉旧记录,再插入新记录

​ 在不更新主键的情况下,如果有任何一个被更新的列更新前和更新后占用的存储空间大小不一致,那么就需要先把这条旧的记录从聚簇索引页面中删除掉,然后再根据更新后列的值创建一条新的记录插入到页面中。

​ 请注意,我们这里所说的删除并不是delete mark操作,而是真正地删除掉,也就是把这条记录从正常记录链表中移除并加入到垃圾链表中,并且修改页面中相应的统计信息(比如PAGE_FREEPAGE_GARBAGE等这些信息)。

针对以上两种不更新主键的情况,设计InnoDB的大佬们设计了一种类型为TRX_UNDO_UPD_EXIST_RECundo日志,它的完整结构如下:

image-20230908231636305

由于原先数据被真正地删除了,因此这里需要保存被更新列更新前的记录。

如果在UPDATE语句中更新的列包含索引列,那么也会添加索引列各列信息这个部分,否则的话是不会添加这个部分的。

现在继续在上面那个事务id为100的事务中更新一条记录,比如我们把id为2的那条记录更新一下:

1
2
3
4
# 更新一条记录
UPDATE undo_demo
SET key1 = 'M249', col = '机枪'
WHERE id = 2;

这个UPDATE语句更新的列大小都没有改动,所以可以采用就地更新的方式来执行:

image-20230908234111523

由于本条UPDATE语句中更新了索引列key1的值,所以需要记录一下索引列各列信息部分,也就是把主键和key1列更新前的信息填入。

(2)更新主键的情况

针对UPDATE语句中更新了记录主键值的这种情况,InnoDB在聚簇索引中分了两步处理:

  1. 将旧记录进行delete mark操作

​ 注意:这里是delete mark操作!。这里一定要和我们上面所说的在不更新记录主键值时,先真正删除旧记录,再插入新记录的方式区分开!

  1. 根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中(需重新定位插入的位置)

针对UPDATE语句更新记录主键值的这种情况,在对该记录进行delete mark操作前,会记录一条类型为TRX_UNDO_DEL_MARK_RECundo日志;之后插入新记录时,会记录一条类型为TRX_UNDO_INSERT_RECundo日志,也就是说每对一条记录的主键值做改动时,会记录2条undo日志。这些日志的格式我们上面都介绍过了,就不赘述了。

之所以只对旧记录做delete mark操作,是因为别的事务同时也可能访问这条记录,如果把它真正的删除加入到垃圾链表后,别的事务就访问不到了。这个功能就是所谓的MVCC,我们后边的章节中会详细介绍

总结:

本章涉及了三种undo日志类型:

插入:TRX_UNDO_INSERT_REC

删除:TRX_UNDO_DEL_MARK_REC

更新(非主键)TRX_UNDO_UPD_EXIST_REC (只有此类型需要记录更新前的数据)