[Spring声明式事务使用的那些坑] 最全的@Transactional注解的避坑指南

一、序言

哈喽小伙伴们,俺是啤酒熊,今天想来和大家聊聊Spring中涉及数据库事务的一些坑。

Spring为开发人员提供了声明式事务的使用方式,即在方法上标记@Transactional注解来开启事务。

我们都清楚,在业务代码对数据进行操作的时候一定是希望有事务控制的。

比如在写商家卖东西的业务代码时,代码的逻辑是商家先生成一个订单(订单信息插入到数据库中),再将钱收入到自己的账户中(数据库中的money增加)。如果后面这个操作失败了,那么前者也一定不能插入成功,这时候就会用到事务的回滚。

虽然大部分做后端开发同学们都有这方面的概念,但是在使用@Transactional注解时依旧会出现一些错误。

前几天在对公司新来的同学们写的代码做code review的时候,看到了他们在Spring的项目中,关于@Transactional注解的一些错误使用。在给他们纠正错误的同时也不禁想到自己也曾掉进过这些坑之中 ヽ(ー_ー)ノ

img

于是便想对该注解的使用做个避坑指南~,分享到社区中。

本文将介绍平常的业务开发中关于@Transactional的常见的几种错误使用,并给出相应的错误代码示例。针对每种错误类型,解释其原因,并给出使用@Transactional注解的正确使用姿势。接下来就让我们一起来看一下吧!

[Spring声明式事务使用的那些坑] 最全的@Transactional注解的避坑指南_第1张图片

二、实验准备

2.1 数据库

我们在数据库中定义一个goods_stock货物库存表,并赋予一些初始数据:

image-20210801102106697

表明目前商品id为good_0001的商品库存为10件。

2.2 Spring Boot+Mybatis

我们在Java的方法中利用Mybatis进行减库存的操作,并在方法上标注@Transactional的注解,看该注解是否能使事务失效,遇到错误就回滚。

项目结构如下:

[Spring声明式事务使用的那些坑] 最全的@Transactional注解的避坑指南_第2张图片

我们将利用Swagger调用Controller层中的接口,在接口中调用ServiceGoodsStockServiceImp中的具体业务代码,即减库存的操作。

具体的sql语句,执行库存减10操作,即若业务代码执行成功,该商品库存变为0:




    
        update goods_stock set stock = stock - 10
    

三、抛出异常

3.1 异常传播未出@Transactional标记的方法

很多时候,在真实的业务开发中,总希望接口能返回一个固定的类实例——这叫做统一返回结果。在本文中,以Result类作为统一返回结果,具体可看本文附带代码。

于是就有可能为了方便就直接在Service的方法中return一个Result类对象,为了避免受异常的影响而无法返回该结果集,就会使用try-catch语句,当业务代码出现错误而抛出异常时会捕获此异常,将异常信息写入Result的相关字段中,返回给调用者。下面给出该类型的实例:

Controller层:

@Controller
@RestController
@Api( tags = "测试事务是否生效")
@RequestMapping("/test/transactionalTest")
@Slf4j
public class GoodsStockController {

    @Autowired
    private GoodsStockService goodsStockService;
    /**
     * create by: Beer Bear
     * description: 第一个方法。
     * create time: 2021/7/25 21:38
     */
    @GetMapping("/exception/first")
    @ApiOperation(value = "关于异常的第一个方法,不能够回滚", notes = "因为异常未能被事务发现,所以没有回滚")
    @ResponseBody
    public Result firstFunctionAboutException(){
        try{
            return goodsStockService.firstFunctionAboutException();
        }catch (Exception e){
            return Result.server_error().Message("操作失败:"+e.getMessage());
        }
    }
}

Service中的方法:

@Autowired
    private GoodsStockMapper goodsStockMapper;

    @Override
    @Transactional
    public Result firstFunctionAboutException() {
        try{
            log.info("减库存开始");
            goodsStockMapper.updateStock();
            if(1 == 1) throw new RuntimeException();
            return Result.ok();
        }catch (Exception e){
            log.info("减库存失败!" + e.getMessage());
            return Result.server_error().Message("减库存失败!" + e.getMessage());
        }
    }

