Spring Boot业务代码中使用@Transactional事务失效总结

1、概述

我们知道 Spring 声明式事务功能提供了极其方便的事务配置方式,配合 Spring Boot 的自动配置,大多数 Spring Boot 项目只需要在方法上标记 @Transactional注解,即可一键开启方法的事务性配置。当然后端开发人员对数据库事务这个概念并不陌生,也知道如果整体考虑多个数据库操作要么成功要么失败时,需要通过数据库事务来实现多个操作的一致性和原子性。如下所示:

java 复制代码
 @Override
    @Transactional(rollbackFor = Exception.class)
    public void addUser(UserParam param) {
        User user = PtcBeanUtils.copy(param, User.class);
        userDAO.insert(user);
        if (!CollectionUtils.isEmpty(param.getRoleIds())) {
            userRoleService.addUserRole(user.getId(), param.getRoleIds());
        }
    }

大多数开发仅限于为方法标记 @Transactional来开启声明式事务,认为就可以高枕无忧了,不会去关注事务是否有效、出错后事务是否正确回滚,也不会考虑复杂的业务代码中涉及多个子业务逻辑时,怎么正确处理事务。事务没有被正确处理,一般来说不会过于影响正常流程,也不容易在测试阶段被发现。但当系统越来越复杂、压力越来越大之后,就会带来大量的数据不一致问题,随后就是大量的人工介入查看和修复数据。

正是因为声明式事务@Transactional使用简单,所以很多开发人员不注重细节点,但是@Transactional条条框框还蛮多的,可谓是细节点拉满,如果不注意也不小心就会掉进坑里,今天就让我们一起来了解使用细节,把坑填平咯

2、@Transactional理解

java 复制代码
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Transactional {

  @AliasFor("transactionManager")
  String value() default "";

  @AliasFor("value")
  String transactionManager() default "";

  Propagation propagation() default Propagation.REQUIRED;

  Isolation isolation() default Isolation.DEFAULT;

  int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;

  boolean readOnly() default false;

  Class<? extends Throwable>[] rollbackFor() default {};

  String[] rollbackForClassName() default {};

  Class<? extends Throwable>[] noRollbackFor() default {};

  String[] noRollbackForClassName() default {};

}

从上面看出@Transactional既可以作用于类上,也可以作用于方法上,作用于类: 表示所有该类的public方法都配置相同的事务属性信息。接下来再看看其属性:

propagation: 设置事务的传播行为,主要解决是A方法调用B方法时,事务的传播方式问题的,默认值为 Propagation.REQUIRED,其他属性值信息如下:

|---------------|-----------------------------------------------|
| 事务传播行为 | 解释 |
| REQUIRED(默认值) | A调用B,B需要事务,如果A有事务B就加入A的事务中,如果A没有事务,B就自己创建一个事务 |
| REQUIRED_NEW | A调用B,B需要新事务,如果A有事务就挂起,B自己创建一个新的事务 |
| SUPPORTS | A调用B,B有无事务无所谓,A有事务就加入到A事务中,A无事务B就以非事务方式执行 |
| NOT_SUPPORTS | A调用B,B以无事务方式执行,A如有事务则挂起 |
| NEVER | A调用B,B以无事务方式执行,A如有事务则抛出异常 |
| MANDATORY | A调用B,B要加入A的事务中,如果A无事务就抛出异常 |
| NESTED | A调用B,B创建一个新事务,A有事务就作为嵌套事务存在,A没事务就以创建的新事务执行 |

isolation : 事务的隔离级别,默认值为 Isolation.DEFAULT。指定事务的隔离级别,事务并发存在三大问题:脏读、不可重复读、幻读/虚读。可以通过设置事务的隔离级别来保证并发问题的出现,常用的是READ_COMMITTED 和REPEATABLE_READ

