基于Spring中的事务@Transactional细节与易错点、幻读
作者:日常打BUG
ACID,事务内的一组操作具有 原子性 、一致性、隔离性、持久性。
Atomicity
(原子性):一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被恢复(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。Consistency
(一致性):在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发性地完成预定的工作。Isolation
(隔离性):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交(Read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(Serializable)。Durability
(持久性):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
为什么要使用事务?
就是一组操作中,存在着多个更新修改操作,并且要满足事务的相关要求,所以就需要使用到事务。最常见的例子就是银行两个账户间的转账,包含的A扣款 、B到账等多个操作,这些个操作需要具备事务的特性。比如说,要么A成功扣款,B也成功到账;不能出现A扣款了,B没到账(原子性);也不能出现现在AB都处理成功了,后续又出现A账户的钱又增多了(持久性);也不能出现A账号初始余额充足,两个并发处理,导致出现余额为负的情况(隔离性)。
如何使用事务?
在spring中可以使用声明性的注解事务,即在有需要使用的方法、类上,用@Transactional
修饰即可。修饰的方法、类就是这个事务的包裹区域。出现了对应的异常就会在AOP中触发回滚。
默认的回滚是错误与运行异常,不包括检验异常。
rollbackFor参数支持用户自行设置,例如可定义异常跟运行异常,如下所示;也支持自定义异常类
@Transactional(rollbackFor = { Exception.class, RuntimeException.class })
默认的事务传播机制是Propagation.REQUIRED
事务的传播本质是确定好事务的限制区域,即哪些代码是受到事务保护的,出现异常可以回滚。
细节点:
- 代码出现事务配置的异常,在事务内的会自动回滚;如果在对应的方法体内使用了try catch捕获异常,异常没有抛出去,那就不会回滚,需要手动回滚了。在catch语句中增加手动回滚的TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();语句
- public 方法的事务才生效
事务的传播带来的几种结果
- 外层没有事务的话,内在的子方法,没有的就没有;有事务的就会有事务,有事务的效果形同Propagation.REQUIRES_NEW,互相独立,每个都是一个不同的新事务
- 外层有事务的话,那这个整个方法都在一个事务的区域范围内,内外任何一处回滚,都是整个回滚。但是Propagation.NESTED修饰的内部方法,可以单独回滚掉自己这个内部方法,作为一个嵌入子事务所具有的独特性。
- 外层有还是没有事务,Propagation.REQUIRES_NEW修饰的方法都是作为一个独立的事务,自己独立控制回滚与提交,与外层事务无关联。
此处举例的事务,指的是 默认值为 Propagation.REQUIRED的传播行为,以及Propagation.NESTED的传播行为。
两个特例
- 同一个类中有A、B两个方法,A调用B方法。A没事务,B有事务,B有异常时,回滚失败
A没事务 | A有事务 | |
B没事务 | 没有事务的效果 不分析 | 事务生效,B的异常,可以让整个A回滚 |
B有事务 | 事务失效 | 事务生效,同上 不分析 |
若是A/B在同一个类中,A方法有事务,B方法没有事务,这个时候事务会生效,原因是异常传导到了A方法中;
A方法没事务,B方法有事务,A调用B方法。若是A/B在同一个类中,B方法事务失效。A/B在不同的类,B方法有事务效果。
原因分析:这是动态代理导致的,当要执行B方法的回滚时,此时A调用的B方法,不是动态代理的那个类,无法进行回滚。
- A方法循环调用B方法,A方法有事务,B方法启用新事务,B方法处理成功一条提交一条的数据;B方法遇到异常,有异常的那条回滚,不影响之前处理成功提交的数据。
从之前的推断来看,Propagation.REQUIRES_NEW修饰的内部方法独立一个新事务,跟外层没有关系,其实是两个事务了,外层事务回滚内存的也不会回滚;内层回滚也不影响外层事务。
但是实际结果还是有点不太一样,若是A/B在不同类中,可以达到这个效果;同一个类的话,就会回滚失败。跟上面AB方法调用的结果类似。
究其原因,还是由于使用了动态代理来进行事务AOP的,此时的B方法一旦触发回滚就是事务回滚异常了。那么要想一个类中两个方法间调用达到部分提交的效果,需要使用ApplicationContext 上下文对象获取当前类对象,再进行调用;
// 使用 ApplicationContext 上下文对象获取该对象; @Autowired private ApplicationContext applicationContext; CurrentClass classService = applicationContext.getBean(CurrentClass.class); //再用这个对象去调用同类的其他方法 classService.b();
总结: 事务的实现依赖于动态代理,因此在同一个类中使用了类的其他方法时,就需要额外注意了,只有使用动态代理的对象去调用方法时,才会有事务回滚的操作。
事务传播属性propagation
propagation 代表事务的传播行为,默认值为 Propagation.REQUIRED,总共的属性信息如下:
Propagation.REQUIRED
:如果当前存在事务,则加入该事务,如果当前不存在事务,则创建一个新的事务。(默认传播行为,一定会有一个事务)
( 也就是说如果A方法和B方法都添加了注解,在默认传播模式下,A方法内部调用B方法,会把两个方法的事务合并为一个事务 )
Propagation.SUPPORTS
:如果当前存在事务,则加入该事务;如果当前不存在事务,则以非事务的方式继续运行。(以当前是否有事务为标准,可以有事务,也可以没有事务)Propagation.MANDATORY
:如果当前存在事务,则加入该事务;如果当前不存在事务,则抛出异常。(要求当前有事务,就能运行;没有就会异常)Propagation.REQUIRES_NEW
:重新创建一个新的事务,如果当前存在事务,暂停当前的事务。
( 当类A中的 a 方法用默认Propagation.REQUIRED模式,类B中的 b方法加上采用 Propagation.REQUIRES_NEW模式,然后在 a 方法中调用 b方法操作数据库,然而 a方法抛出异常后,b方法并没有进行回滚,因为Propagation.REQUIRES_NEW会暂停 a方法的事务 )
Propagation.NOT_SUPPORTED
:以非事务的方式运行,如果当前存在事务,暂停当前的事务。(以非事务的方式运行,当前有不报错)Propagation.NEVER
:以非事务的方式运行,如果当前存在事务,则抛出异常。Propagation.NESTED
:如果当前存在事务,则嵌套事务内执行,如果不存在事务和 Propagation.REQUIRED 效果一样。
( 也就是说如果A方法和B方法都添加了注解,在A默认传播模式下,B方法加上采用 Propagation.NESTED模式,A方法内部调用B方法,A回滚,B也会回滚;但B回滚,A不会回滚 )
数据库隔离级别
事务的隔离级别依赖于数据库的隔离级别,mysql的默认隔离级别是可重复读(repeatable read),对应的效果是在一个事务内重复读取一个表中的数据,一直会是一样的,并不会读取到那些个此事务范围内其他事务 未提交(脏读)、已提交(不可重复读)的修改记录。
在对数据进行测试隔离级别时,需要先对数据库进行一系列的设置,包括关闭自动提交、查看当前的隔离级别,相关命令如下所示:
//由于变量autocommit分会话系统变量与全局系统变量, Value的值为ON,表示autocommit开启。 OFF表示autocommit关闭。 show session variables like 'autocommit'; show global variables like 'autocommit'; //关闭当前会话的自动提交 set session autocommit=0; //开启一个事务 start transaction; begin; //回滚 rollback; //提交事务 commot;
//查看当前的隔离级别 查看全局、当前会话的隔离级别 select @@tx_isolation; SELECT @@global.tx_isolation, @@session.tx_isolation; //设置当前会话的隔离级别为read uncommitted级别: set session transaction isolation level read uncommitted; //设置当前会话的隔离级别为read committed级别: set session transaction isolation level read committed; //设置当前会话的隔离级别为repeatable read级别: set session transaction isolation level repeatable read; //设置当前会话的隔离级别为serializable级别: set session transaction isolation level serializable; //展示连接id select connection_id(); //数据库超时设置查询 show session variables like '%timeout';
事务的隔离级别总共分为:未提交读(read uncommitted)、已提交读(read committed)、可重复读(repeatable read)、串行化(serializable)。
下面将对这四种一一展开说明:
1、未提交读(会有脏读的现象)
A事务已执行,但未提交;B事务查询到A事务的更新后数据;A事务回滚;那么之前读取到的A事务为提交的数据就是脏数据了。最低的隔离级别,很少会使用到。
---脏读,读取到了未提交的数据(新增、修改和删除); 除此之外,还有会不可重复读、幻读的现象。
事务1设置如上所示,隔离级别为读未提交,关闭了自动提交。此时开始一个事务,看到的有两条数据;
再打开一个窗口,关闭自动提交,然后进行新增改的操作
最后的结果如上所示,事务1读取到了另一事务未提交时的新增、修改跟删除的数据。
2、已提交读
(会有不能重复读的现象,因为每次读取都是读最新的,那就可能前后两次会有差异了)
会读取这一段时间内其他事务对这些数据的变更操作,A事务执行更新;B事务查询;A事务又执行更新;B事务再次查询时,B事务前后两次查询到的数据不一致;例如事务B要更新状态,因此先进行一次查询,此时状态为1,一系列操作后,马上就要更新了,此时再次查询,第二次查询出来的状态变成了2。
---不可重复读,一般指的是删除、更新、新增;还会有幻读的现象
不可重复读的结果就是事务1能够读取到另外事务的 新增、修改、删除操作,与脏读的区别在于,一个是提交后才能读取到,一个是未提交的实时操作就能读取到。
3、可重复读 (有可能覆盖掉其他事务的操作)
可重复读是mysql数据的默认隔离级别,也是使用的较多的一种隔离级别,下面重点对其分析分析。
A事务无论执行多少次,只要不提交,在这事务内同一个SQL的查询值永远都不变;可以理解成A事务内的所有查询都是 查询A事务开始时那一瞬间的数据快照;
幻读: 由于互相隔离,以及可重复读的特性,另一个事务也同时在处理同一数据的话,就会有一种空幻的现象,好像少了点什么。例如,两个事务都带id去插入同一数据,那么后插入的数据会加锁执行失败(另一事务未提交)或者主键冲突(另一事务已提交),而插入失败后再去查询,又会发现并没有找到重复那条数据的,就会有种读到了空白的感觉,少读取到了内容。 幻读不仅是插入,更新、删除也会有这样的现象的。
现实的一个例子,就是离银行还款日期之前,A去查看账单表,获取到了此次的账单数据,求得了总和,根据账单综合就将账单还清了,并且还再次查询,显示已经还清了。此时,A将本次的查询,还款操作提交到数据库,在开开心心下班前,突然心血来潮再次进行了查询账单操作,突然多了几条消费记录了,需要再次还款。A就感觉 提交事务前的查询有点幻读了,少了几条数据。
事务1在另一个事务提交后,再对同样的数据做修改 删除 新增操作。
---幻读,一般值的是新增;就是明明查询不到这条数据,去新增时会报错。
其实更新、删除也会有的,例如更新同一条用id+status去更新是,后提交的会更新失败,这一特性也可用来加锁,即CAS来更新数据,这样后操作的肯定就不会覆盖前面的数据了。
已经被删除的数据,此时去更新,也不会生效了,在这个事务内再次查询还是删除前的那个数据快照。
如果更新数据时只用id,存在并发修改的情况,那么后提交的必定覆盖之前事务的更新操作。比如本来数据的状态是1,事务2将数据状态有1->2,而事务1看的的状态还是1,事务1直接使用id更新的话,将数据的状态变成了3。事务1以为是1->3 ,其实是由 2->3,中间的状态2直接就被覆盖了。因此高并发的更新,需要慎重。
幻读总结: 很多对幻读的解释是,一个事务在查询的同时,另一个事务插入了数据,然后前一个事务再次查询就会发现多了几条数据,这个现象是不存在的,如果出现了,那说明当前的隔离级别是读已提交了。
可重复读中的就是说同一个事务了多次查询返回的数据肯定是一样的,这是毋庸置疑的,这也是与读已提交的区别。因此,只有在当前事务提交后,再次查询才会刷新到另一事务的改变。
那么我理解的幻读就是,在另一事务新增数据并提交后,此时的事务去新增同样一条数据,会报错的,而此时再去查询又是查无数据,这种现象才是幻读。
更新数据层面就是,事务2已经将数据的状态改变提交了,事务1用旧的状态作为条件去更新,影响行数会是0,这也是一种幻读。 更新已经被删除的数据,也是影响行数为0。
数据库最终的执行还是串行的,只是在前置的一些操作可以并发,最终更新到数据库,只能是有一条成功,由于一些规则的设置,就会出现上述的现象了。
4、串行化(没有并发操作)
串行化是最高的隔离级别,即事务排队串行执行了,没有了并发操作,也不会发生上述所说的脏读、不可重复读、幻读的现象,这个的使用场景不多,理解起来也较为的简单。
总结: 数据库的隔离级别就是一个事务内,对于另一事务的并发操作会有怎么样的效果;
- 另一事务操作时就能看到修改后的数据,就是读未提交
- 另一事务操作并提交后 能看到修改后的数据,就是读已提交
- 另一事务操作提交后,当前事务依旧看不到相应的修改,事务开始什么数据,事务结束也是读取到同样的数据,就是可重复读
所有的事务都排队依次执行了,一次只能有一个进行修改,没有了并行,就是串行化
Spring事务隔离级别比数据库事务隔离级别多一个default
除了上述的四个隔离级别,多出来 DEFAULT (默认)这是一个PlatfromTransactionManager默认的隔离级别,即使用数据库默认的事务隔离级别。另外四个与JDBC的隔离级别相对应,可以显性去指定其隔离级别。
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。
您可能感兴趣的文章:
- 浅谈Spring中@Transactional事务回滚及示例(附源码)
- Spring事务@Transactional注解四种不生效案例场景分析
- springboot编程式事务TransactionTemplate的使用说明
- Spring中的@Transactional的工作原理
- spring声明式事务@Transactional底层工作原理
- spring中12种@Transactional的失效场景(小结)
- Spring事务处理Transactional,锁同步和并发线程
- spring声明式事务 @Transactional 不回滚的多种情况以及解决方案
- Spring Transaction事务实现流程源码解析