firstFunctionAboutException方法的try代码块中,一定会抛出一个RuntimeException的异常,但这样是否能回滚呢?我们不妨通过实验来看一下:

利用Swagger调用接口:

[Spring声明式事务使用的那些坑] 最全的@Transactional注解的避坑指南_第3张图片

调用接口后,按理说事务应该回滚,库存数量不会变为0,但结果却是:

image-20210801112221671

为了节缩篇幅,下文中不再将出现这些截图,而是以文字代替

显然事务没有回滚。我们都知道当程序执行时出现错误而抛出异常时,事务才会回滚,这里虽然出现了异常但却被方法本身消化了(catch掉了),异常没有被事务所发现,所以这样子是不会出现回滚的。

下面我们我们给出相关正确的解决方法——将service中的try-catch语句去掉:

@Override
@Transactional
public void secondFunctionAboutException() {
    log.info("减库存开始");
    goodsStockMapper.updateStock();
    if(1 == 1) throw new RuntimeException();
}

这样子就能实现事务回滚了。(不过这样的话,异常怎么办呢,总不能直接报个异常吧,很简单,将异常放在Controller层去处理就行了。)

在此总结避坑指南的第一坑

当标记了@Transactional注解的方法中出现异常时,如果该异常未传播到该方法外,则事务不会回滚;反之,只有异常传播到该方法之外,事务才会回滚。

3.2 明明抛出了异常却不回滚

现在我们都知道了当程序执行时出现错误而抛出异常时,只要别去处理该异常,让异常突破@Transactional所标注的方法,就能实现期望的回滚。

但是事实真的如此么?下面我们再来看个案例:

@Override
@Transactional
public void thirdFunctionAboutException() throws Exception {
    log.info("减库存开始");
    goodsStockMapper.updateStock();
    if(1 == 1) throw new Exception();
}

实际上,这个方法中的事务并不会回滚。

这也是我们在实际开发时最经常犯的错误,觉得只有抛出异常了就一定会回滚,结果被现实啪啪啪的打脸。

[Spring声明式事务使用的那些坑] 最全的@Transactional注解的避坑指南_第4张图片

但我并不觉得这是件丢人的事情,因为我们去用一个工具的时候,或许一开始确实没精力和能力去学习它的一些原理,从而掉入了一些我们不易发现的坑。只要后面坚持学习,就一定会慢慢把这些坑填满,自己也就越来越强了。

好,言归正传,为啥在此事务不会回滚呢。我们将该方法与上面的secondFunctionAboutException一对比,发现只不过是RuntimeExceptionException的区别。确实是这样,就是因为Spring@Transactional注解就是默认只有当抛出RuntimeException运行时异常时,才会回滚。

Spring通常采用RuntimeException表示不可恢复的错误条件。也就是说对于其他异常,Spring觉得无所谓所以就不回滚。

下面我给出两种解决方案:

@Override
@Transactional
public void thirdFunctionAboutException1(){
    try{
        log.info("减库存开始");
        goodsStockMapper.updateStock();
        if(1 == 1) throw new Exception();
    }catch (Exception e){
        log.info("出现异常"+e.getMessage());
        throw new RuntimeException("手动抛出RuntimeException");
    }
}

@Override
@Transactional(rollbackFor = Exception.class)
public void thirdFunctionAboutException2() throws Exception {
    log.info("减库存开始");
    goodsStockMapper.updateStock();
    if(1 == 1) throw new Exception();
}

第一种我们手动来抛出RuntimeException异常,第二种是改变默认的@Transactional回滚的异常设置(RuntimeException是继承了Exception异常的)。

@Transactional(rollbackFor = Exception.class)

在此总结避坑指南的第二坑

默认情况下,如果我们抛出的异常不是RuntimeException时,事务依旧不会回滚;需要手动抛出RuntimeException异常或者更改Spring中@Transactional默认配置。

四、事务还是不生效

