事务是需要保证原子性的,也就是要么成功要么失败没有中间态,所以出现中途失败没有保证原子性的情况比如:
- 事务执行过程中可能遇到各种错误,比如服务器本身的错误,操作系统错误,甚至是突然断电导致的错误。
- 程序员可以在事务执行过程中手动输入 ROLLBACK 语句结束当前的事务的执行。
这两种情况都会导致事务执行到一半就结束,但是事务执行过程中可能已经修改了很多东西,为了保证事务的原子性,我们需要把东西改回原先的样子,这个过程就称之为
回滚 (英文名: rollback )
,这样就可以造成一个假象:这个事务看起来什么都没做,所以符合 原子性 要求。
从上边的描述中我们已经能隐约感觉到,每当我们要对一条记录做改动时
(这里的 改动 可以指 INSERT 、DELETE 、 UPDATE )
,都需要留一手 —— 把回滚时所需的东西都给记下来。比方说:
- 你插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉就好了。
- 你删除了一条记录,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了。
- 你修改了一条记录,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值就好了。
设计数据库的大叔把这些为了回滚而记录的这些东东称之为撤销日志,英文名为
undo log
,我们也可以土洋结合,称之为 undo日志 。这里需要注意的一点是,由于查询操作( SELECT )
并不会修改任何用户记录,所以在查询操作执行时,并不需要记录相应的 undo日志 。在真实的 InnoDB 中, undo日志 其实并不像我们上边所说的那么简单,不同类型的操作产生的 undo日志 的格式也是不同的,不过先暂时把这些容易让人脑子糊的具体细节放一放,我们先回过头来看看 事务id 是个神马玩意儿。
- 只读事务:在只读事务中不可以对普通的表(其他事务也能访问到的表)进行增、删、改操作,但可以对临时表做增、删、改操作。
通过开始START TRANSACTION READ ONLY
语句开启一个只读事务。- 我们可以通过
START TRANSACTION READ WRITE
语句开启一个读写事务,或者使用 BEGIN 、 START TRANSACTION 语句开启的事务默认也算是读写事务
。在读写事务中可以对表执行增删改查操作。
如果某个事务执行过程中对某个表执行了增、删、改操作,那么 InnoDB 存储引擎就会给它分配一个独一无二的事务id
- 对于
只读事务
来说,只有在它第一次对某个用户创建的临时表执行增、删、改操作时才会为这个事务分配一个 事务id ,否则的话是不分配 事务id 的。- 对于
读写事务
来说,只有在它第一次对某个表(包括用户创建的临时表)执行增、删、改操作时才会为这个事务分配一个 事务id ,否则的话也是不分配 事务id 的。有的时候虽然我们开启了一个读写事务,但是在这个事务中全是查询语句,并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个 事务id 。
我们前边说过对某个查询语句执行
EXPLAIN分析
它的查询计划时,有时候在Extra列会看到Using temporary的提示
,这个表明在执行该查询语句时会用到内部临时表。这个所谓的内部临时表和我们手动用CREATE TEMPORARY TABLE
创建的用户临时表并不一样,在事务回滚时并不需要把执行SELECT
语句过程中用到的内部临时表也回滚,在执行SELECT
语句用到内部临时表时并不会为它分配事务id
。
这个 事务id 本质上就是一个数字,它的分配策略和我们前边提到的对隐藏列 row_id (当用户没有为表创建主键和 UNIQUE 键时 InnoDB 自动创建的列)的分配策略大抵相同,这样就可以保证整个系统中分配的 事务id 值是一个递增的数字。先被分配 id 的事务得到的是较小的 事务id ,后被分配 id 的事务得到的是较大的 事务id 。
- 服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个 事务id 时,就会把该变量的值当作 事务id 分配给该事务,并且把该变量自增1。
- 每当这个变量的值为 256 的倍数时,就会将该变量的值刷新到系统表空间的页号为 5 的页面中一个称之为Max Trx ID 的属性处,这个属性占用 8 个字节的存储空间。
- 当系统下一次重新启动时,会将上边提到的 Max Trx ID 属性加载到内存中,将该值加上256之后赋值给我们前边提到的全局变量(因为在上次关机时该全局变量的值可能大于 Max Trx ID 属性值)。
我们前边唠叨 InnoDB 记录行格式的时候重点强调过:聚簇索引的记录除了会保存完整的用户数据以外,而且还会自动添加名为trx_id、roll_pointer的隐藏列,如果用户没有在表中定义主键以及UNIQUE键,还会自动添加一个名为row_id的隐藏列。所以一条记录在页面中的真实结构看起来就是这样的:
其中的 trx_id 列其实还蛮好理解的,就是某个对这个聚簇索引记录做改动的语句所在的事务对应的 事务id 而已(此处的改动可以是 INSERT 、 DELETE 、 UPDATE 操作)。
为了实现事务的原子性,innoDB存储引擎在增删改一条记录时,都需要先把对应的undo日志记录下来,又是update会记录两条。
那么一个事务在执行过程中的各种增删改查都会对应很多条undo日志,这些日志从0开始编号,undo0号、undo1号.....这个编号也被称为undo no
这些 undo日志 是被记录到类型为
FIL_PAGE_UNDO_LOG
(对应的十六进制是 0x0002 ,忘记了页面类型是个啥的同学需要回过头再看看前边的章节)的页面中。这些页面可以从系统表空间中分配,也可以从一种专门存放 undo日志 的表空间,也就是所谓的undo tablespace
中分配。
- 建一张表
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;
- 查看表对应的table id是多少(通过系统数据库information_schema的 innodb_sys_tables表),我们就假设上面这个表的table id为138
我们前边说过,当我们向表中插入一条记录时会有 乐观插入 和 悲观插入 的区分,但是不管怎么插入,最终导致的结果就是这条记录被放到了一个数据页中。如果希望回滚这个插入操作,那么把这条记录删除就好了,也就是说在写对应的 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日志
也都是针对聚簇索引记录而言的,我们之后就不强调了。
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日志类似,会将日志中的某些属性进行压缩
还记得每条记录前面那三个隐藏列吗,现在揭幕
roll pointer
隐藏列的含义,这个占用 7 个字节的字段其实一点都不神秘,本质上就是一个指向记录对应的undo
日志 的一个指针。比方说我们上边向undo_demo
表里插入了2条记录,每条记录都有与其对应的一条undo
日志 。记录被存储到了类型为FIL_PAGE_INDEX
的页面中(就是我们前边一直所说的 数据页 ),undo
日志 被存放到了类型为FIL_PAGE_UNDO_LOG
的页面中。效果如图所示:
trx_id
就是记录这条记录对于的事务id哦,从图中可以看到roll_pointer
本质就是一个指针,指向记录对应的undo
日志。不过这7 个字节的 roll_pointer
的每一个字节具体的含义我们后边唠叨完如何分配存储undo
日志的页面之后再具体说
我们知道插入到页面中的记录会根据记录头信息中的 next_record 属性组成一个单向链表,我们把这个链表称之为
正常记录链表
,而被删除的记录也会记录在头信息中的next_record属性组成一个链表,这个链表所占的空间可以重复利用,所以称为垃圾链表。在Page Header部分有一个称之为PAGE_FREE的属性,他指向被删除记录的垃圾链表中的节点,可以想象成下面的结构
可以看到里面的delete_mask标志位就是标志该条记录的状态
页面的 Page Header 部分的 PAGE_FREE 属性的值代表指向 垃圾链表 头节点的指针。假设现在我们准备使用 DELETE 语句把 正常记录链表 中的最后一条记录给删除掉,其实这个删除的过程需要经历两个阶段:
仅仅将记录的
delete_mask
标识位设置为1
,其他的不做修改(其实会修改记录的trx_id 、roll_pointer
这些隐藏列的值)。设计InnoDB
的大叔把这个阶段称之为delete mark
。被删除的记录一直处于中间态
当该删除语句所在的事务提交之后,会有专门的线程后来真正的把记录删除掉。所谓真正的删除就是把该记录从 正常记录链表 中移除,并且加入到 垃圾链表 中,然后还要调整一些页面的其他信息,比如页面中的用户记录数量
PAGE_N_RECS 、上次插入记录的位置 PAGE_LAST_INSERT 、垃圾链表头节点的指针PAGE_FREE 、页面中可重用的字节数量 PAGE_GARBAGE 、还有页目录的一些信息等等。设计 InnoDB 的大叔把这个阶段称之为 purge 。
对照着图我们还要注意一点,将被删除记录加入到 垃圾链表 时,实际上加入到链表的头节点处,会跟着修改 PAGE_FREE 属性的值。
页面的Page Header部分有一个PAGE_GARBAGE属性,该属性记录着当前页面中可重用存储空间占用的总字节数。每当有已删除记录被加入到垃圾链表后,都会把这个PAGE_GARBAGE属性的值加上该已删除记录占用的存储空间大小。当有新插入的记录,会根据此属性看是否可以容纳;如果不可以容纳直接申请新的空间来存储这条记录;如果可以容纳,那么直接重用垃圾链表开始的可重用空间,可是如果插入的这条记录不大于这个可用空间的头结点,会出现碎片空间即这一点用不到的地方;也许你会想这点空间永远用不到了吗,当然不是PAGE_GARBAGE属性会记录下来,这些碎片空间在整个页面快使用完前并不会被重新利用,不过当页面快满时,如果再插入一条记录,此时页面中并不能分配一条完整记录的空间,这时候会首先看一看PAGE_GARBAGE的空间和剩余可利用的空间加起来是不是可以容纳下这条记录,如果可以的话,InnoDB会尝试重新组织页内的记录,重新组织的过程就是先开辟一个临时页面,把页面内的记录依次插入一遍,因为依次插入时并不会产生碎片,之后再把临时页面的内容复制到本页面,这样就可以把那些碎片空间都解放出来(很显然重新组织页面内的记录比较耗费性能)。
回顾阶段一,在提交事务之前只会处在阶段一,等提交后我们就不需要回滚了,我们现在只关注阶段一也就是delete mark阶段,所以现在介绍一下InnoDB设计了一种称为
TRX_UNDO_DEL_MARK_REC
类型的undo日志
这里你也许会有疑惑,在进行
delete mark
的时候为什么要把旧的这两列隐藏列的值给记录下来,有一个好处就是可以根据这两个值找到当时插入这条记录对于的undo
日志啊,比如我们现在在一个事务中先插入一条记录再删除一条记录,过程如下:
从图中可以看出来,执行完
delete mark
操作后,它对应的undo
日志和INSERT
操作对应的undo
日志就串成了一个链表。这个很有意思啊,这个链表就称之为 版本链 ,现在貌似看不出这个 版本链 有啥用,等我们再往后看看,讲完UPDATE
操作对应的undo
日志后,这个所谓的版本链
就慢慢的展现出它的牛逼之处了。
与插入的undo日志类型不同,删除的还多了个
索引列各列信息
的内容,也就是说如果某个列被包含在某个索引中,那么它的相关信息就应该被记录到这个 索引列各列信息 部分,所谓的相关信息包括该列在记录中的位置(用 pos 表示),该列占用的存储空间大小(用 len 表示),该列实际值(用 value 表示)。所以 索引列各列信息 存储的内容实质上就是的一个列表 。这部分信息主要是用在事务提交后,对该 中间状态记录 做真正删除的阶段二,也就是 purge 阶段中使用的
BEGIN; # 显式开启一个事务,假设该事务的id为100
# 插入两条记录
INSERT INTO undo_demo(id, key1, col)
VALUES (1, 'AWM', '狙击枪'), (2, 'M416', '步枪');
# 删除一条记录
DELETE FROM undo_demo WHERE id = 1;
由于
undo_demo
表中有2个索引:一个是聚簇索引,一个是二级索引 idx_key1 。只要是包含在索引中的列,那么这个列在记录中的位置( pos ),占用存储空间大小( len )和实际值( value )就需要存储到undo日志 中。
从上边的叙述中可以看到,
<0, 4, 1> 和 < 3, 3, 'AWM'>
共占用 11 个字节。然后index_col_info len
本身占用 2 个字节,所以加起来一共占用13
个字节,把数字13
就填到了index_col_info len
的属性中
在执行 UPDATE 语句时, InnoDB 对更新主键和不更新主键这两种情况有截然不同的处理方案。
在不更新主键的情况下还有细分为更新的列占用的空间发生变化和不发生变化
在更新记录时,对于被更新的
每个列
来说,如果更新后的列和更新前的列占用的空间一样那么就可以进行就地更新,也就是直接在原有的基础上修改对应列的值,再次强调是每个列
在更新前和更新后占用空间一样大,只要任意列不满足或大或小都不行!
比方说现在 undo_demo 表里还有一条 id 值为 2 的记录,它的各个列占用的大小如图所示(因为采用 utf8 字符集,所以 ‘步枪’ 这两个字符占用6个字节):
下面对这条记录进行更新,key1不满足前后大小不变(从4个字节到3个字节),col列满足,总体不满就地更新
UPDATE undo_demo
SET key1 = 'P92', col = '手枪'
WHERE id = 2;
换一种更新方式,key1列从M416到M249不变,col列也不变,所以满足原地更新的条件
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日志 ,它的完整结构如下:
大致结构与del的undo日志差不多需要注意一下两点
n_updated
属性表示本条UPDATE
语句执行后将有几个列被更新,后边跟着的分别表示被更新列在记录中的位置、更新前该列占用的存储空间大小、更新前该列的真实值。
- 如果在
UPDATE
语句中更新的列包含索引列,那么也会添加 索引列各列信息 这个部分,否则的话是不会添加这个部分的。
可以看到下面更新语句属于是就地更新的范围,在真正改动页面时,会记录一条类型
TRX_UNDO_UPD_EXIST_REC 的 undo日志
,长这样:对于一下我们需要注意几个地方
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;
对于更新主键的需要分步骤,在聚簇索引中,记录是按照主键值的大小连成了一个单向链表的,如果我们更新了某条记录的主键值,意味着这条记录在聚簇索引中的位置将会发生改变,比如你将记录的主键值从1更新为10000,如果还有非常多的记录的主键值分布在 1 ~ 10000 之间的话,那么这两条记录在聚簇索引中就有可能离得非常远,甚至中间隔了好多个页面。针对 UPDATE 语句中更新了记录主键值的这种情况
高能注意:这里是delete mark操作!这里是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日志
。