19 Spring Transactions Common Errors Part 1

19 Spring Transactions Common Errors Part 1 #

Hello, I’m Fu Jian.

In the previous lesson, we learned about some common issues with Spring Data for database operations. In this lesson, let’s talk about a very important topic in database operations - transaction management.

There are two ways to configure Spring transaction management. The first way is to use XML for fuzzy matching and binding transaction management. The second way is to use annotations, which allows you to configure each method that requires transaction processing individually. You just need to add the @Transactional annotation and configure the attributes within the annotation. In our error case demonstration, we will use the more convenient annotation-based approach.

In addition, I’d like to mention that when Spring initializes, it scans and intercepts methods related to transactions. If the target method has a transaction, Spring will create a Proxy object corresponding to the bean and perform related transactional operations.

Before we start discussing transactions, we need to set up a simple Spring database environment. Here, I have chosen the most popular MySQL + Mybatis as the basic environment for database operations. To use it properly, we also need to import some configuration files and classes. Let me list them briefly.

  1. The database configuration file, jdbc.properties, which configures the data connection information.
jdbc.driver=com.mysql.cj.jdbc.Driver

jdbc.url=jdbc:mysql://localhost:3306/spring?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC&useSSL=false

jdbc.username=root
jdbc.password=pass
  1. The JDBC configuration class, which loads the relevant configuration items from the above jdbc.properties and creates Beans for JdbcTemplate, DataSource, TransactionManager, etc.
public class JdbcConfig {
    @Value("${jdbc.driver}")
    private String driver;

    @Value("${jdbc.url}")
    private String url;

    @Value("${jdbc.username}")
    private String username;

    @Value("${jdbc.password}")
    private String password;

    @Bean(name = "jdbcTemplate")
    public JdbcTemplate createJdbcTemplate(DataSource dataSource) {
        return new JdbcTemplate(dataSource);
    }

    @Bean(name = "dataSource")
    public DataSource createDataSource() {
        DriverManagerDataSource ds = new DriverManagerDataSource();
        ds.setDriverClassName(driver);
        ds.setUrl(url);
        ds.setUsername(username);
        ds.setPassword(password);
        return ds;
    }

    @Bean(name = "transactionManager")
    public PlatformTransactionManager createTransactionManager(DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}
  1. The application configuration class, which uses annotations to configure the data source, MyBatis Mapper scan path, and transactions, etc.
@Configuration
@ComponentScan
@Import({JdbcConfig.class})
@PropertySource("classpath:jdbc.properties")
@MapperScan("com.spring.puzzle.others.transaction.example1")
@EnableTransactionManagement
@EnableAutoConfiguration(exclude={DataSourceAutoConfiguration.class})
@EnableAspectJAutoProxy(proxyTargetClass = true, exposeProxy = true)
public class AppConfig {
    public static void main(String[] args) throws Exception {
        ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
    }
}

Once we have completed the above basic configuration and code, we can start explaining the case study.

Case 1: Unchecked exceptions and transaction rollback #

In our system, we need to add a student management feature where each new student’s information is stored in the database. We have introduced a Student class and a related Mapper.

The Student class is defined as follows:

public class Student implements Serializable {
    private Integer id;
    private String realname;
    public Integer getId() {
        return id;
    }
    public void setId(Integer id) {
        this.id = id;
    }
    public String getRealname() {
        return realname;
    }
    public void setRealname(String realname) {
        this.realname = realname;
    }
}

The corresponding Mapper class for Student is defined as follows:

@Mapper
public interface StudentMapper {
    @Insert("INSERT INTO `student`(`realname`) VALUES (#{realname})")
    void saveStudent(Student student);
}

The database table schema for the student table is as follows:

CREATE TABLE `student` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `realname` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

The StudentService class is the business class which includes a method called saveStudent. We will test if this transaction will rollback by adding the following logic: if the username is “xiaoming”, we will directly throw an exception to trigger the rollback of the transaction.

@Service
public class StudentService {
    @Autowired
    private StudentMapper studentMapper;

