第21章 后悔了怎么办-undo日志
事务回滚的需求
我们说过 事务 需要保证 原子性,也就是事务中的操作要么全部完成,要么什么也不做。但是偏偏有时候事务执行到一半会出现一些情况,比如:
-
情况一:事务执行过程中可能遇到各种错误,比如服务器本身的错误,操作系统错误,甚至是突然断电导致的错误。
-
情况二:程序员可以在事务执行过程中手动输入
ROLLBACK语句结束当前的事务的执行。
这两种情况都会导致事务执行到一半就结束,但是事务执行过程中可能已经修改了很多东西,为了保证事务的原子性,我们需要把东西改回原先的样子,这个过程就称之为 回滚(英文名:rollback),这样就可以造成一个假象:这个事务看起来什么都没做,所以符合 原子性 要求。
小时候我非常痴迷于象棋,总是想找厉害的大人下棋,赢棋是不可能赢棋的,这辈子都不可能赢棋的,又不想认输,只能偷偷的悔棋才能勉强玩的下去。悔棋 就是一种非常典型的 回滚 操作,比如棋子往前走两步,悔棋 对应的操作就是向后走两步;比如棋子往左走一步,悔棋 对应的操作就是向右走一步。数据库中的回滚跟 悔棋 差不多,你插入了一条记录,回滚 操作对应的就是把这条记录删除掉;你更新了一条记录,回滚 操作对应的就是把该记录更新为旧值;你删除了一条记录,回滚 操作对应的自然就是把该记录再插进去。说的貌似很简单的样子[手动偷笑😏]。
从上面的描述中我们已经能隐约感觉到,每当我们要对一条记录做改动时(这里的 改动 可以指 INSERT、DELETE、UPDATE),都需要留一手 —— 把回滚时所需的东西都给记下来。比方说:
-
你插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉就好了。
-
你删除了一条记录,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了。
-
你修改了一条记录,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值就好了。
设计数据库的大佬把这些为了回滚而记录的这些东东称之为 撤销日志,英文名为 undo log,我们也可以土洋结合,称之为 undo 日志。这里需要注意的一点是,由于查询操作(SELECT)并不会修改任何用户记录,所以在查询操作执行时,并不需要记录相应的 undo 日志。在真实的 InnoDB 中,undo 日志 其实并不像我们上面所说的那么简单,不同类型的操作产生的 undo 日志 的格式也是不同的,不过先暂时把这些容易让人脑子糊的具体细节放一放,我们先回过头来看看 事务id 是个神马玩意儿。
事务id
给事务分配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。
|
上面描述的事务 id 分配策略是针对 MySQL 5.7 来说的,前面的版本的分配方式可能不同~ |
事务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。
undo日志的格式
为了实现事务的 原子性,InnoDB 存储引擎在实际进行增、删、改一条记录时,都需要先把对应的 undo日志 记下来。一般每对一条记录做一次改动,就对应着一条 undo日志,但在某些更新记录的操作中,也可能会对应着 2 条 undo日志,这个我们后边会仔细介绍。一个事务在执行过程中可能新增、删除、更新若干条记录,也就是说需要记录很多条对应的 undo日志,这些 undo日志 会被从 0 开始编号,也就是说根据生成的顺序分别被称为 第0号undo日志、第1号undo日志、…、第n号undo日志 等,这个编号也被称之为 undo no。
这些 undo日志 是被记录到类型为 FIL_PAGE_UNDO_LOG(对应的十六进制是 0x0002,忘记了页面类型是什么的同学需要回过头再看看前面的章节)的页面中。这些页面可以从系统表空间中分配,也可以从一种专门存放 undo日志 的表空间,也就是所谓的 undo tablespace 中分配。不过关于如何分配存储 undo日志 的页面这个事情我们稍后再说,现在先来看看不同操作都会产生什么样子的 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 列是一个普通的列。我们前面介绍 InnoDB 的数据字典时说过,每个表都会被分配一个唯一的 table id,我们可以通过系统数据库 information_schema 中的 innodb_sys_tables 表来查看某个表对应的 table id 是什么,现在我们查看一下 undo_demo 对应的 table id 是多少:
mysql> SELECT * FROM information_schema.innodb_sys_tables WHERE name = 'xiaohaizi/undo_demo';
+----------+---------------------+------+--------+-------+-------------+------------+---------------+------------+
| TABLE_ID | NAME | FLAG | N_COLS | SPACE | FILE_FORMAT | ROW_FORMAT | ZIP_PAGE_SIZE | SPACE_TYPE |
+----------+---------------------+------+--------+-------+-------------+------------+---------------+------------+
| 138 | xiaohaizi/undo_demo | 33 | 6 | 482 | Barracuda | Dynamic | 0 | Single |
+----------+---------------------+------+--------+-------+-------------+------------+---------------+------------+
1 row in set (0.01 sec)
从查询结果可以看出,undo_demo 表对应的 table id 为 138,先把这个值记住,我们后边有用。
INSERT操作对应的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 的真实面纱了,这个占用 7 个字节的字段其实一点都不神秘,本质上就是一个指向记录对应的 undo日志 的一个指针。比方说我们上面向 undo_demo 表里插入了 2 条记录,每条记录都有与其对应的一条 undo日志。记录被存储到了类型为 FIL_PAGE_INDEX 的页面中(就是我们前面一直所说的 数据页),undo日志 被存放到了类型为 FIL_PAGE_UNDO_LOG 的页面中。效果如图所示:
从图中也可以更直观的看出来,roll_pointer 本质就是一个指针,指向记录对应的undo日志。不过这 7 个字节的 roll_pointer 的每一个字节具体的含义我们后边介绍完如何分配存储 undo 日志的页面之后再具体说~
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日志,它的完整结构如下图所示:
这个里边的属性也太多了点儿吧~(其实大部分属性的意思我们上面已经介绍过了) 是的,的确有点多,不过大家千万不要在意,如果记不住千万不要勉强自己,我这里把它们都列出来让大家混个脸熟而已。劳烦大家先克服一下密集恐急症,再抬头大致看一遍上面的这个类型为 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日志就串成了一个链表。这个很有意思啊,这个链表就称之为 版本链,现在貌似看不出这个 版本链 有什么用,等我们再往后看看,讲完UPDATE操作对应的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 个字节来存储。
-
画一个图演示一下就是这样:
-
对于 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的属性中。 -
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 等这些信息)。不过这里做真正删除操作的线程并不是在介绍 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 操作!这里是 delete mark 操作!这里是 delete mark 操作!也就是说在
UPDATE语句所在的事务提交前,对旧记录只做一个 delete mark 操作,在事务提交后才由 专门的线程做 purge 操作,把它加入到垃圾链表中。这里一定要和我们上面所说的在不更新记录主键值时,先真正删除旧记录,再插入新记录的方式区分开!之所以只对旧记录做 delete mark 操作,是因为别的事务同时也可能访问这条记录,如果把它真正的删除加入到垃圾链表后,别的事务就访问不到了。这个功能就是所谓的 MVCC,我们后边的章节中会详细介绍什么是个 MVCC。
-
根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中(需重新定位插入的位置)。
由于更新后的记录主键值发生了改变,所以需要重新从聚簇索引中定位这条记录所在的位置,然后把它插进去。
针对 UPDATE 语句更新记录主键值的这种情况,在对该记录进行 delete mark 操作前,会记录一条类型为 TRX_UNDO_DEL_MARK_REC 的 undo 日志;之后插入新记录时,会记录一条类型为 TRX_UNDO_INSERT_REC 的 undo 日志,也就是说每对一条记录的主键值做改动时,会记录 2 条 undo 日志。这些日志的格式我们上面都介绍过了,就不赘述了。
其实还有一种称为
TRX_UNDO_UPD_DEL_REC的undo日志的类型我们没有介绍,主要是想避免引入过多的复杂度,如果大家对这种类型的undo日志的使用感兴趣的话,可以额外查一下别的资料。
通用链表结构
在写入 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 这两个结构组成的链表的示意图就是这样:
|
上述链表结构我们在前面的文章中频频提到,尤其是在表空间那一章重点描述过,不过我不敢奢求大家都记住了,所以在这里又强调一遍,希望大家不要嫌我烦,我只是怕大家忘了学习后续内容吃力而已~ |
FIL_PAGE_UNDO_LOG页面
我们前面介绍表空间的时候说过,表空间其实是由许许多多的页面构成的,页面默认大小为 16KB。这些页面有不同的类型,比如类型为 FIL_PAGE_INDEX 的页面用于存储聚簇索引以及二级索引,类型为 FIL_PAGE_TYPE_FSP_HDR 的页面用于存储表空间头部信息的,还有其他各种类型的页面,其中有一种称之为 FIL_PAGE_UNDO_LOG 类型的页面是专门用来存储 undo日志 的,这种类型的页面的通用结构如下图所示(以默认的 16KB 大小为例):
“类型为 FIL_PAGE_UNDO_LOG 的页” 这种说法太绕口,以后我们就简称为 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 服务,不能直接删除掉,对它们的处理需要区别对待。当然,如果你看这段话迷迷糊糊的话,那就不需要再看一遍了,现在只需要知道 undo 日志分为 2 个大类就好了,更详细的东西我们后边会仔细介绍的。
-
-
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结构(链表的普通节点,我们上面刚说的)。
Undo页面链表
单个事务中的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链表。
总结一句就是: 按需分配,什么时候需要什么时候再分配,不需要就不分配。
多个事务中的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页面 链表。
undo日志具体写入过程
段(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 的地址了么~
|
这部分关于段的各种概念我们在表空间那一章中都有详细解释,在这里重提一下只是为了唤醒大家沉睡的记忆,如果有任何不清楚的地方可以再次跳回表空间的那一章仔细读一下。 |
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日志。
Undo 页面链表什么时候会被重用,怎么重用我们之后会详细说的。事务的 PREPARE 阶段是在所谓的分布式事务中才出现的,本书中不会介绍更多关于分布式事务的事情,所以大家目前忽略这个状态就好了。
-
-
TRX_UNDO_LAST_LOG:本 Undo页面 链表中最后一个Undo Log Header的位置。 -
TRX_UNDO_FSEG_HEADER:本 Undo页面 链表对应的段的Segment Header信息(就是我们上一节介绍的那个10字节结构,通过这个信息可以找到该段对应的INODE Entry)。 -
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 中。
Undo 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 日志在页面中的偏移量的。关于什么时候重用 Undo 页面链表,怎么重用这个链表我们稍后会详细说明的,现在先理解 TRX_UNDO_NEXT_LOG 和 TRX_UNDO_PREV_LOG 这两个属性的意思就好了。
-
TRX_UNDO_HISTORY_NODE:一个 12 字节的List Node结构,代表一个称之为History链表的节点。
小结
对于没有被重用的 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页面 链表的示意图就是这样:
重用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日志,效果看起来就是这样:
回滚段
回滚段的概念
我们现在知道一个事务在执行过程中最多可以分配 4 个 Undo页面 链表,在同一时刻不同事务拥有的 Undo页面 链表是不一样的,所以在同一时刻系统里其实可以有许许多多个 Undo页面 链表存在。为了更好的管理这些链表,设计 InnoDB 的大佬又设计了一个称之为 Rollback Segment Header 的页面,在这个页面中存放了各个 Undo页面 链表的 frist undo page 的 页号,他们把这些 页号 称之为 undo slot。我们可以这样理解,每个 Undo页面 链表都相当于是一个班,这个链表的 first undo page 就相当于这个班的班长,找到了这个班的班长,就可以找到班里的其他同学(其他同学相当于 normal undo page)。有时候学校需要向这些班级传达一下精神,就需要把班长都召集在会议室,这个 Rollback Segment Header 就相当于是一个会议室。
我们看一下这个称之为 Rollback Segment Header 的页面长什么样(以默认的 16KB 为例):
设计 InnoDB 的大佬规定,每一个 Rollback Segment Header 页面都对应着一个段,这个段就称为 Rollback Segment,翻译过来就是 回滚段。与我们之前介绍的各种段不同的是,这个 Rollback Segment 里其实只有一个页面(这可能是设计 InnoDB 的大佬们的一种洁癖,他们可能觉得为了某个目的去分配页面的话都得先申请一个段,或者他们觉得虽然目前版本的 MySQL 里 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大小的页面来说,这个RX_RSEG_UNDO_SLOTS部分共存储了1024个undo slot,所以共需1024 × 4 = 4096个字节。
从回滚段中申请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日志还有用呢~)。
-
多个回滚段
我们说一个事务执行过程中最多分配 4 个 Undo页面 链表,而一个回滚段里只有 1024 个 undo slot,很显然 undo slot 的数量有点少啊。我们即使假设一个读写事务执行过程中只分配 1 个 Undo页面 链表,那 1024 个 undo slot 也只能支持 1024 个读写事务同时执行,再多了就崩溃了。这就相当于会议室只能容下 1024 个班长同时开会,如果有几千人同时到会议室开会的话,那后来的那些班长就没地方坐了,只能等待前面的人开完会自己再进去开。
话说在 InnoDB 的早期发展阶段的确只有一个回滚段,但是设计 InnoDB 的大佬后来意识到了这个问题,咋解决这问题呢?会议室不够,多盖几个会议室不就得了。所以设计 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 页面 链表。我们画个示意图:
把图一画出来就清爽多了。
回滚段的分类
我们把这 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日志 的类型,比方说 MLOG_UNDO_HDR_CREATE、MLOG_UNDO_INSERT、MLOG_UNDO_INIT 等等等等,也就是说我们对 Undo页面 做的任何改动都会记录相应类型的 redo日志。但是对于临时表来说,因为修改临时表而产生的 undo日志 只需要在系统运行过程中有效,如果系统奔溃了,那么在重启时也不需要恢复这些 undo 日志所在的页面,所以在写针对临时表的 Undo页面 时,并不需要记录相应的 redo日志。总结一下针对普通表和临时表划分不同种类的 回滚段 的原因:在修改针对普通表的回滚段中的 Undo页面 时,需要记录对应的 redo日志,而修改针对临时表的回滚段中的 Undo页面 时,不需要记录对应的 redo日志。
|
实际上在 MySQL 5.7.21 这个版本中,如果我们仅仅对普通表的记录做了改动,那么只会为该事务分配针对普通表的回滚段,不分配针对临时表的回滚段。但是如果我们仅仅对临时表的记录做了改动,那么既会为该事务分配针对普通表的回滚段,又会为其分配针对临时表的回滚段(不过分配了回滚段并不会立即分配 undo slot,只有在真正需要 Undo页面 链表时才会去分配回滚段中的 undo slot)。 |
为事务分配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 就可以了。
回滚段相关配置
配置回滚段数量
我们前面说系统中一共有 128 个回滚段,其实这只是默认值,我们可以通过启动参数 innodb_rollback_segments 来配置回滚段的数量,可配置的范围是 1~128。但是这个参数并不会影响针对临时表的回滚段数量,针对临时表的回滚段数量一直是 32,也就是说:
-
如果我们把
innodb_rollback_segments的值设置为1,那么只会有1个针对普通表的可用回滚段,但是仍然有32个针对临时表的可用回滚段。 -
如果我们把
innodb_rollback_segments的值设置为2~33之间的数,效果和将其设置为 1 是一样的。 -
如果我们把
innodb_rollback_segments设置为大于33的数,那么针对普通表的可用回滚段数量就是该值减去32。
配置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)成一个小文件。而系统表空间的大小只能不断的增大,却不能截断。