1. 事务回滚的需求
我们说过事务需要保证原子性 ,也就是事务中的操作要么全部完成,要么什么也不做。但是偏偏有时候事务执行到一半会出现一些情况,比如:
- 情况一:事务执行过程中可能遇到各种错误,比如服务器本身的错误,操作系统错误,甚至是突然断电导致的错误。
- 情况二:程序员可以在事务执行过程中手动输入 ROLLBACK 语句结束当前的事务的执行。
这两种情况都会导致事务执行到一半就结束,但是事务执行过程中可能已经修改了很多东西,为了保证事务的原子性,我们需要把东西改回原先的样子,这个过程就称之为 回滚 (英文名: rollback )
2. undo日志的格式
为了实现事务的 原子性 ,InnoDB 存储引擎在实际进行增、删、改一条记录时,都需要先把对应的 undo日志 记下来,我们会为这些日志编号,也叫 undo no
undo 日志是存储在系统表空间中或一种专门存放 undo日志 的表空间(undo tablespace)
INSERT操作对应的undo日志
插入一条记录到一个数据页中。如果希望回滚这个插入操作,那么把这条记录删除就好了,也就是说在写对应的 undo 日志时,主要是把这条记录的主键信息记上。插入操作对应类型为 TRX_UNDO_INSERT_REC 的 undo日志 ,它的完整结构如下图所示:
undo log日志编号 在一个事务中是从 0 开始递增的,也就是说只要事务没提交,每生成一条 undo日志 ,那么该条日志的 undo log日志编号 就增1
如果记录中的主键只包含一个列,那么在类型为 TRX_UNDO_INSERT_REC 的 undo日志 中只需要把该列占用的存储空间大小和真实值记录下来,如果记录中的主键包含多个列,那么每个列占用的存储空间大小和对应的真实值都需要记录下来(图中的 len 就代表列占用的存储空间大小, value 就代表列的真实值)
3. 事务
3.1 事务中会出现的问题
3.1.1 脏写
事务A和事务B同时在更新一条数据,事务A先把他更新为A值,事务B紧接着就把他更新为B值,如下:

此时事务B是后更新那行数据的值,所以此时那行数据的值是B值。
而且此时事务A更新之后会记录一条undo log日志。事务A是先更新的,他在更新之前,这行数据的值为NULL,所以此时事务A的undo log日志大概就是:更新之前这行数据的值为NULL,主键为XX
那么此时事务B更新完了数据的值为B,结果此时事务A突然回滚了,那么就会用事务A的undo log日志去回滚。
此时事务A一回滚,直接就会把那行数据的值更新回之前的NULL值!所以此时事务A回滚了,可能看起来这行数据的值就是NULL了,如下图。
所以对于事务B看到的场景,就是自己明明更新了,结果值却没了,这就是脏写!
所谓脏写,就是我刚才明明写了一个数据值,结果过了一会儿却没了!真是莫名其妙。
而他的本质就是事务B去修改了事务A修改过的值,但是此时事务A还没提交,所以事务A随时会回滚,
导致事务B修改的值也没了,这就是脏写的定义。
3.1.2 脏读
假设事务A更新了一行数据的值为A值还未提交,此时事务B去查询了一下这行数据的值,看到的值是不是A值?
没错,此时如下图所示。

此时事务A发生回滚,事务B再去查询,得到的是null值。
这就是脏读,他的本质其实就是事务B去查询了事务A修改过的数据,但是此时事务A还没提交,所以事务A随时会回滚导致事务B再次查询就读不到刚才事务A修改的数据了!这就是脏读。
其实一句话总结:无论是脏写还是脏读,都是因为一个事务去更新或者查询了另外一个还没提交的事务
更新过的数据。
因为另外一个事务还没提交,所以他随时可能会反悔会回滚,那么必然导致你更新的数据就没了,或者
你之前查询到的数据就没了,这就是脏写和脏读两种坑爹场景。
3.1.3 不可重复读

事务A可能第一次查询到的是A值,那么他可能希望的是在事务执行期间,如果多次查询数据,都是同样的一个A值,他希望这个A值是他重复读取的时候一直可以读到的!他希望这行数据的值是可重复读的!
但是此时,明显A值不是可重复读的,因为事务B和事务C一旦更新了值并且提交了,事务A会读到别的值,所以此时这行数据的值是不可重复读的!此时对于你来说,这个不可重复读的场景,就是一种问题了!
但是如果你希望的是,假设你事务A刚开始执行,第一次查询读到的是值A,然后后续你希望事务执行期间,读到的一直都是这个值A,不管其他事务如何更新这个值,哪怕他们都提交了,你就希望你读到的一直是第一次查询到的值A,那么你就是希望可重复读的。
如果你期望的是可重复读,但是数据库表现的是不可重复读,让你事务A执行期间多次查到的值都不一样,都是别的提交过的事务修改过的值,那么此时你就可以认为,数据库有问题,这个问题就是“不可重复读”的问题!
3.1.4 幻读

