Spring事务的那些坑

图片来自pixabay.com的distelapparath会员

Spring MVC框架对数据访问事务抽象封装在spring-tx模块中,一个简单@Transactional注解就可以对一个方法调用开启事务,在使用过程中,若对Spring MVC的事务配置和原理了解不够清楚,一不小心就会落入坑中。

注:本文的代码技术讨论基于Spring Framework Tx 5.2.6.RELEASE版本 + Spring Boot 2.2.7.RELEASE版本。

1. 坑1 - 事务方法调用过程中的异常抛出

对一个方法调用开启事务,是希望在运行过程中,一旦有未知异常发生,则需要回滚数据库表的相关写操作,保证数据操作的原子性和一致性。

下面的insertUser()方法中,首先对一个user记录进行了保存写入操作,然后有一个异常被抛出,这个时候user记录是否会被回滚?

@Service
public class UserService {

    @Autowired
    UserInfoDAO userInfoDAO;

    @Transactional
    public void insertUser(UserInfoDTO user) throws Exception {
        userInfoDAO.insert(user);
        throw new Exception("Oh, an error happened");
        return user;
    }

}

答案其实是不会,user记录还是被保存到了数据库表中。其原因是@Transactional在未配置任何rollbackFor异常类时,将会执行如下缺省配置,即只对RuntimeException和Error两种异常发生时才执行回滚操作,上面的代码中抛出的异常类是Exception,未命中回滚条件。

public class DefaultTransactionAttribute extends DefaultTransactionDefinition implements TransactionAttribute {

    public boolean rollbackOn(Throwable ex) {
        return ex instanceof RuntimeException || ex instanceof Error;
    }

}

若希望在上面的情况下,回滚user记录的写入操作,一个解决办法是,在标注Transactional时指定如下的回滚异常类,

@Transactional(rollbackFor = Exception.class)

更多的Transactional注解配置说明见下文。

2. 坑2 - 内部调用方法被标注为事务

在下面的代码样例中,UserService提供了两个方法,

  • public void insertUser(UserInfoDTO user)
  • private void insertUserInner(UserInfoDTO user)

前一个方法将会调用后者,后者执行数据记录保存操作,@Transactional注解标注在后者方法上。

@RestController
public class TestController {

    @Autowired
    private UserService userService;

    @RequestMapping(value = "/test", method = RequestMethod.POST)
    public UserInfoDTO addUser(@RequestBody UserInfoDTO user) throws Exception {
        return userService.insertUser(user);
    }

}


@Service
public class UserService {

    @Autowired
    UserInfoDAO userInfoDAO;

    public void insertUser(UserInfoDTO user) throws Exception {
        insertUserInner(user);
    }

    @Transactional
    private void insertUserInner(UserInfoDTO user) throws Exception {
        userInfoDAO.insert(user);
        throw new RuntimeException("Oh, an error happened");
    }

}

若在insertUserInner方法中出现异常,上面的user记录同样也不会被回滚,原因是,
1. Transactional的实现是通过Spring AOP原理,由于Spring AOP对内部方法调用不起作用,数据事务也无法正常启动。
2. @Transactional注解只对public方法起作用,对于private/protected/package-visible的方法上是无效的。

要想对内部调用或私有方法启用数据事务,可以考虑使用可编程的数据事务

3. 坑3 - 在一个事务方法调用了另外一个事务方法

在下面的代码样例中,事务方法UserAService.insertUser()调用了另外一个UserBService.insertUser()事务方法,并通过try/catch对后者的异常进行了日志输出处理。

@Service
public class UserAService {

    @Autowired
    UserBService userBService;

    @Autowired
    UserInfoDAO userInfoDAO;

    @Transactional()
    public void insertUser(UserInfoDTO user) throws Exception {
        // 保存用户记录A
        userInfoDAO.insert(user);

        try {
            userBService.insertUser(user);
        } catch (Throwable e){
            System.out.println(e.toString());
        }
    }

}

@Service
public class UserBService {

    @Autowired
    UserInfoDAO userInfoDAO;

    @Transactional()
    public void insertUser(UserInfoDTO user) throws Exception {
        // 保存用户记录B
        userInfoDAO.insert(user);
        throw new RuntimeException("Oh, an error happened");
    }

}

在UserBService.insertUser()方法执行过程中,若有未知异常发生,用户记录A和用户记录B在数据库表中的状态是怎样?是保存,还是回滚了?

有很多人认为,由于异常被try/catch了,所以用户记录A将会被正常保存,而用户记录B会被回滚,而事实上是,两个用户记录都未被保存到。

查看日志可以看到如下的报错信息,

org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only at 
org.springframework.transaction.support.AbstractPlatformTransactionManager.processRollback(AbstractPlatformTransactionManager.java:870)at 
org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:707) at 
org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:385) at 
org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:118) at 
org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) at 
com.pphh.demo.service.UserAService$$EnhancerBySpringCGLIB$$73a6d5aa.insertUser(<generated>) ~[classes/:na]