|------------------|---------------------------------------------------------|
| isolation属性 | 解释 |
| DEFAULT | 默认隔离级别,取决于当前数据库隔离级别,例如MySQL默认隔离级别是REPEATABLE_READ |
| READ_UNCOMMITTED | A事务可以读取到B事务尚未提交的事务记录,不能解决任何并发问题,安全性最低,性能最高 |
| READ_COMMITTED | A事务只能读取到其他事务已经提交的记录,不能读取到未提交的记录。可以解决脏读问题,但是不能解决不可重复读和幻读 |
| REPEATABLE_READ | A事务多次从数据库读取某条记录结果一致,可以解决不可重复读,不可以解决幻读 |
| SERIALIZABLE | 串行化,可以解决任何并发问题,安全性最高,但是性能最低 |

timeout : 事务的超时时间,默认值为 -1。如果超过该时间限制但事务还没有完成,则自动回滚事务。

readOnly: 指定事务是否为只读事务,默认值为 false;为了忽略那些不需要事务的方法,比如读取数据,可以设置 read-only 为 true。

rollbackFor: 用于指定能够触发事务回滚的异常类型,可以指定多个异常类型。

noRollbackFor: 抛出指定的异常类型,不回滚事务,也可以指定多个异常类型。

3、@Transactional失效场景、原因及修正方式

3.1 同一个类中的方法通过this调用导致失效

java 复制代码
public void addUser(UserParam param) {
        User user = PtcBeanUtils.copy(param, User.class);
        // 新增用户
        userDAO.insert(user);
        // 添加用户角色
        this.addUserRole(user.getId(), param.getRoleIds());
        log.info("执行结束了");
    }

    @Transactional(rollbackFor = Exception.class)
    public void addUserRole(Long userId, List<Long> roleIds) {
        if (CollectionUtils.isEmpty(roleIds)) {
            return;
        }
        List<UserRole> userRoles = new ArrayList<>();
        roleIds.forEach(roleId -> {
            UserRole userRole = new UserRole();
            userRole.setUserId(userId);
            userRole.setRoleId(roleId);
            userRoles.add(userRole);
        });
        userRoleDAO.insertBatch(userRoles);
        throw new RuntimeException("发生异常咯");
    }

执行#addUser()会发现事务控制失效,发生异常事务并没有回滚,用户和角色绑定都插入成功了。

这里,我给出@Transactional生效原则 1,必须通过代理过的类从外部调用目标方法才能生效.

Spring 是通过 AOP 技术对方法进行增强实现事务控制的,要调用增强过的方法必然是调用代理后的对象,而这里this是原生对象,并不是代理,自然就没有事务控制了。

解决方法:

1、将addUser()方法开启事务即加上@Transactional(rollbackFor = Exception.class),这里本就该开启。里面的方式自动延用主方法的事务配置

2、将this换成代理的userService, 可以自己注入自己@Resource private UserService userService,当然也可以不用注入,直接在Spring容器中获取userService这个bean

3、通过开启aop的代理来获取当前service对象,然后来获取对象,思路和第二种方式类似

3.2 异常被catch"吃掉了"导致@Transactional失效

如下所示:

java 复制代码
@Transactional(rollbackFor = Exception.class)
    public void addUser(UserParam param) {
        try {
            User user = PtcBeanUtils.copy(param, User.class);
            // 完成一些逻辑处理
          
            .......
              
            // 添加用户角色
            this.addUserRole(user.getId(), param.getRoleIds());
            log.info("执行结束了");
        } catch (Exception e) {
            log.error(e.getMessage());
        }
    }

    @Transactional(rollbackFor = Exception.class)
    public void addUserRole(Long userId, List<Long> roleIds) {
        if (CollectionUtils.isEmpty(roleIds)) {
            return;
        }
        List<UserRole> userRoles = new ArrayList<>();
        roleIds.forEach(roleId -> {
            UserRole userRole = new UserRole();
            userRole.setUserId(userId);
            userRole.setRoleId(roleId);
            userRoles.add(userRole);
        });
        userRoleDAO.insertBatch(userRoles);
        throw new RuntimeException("发生异常咯");
    }

