MySQL是怎样运行的:(22)undo日志(上)
Session 22 undo日志(上)
为了保证事务的原子性,当导致事务执行到一半就结束时,我们需要把情况改回原先的样子。这个过程就称之为==回滚
==(英文名:rollback
),这样就可以造成一个假象:这个事务看起来什么都没做,所以符合原子性
要求。
每当我们要对一条记录做改动时(这里的改动
可以指INSERT
、DELETE
、UPDATE
),都需要留一手 —— 把回滚时所需的东西都给记下来。比方说:
- 你插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉
- 你删除了一条记录,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中
- 你修改了一条记录,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值
这些为了回滚而记录的内容称为撤销日志(undo日志),SELECT不需要相应的undo日志。
本章辅助表:
1 | CREATE TABLE undo_demo ( |
有的时候虽然我们开启了一个事务,但是在这个事务中全是查询语句,并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个事务id
。
我们前面介绍InnoDB
记录行格式的时候介绍过,一条记录在页面中的真实结构看起来是这样的:
其中的trx_id
列,就是某个对这个聚簇索引记录做改动的语句所在的事务对应的事务id
而已(此处的改动可以是INSERT
、DELETE
、UPDATE
操作)。至于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_REC
的undo日志
,它的完整结构如下图所示:
小贴士:当我们向某个表中插入一条记录时,实际上需要向聚簇索引和所有的二级索引都插入一条记录。不过记录undo日志时,我们只需要考虑向聚簇索引插入记录时的情况就好了,因为其实聚簇索引记录和二级索引记录是一一对应的,我们在回滚插入操作时,只需要知道这条记录的主键信息,然后根据主键信息做对应的删除操作,做删除操作时就会顺带着把所有二级索引中相应的记录也删除掉。后边说到的DELETE操作和UPDATE操作对应的undo日志也都是针对聚簇索引记录而言的,我们之后就不强调了。
例子(该记录的主键为一个INT值1)
rolL_pointer隐藏列的含义
roll_pointer
本质就是一个指针,指向记录对应的undo日志。
比方说我们上面向undo_demo
表里插入了2条记录,每条记录都有与其对应的一条undo日志
。undo日志
被存放到了类型为FIL_PAGE_UNDO_LOG
的页面中。(事务id均为100,表明两次操作属于同一事务)
DELETE操作对应的undo日志
我们在前面介绍数据页结构的时候说过,被删除的记录其实也会根据记录头信息中的next_record
属性组成一个链表,只不过这个链表中的记录占用的存储空间可以被重新利用,所以也称这个链表为垃圾链表
。Page Header
部分有一个称之为PAGE_FREE
的属性,它指向由被删除记录组成的垃圾链表中的头节点。
为了突出主题,在这个简化版的示意图中,我们只把记录的delete_mask
标志位展示了出来。
假设现在我们准备使用DELETE
语句把正常记录链表
中的最后一条记录给删除掉,其实这个删除的过程需要经历两个阶段:
阶段一:仅仅将记录的delete_mask
标识位设置为1
,其他的不做修改(其实会修改记录的trx_id
、roll_pointer
这些隐藏列的值)。设计InnoDB
的大佬把这个阶段称之为==delete mark
==。
在删除语句所在的事务提交之前,被删除的记录一直都处于这种所谓的中间状态
。
阶段二:当该删除语句所在的事务提交之后,会有专门的线程来真正的把记录删除掉。所谓真正的删除就是把该记录从正常记录链表
中移除,并且加入到垃圾链表
(的头部)中,然后还要调整一些页面的其他信息,比如页面中的用户记录数量PAGE_N_RECS
、上次插入记录的位置PAGE_LAST_INSERT
、还有页目录的一些信息等等。设计InnoDB
的大佬把这个阶段称之为==purge
==(清理)。
**在删除语句所在的事务提交之前,只会经历阶段一
**,也就是delete mark
阶段(提交之后我们就不用回滚了,所以只需考虑对删除操作的阶段一
做的影响进行回滚)。设计InnoDB
的大佬为此设计了一种称之为TRX_UNDO_DEL_MARK_REC
类型的undo日志
,它的完整结构如下图所示:
在对一条记录进行delete mark
操作前,需要把该记录的旧的trx_id
和roll_pointer
隐藏列的值都给记到对应的undo日志
中来,就是我们图中显示的old trx_id
和old roll_pointer
属性。
可以看出来,执行完delete mark
操作后,它对应的undo
日志和INSERT
操作对应的undo
日志就串成了一个链表。这个链表就称之为==版本链
==。
例子:
1 | BEGIN; # 显式开启一个事务,假设该事务的id为100 |
(注意"狙击枪"
不属于任何索引列,因此不在此日志中)
与类型为TRX_UNDO_INSERT_REC
的undo日志
不同,类型为TRX_UNDO_DEL_MARK_REC
的undo
日志还多了一个索引列各列信息
的内容,也就是说如果某个列被包含在某个索引中,那么它的相关信息就应该被记录到这个索引列各列信息
部分。这部分信息主要是用在事务提交后,对该中间状态记录
做真正删除的阶段二,也就是purge
阶段中使用的,具体如何使用现在我们可以忽略。
UPDATE操作对应的undo日志
在执行UPDATE
语句时,InnoDB
对更新主键和不更新主键这两种情况有截然不同的处理方案。
(1)不更新主键的情况
- 就地更新
对于被更新的每个列来说,如果更新后的列和更新前的列占用的存储空间都一样大,那么就可以进行就地更新
,也就是直接在原记录的基础上修改对应列的值。
- 先删除掉旧记录,再插入新记录
在不更新主键的情况下,如果有任何一个被更新的列更新前和更新后占用的存储空间大小不一致,那么就需要先把这条旧的记录从聚簇索引页面中删除掉,然后再根据更新后列的值创建一条新的记录插入到页面中。
请注意,我们这里所说的删除
并不是delete mark
操作,而是真正地删除掉,也就是把这条记录从正常记录链表
中移除并加入到垃圾链表
中,并且修改页面中相应的统计信息(比如PAGE_FREE
、PAGE_GARBAGE
等这些信息)。
针对以上两种不更新主键的情况,设计InnoDB
的大佬们设计了一种类型为TRX_UNDO_UPD_EXIST_REC
的undo日志
,它的完整结构如下:
由于原先数据被真正地删除了,因此这里需要保存被更新列更新前的记录。
如果在UPDATE
语句中更新的列包含索引列,那么也会添加索引列各列信息
这个部分,否则的话是不会添加这个部分的。
现在继续在上面那个事务id为100的事务中更新一条记录,比如我们把id为2的那条记录更新一下:
1 | # 更新一条记录 |
这个UPDATE
语句更新的列大小都没有改动,所以可以采用就地更新
的方式来执行:
由于本条UPDATE
语句中更新了索引列key1
的值,所以需要记录一下索引列各列信息
部分,也就是把主键和key1
列更新前的信息填入。
(2)更新主键的情况
针对UPDATE
语句中更新了记录主键值的这种情况,InnoDB
在聚簇索引中分了两步处理:
- 将旧记录进行
delete mark
操作
注意:这里是delete mark操作!。这里一定要和我们上面所说的在不更新记录主键值时,先真正删除旧记录,再插入新记录的方式区分开!
- 根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中(需重新定位插入的位置)
针对UPDATE
语句更新记录主键值的这种情况,在对该记录进行delete mark
操作前,会记录一条类型为TRX_UNDO_DEL_MARK_REC
的undo日志
;之后插入新记录时,会记录一条类型为TRX_UNDO_INSERT_REC
的undo日志
,也就是说每对一条记录的主键值做改动时,会记录2条undo日志
。这些日志的格式我们上面都介绍过了,就不赘述了。
之所以只对旧记录做delete mark操作,是因为别的事务同时也可能访问这条记录,如果把它真正的删除加入到垃圾链表后,别的事务就访问不到了。这个功能就是所谓的MVCC,我们后边的章节中会详细介绍
总结:
本章涉及了三种undo日志类型:
插入:TRX_UNDO_INSERT_REC
删除:TRX_UNDO_DEL_MARK_REC
更新(非主键)TRX_UNDO_UPD_EXIST_REC
(只有此类型需要记录更新前的数据)