大家好。 我们知道,事务需要保证原子性,也就是事务中的操作要么全部完成,要么什么也不做。但是我们在开发的过程中肯定都遇到过事务执行到一半就结束,但是事务执行过程中可能已经修改了很多东西的情况,为了保证事务的原子性,这个时候我们就需要把东西改回原先的样子,这个过程称之为回滚(英文名:rollback)。
为了满足回滚操作,每当我们要对一条记录做改动时(这里的改动可以指INSERT、DELETE、UPDATE),mysql都会把回滚时所需的东西记录下来,这些为了回滚而记录的东西称之为撤销日志,也就是我们今天要讲的undo日志。下面我们就来聊一聊这个undo日志。
一、事务id
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. 事务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。
3. trx_id隐藏列
我们知道,聚簇索引的记录除了会保存完整的用户数据以外,而且还会自动添加名为trx_id、roll_pointer的隐藏列,如果用户没有在表中定义主键以及UNIQUE键,还会自动添加一个名为row_id的隐藏列。所以一条记录在页面中的真实结构看起来就是这样的:
其中的trx_id列就是某个对这个聚簇索引记录做改动的语句所在的事务对应的事务id(此处的改动可以是INSERT、DELETE、UPDATE操作)。
二、undo日志的格式
为了实现事务的原子性,InnoDB存储引擎在实际进行增、删、改一条记录时,都需要先把对应的undo日志记下来。一般每对一条记录做一次改动,就对应着一条undo日志,但在某些更新记录的操作中,也可能会对应着2条undo日志。一个事务在执行过程中可能新增、删除、更新若干条记录,也就是说需要记录很多条对应的undo日志,这些undo日志会被从0开始编号,这个编号也被称之为undo no。
这些undo日志是被记录到类型为FIL_PAGE_UNDO_LOG的页面中。这些页面可以从系统表空间中分配,也可以从一种专门存放undo日志的表空间,也就是所谓的undo tablespace中分配。下面我们就来看看不同操作都会产生什么样子的undo日志。为了方便讲解,我们先来创建一个名为undo_demo的表:
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;
这个表中有3个列,其中id列是主键,我们为key1列建立了一个二级索引,col列是一个普通的列。我们知道每个表都会被分配一个唯一table id ,我们可以通过系统数据库information_schema中的 innodb_tables表来查看某个表对应的table id是什么,现在我们查看一下undo_demo表对应的table id是多少:
1. INSERT操作对应的undo日志
当我们向表中插入一条记录时会有乐观插入和悲观插入的区分,但是不管怎么插入,最终导致的结果就是这条记录被放到了一个数据页中。如果希望回滚这个插入操作,那么把这条记录删除就好了,也就是说在写对应的undo日志时,主要是把这条记录的主键信息记上。所以InnoDB设计了一个类型为TRX_UNDO_INSERT_REC的undo日志 ,它的完整结构如下图所示:
根据示意图我们强调几点:
-
undo no在一个事务中是从0开始递增的,也就是说只要事务没提交,每生成一条undo日志,那么该条日志的undo no就增1。
-
如果记录中的主键只包含一个列,那么在类型为TRX_UNDO_INSERT_REC的undo日志中只需要把该列占用的存储空间大小和真实值记录下来,如果记录中的主键包含多个列,那么每个列占用的存储空间大小和对应的真实值都需要记录下来(图中的len就代表列占用的存储空间大小,value就代表列的真实值)。
现在我们向undo_demo 中插入两条记录:
BEGIN; # 显式开启一个事务,假设该事务的id为100
# 插入两条记录
INSERT INTO undo_demo(id, key1, col)
VALUES (1, 'AWM', '狙击枪'), (2, 'M416', '步枪');
因为记录的主键只包含一个id列,所以我们在对应的undo日志中只需要将待插入记录的id列占用的存储空间长度和真实值记录下来。本例中插入了两条记录,所以会产生两条类型为TRX_UNDO_INSERT_REC的undo日志 :
第一条undo日志的undo no为0,记录主键占用的存储空间长度为 4,真实值为1。画一个示意图就是这样:
第二条undo日志的undo no为1 ,记录主键占用的存储空间长度为 4 ,真实值为2。画一个示意图就是这样(与第一条undo日志对比,undo no和主键各列信息有不同):
下面我们再来说一下上面提到的roll_pointer。这个占用 7个字节的字段本质上就是一个指向记录对应的undo日志的一个指针。比方说我们上边向undo_demo表里插入了2条记录,每条记录都有与其对应的一条undo日志 。记录被存储到了类型为FIL_PAGE_INDEX的页面中,undo日志被存放到了类型为FIL_PAGE_UNDO_LOG的页面中。效果如图所示:
2. DELETE操作对应的undo日志
我们知道插入到页面中的记录会根据记录头信息中的next_record属性组成一个单向链表,我们把这个链表称之为正常记录链表;被删除的记录其实也会根据记录头信息中的next_record属性组成一个链表,只不过这个链表中的记录占用的存储空间可以被重新利用,所以也称这个链表为垃圾链表。Page Header部分有一个称之为PAGE_FREE的属性,它指向由被删除记录组成的垃圾链表中的头节点。为了下边内容的讲解,我们先画一个图,假设此刻某个页面中的记录分布情况是这样的:
为了突出主题,在这个简化版的示意图中,我们只把记录的delete_mask标志位展示了出来。从图中可以看出,正常记录链表中包含了3条正常记录,垃圾链表里包含了2条已删除记录,在垃圾链表中的这些记录占用的存储空间可以被重新利用。页面的Page Header部分的 PAGE_FREE属性的值代表指向垃圾链表头节点的指针。假设现在我们准备使用DELETE语句把正常记录链表中的最后一条记录给删除掉,其实这个删除的过程需要经历两个阶段:
阶段一: 仅仅将记录的delete_mask标识位设置为1 ,其他的不做修改(其实会修改记录的trx_id、roll_pointer这些隐藏列的值)。这个阶段称之为 delete mark 。把这个过程画下来就是这样:
可以看到,正常记录链表中的最后一条记录的delete_mask值被设置为1 ,但是并没有被加入到垃圾链表。也就是此时记录处于一个中间状态。在删除语句所在的事务提交之前,被删除的记录一直都处于这种所谓的中间状态。
阶段二: 当该删除语句所在的事务提交之后,会有专门的线程后来真正的把记录删除掉。所谓真正的删除就是把该记录从正常记录链表中移除,并且加入到垃圾链表中,然后还要调整一些页面的其他信息,比如页 面中的用户记录数量PAGE_N_RECS、上次插入记录的位置 PAGE_LAST_INSERT、垃圾链表头节点的指针PAGE_FREE、页面中可重用的字节数量PAGE_GARBAGE 、还有页目录的一些信息等等。这个阶段称之为purge 。
把阶段二执行完了,这条记录就算是真正的被删除掉了。这条已删除记录占用的存储空间也可以被重新利用了。画下来就是这样:
注意:将被删除记录加入到垃圾链表时,实际上加入到链表的头节点处,会跟着修改PAGE_FREE属性的值。
从上边的描述中我们也可以看出来,在删除语句所在的事务提交之前,只会经历阶段一,也就是delete mark阶段。InnoDB为此设计了一种称之为TRX_UNDO_DEL_MARK_REC 类型的undo日志 ,它的完整结构如下图所示:
在这个类型为TRX_UNDO_DEL_MARK_REC的undo日志中的属性中,我们要特别注意以下这几点:
-
在对一条记录进行delete mark操作前,需要把该记录的旧的 trx_id和roll_pointer隐藏列的值都给记到对应的undo日志中来,就是我们图中显示的old trx_id和old roll_pointer属性。这样就可以通过undo日志的old roll_pointer找到记录在修改之前对应的undo日志。比方说在一个事务中,我们先插入了一条记录,然后又执行对该记录的删除操作,这个过程的示意图就是这样:
从图中可以看出来,执行完delete mark操作后,它对应的undo日志和INSERT操作对应的undo日志就串成了一个链表。这个链表就称之为版本链。 -
与类型为TRX_UNDO_INSERT_REC的undo日志不同,类型为 TRX_UNDO_DEL_MARK_REC的undo日志还多了一个索引列各列信息的内容,也就是说如果某个列被包含在某个索引中,那么它的相关信息就应该被记录到这个索引列各列信息部分,所谓的相关信息包括该列在记录中的位置(用pos表示),该列占用的存储空间大小(用len 表示),该列实际值(用value 表示)。所以索引列各列信息存储的内容实质上就是一个列表。这部分信息主要是用在事务提交后,对该中间状态记录做真正删除的阶段二,也就是purge 阶段中使用的。
下面我们在undo_demo表中删除一条记录,比如我们把id为1的那条记录删除掉:
BEGIN; # 显式开启一个事务,假设该事务的id为100
# 插入两条记录
INSERT INTO undo_demo(id, key1, col)
VALUES (1, 'AWM', '狙击枪'), (2, 'M416', '步枪');
# 删除一条记录
DELETE FROM undo_demo WHERE id = 1;
这个delete mark 操作对应的 undo日志 的结构就是这样:
对照着这个图,我们得注意下边几点:
- 因为这条undo日志是id为100的事务中产生的第3条undo日志,所以它对应的undo no就是2 。
- 在对记录做delete mark操作时,记录的 trx_id 隐藏列的值是100,所以把100填入old trx_id属性中。然后把记录的roll_pointer隐藏列的值取出来,填入old roll_pointer属性中,这样就可以通过old roll_pointer属性值找到最近一次对该记录做改动时产生的undo日志。
- 由于undo_demo表中有2个索引:一个是聚簇索引,一个是二级索引 idx_key1。只要是包含在索引中的列,那么这个列在记录中的位置(pos),占用存储空间大小(len)和实际值(value)就需要存储到 undo日志中。
对于主键来说,只包含一个id列,存储到undo日志中的相关信息分别是:
pos: id列是主键,也就是在记录的第一个列,它对应的pos值为0。pos占用1个字节来存储。
len: id列的类型为INT,占用4个字节,所以len的值为4 。len占用1个字节来存储。
value: 在被删除的记录中id列的值为1,也就是value的值为1。value占用4个字节来存储。画一个图演示一下就是这样:
所以对于id列来说,最终存储的结果就是<0, 4, 1> ,存储这些信息占用的存储空间大小为1 + 1 + 4 = 6 个字节。
对于idx_key1来说,只包含一个key1列,存储到undo日志中的相关信息分别是:
pos: key1列是排在id列、 trx_id列、 roll_pointer列之后的,它对应的pos值为3。pos占用1个字节来存储。
len: key1列的类型为VARCHAR(100),使用utf8字符集,被删除的记录实际存储的内容是AWM,所以一共占用3个字节,所以len的值为3。len占用1个字节来存储。
value: 在被删除的记录中key1列的值为AWM,也就是value的值为 AWM。value占用3个字节来存储。画一个图演示一下就是这样:
所以对于key1 列来说,最终存储的结果就是<3, 3, ‘AWM’> ,存储这些信息占用的存储空间大小 为1 + 1 + 3 = 5 个字节。
从上边的叙述中可以看到,<0, 4, 1> 和 ❤️, 3, ‘AWM’> 共占用11 个字节。然后index_col_info len本身占用2个字节,所以加起来一共占用13个字节,把数字13就填到了index_col_info len的属性中。
3. UPDATE操作对应的undo日志
在执行UPDATE语句时, InnoDB对更新主键和不更新主键这两种情况有截然不同的处理方案。
不更新主键的情况:在不更新主键的情况下,又可以细分为被更新的列占用的存储空间不发生变化和发生变化的情况。
- 就地更新(in-place update)
更新记录时,对于被更新的每个列来说,如果更新后的列和更新前的列占用的存储空间都一样大,那么就可以进行就地更新,也就是直接在原记录的基础上修改对应列的值。比方说现在undo_demo表里还有一条id值为2的记录,它的各个列占用的大小如图所示(因为采用utf8字符集,所以’步枪’这两个字符占用6个字节):
假如我们有这样的UPDATE 语句:
UPDATE undo_demo SET key1 = 'P92', col = '手枪' WHERE id = 2;
在这个UPDATE 语句中, col 列从步枪被更新为手枪,前后都占用6个字节,也就是占用的存储空间大小未改变;key1列从M416被更新为P92 ,也就是从4个字节被更新为3个字节,这就不满足就地更新需要的条件了,所以不能进行就地更新。但是如果UPDATE语句长这样:
UPDATE undo_demo SET key1 = 'M249', col = '机枪' WHERE id = 2;
由于各个被更新的列在更新前后占用的存储空间是一样大的,所以这样的语句可以执行就地更新。
- 先删除掉旧记录,再插入新记录
在不更新主键的情况下,如果有任何一个被更新的列更新前和更新后占用的存储空间大小不一致,那么就需要先把这条旧的记录从聚簇索引页面中删除掉,然后再根据更新后列的值创建一条新的记录插入到页面中。
注意一下,这里所说的删除并不是delete mark操作,而是真正的删除掉,也就是把这条记录从正常记录链表中移除并加入到垃圾链表中,并且修改页面中相应的统计信息(比如PAGE_FREE、 PAGE_GARBAGE 等这些信息)。不过这里做真正删除操作的线程是由用户线程同步执行真正的删除操作,真正删除之后紧接着就要根据各个列更新后的值创建的新记录插入。
这里如果新创建的记录占用的存储空间大小不超过旧记录占用的空间,那么可以直接重用被加入到垃圾链表中的旧记录所占用的存储空间,否则的话需要在页面中新申请一段空间以供新记录使用,如果本页面内已 经没有可用的空间的话,那就需要进行页面分裂操作,然后再插入新记录。
针对UPDATE 不更新主键的情况(包括上边所说的就地更新和先删除旧记录再插入新记录),InnoDB设计了一种类型为TRX_UNDO_UPD_EXIST_REC的undo日志 ,它的完整结构如下:
其实大部分属性和我们介绍过的TRX_UNDO_DEL_MARK_REC类型的 undo日志是类似的,不过还是要注意这么几点:
-
n_updated 属性表示本条UPDATE 语句执行后将有几个列被更新,后边跟着的分别表示被更新列在记录中的位置、更新前该列占用的存储空间大小、更新前该列的真实值。
-
如果在UPDATE 语句中更新的列包含索引列,那么也会添加索引列各列信息这个部分,否则的话是不会添加这个部分的。
现在继续在上边那个事务id为100的事务中更新一条记录,比如我们把id为2的那条记录更新一下:
BEGIN; # 显式开启一个事务,假设该事务的id为100
# 插入两条记录
INSERT INTO undo_demo(id, key1, col)
VALUES (1, 'AWM', '狙击枪'), (2, 'M416', '步枪');
# 删除一条记录
DELETE FROM undo_demo WHERE id = 1;
# 更新一条记录
UPDATE undo_demo
SET key1 = 'M249', col = '机枪'
WHERE id = 2;
这个UPDATE 语句更新的列大小都没有改动,所以可以采用就地更新的方式来执行,在真正改动页面记录时,会 先记录一条类型为TRX_UNDO_UPD_EXIST_REC的undo日志 ,长这样:
对照着这个图我们注意一下这几个地方:
- 因为这条undo日志 是 id 为 100 的事务中产生的第4条undo日志 ,所以它对应的undo no就是3。
- 这条日志的roll_pointer指向undo no为1的那条日志,也就是插入主键值为2的记录时产生的那条undo日志 ,也就是最近一次对该记录做改动时产生的undo日志。
- 由于本条UPDATE语句中更新了索引列key1的值,所以需要记录一下索引列各列信息部分,也就是把主键和key1列更新前的信息填入。
更新主键的情况:在聚簇索引中,记录是按照主键值的大小连成了一个单向链表的,如果我们更新了某条记录的主键值,意味着这条记录在聚簇索引中的位置将会发生改变,比如你将记录的主键值从1更新为10000,如果还有非常多的记录的主键值分布在1 ~ 10000之间的话,那么这两条记录在聚簇索引中就有可能离得非常远,甚至中间隔了好多个页面。针对UPDATE 语句中更新了记录主键值的这种情况,InnoDB 在聚簇索引中分了两步处理:
步骤一: 将旧记录进行delete mark操作
注意:这里是delete mark操作!也就是说在UPDATE语句所在的事务提交前,对旧记录只做一个delete mark操作,在事务提交后才由专门的线程做purge操作,把它加入到垃圾链表中。这里一定要和我们上边所说的在不更新记录主键值时,先真正删除旧记录,再插入新记录的方式区分开!
步骤二: 根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中(需重新定位插入的位置)。
由于更新后的记录主键值发生了改变,所以需要重新从聚簇索引中定位这条记录所在的位置,然后把它插进去。
针对UPDATE语句更新记录主键值的这种情况,在对该记录进行delete mark操作前,会记录一条类型为TRX_UNDO_DEL_MARK_REC的undo日志;之后插入新记录时,会记录一条类型为 TRX_UNDO_INSERT_REC的undo日志,也就是说每对一条记录的主键值做改动时,会记录2条undo日志。
好了,今天就讲到这里了,今天我们讲了什么是undo日志以及增删改操作对应的undo日志是什么样的。明天我们继续讲undo接下来的部分。
大家有什么想法欢迎留言讨论。也希望大家能给作者点个关注,谢谢大家!最后依旧是请各位老板有钱的捧个人场,没钱的也捧个人场,谢谢各位老板!