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.
- 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
- 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);
}
}
- 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!