1.事务回滚的需求
我们说过事务
需要保证原子性
,也就是事务中的操作要么全部完成,要么什么也不做。但是偏偏有时候事务执行到一半会出现一些情况,比如:
- 情况一:事务执行过程中可能遇到各种错误,比如服务器本身的错误,操作系统错误,甚至是突然断电导致的错误。
- 情况二:程序员可以在事务执行过程中手动输入
ROLLBACK
语句结束当前的事务的执行。
这两种情况都会导致事务执行到一半就结束,但是事务执行过程中可能已经修改了很多东西,为了保证事务的原子性,我们需要把东西改回原先的样子,这个过程就称之为回滚
(英文名:rollback
),这样就可以造成一个假象:这个事务看起来什么都没做,所以符合原子性
要求。
每当我们要对一条记录做改动时(这里的改动
可以指INSERT
、DELETE
、UPDATE
),都需要留一手 —— 把回滚时所需的东西都给记下来。比方说:
- 你插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉就好了。
- 你删除了一条记录,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了。
- 你修改了一条记录,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值就好了。
数据库把这些为了回滚而记录的这些东西称之为撤销日志,英文名为undo log
,我们也可以土洋结合,称之为undo日志
。这里需要注意的一点是,由于查询操作(SELECT
)并不会修改任何用户记录,所以在查询操作执行时,并不需要记录相应的undo日志
。在真实的InnoDB
中,undo日志
其实并不像我们上边所说的那么简单,不同类型的操作产生的undo日志
的格式也是不同的,不过先暂时把这些具体细节放一放,我们先回过头来看看事务id
。
2.事务id
2.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
。
只有在事务对表中的记录做改动时才会为这个事务分配一个唯一的**事务id**
。
上边描述的事务id分配策略是针对MySQL 5.7来说的,前边的版本的分配方式可能不同。
2.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
。
2.3trx_id隐藏列
聚簇索引的记录除了会保存完整的用户数据以外,而且还会自动添加名为trx_id、roll_pointer的隐藏列,如果用户没有在表中定义主键以及UNIQUE键,还会自动添加一个名为row_id的隐藏列。所以一条记录在页面中的真实结构看起来就是这样的:
其中的trx_id
列其实还蛮好理解的,就是某个对这个聚簇索引记录做改动的语句所在的事务对应的事务id
而已(此处的改动可以是INSERT
、DELETE
、UPDATE
操作)。至于roll_pointer
隐藏列我们后边分析。
3.undo日志的格式
为了实现事务的原子性
,InnoDB
存储引擎在实际进行增、删、改一条记录时,都需要先把对应的undo日志
记下来。一般每对一条记录做一次改动,就对应着一条undo日志
,但在某些更新记录的操作中,也可能会对应着2条undo日志
。一个事务在执行过程中可能新增、删除、更新若干条记录,也就是说需要记录很多条对应的undo日志
,这些undo日志
会被从0
开始编号,也就是说根据生成的顺序分别被称为第0号undo日志
、第1号undo日志
、…、第n号undo日志
等,这个编号也被称之为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_sys_tables
表来查看某个表对应的table id
是什么,现在我们查看一下undo_demo
对应的table id
是多少:
mysql> SELECT * FROM information_schema.innodb_sys_tables WHERE name = 'yhd/undo_demo';
+----------+---------------------+------+--------+-------+-------------+------------+---------------+------------+
| TABLE_ID | NAME | FLAG | N_COLS | SPACE | FILE_FORMAT | ROW_FORMAT | ZIP_PAGE_SIZE | SPACE_TYPE |
+----------+---------------------+------+--------+-------+-------------+------------+---------------+------------+
| 138 | yhd/undo_demo | 33 | 6 | 482 | Barracuda | Dynamic | 0 | Single |
+----------+---------------------+------+--------+-------+-------------+------------+---------------+------------+
1 row in set (0.01 sec)
从查询结果可以看出,undo_demo
表对应的table id
为138
。
3.1INSERT操作对应的undo日志
当我们向表中插入一条记录时会有乐观插入
和悲观插入
的区分,但是不管怎么插入,最终导致的结果就是这条记录被放到了一个数据页中。如果希望回滚这个插入操作,那么把这条记录删除就好了,也就是说在写对应的undo
日志时,主要是把这条记录的主键信息记上。所以InnoDB
设计了一个类型为TRX_UNDO_INSERT_REC
的undo日志
,它的完整结构如下图所示:
根据示意图我们强调几点:
undo no
在一个事务中是从0
开始递增的,也就是说只要事务没提交,每生成一条undo日志
,那么该条日志的undo no
就增1。- 如果记录中的主键只包含一个列,那么在类型为
TRX_UNDO_INSERT_REC
的undo日志
中只需要把该列占用的存储空间大小和真实值记录下来,如果记录中的主键包含多个列,那么每个列占用的存储空间大小和对应的真实值都需要记录下来(图中的len
就代表列占用的存储空间大小,value
就代表列的真实值)。
当我们向某个表中插入一条记录时,实际上需要向聚簇索引和所有的二级索引都插入一条记录。不过记录undo日志时,我们只需要考虑向聚簇索引插入记录时的情况就好了,因为其实聚簇索引记录和二级索引记录是一一对应的,我们在回滚插入操作时,只需要知道这条记录的主键信息,然后根据主键信息做对应的删除操作,做删除操作时就会顺带着把所有二级索引中相应的记录也删除掉。后边说到的DELETE操作和UPDATE操作对应的undo日志也都是针对聚簇索引记录而言的。
现在我们向undo_demo
中插入两条记录:
BEGIN; # 显式开启一个事务,假设该事务的id为100
# 插入两条记录
INSERT INTO undo_demo(id, key1, col)
VALUES (1, 'AWM', '狙击枪'), (2, 'M416', '步枪');
因为记录的主键只包含一个id
列,所以我们在对应的undo日志
中只需要将待插入记录的id
列占用的存储空间长度(id
列的类型为INT
,INT
类型占用的存储空间长度为4
个字节)和真实值记录下来。本例中插入了两条记录,所以会产生两条类型为TRX_UNDO_INSERT_REC
的undo日志
:
- 第一条
undo日志
的undo no
为0
,记录主键占用的存储空间长度为4
,真实值为1
。画一个示意图就是这样: - 第二条
undo日志
的undo no
为1
,记录主键占用的存储空间长度为4
,真实值为2
。画一个示意图就是这样(与第一条undo日志
对比,undo no
和主键各列信息有不同):
为了最大限度的节省undo日志占用的存储空间,和我们前边说过的redo日志类似,InnoDB会给undo日志中的某些属性进行压缩处理。
①roll_pointer隐藏列的含义
roll_pointer
本质上就是一个指向记录对应的undo日志
的一个指针。比方说我们上边向undo_demo
表里插入了2条记录,每条记录都有与其对应的一条undo日志
。记录被存储到了类型为FIL_PAGE_INDEX
的页面中(就是我们前边一直所说的数据页
),undo日志
被存放到了类型为FIL_PAGE_UNDO_LOG
的页面中。效果如图所示:
**roll_pointer**
本质就是一个指针,指向记录对应的undo日志。
3.2 DELETE操作对应的undo日志
插入到页面中的记录会根据记录头信息中的next_record
属性组成一个单向链表,我们把这个链表称之为正常记录链表
;被删除的记录其实也会根据记录头信息中的next_record
属性组成一个链表,只不过这个链表中的记录占用的存储空间可以被重新利用,所以也称这个链表为垃圾链表
。Page Header
部分有一个称之为PAGE_FREE
的属性,它指向由被删除记录组成的垃圾链表中的头节点。我们先画一个图,假设此刻某个页面中的记录分布情况是这样的(这个不是undo_demo
表中的记录,只是我们随便举的一个例子):
为了突出主题,在这个简化版的示意图中,我们只把记录的delete_mask
标志位展示了出来。从图中可以看出,正常记录链表
中包含了3条正常记录,垃圾链表
里包含了2条已删除记录,在垃圾链表
中的这些记录占用的存储空间可以被重新利用。页面的Page Header
部分的PAGE_FREE
属性的值代表指向垃圾链表
头节点的指针。假设现在我们准备使用DELETE
语句把正常记录链表
中的最后一条记录给删除掉,其实这个删除的过程需要经历两个阶段:
阶段一:仅仅将记录的
delete_mask
标识位设置为1
,其他的不做修改(其实会修改记录的trx_id
、roll_pointer
这些隐藏列的值)。InnoDB
把这个阶段称之为delete mark
。
把这个过程画下来就是这样:
可以看到,正常记录链表
中的最后一条记录的delete_mask
值被设置为1
,但是并没有被加入到垃圾链表
。也就是此时记录处于一个中间状态
。在删除语句所在的事务提交之前,被删除的记录一直都处于这种所谓的中间状态
。为啥会有这种奇怪的中间状态呢?其实主要是为了实现一个称之为MVCC的功能。
阶段二:当该删除语句所在的事务提交之后,会有专门的线程后来真正的把记录删除掉。所谓真正的删除就是把该记录从
正常记录链表
中移除,并且加入到垃圾链表
中,然后还要调整一些页面的其他信息,比如页面中的用户记录数量PAGE_N_RECS
、上次插入记录的位置PAGE_LAST_INSERT
、垃圾链表头节点的指针PAGE_FREE
、页面中可重用的字节数量PAGE_GARBAGE
、还有页目录的一些信息等等。InnoDB
把这个阶段称之为purge
。
把阶段二
执行完了,这条记录就算是真正的被删除掉了。这条已删除记录占用的存储空间也可以被重新利用了。画下来就是这样:
将被删除记录加入到垃圾链表
时,实际上加入到链表的头节点处,会跟着修改PAGE_FREE
属性的值。
页面的Page Header部分有一个PAGE_GARBAGE属性,该属性记录着当前页面中可重用存储空间占用的总字节数。每当有已删除记录被加入到垃圾链表后,都会把这个PAGE_GARBAGE属性的值加上该已删除记录占用的存储空间大小。PAGE_FREE指向垃圾链表的头节点,之后每当新插入记录时,首先判断PAGE_FREE指向的头节点代表的已删除记录占用的存储空间是否足够容纳这条新插入的记录,如果不可以容纳,就直接向页面中申请新的空间来存储这条记录(并不会尝试遍历整个垃圾链表,找到一个可以容纳新记录的节点)。如果可以容纳,那么直接重用这条已删除记录的存储空间,并且把PAGE_FREE指向垃圾链表中的下一条已删除记录。但是这里有一个问题,如果新插入的那条记录占用的存储空间大小小于垃圾链表的头节点占用的存储空间大小,那就意味头节点对应的记录占用的存储空间里有一部分空间用不到,这部分空间就被称之为碎片空间。那这些碎片空间岂不是永远都用不到了么?其实也不是,这些碎片空间占用的存储空间大小会被统计到PAGE_GARBAGE属性中,这些碎片空间在整个页面快使用完前并不会被重新利用,不过当页面快满时,如果再插入一条记录,此时页面中并不能分配一条完整记录的空间,这时候会首先看一看PAGE_GARBAGE的空间和剩余可利用的空间加起来是不是可以容纳下这条记录,如果可以的话,InnoDB会尝试重新组织页内的记录,重新组织的过程就是先开辟一个临时页面,把页面内的记录依次插入一遍,因为依次插入时并不会产生碎片,之后再把临时页面的内容复制到本页面,这样就可以把那些碎片空间都解放出来(很显然重新组织页面内的记录比较耗费性能)。
从上边的描述中我们也可以看出来,在删除语句所在的事务提交之前,只会经历阶段一
,也就是delete mark
阶段(提交之后我们就不用回滚了,所以只需考虑对删除操作的阶段一
做的影响进行回滚)。InnoDB
为此设计了一种称之为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
表示)。所以索引列各列信息
存储的内容实质上就是<pos, len, value>
的一个列表。这部分信息主要是用在事务提交后,对该中间状态记录
做真正删除的阶段二,也就是purge
阶段中使用的。
现在继续在上边那个事务id为100
的事务中删除一条记录,比如我们把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, 3, 'AWM'>
共占用11
个字节。然后index_col_info len
本身占用2
个字节,所以加起来一共占用13
个字节,把数字13
就填到了index_col_info len
的属性中。
3.3 UPDATE操作对应的undo日志
在执行UPDATE
语句时,InnoDB
对更新主键和不更新主键这两种情况有截然不同的处理方案。
①不更新主键的情况
在不更新主键的情况下,又可以细分为被更新的列占用的存储空间不发生变化和发生变化的情况。
- 就地更新(in-place update)
更新记录时,对于被更新的每个列来说,如果更新后的列和更新前的列占用的存储空间都一样大,那么就可以进行就地更新
,也就是直接在原记录的基础上修改对应列的值。 - 先删除掉旧记录,再插入新记录
在不更新主键的情况下,如果有任何一个被更新的列更新前和更新后占用的存储空间大小不一致,那么就需要先把这条旧的记录从聚簇索引页面中删除掉,然后再根据更新后列的值创建一条新的记录插入到页面中。
注意,这里所说的删除
并不是delete mark
操作,而是真正的删除掉,也就是把这条记录从正常记录链表
中移除并加入到垃圾链表
中,并且修改页面中相应的统计信息(比如PAGE_FREE
、PAGE_GARBAGE
等这些信息)。不过这里做真正删除操作的线程并不是在DELETE
语句中做purge
操作时使用的另外专门的线程,而是由用户线程同步执行真正的删除操作,真正删除之后紧接着就要根据各个列更新后的值创建的新记录插入。
这里如果新创建的记录占用的存储空间大小不超过旧记录占用的空间,那么可以直接重用被加入到垃圾链表
中的旧记录所占用的存储空间,否则的话需要在页面中新申请一段空间以供新记录使用,如果本页面内已经没有可用的空间的话,那就需要进行页面分裂操作,然后再插入新记录。
针对UPDATE
不更新主键的情况(包括上边所说的就地更新和先删除旧记录再插入新记录),InnoDB设计了一种类型为
TRX_UNDO_UPD_EXIST_REC的
undo日志`,它的完整结构如下:
其实大部分属性和我们介绍过的TRX_UNDO_DEL_MARK_REC
类型的undo日志
是类似的,不过还是要注意这么几点:
n_updated
属性表示本条UPDATE
语句执行后将有几个列被更新,后边跟着的<pos, old_len, old_value>
分别表示被更新列在记录中的位置、更新前该列占用的存储空间大小、更新前该列的真实值。- 如果在
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操作,把它加入到垃圾链表中。之所以只对旧记录做delete mark操作,是因为别的事务同时也可能访问这条记录,如果把它真正的删除加入到垃圾链表后,别的事务就访问不到了。这个功能就是所谓的MVCC。
根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中(需重新定位插入的位置)。
由于更新后的记录主键值发生了改变,所以需要重新从聚簇索引中定位这条记录所在的位置,然后把它插进去。
针对UPDATE
语句更新记录主键值的这种情况,在对该记录进行delete mark
操作前,会记录一条类型为TRX_UNDO_DEL_MARK_REC
的undo日志
;之后插入新记录时,会记录一条类型为TRX_UNDO_INSERT_REC
的undo日志
,也就是说每对一条记录的主键值做改动时,会记录2条undo日志
。
4.通用链表结构
在写入undo日志
的过程中会使用到多个链表,很多链表都有同样的节点结构,如图所示:
在某个表空间内,我们可以通过一个页的页号和在页内的偏移量来唯一定位一个节点的位置,这两个信息也就相当于指向这个节点的一个指针。所以:
Pre Node Page Number
和Pre Node Offset
的组合就是指向前一个节点的指针Next Node Page Number
和Next Node Offset
的组合就是指向后一个节点的指针。
整个List Node
占用12
个字节的存储空间。
为了更好的管理链表,InnoDB
还提出了一个基节点的结构,里边存储了这个链表的头节点
、尾节点
以及链表长度信息,基节点的结构示意图如下:
其中:
List Length
表明该链表一共有多少节点。First Node Page Number
和First Node Offset
的组合就是指向链表头节点的指针。Last Node Page Number
和Last Node Offset
的组合就是指向链表尾节点的指针。
整个List Base Node
占用16
个字节的存储空间。
所以使用List Base Node
和List Node
这两个结构组成的链表的示意图就是这样:
5.FIL_PAGE_UNDO_LOG页面
表空间其实是由许许多多的页面构成的,页面默认大小为16KB
。这些页面有不同的类型,比如类型为FIL_PAGE_INDEX
的页面用于存储聚簇索引以及二级索引,类型为FIL_PAGE_TYPE_FSP_HDR
的页面用于存储表空间头部信息的,还有其他各种类型的页面,其中有一种称之为FIL_PAGE_UNDO_LOG
类型的页面是专门用来存储undo日志
的,这种类型的页面的通用结构如下图所示(以默认的16KB
大小为例):
我们就简称为Undo页面
,上图中的File Header
和File Trailer
是各种页面都有的通用结构。Undo Page Header
是Undo页面
所特有的,我们来看一下它的结构:
其中各个属性的意思如下:
TRX_UNDO_PAGE_TYPE
:本页面准备存储什么种类的undo日志
。
前边介绍了好几种类型的undo日志
,它们可以被分为两个大类:TRX_UNDO_INSERT
(使用十进制1
表示):类型为TRX_UNDO_INSERT_REC
的undo日志
属于此大类,一般由INSERT
语句产生,或者在UPDATE
语句中有更新主键的情况也会产生此类型的undo日志
。TRX_UNDO_UPDATE
(使用十进制2
表示),除了类型为TRX_UNDO_INSERT_REC
的undo日志
,其他类型的undo日志
都属于这个大类,比如我们前边说的TRX_UNDO_DEL_MARK_REC
、TRX_UNDO_UPD_EXIST_REC
啥的,一般由DELETE
、UPDATE
语句产生的undo日志
属于这个大类。
这个TRX_UNDO_PAGE_TYPE
属性可选的值就是上边的两个,用来标记本页面用于存储哪个大类的undo日志
,不同大类的undo日志
不能混着存储,比如一个Undo页面
的TRX_UNDO_PAGE_TYPE
属性值为TRX_UNDO_INSERT
,那么这个页面就只能存储类型为TRX_UNDO_INSERT_REC
的undo日志
,其他类型的undo日志
就不能放到这个页面中了。
之所以把undo日志分成两个大类,是因为类型为TRX_UNDO_INSERT_REC的undo日志在事务提交后可以直接删除掉,而其他类型的undo日志还需要为所谓的MVCC服务,不能直接删除掉,对它们的处理需要区别对待。
TRX_UNDO_PAGE_START
:表示在当前页面中是从什么位置开始存储undo日志
的,或者说表示第一条undo日志
在本页面中的起始偏移量。TRX_UNDO_PAGE_FREE
:与上边的TRX_UNDO_PAGE_START
对应,表示当前页面中存储的最后一条undo
日志结束时的偏移量,或者说从这个位置开始,可以继续写入新的undo日志
。
假设现在向页面中写入了3条undo日志
,那么TRX_UNDO_PAGE_START
和TRX_UNDO_PAGE_FREE
的示意图就是这样:
当然,在最初一条undo日志
也没写入的情况下,TRX_UNDO_PAGE_START
和TRX_UNDO_PAGE_FREE
的值是相同的。TRX_UNDO_PAGE_NODE
:代表一个List Node
结构(链表的普通节点,我们上边刚说的)。
6.Undo页面链表
6.1单个事务中的Undo页面链表
因为一个事务可能包含多个语句,而且一个语句可能对若干条记录进行改动,而对每条记录进行改动前,都需要记录1条或2条的undo日志
,所以在一个事务执行过程中可能产生很多undo日志
,这些日志可能一个页面放不下,需要放到多个页面中,这些页面就通过我们上边介绍的TRX_UNDO_PAGE_NODE
属性连成了链表:
链表中的第一个Undo页面
称它为first undo page
,其余的Undo页面
称之为normal undo page
,这是因为在first undo page
中除了记录Undo Page Header
之外,还会记录其他的一些管理信息。
在一个事务执行过程中,可能混着执行INSERT
、DELETE
、UPDATE
语句,也就意味着会产生不同类型的undo日志
。但是,同一个Undo页面
要么只存储TRX_UNDO_INSERT
大类的undo日志
,要么只存储TRX_UNDO_UPDATE
大类的undo日志
,反正不能混着存,所以在一个事务执行过程中就可能需要2个Undo页面
的链表,一个称之为insert undo链表
,另一个称之为update undo链表
,画个示意图就是这样:
另外,InnoDB
规定对普通表和临时表的记录改动时产生的undo日志
要分别记录(我们稍后阐释为啥这么做),所以在一个事务中最多有4个以Undo页面
为节点组成的链表:
当然,并不是在事务一开始就会为这个事务分配这4个链表,具体分配策略如下:
- 刚刚开启事务时,一个
Undo页面
链表也不分配。 - 当事务执行过程中向普通表中插入记录或者执行更新记录主键的操作之后,就会为其分配一个
普通表的insert undo链表
。 - 当事务执行过程中删除或者更新了普通表中的记录之后,就会为其分配一个
普通表的update undo链表
。 - 当事务执行过程中向临时表中插入记录或者执行更新记录主键的操作之后,就会为其分配一个
临时表的insert undo链表
。 - 当事务执行过程中删除或者更新了临时表中的记录之后,就会为其分配一个
临时表的update undo链表
。
总结一句就是:按需分配,啥时候需要啥时候再分配,不需要就不分配。
6.2多个事务中的Undo页面链表
为了尽可能提高undo日志
的写入效率,不同事务执行过程中产生的undo日志需要被写入到不同的Undo页面链表中。比方说现在有事务id
分别为1
、2
的两个事务,我们分别称之为trx 1
和trx 2
,假设在这两个事务执行过程中:
trx 1
对普通表做了DELETE
操作,对临时表做了INSERT
和UPDATE
操作。InnoDB
会为trx 1
分配3个链表,分别是:- 针对普通表的
update undo链表
- 针对临时表的
insert undo链表
- 针对临时表的
update undo链表
。
- 针对普通表的
trx 2
对普通表做了INSERT
、UPDATE
和DELETE
操作,没有对临时表做改动。InnoDB
会为trx 2
分配2个链表,分别是:- 针对普通表的
insert undo链表
- 针对普通表的
update undo链表
。
- 针对普通表的
综上所述,在trx 1
和trx 2
执行过程中,InnoDB
共需为这两个事务分配5个Undo页面
链表,画个图就是这样:
如果有更多的事务,那就意味着可能会产生更多的Undo页面
链表。
7.undo日志具体写入过程
7.1段(Segment)的概念
段
是一个逻辑上的概念,本质上是由若干个零散页面和若干个完整的区组成的。比如一个B+
树索引被划分成两个段,一个叶子节点段,一个非叶子节点段,这样叶子节点就可以被尽可能的存到一起,非叶子节点被尽可能的存到一起。每一个段对应一个INODE Entry
结构,这个INODE Entry
结构描述了这个段的各种信息,比如段的ID
,段内的各种链表基节点,零散页面的页号有哪些等信息。为了定位一个INODE Entry
,InnoDB
设计了一个Segment Header
的结构,整个Segment Header
占用10个字节大小,各个属性的意思如下:
Space ID of the INODE Entry
:INODE Entry
结构所在的表空间ID。Page Number of the INODE Entry
:INODE Entry
结构所在的页面页号。Byte Offset of the INODE Ent
:INODE Entry
结构在该页面中的偏移量
知道了表空间ID、页号、页内偏移量,就可以唯一定位一个INODE Entry
的地址。
7.2 Undo Log Segment Header
InnoDB
规定,每一个Undo页面
链表都对应着一个段
,称之为Undo Log Segment
。也就是说链表中的页面都是从这个段里边申请的,所以他们在Undo页面
链表的第一个页面,也就是上边提到的first undo page
中设计了一个称之为Undo Log Segment Header
的部分,这个部分中包含了该链表对应的段的segment header
信息以及其他的一些关于这个段的信息,所以Undo
页面链表的第一个页面其实长这样:
可以看到这个Undo
链表的第一个页面比普通页面多了个Undo Log Segment Header
,我们来看一下它的结构:
TRX_UNDO_STATE
:本Undo页面
链表处在什么状态。
一个Undo Log Segment
可能处在的状态包括:TRX_UNDO_ACTIVE
:活跃状态,也就是一个活跃的事务正在往这个段里边写入undo日志
。TRX_UNDO_CACHED
:被缓存的状态。处在该状态的Undo页面
链表等待着之后被其他事务重用。TRX_UNDO_TO_FREE
:对于insert undo
链表来说,如果在它对应的事务提交之后,该链表不能被重用,那么就会处于这种状态。TRX_UNDO_TO_PURGE
:对于update undo
链表来说,如果在它对应的事务提交之后,该链表不能被重用,那么就会处于这种状态。TRX_UNDO_PREPARED
:包含处于PREPARE
阶段的事务产生的undo日志
。
TRX_UNDO_LAST_LOG
:本Undo页面
链表中最后一个Undo Log Header
的位置。TRX_UNDO_FSEG_HEADER
:本Undo页面
链表对应的段的Segment Header
信息。TRX_UNDO_PAGE_LIST
:Undo页面
链表的基节点。
Undo页面
的Undo Page Header
部分有一个12字节大小的TRX_UNDO_PAGE_NODE
属性,这个属性代表一个List Node
结构。每一个Undo页面
都包含Undo Page Header
结构,这些页面就可以通过这个属性连成一个链表。这个TRX_UNDO_PAGE_LIST
属性代表着这个链表的基节点,当然这个基节点只存在于Undo页面
链表的第一个页面,也就是first undo page
中。
7.3Undo Log Header
一个事务在向Undo页面
中写入undo日志
时的方式是十分简单暴力的,就是直接往里怼,写完一条紧接着写另一条,各条undo日志
之间是亲密无间的。写完一个Undo页面
后,再从段里申请一个新页面,然后把这个页面插入到Undo页面
链表中,继续往这个新申请的页面中写。InnoDB
认为同一个事务向一个Undo页面
链表中写入的undo日志
算是一个组,比方说我们上边介绍的trx 1
由于会分配3个Undo页面
链表,也就会写入3个组的undo日志
;trx 2
由于会分配2个Undo页面
链表,也就会写入2个组的undo日志
。在每写入一组undo日志
时,都会在这组undo日志
前先记录一下关于这个组的一些属性,InnoDB
把存储这些属性的地方称之为Undo Log Header
。所以Undo页面
链表的第一个页面在真正写入undo日志
前,其实都会被填充Undo Page Header
、Undo Log Segment Header
、Undo Log Header
这3个部分,如图所示:
这个Undo Log Header
具体的结构如下:
TRX_UNDO_TRX_ID
:生成本组undo日志
的事务id
。TRX_UNDO_TRX_NO
:事务提交后生成的一个需要序号,使用此序号来标记事务的提交顺序(先提交的此序号小,后提交的此序号大)。TRX_UNDO_DEL_MARKS
:标记本组undo
日志中是否包含由于Delete mark
操作产生的undo日志
。TRX_UNDO_LOG_START
:表示本组undo
日志中第一条undo日志
的在页面中的偏移量。TRX_UNDO_XID_EXISTS
:本组undo日志
是否包含XID信息。TRX_UNDO_DICT_TRANS
:标记本组undo日志
是不是由DDL语句产生的。TRX_UNDO_TABLE_ID
:如果TRX_UNDO_DICT_TRANS
为真,那么本属性表示DDL语句操作的表的table id
。TRX_UNDO_NEXT_LOG
:下一组的undo日志
在页面中开始的偏移量。TRX_UNDO_PREV_LOG
:上一组的undo日志
在页面中开始的偏移量。一般来说一个Undo页面链表只存储一个事务执行过程中产生的一组undo日志,但是在某些情况下,可能会在一个事务提交之后,之后开启的事务重复利用这个Undo页面链表,这样就会导致一个Undo页面中可能存放多组Undo日志,TRX_UNDO_NEXT_LOG和TRX_UNDO_PREV_LOG就是用来标记下一组和上一组undo日志在页面中的偏移量的。
TRX_UNDO_HISTORY_NODE
:一个12字节的List Node
结构,代表一个称之为History
链表的节点。
7.4 小结
对于没有被重用的Undo页面
链表来说,链表的第一个页面,也就是first undo page
在真正写入undo日志
前,会填充Undo Page Header
、Undo Log Segment Header
、Undo Log Header
这3个部分,之后才开始正式写入undo日志
。对于其他的页面来说,也就是normal undo page
在真正写入undo日志
前,只会填充Undo Page Header
。链表的List Base Node
存放到first undo page
的Undo Log Segment Header
部分,List Node
信息存放到每一个Undo页面
的undo Page Header
部分,所以画一个Undo页面
链表的示意图就是这样:
8.重用Undo页面
为了能提高并发执行的多个事务写入undo日志
的性能,InnoDB
决定为每个事务单独分配相应的Undo页面
链表(最多可能单独分配4个链表)。但是这样也造成了一些问题,比如其实大部分事务执行过程中可能只修改了一条或几条记录,针对某个Undo页面
链表只产生了非常少的undo日志
,这些undo日志
可能只占用一点存储空间,每开启一个事务就新创建一个Undo页面
链表(虽然这个链表中只有一个页面)来存储这么一点undo日志
岂不是太浪费了么?的确是挺浪费,于是InnoDB
决定在事务提交后在某些情况下重用该事务的Undo页面
链表。一个Undo页面
链表是否可以被重用的条件很简单:
- 该链表中只包含一个
Undo页面
。
如果一个事务执行过程中产生了非常多的undo日志
,那么它可能申请非常多的页面加入到Undo页面
链表中。在该事物提交后,如果将整个链表中的页面都重用,那就意味着即使新的事务并没有向该Undo页面
链表中写入很多undo日志
,那该链表中也得维护非常多的页面,那些用不到的页面也不能被别的事务所使用,这样就造成了另一种浪费。所以InnoDB
规定,只有在Undo页面
链表中只包含一个Undo页面
时,该链表才可以被下一个事务所重用。 - 该
Undo页面
已经使用的空间小于整个页面空间的3/4。
Undo页面
链表按照存储的undo日志
所属的大类可以被分为insert undo链表
和update undo链表
两种,这两种链表在被重用时的策略也是不同的,我们分别看一下:
- insert undo链表
insert undo链表
中只存储类型为TRX_UNDO_INSERT_REC
的undo日志
,这种类型的undo日志
在事务提交之后就没用了,就可以被清除掉。所以在某个事务提交后,重用这个事务的insert undo链表
(这个链表中只有一个页面)时,可以直接把之前事务写入的一组undo日志
覆盖掉,从头开始写入新事务的一组undo日志
。
假设有一个事务使用的insert undo链表
,到事务提交时,只向insert undo链表
中插入了3条undo日志
,这个insert undo链表
只申请了一个Undo页面
。假设此刻该页面已使用的空间小于整个页面大小的3/4,那么下一个事务就可以重用这个insert undo链表
(链表中只有一个页面)。假设此时有一个新事务重用了该insert undo链表
,那么可以直接把旧的一组undo日志
覆盖掉,写入一组新的undo日志
。
在重用Undo页面链表写入新的一组undo日志时,不仅会写入新的Undo Log Header,还会适当调整Undo Page Header、Undo Log Segment Header、Undo Log Header中的一些属性,比如TRX_UNDO_PAGE_START、TRX_UNDO_PAGE_FREE等等。
- update undo链表
在一个事务提交后,它的update undo链表
中的undo日志
也不能立即删除掉(这些日志用于MVCC)。所以如果之后的事务想重用update undo链表
时,就不能覆盖之前事务写入的undo日志
。这样就相当于在同一个Undo页面
中写入了多组的undo日志
。
9.回滚段
9.1回滚段的概念
一个事务在执行过程中最多可以分配4个Undo页面
链表,在同一时刻不同事务拥有的Undo页面
链表是不一样的,所以在同一时刻系统里其实可以有许许多多个Undo页面
链表存在。为了更好的管理这些链表,InnoDB
又设计了一个称之为Rollback Segment Header
的页面,在这个页面中存放了各个Undo页面
链表的frist undo page
的页号
,这些页号
称之为undo slot
。可以这样理解,每个Undo页面
链表都相当于是一个班,这个链表的first undo page
就相当于这个班的班长,找到了这个班的班长,就可以找到班里的其他同学(其他同学相当于normal undo page
)。有时候学校需要向这些班级传达一下精神,就需要把班长都召集在会议室,这个Rollback Segment Header
就相当于是一个会议室。
InnoDB
规定,每一个Rollback Segment Header
页面都对应着一个段,这个段就称为Rollback Segment
,翻译过来就是回滚段
。与之前介绍的各种段不同的是,这个Rollback Segment
里其实只有一个页面。
了解了Rollback Segment
的含义之后,我们再来看看这个称之为Rollback Segment Header
的页面的各个部分的含义都是啥意思:
TRX_RSEG_MAX_SIZE
:本Rollback Segment
中管理的所有Undo页面
链表中的Undo页面
数量之和的最大值。换句话说,本Rollback Segment
中所有Undo页面
链表中的Undo页面
数量之和不能超过TRX_RSEG_MAX_SIZE
代表的值。
该属性的值默认为无限大,也就是我们想写多少Undo页面
都可以。无限大其实也只是个夸张的说法,4个字节能表示最大的数也就是0xFFFFFFFF,但是0xFFFFFFFF这个数有特殊用途,所以实际上TRX_RSEG_MAX_SIZE的值为0xFFFFFFFE。
TRX_RSEG_HISTORY_SIZE
:History
链表占用的页面数量。TRX_RSEG_HISTORY
:History
链表的基节点。TRX_RSEG_FSEG_HEADER
:本Rollback Segment
对应的10字节大小的Segment Header
结构,通过它可以找到本段对应的INODE Entry
。TRX_RSEG_UNDO_SLOTS
:各个Undo页面
链表的first undo page
的页号
集合,也就是undo slot
集合。
一个页号占用4
个字节,对于16KB
大小的页面来说,这个TRX_RSEG_UNDO_SLOTS
部分共存储了1024
个undo slot
,所以共需1024 × 4 = 4096
个字节。
9.2 从回滚段中申请Undo页面链表
初始情况下,由于未向任何事务分配任何Undo页面
链表,所以对于一个Rollback Segment Header
页面来说,它的各个undo slot
都被设置成了一个特殊的值:FIL_NULL
(对应的十六进制就是0xFFFFFFFF
),表示该undo slot
不指向任何页面。
随着时间的流逝,开始有事务需要分配Undo页面
链表了,就从回滚段的第一个undo slot
开始,看看该undo slot
的值是不是FIL_NULL
:
- 如果是
FIL_NULL
,那么在表空间中新创建一个段(也就是Undo Log Segment
),然后从段里申请一个页面作为Undo页面
链表的first undo page
,然后把该undo slot
的值设置为刚刚申请的这个页面的页号,这样也就意味着这个undo slot
被分配给了这个事务。 - 如果不是
FIL_NULL
,说明该undo slot
已经指向了一个undo链表
,也就是说这个undo slot
已经被别的事务占用了,那就跳到下一个undo slot
,判断该undo slot
的值是不是FIL_NULL
,重复上边的步骤。
一个Rollback Segment Header
页面中包含1024
个undo slot
,如果这1024
个undo slot
的值都不为FIL_NULL
,这就意味着这1024
个undo slot
都已经被分配给了某个事务,此时由于新事务无法再获得新的Undo页面
链表,就会回滚这个事务并且给用户报错:
Too many active concurrent transactions
用户看到这个错误,可以选择重新执行这个事务(可能重新执行时有别的事务提交了,该事务就可以被分配Undo页面
链表了)。
当一个事务提交时,它所占用的undo slot
有两种命运:
- 如果该
undo slot
指向的Undo页面
链表符合被重用的条件(就是我们上边说的Undo页面
链表只占用一个页面并且已使用空间小于整个页面的3/4)。
该undo slot
就处于被缓存的状态,InnoDB
规定这时该Undo页面链表的TRX_UNDO_STATE
属性(该属性在first undo page
的Undo Log Segment Header
部分)会被设置为TRX_UNDO_CACHED
。
被缓存的undo slot
都会被加入到一个链表,根据对应的Undo页面
链表的类型不同,也会被加入到不同的链表:- 如果对应的
Undo页面
链表是insert undo链表
,则该undo slot
会被加入insert undo cached链表
。 - 如果对应的
Undo页面
链表是update undo链表
,则该undo slot
会被加入update undo cached链表
。
- 如果对应的
一个回滚段就对应着上述两个cached链表
,如果有新事务要分配undo slot
时,先从对应的cached链表
中找。如果没有被缓存的undo slot
,才会到回滚段的Rollback Segment Header
页面中再去找。
- 如果该
undo slot
指向的Undo页面
链表不符合被重用的条件,那么针对该undo slot
对应的Undo页面
链表类型不同,也会有不同的处理:- 如果对应的
Undo页面
链表是insert undo链表
,则该Undo页面
链表的TRX_UNDO_STATE
属性会被设置为TRX_UNDO_TO_FREE
,之后该Undo页面
链表对应的段会被释放掉(也就意味着段中的页面可以被挪作他用),然后把该undo slot
的值设置为FIL_NULL
。 - 如果对应的
Undo页面
链表是update undo链表
,则该Undo页面
链表的TRX_UNDO_STATE
属性会被设置为TRX_UNDO_TO_PRUGE
,则会将该undo slot
的值设置为FIL_NULL
,然后将本次事务写入的一组undo
日志放到所谓的History链表
中(需要注意的是,这里并不会将Undo页面
链表对应的段给释放掉,因为这些undo
日志还有用)。
- 如果对应的
9.3 多个回滚段
一个事务执行过程中最多分配4
个Undo页面
链表,而一个回滚段里只有1024
个undo slot
,很显然undo slot
的数量有点少。即使假设一个读写事务执行过程中只分配1
个Undo页面
链表,那1024
个undo slot
也只能支持1024
个读写事务同时执行,再多了就崩溃了。
在InnoDB
的早期只有一个回滚段,但后来定义了128
个回滚段,也就相当于有了128 × 1024 = 131072
个undo slot
。假设一个读写事务执行过程中只分配1
个Undo页面
链表,那么就可以同时支持131072
个读写事务并发执行。
只读事务并不需要分配Undo页面链表,MySQL 5.7中所有刚开启的事务默认都是只读事务,只有在事务执行过程中对记录做了某些改动时才会被升级为读写事务。
每个回滚段都对应着一个Rollback Segment Header
页面,有128个回滚段,自然就要有128个Rollback Segment Header
页面,这些页面的地址需要找个地方存一下!于是InnoDB
在系统表空间的第5
号页面的某个区域包含了128个8字节大小的格子:
每个8字节的格子的构造就像这样:
如果所示,每个8字节的格子其实由两部分组成:
- 4字节大小的
Space ID
,代表一个表空间的ID。 - 4字节大小的
Page number
,代表一个页号。
也就是说每个8字节大小的格子
相当于一个指针,指向某个表空间中的某个页面,这些页面就是Rollback Segment Header
。这里需要注意的一点事,要定位一个Rollback Segment Header
还需要知道对应的表空间ID,这也就意味着不同的回滚段可能分布在不同的表空间中。
所以通过上边的叙述我们可以大致清楚,在系统表空间的第5
号页面中存储了128个Rollback Segment Header
页面地址,每个Rollback Segment Header
就相当于一个回滚段。在Rollback Segment Header
页面中,又包含1024
个undo slot
,每个undo slot
都对应一个Undo页面
链表。我们画个示意图:
9.4回滚段的分类
把这128个回滚段给编一下号,最开始的回滚段称之为第0号回滚段
,之后依次递增,最后一个回滚段就称之为第127号回滚段
。这128个回滚段可以被分成两大类:
- 第
0
号、第33~127
号回滚段属于一类。其中第0
号回滚段必须在系统表空间中(就是说第0
号回滚段对应的Rollback Segment Header
页面必须在系统表空间中),第33~127
号回滚段既可以在系统表空间中,也可以在自己配置的undo
表空间中。
如果一个事务在执行过程中由于对普通表的记录做了改动需要分配Undo页面
链表时,必须从这一类的段中分配相应的undo slot
。 - 第
1~32
号回滚段属于一类。这些回滚段必须在临时表空间(对应着数据目录中的ibtmp1
文件)中。
如果一个事务在执行过程中由于对临时表的记录做了改动需要分配Undo页面
链表时,必须从这一类的段中分配相应的undo slot
。
也就是说如果一个事务在执行过程中既对普通表的记录做了改动,又对临时表的记录做了改动,那么需要为这个记录分配2个回滚段,再分别到这两个回滚段中分配对应的undo slot
。
为啥要把针对普通表和临时表来划分不同种类的回滚段
呢?这个还得从Undo页面
本身说起,我们说Undo页面
其实是类型为FIL_PAGE_UNDO_LOG
的页面的简称,说到底它也是一个普通的页面。在修改页面之前一定要先把对应的redo日志
写上,这样在系统奔溃重启时才能恢复到奔溃前的状态。我们向Undo页面
写入undo日志
本身也是一个写页面的过程,InnoDB
为此还设计了许多种redo日志
的类型,也就是说我们对Undo页面
做的任何改动都会记录相应类型的redo日志
。但是对于临时表来说,因为修改临时表而产生的undo日志
只需要在系统运行过程中有效,如果系统奔溃了,那么在重启时也不需要恢复这些undo
日志所在的页面,所以在写针对临时表的Undo页面
时,并不需要记录相应的redo日志
。总结一下针对普通表和临时表划分不同种类的回滚段
的原因:在修改针对普通表的回滚段中的Undo页面时,需要记录对应的redo日志,而修改针对临时表的回滚段中的Undo页面时,不需要记录对应的redo日志。
实际上在MySQL 5.7.21这个版本中,如果我们仅仅对普通表的记录做了改动,那么只会为该事务分配针对普通表的回滚段,不分配针对临时表的回滚段。但是如果我们仅仅对临时表的记录做了改动,那么既会为该事务分配针对普通表的回滚段,又会为其分配针对临时表的回滚段(不过分配了回滚段并不会立即分配undo slot,只有在真正需要Undo页面链表时才会去分配回滚段中的undo slot)。
9.5 为事务分配Undo页面链表详细过程
接下来以事务对普通表的记录做改动为例,梳理一下事务执行过程中分配Undo页面
链表时的完整过程:
事务在执行过程中对普通表的记录首次做改动之前,首先会到系统表空间的第
5
号页面中分配一个回滚段(其实就是获取一个Rollback Segment Header
页面的地址)。一旦某个回滚段被分配给了这个事务,那么之后该事务中再对普通表的记录做改动时,就不会重复分配了。
使用round-robin
(循环使用)方式来分配回滚段。比如当前事务分配了第0
号回滚段,那么下一个事务就要分配第33
号回滚段,下下个事务就要分配第34
号回滚段,简单一点的说就是这些回滚段被轮着分配给不同的事务。在分配到回滚段后,首先看一下这个回滚段的两个
cached链表
有没有已经缓存了的undo slot
,比如如果事务做的是INSERT
操作,就去回滚段对应的insert undo cached链表
中看看有没有缓存的undo slot
;如果事务做的是DELETE
操作,就去回滚段对应的update undo cached链表
中看看有没有缓存的undo slot
。如果有缓存的undo slot
,那么就把这个缓存的undo slot
分配给该事务。如果没有缓存的
undo slot
可供分配,那么就要到Rollback Segment Header
页面中找一个可用的undo slot
分配给当前事务。
从Rollback Segment Header
页面中分配可用的undo slot
的方式我们上边也说过了,就是从第0
个undo slot
开始,如果该undo slot
的值为FIL_NULL
,意味着这个undo slot
是空闲的,就把这个undo slot
分配给当前事务,否则查看第1
个undo slot
是否满足条件,依次类推,直到最后一个undo slot
。如果这1024
个undo slot
都没有值为FIL_NULL
的情况,就直接报错(一般不会出现这种情况)。找到可用的
undo slot
后,如果该undo slot
是从cached链表
中获取的,那么它对应的Undo Log Segment
已经分配了,否则的话需要重新分配一个Undo Log Segment
,然后从该Undo Log Segment
中申请一个页面作为Undo页面
链表的first undo page
。然后事务就可以把
undo日志
写入到上边申请的Undo页面
链表了!
对临时表的记录做改动的步骤和上述的一样。不过需要再次强调一次,如果一个事务在执行过程中既对普通表的记录做了改动,又对临时表的记录做了改动,那么需要为这个记录分配2个回滚段。并发执行的不同事务其实也可以被分配相同的回滚段,只要分配不同的undo slot就可以了。
10.回滚段相关配置
10.1配置回滚段数量
系统中一共有128
个回滚段,其实这只是默认值,我们可以通过启动参数innodb_rollback_segments
来配置回滚段的数量,可配置的范围是1~128
。但是这个参数并不会影响针对临时表的回滚段数量,针对临时表的回滚段数量一直是32
,也就是说:
- 如果我们把
innodb_rollback_segments
的值设置为1
,那么只会有1个针对普通表的可用回滚段,但是仍然有32个针对临时表的可用回滚段。 - 如果我们把
innodb_rollback_segments
的值设置为2~33
之间的数,效果和将其设置为1
是一样的。 - 如果我们把
innodb_rollback_segments
设置为大于33
的数,那么针对普通表的可用回滚段数量就是该值减去32。
10.2 配置undo表空间
默认情况下,针对普通表设立的回滚段(第0
号以及第33~127
号回滚段)都是被分配到系统表空间的。其中的第0
号回滚段是一直在系统表空间的,但是第33~127
号回滚段可以通过配置放到自定义的undo表空间
中。但是这种配置只能在系统初始化(创建数据目录时)的时候使用,一旦初始化完成,之后就不能再次更改了。我们看一下相关启动参数:
- 通过
innodb_undo_directory
指定undo表空间
所在的目录,如果没有指定该参数,则默认undo表空间
所在的目录就是数据目录。 - 通过
innodb_undo_tablespaces
定义undo表空间
的数量。该参数的默认值为0
,表明不创建任何undo表空间
。
第33~127
号回滚段可以平均分布到不同的undo表空间
中。
如果我们在系统初始化的时候指定了创建了undo表空间,那么系统表空间中的第0号回滚段将处于不可用状态。
比如我们在系统初始化时指定的innodb_rollback_segments
为35
,innodb_undo_tablespaces
为2
,这样就会将第33
、34
号回滚段分别分布到一个undo表空间
中。
设立undo表空间
的一个好处就是在undo表空间
中的文件大到一定程度时,可以自动的将该undo表空间
截断(truncate)成一个小文件。而系统表空间的大小只能不断的增大,却不能截断。
11.总结
为了保证事务的原子性设计,InnoDB引入了undo日志。undo日志记载了回滚一个段所需的必要内容。
在事务对表中的记录进行改动的时候,才会为这个事务分配一个唯一的ID。事务ID值是一个递增的数字。先被分配ID的事务得到的是较小的事务ID,后被分配ID的事务得到的是较大的事务ID。未被分配事务ID的事务ID默认是0。聚簇索引记录中有一个trx_id隐藏列,他代表对这个聚簇索引隐藏记录进行改动的语句所在的事务对应的事务ID。
InnoDB针对不同的场景设计了不同类型的undo日志。
类型为FIL_PAGE_UNDO_LOG的页面是专门用来存储undo日志的,简称为undo页面。
在一个事务执行过程中,最多分配四个undo页面链表:
- 针对普通表的insert undo链表
- 针对普通表的update undo链表
- 针对临时表的insert undo链表
- 针对临时表的update undo链表
只有在真正用到这些链表的时候才会去创建他们。
每个undo页面链表都对应一个undo log segment。undo页面链表的第一个页面中有一个名为undo log segment header 的部分,专门用来存储关于这个段的一些信息。
同一个事务向一个undo页面链表中写入的undo日志算是一个组,每个组都以一个undo log header部分开头。
一个undo页面链表如果可以被重用,需要符合两个条件:
- 该链表只包含一个undo页面
- 该undo页面已经使用的空间小于整个页面空间的3/4
每一个Rollback segmrnt header 页面都对应一个回滚段,每个回滚段包含1024个undo slot,一个undo slot代表一个undo页面链表的第一个页面的页号。目前,InnoDB最多支持128个回滚段,其中第0号,第33127号回滚段是针对普通表设计的,第132号回滚段是针对临时表设计的。
我们可以选择将undo日志记录到专门的undo表空间中,在undo表空间中的文件大到一定程度时,可以自动将该undo表空间截断为小文件。