java 编程式事务、声明式事务

  在Spring体系中,关于事务的管理有两种模式,分别是编程式事务声明式事务

1. 什么是编程式事务

编程式事务是指在代码中手动的管理事务的提交、回滚等操作,代码侵入性比较强,可以通过API自己控制事务。基于底层的API,如PlatformTransactionManager、TransactionDefinition和TransactionTemplate等核心接口,开发者完全可以通过编程的方式进行事务管理。例如:

public void test() {
    TransactionDefinition def = new DefaultTransactionDefinition();
    TransactionStatus status = transactionManager.getTransaction(def);
    try {
        // 事务操作
        // 事务提交
        transactionManager.commit(status);
    } catch (DataAccessException e) {
        // 事务提交
        transactionManager.rollback(status);
        throw e;
    }
}     

2. 什么是声明式事务

声明式事务是建立在AOP之上的,其本质是对方法前后进行拦截,然后在目标方法开始之前创建或者加入一个事务,在执行完目标方法之后根据执行情况提交或者回滚事务。

管理方法允许开发者在配置的帮助下来管理事务,而不需要依赖底层API进行硬编码。开发者可以只使用注解或基于配置的XML来管理事务。例如:

@Transactional
public void test() {
    // 事务操作
}

使用@Transactional即可给test方法增加事务控制。

这两种事务有各自的优缺点,那么这两种事务有哪些各自适用的场景呢?为什么有人会拒绝使用声明式事务呢?

3. 声明式事务的优点

声明式事务帮助我们节省了很多代码,它会自动进行事务的开启、提交和回滚等操作。

声明式事务的管理是使用AOP实现的,本质上就是在目标方法执行前后进行拦截。在目标方法执行前加入或创建一个事务,在目标方法执行后,根据实际情况选择提交或回滚事务。

使用这种方式,对代码没有侵入性,在方法内只需要编写业务逻辑即可。

4. 声明式事务的粒度问题

声明式事务有一个局限,那就是它的最小粒度要作用在方法上。也就是说,如果想要给一部分代码块增加事务,那么就需要把这个部分代码块独立出来作为一个方法。

声明式事务是通过注解实现的,有时还可以通过配置实现,这就会导致一个问题,那就是这个事务有可能被开发者忽略。事务被忽略了有什么问题呢?

如果开发者没有注意到一个方法是被事务嵌套的,那么就可能在方法中加入一些如RPC远程调用、消息发送、缓存更新和文件写入等操作。

我们知道,如果这些操作被包含在事务中,那么就有两个问题:

(1)这些操作自身是无法回滚的,这就会导致数据的不一致。可能RPC调用成功了,但是本地事务回滚了,导致PRC调用无法回滚了。
(2)在事务中有远程调用,就会拉长整个事务,导致本事务的数据库连接一直被占用。

如果类似的操作过多,就会导致数据库连接池耗尽。有些时候,即使没有在事务中进行远程操作,但有些人还是会在不经意间进行一些内存操作,如运算,或者如果遇到分库分表的情况,那么也有可能在不经意间进行跨库操作。

如果是编程式事务,那么在业务代码中就会清楚地看到什么地方开启了事务、什么地方提交了事务、什么时候了回滚事务。有人改动这段代码时,就会强制他考虑要加的代码是否应该在方法事务内。

有些人可能会说,已经有了声明式事务,但是写代码的人没注意,又该怎么办呢?

话虽如此,但我们还是希望可以通过一些机制或者规范降低这些问题发生的概率。

比如建议读者使用编程式事务,而不是声明式事务。笔者就多次遇到开发者没有注意到声明式事务而导致的故障。因为有些时候,声明式事务确实不够明显。

5. 声明式事务用不对容易失效

除了事务的粒度问题,还有一个问题,那就是声明式事务虽然看上去帮我们简化了很多代码,但一旦没用对,也很容易导致事务失效。

例如,以下三种场景就可能导致声明式事务失效:

一、失效场景集一:代理不生效

Spring中对注解解析的尿性都是基于代理的,如果目标方法无法被Spring代理到,那么它将无法被Spring进行事务管理。

Spring生成代理的方式有两种:

  1. 基于接口的JDK动态代理,要求目标代理类需要实现一个接口才能被代理
  2. 基于实现目标类子类的CGLIB代理

Spring在2.0之前,目标类如果实现了接口,则使用JDK动态代理方式,否则通过CGLIB子类的方式生成代理。

而在2.0版本之后,如果不在配置文件中显示的指定spring.aop.proxy-tartget-class的值,默认情况下生成代理的方式为CGLIB,如下图

顺着代理的思路,我们来看看哪些情况会因为代理不生效导致事务管控失败。

(1)将注解标注在接口方法上

@Transactional是支持标注在方法与类上的。一旦标注在接口上,对应接口实现类的代理方式如果是CGLIB,将通过生成子类的方式生成目标类的代理,将无法解析到@Transactional,从而事务失效。

这种错误我们还是犯得比较少的,基本上我们都会将注解标注在接口的实现类方法上,官方也不推荐这种。

(2)被final、static关键字修饰的类或方法