    @Transactional
    public void saveStudent(String realname) throws Exception {
        Student student = new Student();
        student.setRealname(realname);
        studentMapper.saveStudent(student);
        if (student.getRealname().equals("xiaoming")) {
            throw new Exception("This student already exists");
        }
    }
}

We then use the following code to test if saving a student named “xiaoming” will trigger the rollback of the transaction.

public class AppConfig {
    public static void main(String[] args) throws Exception {
        ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
        StudentService studentService = (StudentService) context.getBean("studentService");
        studentService.saveStudent("xiaoming");
    }
}

The result of the execution prints the following message:

Exception in thread "main" java.lang.Exception: This student already exists
	at com.spring.puzzle.others.transaction.example1.StudentService.saveStudent(StudentService.java:23)

As we can see, the exception is indeed thrown, but if we check the database, we will find that a new record has been inserted.

Our conventional thinking might be that in Spring, when an exception is thrown, it will lead to a transaction rollback. After the rollback, no data should be stored in the database. However, in this case, although the exception is thrown, the rollback did not occur as expected. Why is that? We need to study the source code of Spring to find some answers.

Case Analysis #

By debugging along with the saveStudent method, we obtained a call stack like this:

调用栈

From this call stack, we can see the familiar CglibAopProxy, and transaction is essentially a special kind of aspect that is proxied by CglibAopProxy. The interceptor for transaction handling is TransactionInterceptor, which supports the entire architecture of transaction functionality. Let’s analyze how this interceptor implements transactional features.

First, TransactionInterceptor extends TransactionAspectSupport and implements the MethodInterceptor interface. When the target method of the proxy class is executed, it triggers the invoke() method. Since our focus is on exception handling, let’s focus on the part related to exception handling. When it catches an exception, it calls the completeTransactionAfterThrowing method to further process.

protected Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,
      final InvocationCallback invocation) throws Throwable {
      // omitted non-critical code
      Object retVal;
      try {
         retVal = invocation.proceedWithInvocation();
      }
      catch (Throwable ex) {
         completeTransactionAfterThrowing(txInfo, ex);
         throw ex;
      }
finally {
    cleanupTransactionInfo(txInfo);
}

//省略非关键代码
}

在 completeTransactionAfterThrowing 的代码中有这样一个方法 rollbackOn()这是事务的回滚的关键判断条件当这个条件满足时会触发 rollback 操作事务回滚

protected void completeTransactionAfterThrowing(@Nullable TransactionInfo txInfo, Throwable ex) {
    //省略非关键代码
    //判断是否需要回滚
    if (txInfo.transactionAttribute != null && txInfo.transactionAttribute.rollbackOn(ex)) {
       try {
           //执行回滚
           txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
       }
       catch (TransactionSystemException ex2) {
          ex2.initApplicationException(ex);
          throw ex2;
       }
       catch (RuntimeException | Error ex2) {
          throw ex2;
       }
    }
    //省略非关键代码
}

rollbackOn()其实包括了两个层级具体可参考如下代码

public boolean rollbackOn(Throwable ex) {
   // 层级 1:根据"rollbackRules"及当前捕获异常来判断是否需要回滚
   RollbackRuleAttribute winner = null;
   int deepest = Integer.MAX_VALUE;
   if (this.rollbackRules != null) {
      for (RollbackRuleAttribute rule : this.rollbackRules) {
         // 当前捕获的异常可能是回滚“异常”的继承体系中的“一员”
         int depth = rule.getDepth(ex);
         if (depth >= 0 && depth < deepest) {
            deepest = depth;
            winner = rule;
         }
      }
   }
   // 层级 2:调用父类的 rollbackOn 方法来决策是否需要 rollback
   if (winner == null) {
      return super.rollbackOn(ex);
   }
   return !(winner instanceof NoRollbackRuleAttribute);
}

1. RuleBasedTransactionAttribute 自身的 rollbackOn()

当我们在 @Transactional 中配置了 rollbackFor这个方法就会用捕获到的异常和 rollbackFor 中配置的异常做比较如果捕获到的异常是 rollbackFor 配置的异常或其子类就会直接 rollback在我们的案例中由于在事务的注解中没有加任何规则所以这段逻辑处理其实找不到规则即 winner == null),进而走到下一步

1. RuleBasedTransactionAttribute 父类 DefaultTransactionAttribute 的 rollbackOn()

如果没有在 @Transactional 中配置 rollback 属性或是捕获到的异常和所配置异常的类型不一致就会继续调用父类的 rollbackOn() 进行处理

而在父类的 rollbackOn() 中我们发现了一个重要的线索只有在异常类型为 RuntimeException 或者 Error 的时候才会返回 true此时会触发 completeTransactionAfterThrowing 方法中的 rollback 操作事务被回滚

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

