事务传播性、隔离性与MVCC

一、事务传播性

1.1 什么是事务的传播性

事务的传播性一般在事务嵌套时候使用,比如在事务A里面调用了另外一个使用事务的方法,那么这俩个事务是各自作为独立的事务执行提交,还是内层的事务合并到外层的事务一块提交呢,这就是事务传播性要确定的问题。下面一一介绍比较常用的事务传播性。 

首先奉上事务拦截器TransactionInterceptor事务处理流程图:

1.2 PROPAGATION_REQUIRED

Spring默认的事务传播机制,如果外层有事务则当前事务加入到外层事务,一块提交一块回滚,如果外层没有事务则当前开启一个新事务。这个机制可以满足大多数业务场景。

试验:

 public class BoA {
        public void test(){
        boB.sayHello();
    }
}

BoA 和boB都是进行过事务增强后的bo,那么在执行test的时候会开启一个事务(或者test调用方已经存在事务则加入该事务),执行到sayHello()时候由于传播性是PROPAGATION_REQUIRED,所以sayHello方法加入到test的事务,那么sayHello和test就会同时提交,同时回滚。值得注意的是如果test里面调用sayHello时候加了trycatch没有把异常抛出去,而sayHello方法却抛出了异常,那么整个事务也会回滚,这时候调用test的外层会受到”Transaction rolled back because it has been marked as rollback-only的异常,而把sayHello真正的异常吃掉了。

平时我们都是在bo里面调用数据库操作,在rpc和screen调用bo,所以bo层不应该catch掉异常,而应该抛出来,在rpc和screen层catch异常。

1.3 PROPAGATION_REQUIRES_NEW

该传播机制是每次新开启一个事务,同时把外层的事务挂起,当前新事务执行完毕后再恢复上层事务的执行。

以上面代码为例,首先进入test方法前会开启一个事务,然后调用sayHello时候会把test的事务挂起,重新开启一个新事务执行sayHello,执行完毕后恢复test的事务。如果sayHello抛出来异常则sayHello的事务会回滚,那么test方法是否回滚呢?这个要看情况,如果test在调用sayHello 时候使用了trycatch并且异常没有在catch中throw出来,那么test方法不会回滚,这时候sayHello是提交和回滚对test没有影响,。
如果test中没有加trycatch那么,test也会回滚。

1.4 PROPAGATION_SUPPORTS

该传播机制如果外层有事务则加入该事务,如果不存在也不会创建新事务,直接使用非事务方式执行。

以上面代码为例,由于PROPAGATION_SUPPORTS,所以test和sayHello都没有开启事务,没啥好讲的。

下面看下如果test隔离级别是PROPAGATION_REQUIRED,sayHello隔离级别是PROPAGATION_SUPPORTS的情况。这时候外层test会开启一个事务(或者test调用方已经存在事务则加入该事务),然后sayHello执行时候会加入到test的事务和1.2类似,同时提交同时回滚。

1.5 PROPAGATION_NOT_SUPPORTED

该传播机制不支持事务,如果外层存在事务则挂起外层事务 ,然后执行当前逻辑,执行完毕后,恢复外层事务。

同样这里看下如果test使用PROPAGATION_REQUIRED,sayHello隔离级别是PROPAGATION_NOT_SUPPORTED的情况,首先test会开启一个事务(或者test调用方已经存在事务则加入该事务),然后sayHello执行时候会挂起该事务然后在非事务内做自己的事情,做完后在恢复test的事务。 无论sayHello是否抛出异常,sayHello的事务都不会回滚,因为它不在事务范围内,那test?
这个就和1.3一样了,如果test catch住sayHello的异常没有throw出去,那么test就不回滚,否者回滚。

1.6 PROPAGATION_NEVER