@Transactional生效原则2:只有异常传播出了标记了 @Transactional 注解的方法,事务才能回滚。之前我们总结过 基于AOP事务控制实现原理说过在 Spring的 TransactionAspectSupport里有个 invokeWithinTransaction 方法,里面就是处理事务的逻辑。可以看到,只有捕获到异常才能进行后续事务处理:

java 复制代码
protected Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,
      final InvocationCallback invocation) throws Throwable {
      
      ......
        
      try {
        // This is an around advice: Invoke the next interceptor in the chain.
        // This will normally result in a target object being invoked.
        retVal = invocation.proceedWithInvocation();
      }
      catch (Throwable ex) {
        // target invocation exception
        // 捕获到异常,进行回滚操作,如果我们在业务方法已经捕获掉异常,这里就捕获不到了,自然就不会回滚了
        completeTransactionAfterThrowing(txInfo, ex);
        throw ex;
      }
      finally {
        cleanupTransactionInfo(txInfo);
      }
    
      ......
        
      return result;
    }
  }

可以看到,只有捕获到异常时才进行回滚操作,如果我们在业务方法已经捕获掉异常,这里就捕获不到了,自然就不会回滚了。

解决方法:

就是对异常捕获尽量做到局部针对操作,不要笼统把整个方法的代码逻辑都包括进行,这样异常就抛出去了。如果是实在要对代码块进行catch,就用手动回滚的方式去恢复数据(也可以理解为TCC模式)

3.3 @Transactional 属性 rollbackFor 设置错误,导致异常不满足回滚条件

java 复制代码
@Transactional
    public void addUser(UserParam param) {
      User user = PtcBeanUtils.copy(param, User.class);
       
      .......
        
      // 添加用户角色
      this.addUserRole(user.getId(), param.getRoleIds());
      log.info("执行结束了");
    }

    public void addUserRole(Long userId, List<Long> roleIds) throws Exception {
        if (CollectionUtils.isEmpty(roleIds)) {
            return;
        }
        List<UserRole> userRoles = new ArrayList<>();
        roleIds.forEach(roleId -> {
            UserRole userRole = new UserRole();
            userRole.setUserId(userId);
            userRole.setRoleId(roleId);
            userRoles.add(userRole);
        });
        userRoleDAO.insertBatch(userRoles);
        throw new Exception("发生异常咯");
    }

这里#addUser()使用@transactional,但没有设置rollbackFor属性,且#addUserRole()抛出的异常是exception,不是RuntimeException,这样事务也失效了,因为默认情况下,出现 RuntimeException(非受检异常)或 Error 的时候,Spring才会回滚事务

从上面3.2小节的completeTransactionAfterThrowing(txInfo, ex);进去完成回滚操作会判断异常类型是否满足规定,DefaultTransactionAttribute 类能看到如下代码块,可以发现相关证据,通过注释也能看到 Spring 这么做的原因,大概的意思是受检异常一般是业务异常,或者说是类似另一种方法的返回值,出现这样的异常可能业务还能完成,所以不会主动回滚;而Error 或 RuntimeException 代表了非预期的结果,应该回滚:

java 复制代码
 public boolean rollbackOn(Throwable ex) {
    return (ex instanceof RuntimeException || ex instanceof Error);
  }

解决方法:

设置rollbackFor:@Transactional(rollbackFor = Exception.class)。这一点同样很通用,就如果下面的方法抛出的是exception的子类用这个来接收统一处理也是可以的。

3.4 @Transactional 应用在非 public 修饰的方法上

java 复制代码
@Transactional(rollbackFor = Exception.class)
    private void addUserRole(Long userId, List<Long> roleIds) {
        if (CollectionUtils.isEmpty(roleIds)) {
            return;
        }
        List<UserRole> userRoles = new ArrayList<>();
        roleIds.forEach(roleId -> {
            UserRole userRole = new UserRole();
            userRole.setUserId(userId);
            userRole.setRoleId(roleId);
            userRoles.add(userRole);
        });
        userRoleDAO.insertBatch(userRoles);
        throw new RuntimeException("发生异常咯");
    }