查到这里真相大白Spring 处理事务的时候如果没有在 @Transactional 中配置 rollback 属性那么只有捕获到 RuntimeException 或者 Error 的时候才会触发回滚操作而我们案例抛出的异常是 Exception又没有指定与之匹配的回滚规则所以我们不能触发回滚

问题修正

从上述案例解析中我们了解到Spring 在处理事务过程中并不会对 Exception 进行回滚而会对 RuntimeException 或者 Error 进行回滚

这么看来修改方法也可以很简单只需要把抛出的异常类型改成 RuntimeException 就可以了于是这部分代码就可以修改如下

@Service
public class StudentService {
    @Autowired
    private StudentMapper studentMapper;

    @Transactional
    public void saveStudent(String realname) throws Exception {
        Student student = new Student();
        student.setRealname(realname);
        studentMapper.saveStudent(student);
        if (student.getRealname().equals("小明")) {
            throw new RuntimeException("该用户已存在");
        }
    }
}

再执行一下这时候异常会正常抛出数据库里不会有新数据产生表示这时候 Spring 已经对这个异常进行了处理并将事务回滚

但是很明显这种修改方法看起来不够优美毕竟我们的异常有时候是固定死不能随意修改的所以结合前面的案例分析我们还有一个更好的修改方式

具体而言我们在解析 RuleBasedTransactionAttribute.rollbackOn 的代码时提到过 rollbackFor 属性的处理规则也就是我们在 @Transactional 的 rollbackFor 加入需要支持的异常类型在这里是 Exception就可以匹配上我们抛出的异常进而在异常抛出时进行回滚

于是我们可以完善下案例中的注解修改后代码如下

@Transactional(rollbackFor = Exception.class)

再次测试运行你会发现一切符合预期了
## Case 2: Attempting to add transaction to private method

Continuing from the previous case, we have already implemented the functionality to save student information. Next, let's optimize the logic and separate the creation and saving logic of the student. So, I refactored the code and split the instance creation and saving logic into two separate methods. Then, I added the `@Transactional` annotation to the method that saves to the database.