该传播机制不支持事务,如果外层存在事务则直接抛出异常。
IllegalTransactionStateException(
“Existing transaction found for transaction marked with propagation ‘never'”)

1.7 PROPAGATION_MANDATORY

该传播机制是说配置了该传播性的方法只能在已经存在事务的方法中被调用,如果在不存在事务的方法中被调用,会抛出异常。
IllegalTransactionStateException(
“No existing transaction found for transaction marked with propagation ‘mandatory'”);

1.8 PROPAGATION_NESTED

该传播机制特点是可以保存状态保存点,当事务回滚后会回滚到某一个保存点上,从而避免所有嵌套事务都回滚。

上面代码test和sayHello都设置为PROPAGATION_NESTED,如果sayHello抛出异常,两者还是都回滚了,因为sayHello虽然回滚到了savePoint而savepoint里面确实包含了test的操作,但是savepoint后还是会吧异常throw给test,这导致了test的回滚。

总结,只有传播性为PROPAGATION_REQUIRED||PROPAGATION_REQUIRES_NEW||PROPAGATION_NESTED时候才可能开启一个新事务。

二、事务隔离性

2.1 什么是事务的隔离性

事务的隔离性是指多个事务并发执行的时候相互之间不受到彼此的干扰,是事务acid(原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability))中i,根据隔离程度对隔离性又会分类。在具体介绍事务隔离性前有必要介绍几个名词说明数据库并发操作存在的问题。

2.1.1 脏读

所谓脏读是指一个事务中访问到了另外一个事务未提交的数据,具体来说假如有两个事务A和B同时更新一个数据d=1,事务B先执行了select获取到d=1,然后更新d=2但是没有提交,这时候事务A在B没有提交的情况下执行搜索结果d=2,这就是脏读。

2.1.2 不可重复读

所谓不可重复读是指一个事务内在未提交的前提下多次搜索一个数据,搜出来的结果不一致。发生不可重复读的原因是在多次搜索期间这个数据被其他事务更新了。

2.1.3 幻读

所谓幻读是指同一个事务内多次查询(注意查询的sql不一定一样)返回的结果集的不一样(比如新增或者少了一条数据),比如同一个事务A内第一次查询时候有n条记录,但是第二次同等条件下查询却又n+1条记录,这就好像产生了幻觉,为啥两次结果不一样那。其实和不可重复读一样,发生幻读的原因也是另外一个事务新增或者删除或者修改了第一个事务结果集里面的数据。不同在于不可重复读是数据内容被修改了,幻读是数据变多了或者少了。

2.2、事务隔离级别

为了解决事务并发带来的问题,才有了sql规范中的四个事务隔离级别,不同隔离级别对上面三个问题部分或者全部做了避免。注意:下面试验用的两个终端都是同时执行了begin为了模拟事务并发。

2.2.1 Read Uncommitted

读未提交隔离级别,就是指一个事务中可以读取其他事务未提交的数据,这个级别会导致脏读。
本文都是以mysql为例引擎InnoDB,mysql默认事务隔离级别为Repeatable_Read:如图:

下面我们更改隔离级别为Read Uncommitted :

试验:

下面我们打开两个mysql终端,并且关闭自动提交.
终端一:

终端二:

终端一我们开启了一个事务,并且插入了一条数据但是没有提交事务,但是终端二却查询出来了。

终端一执行rollback:

终端二搜索:

在终端1回滚后,终端二又搜不到了,所以有可能在终端一没有回滚时候终端二已经获取并使用终端一的数据,而终端一回滚后,数据已经被使用过了,所以导致了脏读。

总结:该隔离级别会导致 脏读,不可重复读,幻读,是最低级的隔离级别,一般不用的。

2.2.2 Read Committed

读已提交隔离级别,一个事务只能读取到其他事务已经提交的数据,可能导致同一个事务中多次搜查结果不一样。

试验:修改事务隔离级别为Read Committed,

终端一:

终端二:

由于终端一执行后没有commit,所以终端二查询不到。

下面终端一执行commit:

终端二再次执行查询:

终端一提交后,终端二就可以搜查出来了。

总结:该隔离级别会导致不可重复读和幻读,避免了脏读,oracle默认是该隔离级别。实际项目使用mybaits时候虽然隔离级别是read committed,但是在一个事务中多次搜索还是会是同一个结果,这是因为mybatis一级缓存的原因。关于mybatis的缓存机制,可以参考mybatis的缓存机制

2.2.3 Repeatable Read

可重复读隔离级别,一个事务内多次查询数据时候查询的数据内容和第一次查询的一致也就是说第一次查询出来的数据没有被修改,而不管其他事务有没有对这些数据新修改。但是可能其他事务新增一条数据,导致一个事务内查询的结果集里面多了一条记录。mysql默认隔离级别就是这个
试验:
首先修改事务隔离级别为可重复读:

模拟修改数据情况:

终端一:

终端二:

可以知道终端一已经提交的数据在终端二的事务中还是查不到(注意终端二执行begin要在终端一执行commit前,且终端二执行查询在终端一执行commit后,因为我们要模拟并发事务)。

下面再模拟下新增数据情况
终端一:

终端二:

终端一插入了一条记录并且提交,但是终端二还是查询不到新增的记录

总结:这里有点奇怪,按照其他资料显示该隔离级别应该是避免了 脏读,不可重复读,但是还存在幻读,但是试验表明不存在幻读。这种理解其实是错误的,继续试验:

试验一:

t Session A                   Session B
|
| START TRANSACTION;          START TRANSACTION;
|
| SELECT * FROM t_bitfly;
| empty set
|                             INSERT INTO t_bitfly
|                             VALUES (1, 'a');
|
| SELECT * FROM t_bitfly;
| empty set
|                             COMMIT;
|
| SELECT * FROM t_bitfly;
| empty set
|
| INSERT INTO t_bitfly VALUES (1, 'a');
| ERROR 1062 (23000):
| Duplicate entry '1' for key 1
v (shit, 刚刚明明告诉我没有这条记录的)

如此就出现了幻读,以为表里没有数据,其实数据已经存在了,傻乎乎的提交后,才发现数据冲突了。

试验二:

t Session A                  Session B
|
| START TRANSACTION;         START TRANSACTION;
|
| SELECT * FROM t_bitfly;
| +------+-------+
| | id   | value |
| +------+-------+
| |    1 | a     |
| +------+-------+
|                            INSERT INTO t_bitfly
|                            VALUES (2, 'b');
|
| SELECT * FROM t_bitfly;
| +------+-------+
| | id   | value |
| +------+-------+
| |    1 | a     |
| +------+-------+
|                            COMMIT;
|
| SELECT * FROM t_bitfly;
| +------+-------+
| | id   | value |
| +------+-------+
| |    1 | a     |
| +------+-------+
|
| UPDATE t_bitfly SET value='z';
| Rows matched: 2  Changed: 2  Warnings: 0
| (怎么多出来一行)
|
| SELECT * FROM t_bitfly;
| +------+-------+
| | id   | value |
| +------+-------+
| |    1 | z     |
| |    2 | z     |
| +------+-------+
|
v

本事务中第一次读取出一行,做了一次更新后,另一个事务里提交的数据就出现了。也可以看做是一种幻读。

结论:MySQL InnoDB的可重复读并不保证避免幻读,需要应用使用加锁读来保证。

2.2.4 Serializable

最高的隔离级别,完全服从ACID的隔离级别,确保阻止脏读、不可重复读以及幻读,也是最慢(串行化)的事务隔离级别,因为多个事务串行化一个个按照顺序执行,这种不存在并发情况,所以可以避免所有事务并发问题。

终端一:

终端二:

可以看到终端一打开一个事务后,事务二的insert语句会等待知道事务一提交或者超时。

超时:

多版本并发控制(MVCC)

和Java中的多线程问题相同,数据库通常使用锁来实现隔离性。
最原生的锁,锁住一个资源后会禁止其他任何线程访问同一个资源。但是很多应用的一个特点都是读多写少的场景,很多数据的读取次数远大于修改的次数,而读取数据间互相排斥显得不是很必要。所以就使用了一种读写锁的方法,读锁和读锁之间不互斥,而写锁和写锁、读锁都互斥。这样就很大提升了系统的并发能力。之后人们发现并发读还是不够,又提出了能不能让读写之间也不冲突的方法,就是读取数据时通过一种类似快照的方式将数据保存下来,这样读锁就和写锁不冲突了,不同的事务session会看到自己特定版本的数据。当然快照是一种概念模型,不同的数据库可能用不同的方式来实现这种功能。多版本并发控制(MVCC)的实现是通过保存数据在某个时间点的快照来实现的。根据事务开始的时间不同,每个事务对同一张表,同一时刻看到的数据可能是不一样的。

InnoDB 的 MVCC 是通过在每行记录后面保存两个隐藏的列来实现的:一个列保存了行的创建时间,一个保存行的过期时间(或删除时间)。存储的实际值是系统版本号(system version number)。

每开始一个新的事务,系统版本号都会递增。事务开始时刻的系统版本号作为事务的版本号,用来和查询到的每行记录的版本号进行比较。

在 REPEATABLE READ 隔离级别下,MVCC 的具体操作:

  • select:InnoDB 根据以下两个条件检查每行记录:

    a、 InnoDB 只查找版本早于当前事务版本的数据行(也即是行的版本号小于等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改的。
    b、行的删除版本要么未定义,要么大于当前事务版本号。这可以确保事务读取到的行,在事务开始之前未被删除。
    只有符合以上两个条件的记录,才能返回作为查询结果。

  • insert:InnoDB 为新插入的每一行保存当前系统版本号作为行版本号。

  • delete:InnoDB 为删除的每一行保存当前系统版本号作为行删除标识。

  • update:InnoDB 为插入一行新纪录,保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为行删除标识。

参考资料

并发编程网 – ifeve.com  实战Spring事务传播性与隔离性 :http://ifeve.com/%E5%AE%9E%E6%88%98spring%E4%BA%8B%E5%8A%A1%E4%BC%A0%E6%92%AD%E6%80%A7%E4%B8%8E%E9%9A%94%E7%A6%BB%E6%80%A7/
MySQL 事务隔离级别与MVCC:https://coderbee.net/index.php/db/20141020/1056
InnoDB存储引擎MVCC实现原理:https://liuzhengyang.github.io/2017/04/18/innodb-mvcc/
【mysql】关于innodb中MVCC的一些理解:https://www.cnblogs.com/chenpingzhao/p/5065316.html
原文地址:https://www.cnblogs.com/junzi2099/p/8384086.html