幻读指的就是你一个事务用一样的SQL多次查询,结果每次查询都会发现查到了一些之前没看到过的数据
注意,幻读特指的是你查询到了之前查询没看到过的数据!此时就说你是幻读了。
3.1.5 总结
脏写、脏读、不可重复读、幻读,都是因为业务系统会多线程并发执行,每个线程可能都会开启一个事务,每个事务都会执行增删改查操作。
然后数据库会并发执行多个事务,多个事务可能会并发的对缓存页里的同一批数据进行增删改查操作,于是这个并发增删改查同一批数据的问题,可能就会导致我们说的脏写、脏读、不可重复读、幻读,这些问题。
所以这些问题的本质,都是数据库的多事务并发问题,那么为了解决多事务并发问题,数据库才设计了事务隔离机制、MVCC多版本隔离机制、锁机制。
3.2 MVCC(多版本并发控制)
3.2.1 事务 id
给事务分配id的时机:
一个事务可以是一个只读事务,或者是一个读写事务:
- 我们可以通过 START TRANSACTION READ ONLY 语句开启一个只读事务:
在只读事务中不可以对普通的表(其他事务也能访问到的表)进行增、删、改操作,但可以对临时表做增、删、改操作。
- 我们可以通过 START TRANSACTION READ WRITE 语句开启一个读写事务,或者使用 BEGIN 、 START TRANSACTION 语句开启的事务默认也算是读写事务。在读写事务中可以对表执行增删改查操作
如果某个事务执行过程中对某个表执行了增、删、改操作,那么 InnoDB 存储引擎就会给它分配一个独一无二的事务id ,分配方式如下:
对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、改操作时才会为这个事务分配一个 事务id ,否则的话是不分配 事务id 的
对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执行增、删、改操作时才会为这个事务分配一个 事务id ,否则的话也是不分配 事务id 的。
有的时候虽然我们开启了一个读写事务,但是在这个事务中全是查询语句,并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个 事务id
小贴士:事务对表中的记录做改动时才会为这个事务分配一个唯一的 事务id
事务id是怎么生成的:
本质就是一个数字。
服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个 事务id 时,就会把该变量的值当作事务id 分配给该事务,并且把该变量自增1。
每当这个变量的值为 256 的倍数时,就会将该变量的值刷新到系统表空间的页号为 5 的页面中一个称之为
Max Trx ID 的属性处,这个属性占用 8 个字节的存储空间。
当系统下一次重新启动时,会将上边提到的 Max Trx ID 属性加载到内存中,将该值加上256之后赋值给我们前边提到的全局变量
这样就可以保证整个系统中分配的事务id值是一个递增的数字。先被分配 id 的事务得到的是较小的事务id,后被分配 id 的事务得到的是较大的 事务id 。
trx_id隐藏列:
聚簇索引的记录除了会保存完整的用户数据以外,而且还会自动添加名为 trx_id、roll_pointer 的隐藏列,如果用户没有在表中定义主键以及 UNIQUE键,还会自动添加一个名为 row_id 的隐藏列。所以一条记录在页面中的真实结构看起来就是这样的:
3.2.2 undo log多版本链条
- 事务A(id=50),插入了一条数据,那么此时这条数据的隐藏字段以及指向的undo log如下图所示,插入的这条数据的值是值A,因为事务A的id是50,所以这条数据的txr_id就是50,roll_pointer指向一个空的undo log,因为之前这条数据是没有的。

- 接着假设有一个事务B跑来修改了一下这条数据,把值改成了值B,事务B的id是58,那么此时更新之前会生成一个undo log记录之前的值,然后会让roll_pointer指向这个实际的undo log回滚日志,如下图所示。

- 事务B修改了值为值B,此时表里的那行数据的值就是值B了,那行数据的txr_id就是事务B的id,也就是58,roll_pointer指向了undo log,这个undo log就记录你更新之前的那条数据的值。所以看到roll_pointer指向的那个undo log,里面的值是值A,txr_id是50,因为undo log里记录的这个值是事务A插入的,所以这个undo log的txr_id就是50。接着假设事务C又来修改了一下这个值为值C,他的事务id是69,此时会把数据行里的txr_id改成69,然后生成一条undo log,记录之前事务B修改的那个值此时如下图所示,看起来如下。