这个异常日志说当前事务已回滚,无法执行当前事务。查看整个事务执行过程,就可以了解这个报错的原因,
- UserAService.insertUser被调用,开启了事务A,保存用户记录A
- UserBService.insertUser被调用,由于当前已经有存在事务,根据Transactional方法默认事务传播定义,是需要加入到当前事务A
- UserBService.insertUser异常发生,回滚当前事务A,用户记录B被回滚
- UserAService.insertUser执行完毕,准备提交用户记录A,这时发现当前事务A已经被回滚,无法执行当前事务。

要想解决这个问题,让用户记录A将会被正常保存,而用户记录B被回滚,可以将UserBService.insertUser的事务传播定义为Propagation.NEW,

public class UserBService {

    @Transactional(propagation = Propagation.NEW)
    public void insertUser(UserInfoDTO user) throws Exception {
        // 保存用户记录B
        userInfoDAO.insert(user);
        throw new RuntimeException("Oh, an error happened");
    }

}

4. 开启事务调试日志

有时候为了方便查看事务运行情况,可以通过下方法开启事务日志,

# transaction
logging.level.org.springframework.transaction.interceptor=TRACE

如下是一段Trace执行日志,可以看到由于没有回滚规则定义,直接使用了缺省规则。

2020-05-26 12:16:14.329 o.s.t.i.TransactionInterceptor           : Getting transaction for [com.pphh.demo.service.UserService.insertUser]
2020-05-26 12:16:14.490 o.s.t.i.TransactionInterceptor           : Completing transaction for [com.pphh.demo.service.UserService.insertUser] after exception: java.lang.Exception: Oh, an error happened
2020-05-26 12:16:14.490 o.s.t.i.RuleBasedTransactionAttribute    : Applying rules to determine whether transaction should rollback on java.lang.Exception: Oh, an error happened
2020-05-26 12:16:14.490 o.s.t.i.RuleBasedTransactionAttribute    : Winning rollback rule is: null
2020-05-26 12:16:14.490 o.s.t.i.RuleBasedTransactionAttribute    : No relevant rollback rule found: applying default rules

5. Transational注解配置说明

下面对Transational注解的各个配置定义进行了说明,供参考,

public @interface Transactional {

    // 指定事务管理器
    @AliasFor("transactionManager")
    String value() default "";

    // 同上
    @AliasFor("value")
    String transactionManager() default "";

    // 事务嵌套时,事务的传播定义,有如下几种,
    // Propagation.REQUIRED      -- 当前若有事务存在,则加入该事务。若无事务,则创建一个事务。
    // Propagation.SUPPORTS      -- 当前若有事务存在,则加入该事务。若无事务,则继续非事务状态运行。
    // Propagation.MANDATORY     -- 当前必须在事务中,若当前无事务,则抛出异常。
    // Propagation.REQUIRES_NEW  -- 当前方法新起一个事务。当前若有事务存在,则挂起当前事务。
    // Propagation.NOT_SUPPORTED -- 当前方法不支持事务,若有事务存在,则挂起当前事务。
    // Propagation.NEVER         -- 当前方法不支持事务,若有事务存在,则抛出异常。
    Propagation propagation() default Propagation.REQUIRED;

    // 事务隔离级别,有如下几个定义,该选项只适用于REQUIRED和REQUIRES_NEW事务传播级别
    // DEFAULT          -- 数据库缺省
    // READ_UNCOMMITTED -- 读未提交
    // READ_COMMITTED   -- 读已提交
    // REPEATABLE_READ  -- 可重复读
    // SERIALIZABLE     -- 串行
    Isolation isolation() default Isolation.DEFAULT;

    // 事务超时时间,缺省是数据库指定,该选项只适用于REQUIRED和REQUIRES_NEW事务传播级别
    int timeout() default -1;

    // 只读事务,该选项只适用于REQUIRED和REQUIRES_NEW事务传播级别
    boolean readOnly() default false;

    // 事务回滚异常类
    // 多个回滚条件可以通过如下指定
    // @Transactional(rollbackFor = {Exception.class, Error.class})
    Class<? extends Throwable>[] rollbackFor() default {};

    // 事务回滚异常类,必须继承自Throwable
    String[] rollbackForClassName() default {};

    // 事务回滚异常类过滤条件
    Class<? extends Throwable>[] noRollbackFor() default {};

    // 事务回滚异常类过滤条件,必须继承自Throwable
    String[] noRollbackForClassName() default {};
}

6. 参考资料

RestController方法返回Boolean对象时SpringFramework报告HTTP Status 406 – Not Acceptable

图片来自pixabay.com的mrgajowy3会员

1. 问题描述

一个简单的spring mvc样例,spring-webmvc框架版本为5.1.5.RELEASE,里面定义了两个RestController接口方法,

@RestController
@RequestMapping(value = "/api", produces = "application/json; charset=utf-8")
public class SimpleController {

    @RequestMapping(value = "test", method = RequestMethod.GET)
    public String test() {
        return "hello,world";
    }

      @RequestMapping(value = "test2", method = RequestMethod.GET)
    public Boolean test2() {
        return Boolean.TRUE;
    }

}

通过http客户端工具调用/api/test接口能够正常返回信息,但是调用/api/test2时,报如下错误信息,

