Spring事务的传播机制
介绍一下事务
我们要理解下事务概念: 什么是事务呢?事务是并发控制的单位,是用户定义的一个操作序列。有四个特性(ACID):
- 原子性(Atomicity): 事务是数据库的逻辑工作单位,事务中包括的诸操作要么全做,要么全不做。
- 一致性(Consistency): 事务执行的结果必须是使数据库从一个一致性状态变到另一个一致性状态。一致性与原子性是密切相关的。
- 隔离性(Isolation): 一个事务的执行不能被其他事务干扰。
- 持续性/永久性(Durability): 一个事务一旦提交,它对数据库中数据的改变就应该是永久性的。
以上是书面解释,简单来说事务就是把你的操作统一化,要么所有操作都成功,要么就都不成功,如果执行中有某一项操作失败,其之前所有的操作都回滚到未执行这一系列操作之前的状态。
举个例子来说,当你通过某个电商网站购买商品时,这个购买过程就是一个事务。这个事务可能需要进行多个步骤,比如将商品加入购物车、选择付款方式、输入收货地址等等。如果这些步骤中的任何一步出现问题导致交易失败,那么整个事务就会被回滚,你的订单将不会被提交,并且你的支付也不会被扣除。这样,数据库就能够保持数据的一致性和完整性。
如果不考虑隔离性会引发以下安全性问题
脏读、丢失修改、不可重复读、幻读
先理解这三种由于并发访问导致的数据读取问题,再理解事务隔离级别就简单多了。
脏读
A事务读取B事务尚未提交的数据,此时如果B事务发生错误并执行回滚操作,那么A事务读取到的数据就是脏数据。就好像原本的数据比较干净、纯粹,此时由于B事务更改了它,这个数据变得不再纯粹。这个时候A事务立即读取了这个脏数据,但事务B良心发现,又用回滚把数据恢复成原来干净、纯粹的样子,而事务A却什么都不知道,最终结果就是事务A读取了此次的脏数据,称为脏读。
一个事务读取数据并且对数据进行了修改,这个修改对其他事务来说是可见的,即使当前事务没有提交。这时另外一个事务读取了这个还未提交的数据,但第一个事务突然回滚,导致数据并没有被提交到数据库,那第二个事务读取到的就是脏数据,这也就是脏读的由来。
例如:事务 1 读取某表中的数据 A=20,事务 1 修改 A=A-1,事务 2 读取到 A = 19,事务 1 回滚导致对 A 的修改并为提交到数据库, A 的值还是 20。
这种情况常发生于转账与取款操作中
丢失修改(Lost to modify)
在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据。这样第一个事务内的修改结果就被丢失,因此称为丢失修改。
例如:事务 1 读取某表中的数据 A=20,事务 2 也读取 A=20,事务 1 先修改 A=A-1,事务 2 后来也修改 A=A-1,最终结果 A=19,事务 1 的修改被丢失。
不可重复读(Unrepeatable read 前后多次读取,数据内容不一致)
事务A在执行读取操作,由整个事务A比较大,前后读取同一条数据需要经历很长的时间 。而在事务A第一次读取数据,比如此时读取了小明的年龄为20岁,事务B执行更改操作,将小明的年龄更改为30岁,此时事务A第二次读取到小明的年龄时,发现其年龄是30岁,和之前的数据不一样了,也就是数据不重复了,系统不可以读取到重复的数据,成为不可重复读。
指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。
例如:事务 1 读取某表中的数据 A=20,事务 2 也读取 A=20,事务 1 修改 A=A-1,事务 2 再次读取 A =19,此时读取的结果和第一次读取的结果不同。
幻读(Phantom read前后多次读取,数据总量不一致)
事务A在执行读取操作,需要两次统计数据的总量,前一次查询数据总量后,此时事务B执行了新增数据的操作并提交后,这个时候事务A读取的数据总量和之前统计的不一样,就像产生了幻觉一样,平白无故的多了几条数据,成为幻读。
幻读与不可重复读类似。它发生在一个事务读取了几行数据,接着另一个并发事务插入了一些数据时。在随后的查询中,第一个事务就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。
例如:事务 2 读取某个范围的数据,事务 1 在这个范围插入了新的数据,事务 2 再次读取这个范围的数据发现相比于第一次读取的结果多了新的数据。
不可重复读和幻读有什么区别?
- 不可重复读的重点是内容修改或者记录减少比如多次读取一条记录发现其中某些记录的值被修改;
- 幻读的重点在于记录新增比如多次执行同一条查询语句(DQL)时,发现查到的记录增加了。
幻读其实可以看作是不可重复读的一种特殊情况,单独把区分幻读的原因主要是解决幻读和不可重复读的方案不一样。
举个例子:执行 delete
和 update
操作的时候,可以直接对记录加锁,保证事务安全。而执行 insert
操作的时候,由于记录锁(Record Lock)只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁(Gap Lock)。也就是说执行 insert
操作的时候需要依赖 Next-Key Lock(Record Lock+Gap Lock) 进行加锁来保证不出现幻读。
Spring事务的传播
接下来介绍一下Spring中事务的传播机制
什么叫事务传播行为?
事务传播行为(Propagation behavior)指的是当一个事务方法被另一个事务方法调用时,这个事务方法应该如何进行。
例如:method A事务方法调用method B事务方法时,method B是继续在调用者method A的事务中运行呢,还是为自己开启一个新事务运行,这就是由method B 的事务传播行为决定的。
Spring 定义了七种事务传播行为,可以分为三大类:
(1)支持当前事务的情况:
TransactionDefinition.PROPAGATION_REQUIRED:如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务
TransactionDefinition.PROPAGATION_SUPPORTS:如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行
TransactionDefinition.PROPAGATION_MANDATORY:如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。(mandatory:强制性)
(2)不支持当前事务的情况:
TransactionDefinition.PROPAGATION_REQUIRES_NEW :创建一个新的事务,如果当前存在事务,则把当前事务挂起
TransactionDefinition.PROPAGATION_NOT_SUPPORTED:以非事务方式运行,如果当前存在事务,则把当前事务挂起
TransactionDefinition.PROPAGATION_NEVER:以非事务方式运行,如果当前存在事务,则抛出异常
(3)其他情况:
TransactionDefinition.PROPAGATION_NESTED:如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行;如果当前没有事务,则该取值等价于TransactionDefinition.PROPAGATION_REQUIRED
下面对这七种事务传播行为进行详细介绍:
搭建测试环境
首先我们需要搭建一个测试环境,首先建立一张account表来作为测试对象
1 |
|
这个时候我们就有了account表,我们来建立对应的实体类和mapper,为了方便测试采用了mybatis-plus为我们省去一些简单的增删改查代码。
Entity
1 |
|
Service
1 |
|
ServiceImpl
1 |
|
Dao
1 |
|
Controller
1 |
|
注意,使用事务需要开启@EnableTransactionManagement 注解,并且使用事务管理器
1 |
|
好了现在我们搭建了基本的环境,可以开始测试了。
测试七种事务传播类型
REQUIRED(Spring默认的事务传播类型)
如果当前没有事务,则自己新建一个事务,如果当前存在事务,则加入这个事务
- 注 :值得说明的是,spring事务底层是动态代理实现的,在同一个类中进行方法调用,同一类中调用实际上是通过this对象实现的,所以在测试过程中需要将TestA 和 TestB分别放到2个Service中调用,否则会导致事务无法生效的问题。
源码说明如下:
1 |
|
我们首先在Controller里加入测试代码,我们只在testB上声明事务,设置传播行为REQUIRED,伪代码如下:
1 |
|
Service
1 |
|
我们将访问这个路径来进行测试,localhost:8080/account/testrequired/超哥/查猪
简单介绍下这个代码,testA会添加一条名为成俊先生的记录到数据库中,testB会修改名字为传入参数name1的账户余额,1/0主要是为了我们抛出异常来检查事务回滚机制的,抛出异常后续还有一个修改账户余额的操作,我们的验证思路是,传入2个不同的名字参数,查看数据库中相应的修改和增加。
首先我们讲一下没用事务的情况,大家应该都知道,testA将会执行,testB将会执行
updateAccountMoneyByName(name1); 然后因为1/0,将会抛出java.lang.ArithmeticException: / by zero,中断操作,updateAccountMoneyByName(name2); 将会失败,数据库中将会多出一条成俊先生的记录,并且超哥的余额将会改变,而查猪的余额因为之前抛出了异常导致不执行所以不会改变。
现在我们在TestB上加入事务,REQUIRED传播类型,
通过url路径调用controller,我们能够发现,testA中的成俊先生被加入了数据库中,而testB中的修改,因为有事务的原因,抛出了异常超哥和查猪账户余额两个都失败了,超哥的余额改变被回滚了。
验证了第二点,如果当前没有事务,则自己新建一个事务
在执行testB时会自己新建一个事务(如果当前没有事务,则自己新建一个事务),testB抛出异常则只有testB中的操作发生了回滚,也就是b1的存储会发生回滚,但a1数据不会回滚,所以最终a1数据存储成功,b1和b2数据没有存储
接下来我们在TestA上也加入事务,REQUIRED传播类型
验证后发现,数据库没有插入新的数据,数据库还是保持着执行testA方法之前的状态,没有发生改变。testA上声明了事务,在执行testB方法时就加入了testA的事务(当前存在事务,则加入这个事务),在执行testB方法抛出异常后事务会发生回滚,又testA和testB使用的同一个事务,所以事务回滚后testA和testB中的操作都会回滚,也就使得数据库仍然保持初始状态
验证了 当前存在事务,则加入这个事务
SUPPORTS(Spring默认的事务传播类型)
当前存在事务,则加入当前事务,如果当前没有事务,就以非事务方法执行
源码注释如下(太长省略了一部分),其中里面有一个提醒翻译一下就是:“对于具有事务同步的事务管理器,SUPPORTS与完全没有事务稍有不同,因为它定义了可能应用同步的事务范围”。这个是与事务同步管理器相关的一个注意项,这里不过多讨论。
1 |
|
根据场景举栗子,我们只在testB上声明事务,设置传播行为SUPPORTS
1 |
|
调用后我们可以发现,增加了一条新记录,并且还更改了超哥的余额,和不使用事务的情况一样,这种情况下,执行testA的最终结果就是,由于testA没有声明事务,且testB的事务传播行为是SUPPORTS,所以执行testB时就是没有事务的(如果当前没有事务,就以非事务方法执行),则在testB抛出异常时也不会发生回滚,
1 |
|
那么当我们在testA上声明事务且使用REQUIRED传播方式的时候,这个时候执行testB就满足当前存在事务,则加入当前事务,在testB抛出异常时事务就会回滚,最终结果就是加入记录和修改数据都不会执行,数据库没有变化。
MANDATORY
当前存在事务,则加入当前事务,如果当前事务不存在,则抛出异常。
1 |
|
我们只在testB上声明事务,设置传播行为MANDATORY来验证,
1 |
|
这种情形的执行结果就是记录增加成功但是,TestB里的操作失败,并不是事务回滚的原因,而是因为testA方法没有声明事务,在去执行testB方法时就直接抛出事务要求的异常(如果当前事务不存在,则抛出异常),所以testB方法里的内容就没有执行。
那么如果在testA方法进行事务声明,并且设置为REQUIRED,则执行testB时就会使用testA已经开启的事务,遇到异常就正常的回滚了。
REQUIRES_NEW
创建一个新事务,如果存在当前事务,则挂起该事务。
可以理解为设置事务传播类型为REQUIRES_NEW的方法,在执行时,不论当前是否存在事务,总是会新建一个事务。
源码注释如下
1 |
|
为了说明设置REQUIRES_NEW的方法会开启新事务,我们把异常发生的位置换到了testA,然后给testA声明事务,传播类型设置为REQUIRED,testB也声明事务,设置传播类型为REQUIRES_NEW,
1 |
|
这种情形的执行结果就是记录没储存成功,而TestB2条修改成功,因为testB的事务传播设置为REQUIRES_NEW,所以在执行testB时会开启一个新的事务,testA中发生的异常时在testA所开启的事务中,所以这个异常不会影响testB的事务提交,testA中的事务会发生回滚,所以最终成俊的记录就没有存储,而超哥和查猪的余额就修改成功了。
与这个场景对比的一个场景就是testA和testB都设置为REQUIRED,那么上面的代码执行结果就是所有数据都不会存储,因为testA和testB是在同一个事务下的,所以事务发生回滚时,所有的数据都会回滚
NOT_SUPPORTED
始终以非事务方式执行,如果当前存在事务,则挂起当前事务
可以理解为设置事务传播类型为NOT_SUPPORTED的方法,在执行时,不论当前是否存在事务,都会以非事务的方式运行。
源码说明如下
1 |
|
testA传播类型设置为REQUIRED,testB传播类型设置为NOT_SUPPORTED,且异常抛出位置在testB中,伪代码如下
1 |
|
该场景的执行结果就是TestA没有存储,而超哥修改成功。testA有事务,而testB不使用事务,所以执行中testB的修改超哥的记录成功,然后抛出异常,此时testA检测到异常事务发生回滚,但是由于testB不在事务中,所以只有testA的存储记录发生了回滚,最终只有超哥的记录修改成功,而TestA和修改查猪都没有存储
NEVER
不使用事务,如果当前事务存在,则抛出异常
很容易理解,就是我这个方法不使用事务,并且调用我的方法也不允许有事务,如果调用我的方法有事务则我直接抛出异常。
源码注释如下:
1 |
|
TestA设置传播类型为REQUIRED,testB传播类型设置为NEVER,并且把testB中的抛出异常代码去掉
1 |
|
该场景执行,直接抛出事务异常,且不会有数据存储到数据库。由于testA事务传播类型为REQUIRED,所以testA是运行在事务中,而testB事务传播类型为NEVER,所以testB不会执行而是直接抛出事务异常,此时testA检测到异常就发生了回滚,所以最终数据库不会有数据存入。
NESTED
如果当前事务存在,则在嵌套事务中执行,否则REQUIRED的操作一样(开启一个事务)
这里需要注意两点:
- 和REQUIRES_NEW的区别
REQUIRES_NEW是新建一个事务并且新开启的这个事务与原有事务无关,而NESTED则是当前存在事务时(我们把当前事务称之为父事务)会开启一个嵌套事务(称之为一个子事务)。
在NESTED情况下父事务回滚时,子事务也会回滚,而在REQUIRES_NEW情况下,原有事务回滚,不会影响新开启的事务。
- 和REQUIRED的区别
REQUIRED情况下,调用方存在事务时,则被调用方和调用方使用同一事务,那么被调用方出现异常时,由于共用一个事务,所以无论调用方是否catch其异常,事务都会回滚
而在NESTED情况下,被调用方发生异常时,调用方可以catch其异常,这样只有子事务回滚,父事务不受影响
testA设置为REQUIRED,testB设置为NESTED,且异常发生在testA中,伪代码如下
1 |
|
该场景下,所有数据都不会存入数据库,因为在testA发生异常时,父事务回滚则子事务也跟着回滚了
testA设置为REQUIRED,testB设置为NESTED,且异常发生在testB中,伪代码如下
1 |
|
这种场景下,结果是TestA成功了,TestB都失败了,因为调用方catch了被调方的异常,所以只有子事务回滚了。
同样的代码,如果我们把testB的传播类型改为REQUIRED,结果也就变成了:没有数据存储成功。就算在调用方catch了异常,整个事务还是会回滚,因为,调用方和被调方共用的同一个事务
总结
PROPAGATION_REQUIRED
:Spring的默认传播级别,如果上下文中存在事务则加入当前事务,如果不存在事务则新建事务执行。
PROPAGATION_SUPPORTS
:如果上下文中存在事务则加入当前事务,如果没有事务则以非事务方式执行。
PROPAGATION_MANDATORY
:该传播级别要求上下文中必须存在事务,否则抛出异常。
PROPAGATION_REQUIRES_NEW
:每次执行都会创建新事务,并同时将上下文中的事务挂起,执行完当前线程后再恢复上下文中事务。
PROPAGATION_NOT_SUPPORTED
:当上下文中有事务则挂起当前事务,执行完当前逻辑后再恢复上下文事务。
PROPAGATION_NEVER
:该传播级别要求上下文中不能存在事务,否则抛出异常。
PROPAGATION_NESTED
:嵌套事务,如果上下文中存在事务则嵌套执行,如果不存在则新建事务
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!