首先在编译阶段,idea也会提示爆红。提示private的问题。

原因是因为Spring通过CGLIB动态代理来增强生产代理对象,CGLIB 通过继承方式实现代理类,private 方法在子类不可见,自然也就无法进行事务增强。会调用到AbstractFallbackTransactionAttributeSource的computeTransactionAttribute()方法

解决方法:改成public

3.5 @Transactional 注解传播属性 propagation 设置错误

如上面我们新增的用户的同时要添加用户角色,但是假如我们希望即使添加角色错误了,还可以正常新增用户。 会发现都是同时成功或是同时失败。

原因是,主方法添加用户的逻辑和子方法添加用户角色的逻辑是同一个事务,子逻辑标记了事务需要回滚,主逻辑自然也不能提交了。

解决方法:添加一个新的事务即可。这个场景在mybatisplus的@DB切换多数据源的事务时候也会出现(如果有遇到过)

@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)

3.6 @Transactional长事务导致生产事故

很多开发都觉得Spring的声明式事务使用非常简单,即@Transactional,所以从来不注重细节。当 Spring 遇到该注解时,会自动从数据库连接池中获取 connection,并开启事务然后绑定到 ThreadLocal 上,对于@Transactional注解包裹的整个方法都是使用同一个connection连接

如果我们出现了耗时的操作,比如第三方接口调用、业务逻辑复杂、大批量数据处理等就会导致我们我们占用这个connection的时间会很长,数据库连接一直被占用不释放。一旦类似操作过多,就会导致数据库连接池耗尽。这就是典型的长事务问题

长事务引发的常见危害有:

  1. 数据库连接池被占满,应用无法获取连接资源;
  2. 容易引发数据库死锁;
  3. 数据库回滚时间长;
  4. 在主从架构中会导致主从延时变大。

服务系统开始出现故障:数据库监控平台一直收到告警短信,数据库连接不足,出现大量死锁;日志显示调用流程引擎接口出现大量超时;同时一直提示CannotGetJdbcConnectionException,数据库连接池连接占满。

要想解决这个问题其实也不难,只需要对方法进行拆分,将不需要事务管理的逻辑与事务操作分开,这样就可以有效控制事务的时长从而避免长事务。当然对一个方法逻辑拆分成多个子方法很有可能造成上面叙述的事务不生效的情况

Spring的声明式事务使用@Transactional注解在开发时确实很方便,但是稍有不慎使用不当就会导致事务失效数据不一致、甚至是系统数据库性能问题。所以上面满满的干货总结都是出自日常工作中碰到的,有效帮你避坑。

相关推荐
Jacky(易小天)4 分钟前
MongoDB比较查询操作符中英对照表及实例详解
数据库·mongodb·typescript·比较操作符
Karoku06643 分钟前
【企业级分布式系统】ELK优化
运维·服务器·数据库·elk·elasticsearch
小技与小术2 小时前
数据库表设计范式
数据库·mysql
安迁岚2 小时前
【SQL Server】华中农业大学空间数据库实验报告 实验三 数据操作
运维·服务器·数据库·sql·mysql
安迁岚2 小时前
【SQL Server】华中农业大学空间数据库实验报告 实验九 触发器
数据库·sql·mysql·oracle·实验报告
Loganer2 小时前
MongoDB分片集群搭建
数据库·mongodb
LKID体2 小时前
Python操作neo4j库py2neo使用之创建和查询(二)
数据库·python·neo4j
刘大浪2 小时前
后端数据增删改查基于Springboot+mybatis mysql 时间根据当时时间自动填充,数据库连接查询不一致,mysql数据库连接不好用
数据库·spring boot·mybatis
一只爱撸猫的程序猿3 小时前
简单实现一个系统升级过程中的数据平滑迁移的场景实例
数据库·spring boot·程序员
无敌岩雀3 小时前
MySQL中的索引
数据库·mysql