<body><h1>HTTP Status 406 – Not Acceptable</h1><hr class="line" /><p><b>Type</b> Status Report</p><p><b>Description</b> The target resource does not have a current representation that would be acceptable to the user agent, according to the proactive negotiation header fields received in the request, and the server is unwilling to supply a default representation.</p><hr class="line" /><h3>Apache Tomcat/8.5.37</h3></body>

后端日志报如下HttpMediaTypeNotAcceptableException异常信息,

org.springframework.web.HttpMediaTypeNotAcceptableException: Could not find acceptable representation

2. 问题调查

问题奇怪在于,接口api/test能正常返回信息,但接口api/test2报错。比较下两个接口,都差不多,区别在于:前者返回String,后者返回Boolean对象。

调试跟踪api/test2的接口,发现异常HttpMediaTypeNotAcceptableException在如下位置被抛出,

/** 调用栈
 * DispatcherServlet.doDispatch()
 * AbstractHandlerMethodAdapter.handle()
 * RequestMappingHandlerAdapter.invokeHandlerMethod()
 * ServletInvocableHandlerMethod.invokeAndHandle(webRequest, mavContainer, providedArgs)
 * this.returnValueHandlers.handleReturnValue(returnValue, this.getReturnValueType(returnValue), mavContainer, webRequest);
 * AbstractMessageConverterMethodProcessor.handleReturnValue()
 * this.writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage);
 *
 * 到达类AbstractMessageConverterMethodProcessor中的如下方法,
 * 注:如下为反编译代码,非源码,仅供问题调查
 */

protected <T> void writeWithMessageConverters(...){
    ...
    HttpMessageConverter converter;
    GenericHttpMessageConverter genericConverter;
    label138: {
        if(selectedMediaType != null) {
            selectedMediaType = selectedMediaType.removeQualityValue();
            var21 = this.messageConverters.iterator();

            while(var21.hasNext()) {
                converter = (HttpMessageConverter)var21.next();
                genericConverter = converter instanceof GenericHttpMessageConverter?(GenericHttpMessageConverter)converter:null;
                if(genericConverter != null) {
                    if(((GenericHttpMessageConverter)converter).canWrite((Type)declaredType, valueType, selectedMediaType)) {
                        break label138;
                    }
                } else if(converter.canWrite(valueType, selectedMediaType)) {
                    break label138;
                }
            }
        }

        if(outputValue != null) {
            // 异常抛出点
            throw new HttpMediaTypeNotAcceptableException(this.allSupportedMediaTypes);
        }

        return;
   }
   ...
}

查看上传代码,可以了解异常抛出的原因,是因为没有找到合适HttpMessageConverter,无法转换返回的消息。

调试查看this.messageConverters对象,发现有converters列表如下,

this.messageConverters ( ArrayList size = 7)
[0] ByteArrayHttpMessageConverter
[1] StringHttpMessageConverter
[2] ResourceHttpMessageConverter
[3] ResourceRegionHttpMessageConverter
[4] SourceHttpMessageConverter
[5] AllEncompassingFormHttpMessageConverter
[6] Jaxb2CollectionHttpMessageConverter

这个列表没有发现我们熟悉的Json Converter,于是接下来开始追查messageConverters的列表初始化,为什么没有Json converter?

查看spring mvc源码可以看到如下的convert初始化过程,
1. 在类RequestMappingHandlerAdapter中创建this.messageConverters列表对象,并加载各个缺省convert。
2. 其中AllEncompassingFormHttpMessageConverter被创建,在这个类中,其会检查当前JVM中是否加载了Jackson/Gson相关类,若有,则加载json converter。

public class RequestMappingHandlerAdapter {

  public RequestMappingHandlerAdapter() {
      StringHttpMessageConverter stringHttpMessageConverter = new StringHttpMessageConverter();
      stringHttpMessageConverter.setWriteAcceptCharset(false);
      this.messageConverters = new ArrayList(4);
      this.messageConverters.add(new ByteArrayHttpMessageConverter());
      this.messageConverters.add(stringHttpMessageConverter);
      this.messageConverters.add(new SourceHttpMessageConverter());
      this.messageConverters.add(new AllEncompassingFormHttpMessageConverter());
  }

}

public class AllEncompassingFormHttpMessageConverter {

    private static final boolean jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", ...);
    private static final boolean gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", ...);

    public AllEncompassingFormHttpMessageConverter() {
      ...
      if(jackson2Present) {
        this.addPartConverter(new MappingJackson2HttpMessageConverter());
      } else if(gsonPresent) {
        this.addPartConverter(new GsonHttpMessageConverter());
      } else if(jsonbPresent) {
        this.addPartConverter(new JsonbHttpMessageConverter());
      }
      ...
    }
}

由此可以看到,缺少json converter的原因就是缺少相应的Jackson/Gson类库,而默认的converter无法对Boolean对象转换,从而产生HttpMediaTypeNotAcceptableException异常,接而导致HTTP 406的返回消息。

3. 问题解决

在项目中添加如下依赖,加载MappingJackson2HttpMessageConverter到this.messageConverters列表中,问题得到解决。

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>${fasterxml.version}</version>
</dependency>