3.2.3 ReadView机制
执行一个事务的时候,就给你生成一个ReadView,里面比较关键的东西有4个:
- 一个是m_ids,这个就是说此时有哪些事务在MySQL里执行还没提交的列表,如 [50,58,69]
- 一个是min_trx_id,就是m_ids里最小的值
- 一个是max_trx_id,这是说mysql下一个要生成的事务id,就是最大事务id
- 一个是creator_trx_id,就是你这个事务的id
使用说明:
假设原来数据库里就有一行数据,很早以前就有事务插入过了,事务id是32,他的值就是初始值,如下图所示: 
接着呢,此时两个事务并发过来执行了,一个是事务A(id=45),一个是事务B(id=59),事务B是要去更新这行数据的,事务A是要去读取这行数据的值的,此时两个事务如下图所示。 
这个时候事务A第一次查询这行数据,会走一个判断,就是判断一下当前这行数据的txr_id是否小于ReadView中的min_trx_id,此时发现txr_id=32,是小于ReadView里的min_trx_id就是45的,说明你事务开启之前,修改这行数据的事务早就提交了,所以此时可以查到这行数据,如下图所示。

接着事务B开始动手了,他把这行数据的值修改为了值B,然后这行数据的txr_id设置为自己的id,也就是59,同时roll_pointer指向了修改之前生成的一个undo log,接着这个事务B就提交了,如下图所示。 
这个时候事务A再次查询,此时查询的时候,会发现一个问题,那就是此时数据行里的txr_id=59,那么这个txr_id是大于ReadView里的min_txr_id(45),同时小于ReadView里的max_trx_id(60)的,说明更新这条数据的事务,很可能就跟自己差不多同时开启的,于是会看一下这个txr_id=59,是否在ReadView的m_ids列表里?
此时在ReadView的m_ids列表中有45和59两个事务id,直接证实了,这个修改数据的事务是跟自己同一时段并发执行然后提交的,所以对这行数据是不能查询的!如下图所示。

顺着这条数据的roll_pointer顺着undo log日志链条往下找,就会找到最近的一条undo log,trx_id是32,此时发现trx_id=32,是小于ReadView里的min_trx_id(45)的,说明这个undo log版本必然是在事务A开启之前就执行且提交的。
好了,那么就查询最近的那个undo log里的值好了,这就是undo log多版本链条的作用,他可以保存一个快照链条,让你可以读到之前的快照值,如下图。

多个事务并发执行的时候,事务B更新的值,通过这套ReadView+undo log日志链条的机制,就可以保证事务A不会读到并发执行的事务B更新的值,只会读到之前最早的值。
接着假设事务A自己更新了这行数据的值,改成值A,trx_id修改为45,同时保存之前事务B修改的值的快照,突然开启了一个事务C,这个事务的id是78,然后他更新了那行数据的值为值C,还提交了,如下图所示。 
事务A此时再去查询的话,如下图

所以只能查询roll_pointer指向的旧版本
3.2.4 RC和RR级别实现
假设原来数据库里就有一行数据,很早以前就有事务插入过了,事务id是32,他的值就是初始值,如下图所示: 
接着呢,此时两个事务并发过来执行了,一个是事务A(id=45),一个是事务B(id=59),事务B是要去更新这行数据的,事务A是要去读取这行数据的值的,此时两个事务和其中事务A的ReadView如下图所示。 
事务B更新完数据并提交后变为:
此时,根据选择的数据库隔离级别会有两种不同的实现,分别为Read Committed和Read Repeatable。
Read Committed实现
在Read Committed隔离级别下,会在事务A再次查询的时候重新生成一份ReadView,如下图,此时ReadView中m_ids列表中没有事务B的id=59,说明此时事务B已经提交,此时会读取到的值就是值B。
Read Repeatable实现
在Read Repeatable隔离级别下,事务A再次查询的时候不会重新生成ReadView,如下图,此时ReadView中m_ids列表中仍有事务B的id=59,此时会读取到的值就是原始值。
ReadView一旦生成了就不会改变了,这个时候虽然事务B已经结束了,但是事务A的ReadView里,还是会有45和59两个事务id。
他的意思其实就是,在你事务A开启查询的时候,事务B当时是在运行的,就是这个意思。那么好,接着此时事务A去查询这条数据的值,他会惊讶的发现此时数据的trx_id是59了,59一方面是在ReadView的min_trx_id和max_trx_id的范围区间的,同时还在m_ids列表中这说明什么?
说明起码是事务A开启查询的时候,id为59的这个事务B还是在运行的,然后由这个事务B更新了这条数
据,所以此时事务A是不能查询到事务B更新的这个值的,因此这个时候继续顺着指针往历史版本链条上
去找,如下图。

3.2.5 总结

注意:MySQL通过多版本并发控制 在RR级别同样解决了幻读问题。