就算我们注意到了异常与@Transactional的关系,并正确地避开了这些坑,但是我们还是会掉入一些更不容易发现和理解的坑中。在这一小节中我们将继续举出反例,并说明这些例子中事务未生效的原因以及给出解决方法。在这一小节中你还将学到@Transactional事务与Spring AOP的联系。

4.1 示例一

service中添加这样两个方法:

@Override
public void privateFunctionCaller (){
    privateCallee();
}

@Transactional
private void privateCallee(){
    goodsStockMapper.updateStock();
    throw new RuntimeException();
}

Controller中调用serviceprivateFunctionCaller方法从而间接调用标注了@Transactional注解的方法privateCallee

执行代码后,发现事务并没有回滚。这又是为什么呢?

我们在Service的类上标注了@Service注解表示该类作为Bean注入AOP容器,而Spring是通过动态代理来实现AOP的。也就说AOP容器中的Bean实际上都是代理对象。

Spring也正是通过该方式对@Transactional进行支持的,Spring会对原对象中的方法进行封装(即检查到标有该注解的方法时,就会为它加上事务).

这个行为就叫做为目标方法进行增强。虽然Spring实现动态代理的方式是CGLIB,但在此我想以JDK动态代理的实现方式来解释,因为这更便于理解。

[Spring声明式事务使用的那些坑] 最全的@Transactional注解的避坑指南_第5张图片

service.function()可以看出,要是走代理的增强的方式,那么必然function不能是private的。所以private的方法上的事务并不能生效,自然就不能回滚了。

实际上当你写出这种上述的代码时,如果你使用的编译器是IDEA,编译器就会提示报错,当然只是报红,而不会影响编译和执行。

[Spring声明式事务使用的那些坑] 最全的@Transactional注解的避坑指南_第6张图片

Java 中实现动态代理的方式中就有JDK的实现方式和CGLIB。不理解动态代理的同学可以去学习一下代理模式,以及MaBtais在Spring中的实现。

4.2 示例二

那我们是不是只要把private换成public就可以了呢?下面的代码是很多同学刚使用@Transactional时经常掉入一个坑。

@Override
public void publicFunctionCaller (){
    publicCallee();
}

@Override
@Transactional
public void publicCallee(){
    goodsStockMapper.updateStock();
    throw new RuntimeException();
}

我们在Controller中调用Service中的publicFunctionCaller时,发现事务还是不能回滚,这又是为什么呢?

[Spring声明式事务使用的那些坑] 最全的@Transactional注解的避坑指南_第7张图片

上文我们提到,在Controller中,被注入的Service对象其实是他的代理对象,当调用publicCallee方法时,上面是没有@Transactional注解的。

故只是简单的执行service.function()而已,即在代理对象的方法publicFunctionCaller中,先由Service的原对象来调用自己的publicFunctionCaller方法,再由其调用自己的publicCallee方法。压根就不会走代理对象增强过(带有事务)的publicCallee方法。自然事务也就不会回滚。

[Spring声明式事务使用的那些坑] 最全的@Transactional注解的避坑指南_第8张图片

解决办法,我想大家就能自己找到了,那就是在Controller中由注入servicebean直接调用标注了@Transactional的方法,例如前文中的secondFunctionAboutException的被调用。

当然,我们还可以曲线救国,在service中注入自己,这样就能实现代理对象来调用增强的方法:

@Override
@Transactional
public void publicCallee(){
    goodsStockMapper.updateStock();
    throw new RuntimeException();
}

@Autowired
private GoodsStockService self;

@Override
public void aopSelfCaller (){
    self.publicCallee();
}

不过显然这不符合分层结构,也不优雅。

在此总结避坑指南的第三坑

标记了 @Transactioal注解的方法必须是public的且必须由注入bean来直接调用才能事务回滚。

到此为止,@Transactional的避坑指南就算是结束了,大家有啥疑问,请评论留言,咱们相互交流。

也希望大家多多点赞,以后还会继续输出更多优质文章的!

[Spring声明式事务使用的那些坑] 最全的@Transactional注解的避坑指南_第9张图片

本文所有代码,放在Gitee上,需要的小伙伴自取。

你可能感兴趣的:([Spring声明式事务使用的那些坑] 最全的@Transactional注解的避坑指南)