spring声明式事务@Transactional的那些坑

1,404 阅读6分钟

事务有四大特性,分别是原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),把这四大特性的英文首字母拼起来,就是ACID,这个单词对应的中文意思是“酸”,什么东西比较酸呢,柠檬(🍋),把它们联系起来,就容易记着这几个特性了。 在MySQL数据库中,默认的存储引擎Innodb是支持事务的,另一个比较常用的存储引擎MyISAM是不支持事务的。 插曲说一下,一直对MyISAM怎么读比较纠结,我查了一下,在mysql的邮件列表,有人问过这个问题,提到比较多的读法是 [My-I-sam],有相同困惑的朋友可以借鉴一下。 这四个特性的具体含义,这里就不再细说了,我们焦点放在在实际的编程中怎么避免踩坑。

声明式事务的属性

spring对事务的支持有两种方式:编程式事务和声明式事务。编程式事务对代码有侵入性,用起来也比较繁琐,一般也很少使用,声明式事务通过添加 @Transactional 注解来支持,事务的属性会添加在注解上。 声明式事务的几个属性需要了解。

事务传播级别

  1. TransactionDefinition.PROPAGATION_REQUIRED

@Transactional 注解默认的传播方式就是REQUIRED,在该传播模式下,如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。

  1. TransactionDefinition.PROPAGATION_SUPPORTS

表示如果有事务,就加入到当前事务,如果没有,那也不开启事务执行。这种传播级别可用于查询方法,因为SELECT语句既可以在事务内执行,也可以不需要事务;

  1. TransactionDefinition.PROPAGATION_MANDATORY

表示必须要存在当前事务并加入执行,否则将抛出异常。这种传播级别可用于核心更新逻辑,比如用户余额变更,它总是被其他事务方法调用,不能直接由非事务方法调用;

  1. TransactionDefinition.PROPAGATION_REQUIRES_NEW

表示不管当前有没有事务,都必须开启一个新的事务执行。如果当前已经有事务,那么当前事务会挂起,等新事务完成后,再恢复执行;

  1. TransactionDefinition.PROPAGATION_NOT_SUPPORTED

表示不支持事务,如果当前有事务,那么当前事务会挂起,等这个方法执行完成后,再恢复执行;

  1. TransactionDefinition.PROPAGATION_NEVER

不支持事务,而且在检测到当前有事务时,会抛出异常拒绝执行;

  1. TransactionDefinition.PROPAGATION_NESTED

表示如果当前有事务,则开启一个嵌套级别事务,如果当前没有事务,则开启一个新事务。

事务隔离级别

在 org.springframework.transaction.annotation.Isolation 枚举中定义了@Transactional支持的隔离级别:

  • **DEFAULT **使用数据库指定的隔离级别
  • **READ_UNCOMMITTED **读未提交
  • **READ_COMMITTED **读已提交
  • **REPEATABLE_READ **可重复读
  • **SERIALIZABLE **可串行化

超时属性

一个事务所允许执行的最长时间,如果超过该时间限制但事务还没有完成,则自动回滚事务。在 TransactionDefinition 中以 int 的值来表示超时时间,其单位是秒,默认值为-1。-1代表使用底层事务系统默认的超时时间。

只读属性

对于只有读取数据查询的事务,可以指定事务类型为 readonly,即只读事务。只读事务不涉及数据的修改,数据库会提供一些优化手段,适合用在有多条数据库查询操作的方法中。 可以通过下面的语句查询数据库是否开启了自动提交,如果开启了,默认一条语句就是一个事务。

SHOW VARIABLES LIKE 'autocommit';

实际上如果一个事务中只有一条查询语句,是没有必要加readonly属性的,因为不存在不一致的场景。 如果一个事务中有多条查询语句,在可重复读的隔离级别下,在这个事务中能查到的结果是一致的。 举个例子: 对一批量比较大的数据的查询。一般我们不会一次性把所有数据全捞出来,而是采用分页的形式多次查询,先count数量,再使用offset和limit分批查询,如果不使用事务的话,分批查询出来的数据的个数和count查询的数量可能不一致。

回滚规则

默认只有在RunTimeException和Error的时候会进行回滚,checkedException是不会回滚的,这个可以进行指定。

使用举例

@Transactional 注解可作用于接口、类、方法上,一般常用的是把注解加在方法上,达到一个细粒度的控制。

// 当发生异常时进行回滚,使用默认的传播级别REQUIRED
@Transactional(rollbackFor = Exception.class)    
// 当发生MyServiceException异常时进行回滚,使用的传播级别为 REQUIRES_NEW
@Transactional(rollbackFor = MyServiceException.class, propagation = Propagation.REQUIRES_NEW)

容易踩坑的点

  1. 把@Transactional配置在了私有方法上

声明式事务使用的是动态代理,注解只能作用在public方法上,不过一般ide就可以给检查出来这种错误。

@Nullable
protected TransactionAttribute computeTransactionAttribute(Method method, @Nullable Class<?> targetClass) {
    // Don't allow no-public methods as required.
    if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
        return null;
    }
    ...
}
  1. 同类下方法调用了带@Transactional注解的方法,事务不起作用
public class A {
    public void func1() {
        func2();
    }
    
    @Transactional(rollbackFor = Exception.class)  
    public void func2() {
    }
}

如上面的代码,在类A中有两个公有方法:func1 和 func2,func2上有事务注解,如果func1为入口,对func1的调用,func2上的事务注解是不起作用的。 这个地方很容易踩坑,为什么会这样呢,这与spring aop的动态代理有关系,对于使用 this 的方式调用,这种只是自调用,并不会使用代理对象进行调用,也就无法执行切面类。

  1. 传播方式配置错误,和预期不一样
public class A {
    @Autowired private B b;
    
    @Transactional(rollbackFor = Exception.class)
    public void func1() {
        for (int i = 0; i < 10; i++) {
            try {
       			b.func2(); 
            } catch (Exception ignore) {
            }
        }
        // ...
    }
}

public class B {
    @Transactional(rollbackFor = Exception.class)  
    public void func2() {
        // 如果有异常就回滚
    }
}

开发中,经常会有需求是希望批处理任务中某一个有异常能回滚掉,而不影响同一批中的其他正常任务。 如上面的代码所示, func2 中调用了 func1 ,并catch了异常,期望的是: func2 中出现异常, func2 回滚, func1 及for循环中的其他调用不会滚。 而事实上却和我们的期望不一样。 如果 func2 回滚的话,虽然在 func1 中catch了异常,func1还是会回滚,因为func2使用了默认的 REQUIRED 的传播方式,它会加入到这个事务中去,如果 func2 抛出了异常,这个事务就会标记为 rollbackOnly ,导致整个事务回滚。 解决方法:

  • 第一种,去掉func1的事务注解;
  • 第二种,设置func2的事务传播方式为 REQUIRES_NEW
  1. 没有指定数据源

曾经踩过坑,是这样的,一开始我们的项目只引入了一个库,只有一个数据源,那么**@Transactional 是不需要指定数据源的。 后面又引入了一个库,现在就有两个数据源了。 那么@Transactional **是不知道去回滚哪个数据源的数据的。 所以,对于有多个数据源的系统一定要指定要回滚的数据源,统一通过 valuetransactionManager 指定数据源。

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Transactional {
	@AliasFor("transactionManager")
	String value() default "";

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