```java
@Service
public class StudentService {
    @Autowired
    private StudentMapper studentMapper;

    @Autowired
    private StudentService studentService;

    public void saveStudent(String realname) throws Exception {
        Student student = new Student();
        student.setRealname(realname);
        studentService.doSaveStudent(student);
    }

    @Transactional
    private void doSaveStudent(Student student) throws Exception {
        studentMapper.saveStudent(student);
        if (student.getRealname().equals("小明")) {
            throw new RuntimeException("该用户已存在");
        }
    }
}

When executing, we continue to pass the parameter “小明” and see what the result is.

The exception is thrown as expected, but the transaction is not rolled back. I added the transaction annotation to the method, so why didn’t it take effect? Let’s find the answer in the Spring source code.

Case Analysis #

By debugging, we step by step found the root cause of the problem and obtained the following call stack. Let’s analyze the complete process using the Spring source code.

The first part is the process of creating the bean in Spring. After the bean is initialized, it starts the proxy operation, which is processed starting from the postProcessAfterInitialization method in AbstractAutoProxyCreator:

public Object postProcessAfterInitialization(@Nullable Object bean, String beanName) {
   if (bean != null) {
      Object cacheKey = getCacheKey(bean.getClass(), beanName);
      if (this.earlyProxyReferences.remove(cacheKey) != bean) {
         return wrapIfNecessary(bean, beanName, cacheKey);
      }
   }
   return bean;
}

We continue to find our way down, omitting the nonessential code, until we reach the canApply method in AopUtils. This method is used to determine whether the method can be created as a proxy according to the conditions in the aspect definition. Among them, the methodMatcher.matches(method, targetClass) is used to check if the method meets the specified conditions:

public static boolean canApply(Pointcut pc, Class<?> targetClass, boolean hasIntroductions) {
   //Omit nonessential code
   for (Class<?> clazz : classes) {
      Method[] methods = ReflectionUtils.getAllDeclaredMethods(clazz);
      for (Method method : methods) {
         if (introductionAwareMethodMatcher != null ?
               introductionAwareMethodMatcher.matches(method, targetClass, hasIntroductions) :
               methodMatcher.matches(method, targetClass)) {
            return true;
         }
      }
   }
   return false;
}

From matches(), it calls getTransactionAttribute in AbstractFallbackTransactionAttributeSource:

public boolean matches(Method method, Class<?> targetClass) {
   //Omit nonessential code
   TransactionAttributeSource tas = getTransactionAttributeSource();
   return (tas == null || tas.getTransactionAttribute(method, targetClass) != null);
}

In this method, getTransactionAttribute is used to get the transaction attributes from the annotation and determine the transaction strategy based on the attributes.

public TransactionAttribute getTransactionAttribute(Method method, @Nullable Class<?> targetClass) {
      //Omit nonessential code
      TransactionAttribute txAttr = computeTransactionAttribute(method, targetClass);
      //Omit nonessential code
   }
}

Then it calls computeTransactionAttribute, which determines whether to return the transaction attribute based on the method and class types. Here is the relevant code snippet:

protected TransactionAttribute computeTransactionAttribute(Method method, @Nullable Class<?> targetClass) {
   //Omit nonessential code
   if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
      return null;
   }
   //Omit nonessential code
}

There is a condition allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers()). When this condition evaluates to true, it returns null, which means the method will not be proxied and the transaction annotation will not take effect. So, is the condition true here? Let’s take a look.

Condition 1: allowPublicMethodsOnly()

allowPublicMethodsOnly() returns the publicMethodsOnly property in AnnotationTransactionAttributeSource.

protected boolean allowPublicMethodsOnly() {
   return this.publicMethodsOnly;
}

This publicMethodsOnly property is initialized in the constructor of AnnotationTransactionAttributeSource and is set to true by default.

public AnnotationTransactionAttributeSource() {
   this(true);
}

Condition 2: Modifier.isPublic()

This method retrieves the modifiers of the method using method.getModifiers(). The modifiers are static properties in java.lang.reflect.Modifier, and they correspond to different types of modifiers: PUBLIC: 1, PRIVATE: 2, PROTECTED: 4. The method performs a bitwise operation, and it only returns true when the modifiers of the method are of type PUBLIC.

public static boolean isPublic(int mod) {
    return (mod & PUBLIC) != 0;
}

Considering the two conditions mentioned above, you will find that the transactional method annotated with @Transactional needs to be declared as public in order for Spring to process it.

Problem Fix #

After understanding the root cause of the problem, fixing it becomes simple. We just need to change the access modifier of the method from private to public.

However, it should be noted that when invoking this transaction-annotated method, it must be called on the method that has been proxied by Spring AOP. It cannot be called internally within the class or through this. We have emphasized this issue in the previous Spring AOP code analysis, so we will not go into detail here.

@Service
public class StudentService {
    @Autowired
    private StudentMapper studentMapper;

    @Autowired
    private StudentService studentService;

    public void saveStudent(String realname) throws Exception {
        Student student = new Student();
        student.setRealname(realname);
        studentService.doSaveStudent(student);
    }

    @Transactional
    public void doSaveStudent(Student student) throws Exception {
        studentMapper.saveStudent(student);
        if (student.getRealname().equals("小明")) {
            throw new RuntimeException("该学生已存在");
        }
    }
}

Run it again. The exception is thrown as expected, and no new data is generated in the database. The transaction is effective, and the problem is resolved.

Exception in thread "main" java.lang.RuntimeException: 该学生已存在
    	at com.spring.puzzle.others.transaction.example2.StudentService.doSaveStudent(StudentService.java:27)

Key Review #

Through the above two cases, I believe you have gained a further understanding of Spring’s declarative transaction mechanism. Here are the key points summarized:

  • Spring supports declarative transaction mechanism, which is achieved by adding @Transactional to the method to indicate that the method requires transaction support. Therefore, during loading, the strategy for the transaction is determined based on the attributes in @Transactional.
  • @Transactional does not work on private methods, so we should declare methods that require transaction support as public.
  • By default, Spring only rolls back for RuntimeException and Error, not for Exception. If there is a special requirement, it needs to be declared explicitly. For example, specifying the rollbackFor attribute of Transactional as Exception.class.

Thought question #

RuntimeException is a subclass of Exception. If we use rollbackFor=Exception.class, it will also take effect on RuntimeException. So, if we need to perform a rollback operation on Exception but not on RuntimeException, what should we do?

We look forward to your thoughts in the comment section!