CGLIB是通过生成目标类子类的方式生成代理类的,被final、static修饰后,无法继承父类与父类的方法。

(3)类方法内部调用

事务的管理是通过代理执行的方式生效的,如果是方法内部调用,将不会走代理逻辑,也就调用不到了。例如

createUser中调用了内部方法createUser1,并且createUser1方法上设置了事务传播策略为:REQUIRES_NEW,但是因为是内部直接调用,createUser1不能不代理处理,无法进行事务管理。在createUser1方法抛出异常后就插入数据失败了。

但是这种操作在我们业务开发的过程中貌似还挺常见的,怎么样才能保证其成功呢?

方式1:新建一个Service,将方法迁移过去,有点麻瓜。

方式2:在当前类注入自己,调用createUser1时通过注入的userService调用

方式3:通过AopContext.currentProxy()获取代理对象 道理类似于方式2,就是为了通过代理来访问内部方法

(4)当前类没有被Spring管理

这个没什么好说的,都没有被Spring管理成为IOC容器中的一个bean,更别说被事务切面代理到了。

这种Bug看上去比较蠢,但没准真的有人犯错。

二、失效场景集二:框架或底层不支持的功能

这类失效场景主要聚焦在框架本身在解析@Transactional时的内部支持。如果使用的场景本身就是框架不支持的,那事务也是无法生效的。

(1)非public修饰的方法

我们在标有@Transactional的任意方法上打个断点,在idea内能看到事务切面点如下图所示

 点击去这个方法,在开头有这么一个调用

 

 

 不支持非public修饰的方法进行事务管理。

(2)多线程调用

跟上面一样的的操作,我们能够逐层进入到TransactionAspectSupport.prepareTransactionInfo方法。

注意看以下这段话

 从这里我们得知,事务信息是跟线程绑定的。因此在多线程环境下,事务的信息都是独立的,将会导致Spring在接管事务上出现差异。

这个场景我们要尤其注意!

给大家举个例子

主线程A调用线程B保存Id为1的数据,然后主线程A等待线程B执行完成再通过线程A查询id为1的数据。

这时你会发现在主线程A中无法查询到id为1的数据。因为这两个线程在不同的Spring事务中,本质上会导致它们在Mysql中存在不同的事务中。

Mysql中通过MVCC保证了线程在快照读时只读取小于当前事务号的数据,在线程B显然事务号是大于线程A的,因此查询不到数据。

(3)数据库本身不支持事务

比如Mysql的Myisam存储引擎是不支持事务的,只有innodb存储引擎才支持。

这个问题出现的概率极其小,因为Mysql5之后默认情况下是使用innodb存储引擎了。

但如果配置错误或者是历史项目,发现事务怎么配都不生效的时候,记得看看存储引擎本身是否支持事务。

(4)未开启事务

这个也是一个比较麻瓜的问题,在Springboot项目中已经不存在了,已经有DataSourceTransactionManagerAutoConfiguration默认开启了事务管理。

但是在MVC项目中还需要在applicationContext.xml文件中,手动配置事务相关参数。如果忘了配置,事务肯定是不会生效的。

三、失效场景集三:错误使用@Transactional

注意啦注意啦,下面这几种都是高频会出现的Bug!

(1)错误的传播机制

Spring支持了7种传播机制,分别为:

上面不支持事务的传播机制为:PROPAGATION_SUPPORTS,PROPAGATION_NOT_SUPPORTED,PROPAGATION_NEVER。

如果配置了这三种传播方式的话,在发生异常的时候,事务是不会回滚的。

(2)rollbackFor属性设置错误

默认情况下事务仅回滚运行时异常和Error,不回滚受检异常(例如IOException)。

因此如果方法中抛出了IO异常,默认情况下事务也会回滚失败。

我们可以通过指定@Transactional(rollbackFor = Exception.class)的方式进行全异常捕获。

(3)异常被内部catch

UserService

UserService1

 

如上代码UserService调用了UserService1中的方法,并且捕获了UserService1中抛出的异常。

你将能看到控制台出现这样一个报错:

org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only

默认情况下标注了@Transactional注解的方法的事务传播机制是REQUIRED,它的特性是支持当前事务,也就说加入当前事务。我们在UserService中开始事务,然后再UserService1中抛出异常回滚UserService中的事务,将其标记为只读。

但是在UserSevice中我们捕获了异常,此时UserService上的事务认为正常提交事务。最后在提交时发现事务只读,已经被回滚,则抛出了上述异常。

因此这里如果需要对特定的异常进行捕获处理,记得再次将异常抛出,让最外层的事务感知到。

(4)嵌套事务

上面是我想同时回滚UserService与UserService1。但是也会有这种场景只想回滚UserService1中报错的数据库操作,不影响主逻辑UserService中的数据落库。

有两种方式可以实现上述逻辑:

1.直接在UserService1内的整个方法用try/catch包住

2.在UserService1使用Propagation.REQUIRES_NEW传播机制

四、总结

本文为大家分析@Transactional注解使用过程中失效的12种场景

 

 

 

 










posted on 2022-12-26 20:27  胡子就不刮  阅读(3669)  评论(0编辑  收藏  举报

导航