20 Spring Transactions Common Errors Part 2

20 Spring Transactions Common Errors Part 2 #

Hello, I’m Fu Jian.

In the previous lesson, we learned about the principles of Spring transactions and resolved several common issues. In this lesson, we will continue discussing two other problems related to transactions. One is about transaction propagation, and the other is about switching between multiple data sources. Through these two problems, you can gain a deeper understanding of the core mechanism of Spring transactions.

Case 1: Nested Transaction Rollback Error #

In the previous class, we completed the student registration feature. Now, let’s say we need to extend this feature by registering the student for an English compulsory course and updating the number of registered students for that course. To do this, I added two tables.

  1. Table course, which records the course name and the number of registered students.
CREATE TABLE `course` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `course_name` varchar(64) DEFAULT NULL,
  `number` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
  1. Table student_course, which records the many-to-many association between the student table and the course table.
CREATE TABLE `student_course` (
  `student_id` int(11) NOT NULL,
  `course_id` int(11) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

At the same time, I initialized a course in the course table with the following information: id = 1, course_name = “English”, number = 0.

Next, let’s complete the user-related operations, which mainly include two parts.

  1. Add a new student-course record.
@Mapper
public interface StudentCourseMapper {
    @Insert("INSERT INTO `student_course`(`student_id`, `course_id`) VALUES (#{studentId}, #{courseId})")
    void saveStudentCourse(@Param("studentId") Integer studentId, @Param("courseId") Integer courseId);
}
  1. Increase the number of registered students for a course by 1.
@Mapper
public interface CourseMapper {
    @Update("update `course` set number = number + 1 where id = #{id}")
    void addCourseNumber(int courseId);
}

We added a new business class called CourseService to implement the related business logic. We called the above two methods to save the association between the student and the course and to increase the number of registered students for the course by 1. Finally, don’t forget to add the transaction annotation to this method.

@Service
public class CourseService {
    @Autowired
    private CourseMapper courseMapper;

    @Autowired
    private StudentCourseMapper studentCourseMapper;

    // Note that this method is marked with "Transactional"
    @Transactional(rollbackFor = Exception.class)
    public void regCourse(int studentId) throws Exception {
        studentCourseMapper.saveStudentCourse(studentId, 1);
        courseMapper.addCourseNumber(1);
    }
}

We called regCourse() in the previous StudentService.saveStudent() to implement the complete business logic. To avoid the case where an exception during course registration causes the student information to not be saved, we caught the exception thrown by the course registration method here. The expected result is that when an error occurs during course registration, only the course registration part is rolled back, ensuring that the student information remains intact.

@Service
public class StudentService {
  // Other non-essential code is omitted
  @Transactional(rollbackFor = Exception.class)
  public void saveStudent(String realname) throws Exception {
      Student student = new Student();
      student.setRealname(realname);
      studentService.doSaveStudent(student);
      try {
          courseService.regCourse(student.getId());
      } catch (Exception e) {
          e.printStackTrace();
      }
  }
  // Other non-essential code is omitted
}

To verify if the exception meets the expected result, we threw a registration failure exception in regCourse():

throw new Exception("Registration failed");
@Transactional(rollbackFor = Exception.class)
public void regCourse(int studentId) throws Exception {
    studentCourseMapper.saveStudentCourse(studentId, 1);
    courseMapper.addCourseNumber(1);
    throw new Exception("Registration failed");
}

When running this code, we see the following error message in the console:

java.lang.Exception: Registration failed
	at com.spring.puzzle.others.transaction.example3.CourseService.regCourse(CourseService.java:22)
//......Omitting non-critical code.....
Exception in thread "main" org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only
	at org.springframework.transaction.support.AbstractPlatformTransactionManager.processRollback(AbstractPlatformTransactionManager.java:873)
	at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:710)
	at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:533)
	at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:304)
	at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:98)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
	at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:688)
	at com.spring.puzzle.others.transaction.example3.StudentService$$EnhancerBySpringCGLIB$$50cda404.saveStudent(<generated>)
	at com.spring.puzzle.others.transaction.example3.AppConfig.main(AppConfig.java:22)

Here, the exception “Registration failed” is expected, but there is also an additional error message: “Transaction rolled back because it has been marked as rollback-only”.

The final result is that both the student and course information are rolled back, which is not what we expected. We want the internal transaction regCourse() to roll back on its own in case of an exception, without affecting the outer transaction saveStudent() that caught the exception. So what caused this? We need to study the Spring source code to find the answer.

Case Analysis #

Before further analysis, let’s first outline the structure of the entire transaction with pseudocode:

// Outer transaction
@Transactional(rollbackFor = Exception.class)
public void saveStudent(String realname) throws Exception {
    //...Omitting logic code...
    studentService.doSaveStudent(student);
    try {
        // Nested inner transaction
        @Transactional(rollbackFor = Exception.class)
        public void regCourse(int studentId) throws Exception {
            //...Omitting logic code...
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

As we can see, the entire business process consists of 2 layers of transactions: the outer saveStudent() transaction and the inner regCourse() transaction.

In Spring’s declarative transaction handling, there is a propagation attribute that indicates how these methods should use transactions. It specifies the relationship between the transaction of a method and the transaction of the method it calls.

There are 7 possible values for propagation: REQUIRED, SUPPORTS, MANDATORY, REQUIRES_NEW, NOT_SUPPORTED, NEVER, and NESTED. The default value is REQUIRED, which means that if there is an existing transaction, it will join that transaction, otherwise it will create a new transaction.

Based on our pseudocode example, since an outer transaction is declared on saveStudent(), there is already a transaction. With the default propagation value of REQUIRED, regCourse() will join the existing transaction and the two methods will share the same transaction.

Now let’s take a look at the core of Spring’s transaction handling, with reference to TransactionAspectSupport.invokeWithinTransaction():

protected Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,
      final InvocationCallback invocation) throws Throwable {
 
   TransactionAttributeSource tas = getTransactionAttributeSource();
   final TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null);
   final PlatformTransactionManager tm = determineTransactionManager(txAttr);
   final String joinpointIdentification = methodIdentification(method, targetClass, txAttr);
   if (txAttr == null || !(tm instanceof CallbackPreferringPlatformTransactionManager)) {
      // Determine if a transaction needs to be created
      TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);
      Object retVal = null;
      try {
         // Call the actual business method
         retVal = invocation.proceedWithInvocation();
      }
      catch (Throwable ex) {
         // Handle exceptions
         completeTransactionAfterThrowing(txInfo, ex);
         throw ex;
      }
      finally {
         cleanupTransactionInfo(txInfo);
      } 
      // Commit the transaction if returning normally
      commitTransactionAfterReturning(txInfo);
      return retVal;
   }
   //...Omitting non-critical code...
}

This method handles the entire transaction process, including:

  1. Checking if a transaction needs to be created.
  2. Calling the actual business method.
  3. Committing the transaction.
  4. Handling exceptions.

Here, it is important to note that the current scenario involves nested transactions, with the outer transaction doSaveStudent() and the inner transaction regCourse(), both calling this method. Therefore, this method will be invoked twice. Now let’s take a closer look at how the inner transaction handles exceptions.

When an exception is caught, it calls TransactionAspectSupport.completeTransactionAfterThrowing() for exception handling:

protected void completeTransactionAfterThrowing(@Nullable TransactionInfo txInfo, Throwable ex) {
   if (txInfo != null && txInfo.getTransactionStatus() != null) {
      if (txInfo.transactionAttribute != null && txInfo.transactionAttribute.rollbackOn(ex)) {
         try {
            txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
         }
         catch (TransactionSystemException ex2) {
            logger.error("Application exception overridden by rollback exception", ex);
            ex2.initApplicationException(ex);
            throw ex2;
         }
         catch (RuntimeException | Error ex2) {
            logger.error("Application exception overridden by rollback exception", ex);
            throw ex2;
         }
      }
      //......omitted non-critical code.....
   }
}

In this method, we perform some checks on the exception type. If it matches the definition in the declaration, the specific rollback operation is executed. This operation is performed through TransactionManager.rollback():

public final void rollback(TransactionStatus status) throws TransactionException {
   if (status.isCompleted()) {
      throw new IllegalTransactionStateException(
            "Transaction is already completed - do not call commit or rollback more than once per transaction");
   }

   DefaultTransactionStatus defStatus = (DefaultTransactionStatus) status;
   processRollback(defStatus, false);
}

The rollback() method is implemented in AbstractPlatformTransactionManager and calls processRollback():

private void processRollback(DefaultTransactionStatus status, boolean unexpected) {
   try {
      boolean unexpectedRollback = unexpected;

      if (status.hasSavepoint()) {
         // Has savepoint
         status.rollbackToHeldSavepoint();
      }
      else if (status.isNewTransaction()) {
         // Is a new transaction
         doRollback(status);
      }
      else {
         // Inside a larger transaction
         if (status.hasTransaction()) {
            // Branch 1
            if (status.isLocalRollbackOnly() || isGlobalRollbackOnParticipationFailure()) {
               doSetRollbackOnly(status);
            }
         }
         if (!isFailEarlyOnGlobalRollbackOnly()) {
            unexpectedRollback = false;
         }
      }

      //.....omitted non-critical code......
      if (unexpectedRollback) {
         throw new UnexpectedRollbackException(
               "Transaction rolled back because it has been marked as rollback-only");
      }
   }
   finally {
      cleanupAfterCompletion(status);
   }
}

This method distinguishes three different types of situations:

  1. Whether there is a savepoint.
  2. Whether it is a new transaction.
  3. Whether it is inside a larger transaction.

In this case, because we are using the default propagation type REQUIRED, the nested transaction does not start a new transaction. Therefore, in this situation, the current transaction is inside a larger transaction, and the code block under branch 3, condition 1 is executed.

There are two conditions to determine whether to set it as rollback-only: if (status.isLocalRollbackOnly() || isGlobalRollbackOnParticipationFailure()).

If either condition is met, the doSetRollbackOnly() operation is executed. In this current situation, isLocalRollbackOnly() is false, so whether to set it as rollback-only is determined by the isGlobalRollbackOnParticipationFailure() method, which has a default value of true. This means that the decision to rollback is determined by the outer transaction.

Clearly, these conditions are satisfied, and doSetRollbackOnly() is executed:

protected void doSetRollbackOnly(DefaultTransactionStatus status) {
   DataSourceTransactionObject txObject = (DataSourceTransactionObject) status.getTransaction();
   txObject.setRollbackOnly();
}
}

And the final call to setRollbackOnly() in DataSourceTransactionObject:

public void setRollbackOnly() {
   getConnectionHolder().setRollbackOnly();
}

At this point, the operations of the inner transaction are basically completed. It handles exceptions and finally calls setRollbackOnly() in DataSourceTransactionObject.

Next, let’s look at the outer transaction. Because in the outer transaction, our code captures the exception thrown by the inner transaction, so this exception will not be propagated upwards. The final transaction will be handled in commitTransactionAfterReturning() in TransactionAspectSupport.invokeWithinTransaction():

protected void commitTransactionAfterReturning(@Nullable TransactionInfo txInfo) {
   if (txInfo != null && txInfo.getTransactionStatus() != null) {
      txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
   }
}

In this method, we perform the commit operation with the following code:

public final void commit(TransactionStatus status) throws TransactionException {
   //...omitted non-critical code...
   if (!shouldCommitOnGlobalRollbackOnly() && defStatus.isGlobalRollbackOnly()) {
      processRollback(defStatus, true);
      return;
   }

   processCommit(defStatus);
}

In AbstractPlatformTransactionManager.commit(), if both shouldCommitOnGlobalRollbackOnly() and defStatus.isGlobalRollbackOnly() are true, the transaction will be rolled back; otherwise, it will continue to commit the transaction. The purpose of shouldCommitOnGlobalRollbackOnly() is to determine whether the transaction should be committed if a global rollback is detected. The default implementation of this method returns false, which we don’t need to focus on. Let’s continue to look at the implementation of isGlobalRollbackOnly():

public boolean isGlobalRollbackOnly() {
   return ((this.transaction instanceof SmartTransactionObject) &&
         ((SmartTransactionObject) this.transaction).isRollbackOnly());
}

This method eventually enters the isRollbackOnly() in DataSourceTransactionObject:

public boolean isRollbackOnly() {
   return getConnectionHolder().isRollbackOnly();
}

Now let’s review the previous result of the inner transaction processing. It ultimately calls setRollbackOnly() in DataSourceTransactionObject:

public void setRollbackOnly() {
   getConnectionHolder().setRollbackOnly();
}

The execution of these two methods essentially involves accessing and setting the rollbackOnly flag in the ConnectionHolder, which is stored in the transaction property of the DefaultTransactionStatus instance.

So far, the answer is clear. The key to whether the outer transaction is rolled back is the isRollbackOnly() in DataSourceTransactionObject, and the return value of this method is what we set when an exception occurs in the inner transaction.

Therefore, the outer transaction is also rolled back, and the exception message “Transaction rolled back because it has been marked as rollback-only” is printed on the console.

Therefore, the problem is clear. The default transaction propagation attribute of Spring is REQUIRED, as we mentioned earlier, which means that if there is already a transaction, the method joins the transaction, and if there is no transaction, a new transaction is created. Therefore, the inner and outer transactions are in the same transaction. So when we throw an exception in regCourse() and trigger a rollback, this rollback is further propagated, resulting in the rollback of saveStudent(). As a result, the entire transaction is rolled back.

Problem Resolution #

From the above analysis, we learned that Spring has a default propagation attribute, REQUIRED, which means that any exception thrown at any point in the transaction call chain will cause a global rollback.

Knowing this conclusion, the modification is simple. We just need to modify the propagation attribute and change it to REQUIRES_NEW. So the code in this part is modified as follows:

@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
public void regCourse(int studentId) throws Exception {
    studentCourseMapper.saveStudentCourse(studentId, 1);
    courseMapper.addCourseNumber(1);
    throw new Exception("Registration failed");
}

Let’s run it and see:

java.lang.Exception: Registration failed
    	at com.spring.puzzle.others.transaction.example3.CourseService.regCourse(CourseService.java:22)

The exception is thrown normally, the data for registering the course is not saved, but the student is registered successfully. This means that Spring only rolls back the data for registering the course and does not propagate it to the previous level.

Here is a brief explanation of the process:

  • When the child transaction is declared as Propagation.REQUIRES_NEW, it creates a new transaction that is independent of the outer transaction in the createTransactionIfNecessary() method called from TransactionAspectSupport.invokeWithinTransaction().
  • When the rollback is processed in AbstractPlatformTransactionManager.processRollback(), because status.isNewTransaction() returns true as it is in a new transaction, it enters another branch and executes doRollback(), which will rollback the child transaction separately without affecting the main transaction.

With this, the problem is well resolved.

Case 2: The Mystery of Switching Between Multiple Data Sources #

In the previous case, we completed the functionality of student registration and course registration. Let’s assume a new requirement has come up: when each student registers, they need to be issued a campus card and have 50 yuan loaded onto the card. However, the campus card management system is a third-party system that uses a different database, so we need to operate on two databases within the same transaction.

The third-party Card table looks like this:

CREATE TABLE `card` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `student_id` int(11) DEFAULT NULL,
  `balance` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

The corresponding Card object is as follows:

public class Card {
    private Integer id;
    private Integer studentId;
    private Integer balance;
    // Getters and Setters omitted
}

The corresponding Mapper interface is as follows, which contains an insert statement saveCard used to create a campus card record:

@Mapper
public interface CardMapper {
    @Insert("INSERT INTO `card`(`student_id`, `balance`) VALUES (#{studentId}, #{balance})")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    int saveCard(Card card);
}

The business class for Card is as follows, which implements the card and student ID association, and the operation to load 50 yuan onto the card:

@Service
public class CardService {
    @Autowired
    private CardMapper cardMapper;

    @Transactional
    public void createCard(int studentId) throws Exception {
        Card card = new Card();
        card.setStudentId(studentId);
        card.setBalance(50);
        cardMapper.saveCard(card);
    }
}

Case Analysis #

This is a relatively common requirement where student registration and card issuing need to be completed within the same transaction. However, we usually only connect to one data source by default. Previously, we have always connected to the student information data source. Now, we also need to operate on the campus card data source. So, how can we achieve the functionality of operating on two data sources within the same transaction?

Let’s continue searching for answers in the source code of Spring. In Spring, there is an abstract class called AbstractRoutingDataSource, which serves as the intermediary of DataSource routing. It dynamically switches to the desired DataSource based on some key value during runtime. By implementing this class, we can achieve the dynamic switching of data sources we desire.

It is worth emphasizing that this class has several key properties:

  • targetDataSources stores the mapping between keys and database connections.
  • defaultTargetDataSource indicates the default connection.
  • resolvedDataSources stores the mapping between database identifiers and data sources.
public abstract class AbstractRoutingDataSource extends AbstractDataSource implements InitializingBean {
   
   @Nullable
   private Map<Object, Object> targetDataSources;
   
   @Nullable
   private Object defaultTargetDataSource;
   
   private boolean lenientFallback = true;
   
   private DataSourceLookup dataSourceLookup = new JndiDataSourceLookup();
   
   @Nullable
   private Map<Object, DataSource> resolvedDataSources;
   
   @Nullable
   private DataSource resolvedDefaultDataSource;
 
   // Omitted non-essential code
}

AbstractRoutingDataSource implements the InitializingBean interface and overrides the afterPropertiesSet() method. This method is executed during the initialization of the bean and initializes multiple DataSources into resolvedDataSources. The targetDataSources attribute here stores the information of the multiple data sources to be switched.

@Override
public void afterPropertiesSet() {
   if (this.targetDataSources == null) {
      throw new IllegalArgumentException("Property 'targetDataSources' is required");
   }
   this.resolvedDataSources = new HashMap<>(this.targetDataSources.size());
   this.targetDataSources.forEach((key, value) -> {
      Object lookupKey = resolveSpecifiedLookupKey(key);
      DataSource dataSource = resolveSpecifiedDataSource(value);
      this.resolvedDataSources.put(lookupKey, dataSource);
   });
   if (this.defaultTargetDataSource != null) {
      this.resolvedDefaultDataSource = resolveSpecifiedDataSource(this.defaultTargetDataSource);
   }
}

The method responsible for getting the database connection is getConnection(), which calls determineTargetDataSource() to create the connection:

@Override
public Connection getConnection() throws SQLException {
   return determineTargetDataSource().getConnection();
}

@Override
public Connection getConnection(String username, String password) throws SQLException {
   return determineTargetDataSource().getConnection(username, password);
}

determineTargetDataSource() is the core of this section. Its function is to dynamically switch the data source. The number of data sources determines the number of data sources stored in targetDataSources.

targetDataSources is a Map type attribute, where the key represents the name of each data source, and the value corresponds to the DataSource of each data source.

protected DataSource determineTargetDataSource() {
   Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
   Object lookupKey = determineCurrentLookupKey();
   DataSource dataSource = this.resolvedDataSources.get(lookupKey);
   if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
      dataSource = this.resolvedDefaultDataSource;
   }
   if (dataSource == null) {
      throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
   }
   return dataSource;
}

The selection of which data source is determined by determineCurrentLookupKey(), which is an abstract method that requires us to inherit the AbstractRoutingDataSource abstract class and override this method. This method returns a key, which is the beanName in the Bean, and assigns it to the lookupKey. With this key, we can obtain the corresponding DataSource value from the keys of the resolvedDataSources attribute, thus achieving the effect of switching data sources.

protected abstract Object determineCurrentLookupKey();

This way, it seems that the implementation of this method must be completed by us. Next, we will complete a series of related code to solve this problem.

Problem Resolution #

First, let’s create a MyDataSource class that inherits from AbstractRoutingDataSource and overrides determineCurrentLookupKey():

public class MyDataSource extends AbstractRoutingDataSource {
    private static final ThreadLocal<String> key = new ThreadLocal<String>();
    
    @Override
    protected Object determineCurrentLookupKey() {
        return key.get();
    }
    
    public static void setDataSource(String dataSource) {
        key.set(dataSource);
    }
    
    public static String getDatasource() {
        return key.get();
    }
    
    public static void clearDataSource() {
        key.remove();
    }
}

Next, we need to modify JdbcConfig. Here, I have created a new dataSource, renamed the original dataSource to dataSourceCore, and then put the newly defined dataSourceCore and dataSourceCard into a Map with the keys ‘core’ and ‘card’ respectively. Finally, I assigned the Map to setTargetDataSources.

public class JdbcConfig {
    // omitted non-relevant code
    
    @Bean(name = "dataSourceCore")
    public DataSource createCoreDataSource() {
        DriverManagerDataSource ds = new DriverManagerDataSource();
        ds.setDriverClassName(driver);
        ds.setUrl(url);
        ds.setUsername(username);
        ds.setPassword(password);
        return ds;
    }
    
    @Bean(name = "dataSourceCard")
    public DataSource createCardDataSource() {
        DriverManagerDataSource ds = new DriverManagerDataSource();
        ds.setDriverClassName(cardDriver);
        ds.setUrl(cardUrl);
        ds.setUsername(cardUsername);
        ds.setPassword(cardPassword);
        return ds;
    }
    
    @Bean(name = "dataSource")
    public MyDataSource createDataSource() {
        MyDataSource myDataSource = new MyDataSource();
        Map<Object, Object> map = new HashMap<>();
        map.put("core", dataSourceCore);
        map.put("card", dataSourceCard);
        myDataSource.setTargetDataSources(map);
        myDataSource.setDefaultTargetDataSource(dataSourceCore);
        return myDataSource;
    }
    
    // omitted non-relevant code
}

One last question remains: when will the setDataSource method be executed?

We can use Spring AOP to set it up by configuring the data source type as an annotated tag. Add the @DataSource annotation to the methods in the Service layer that need to switch data sources.

We’ve defined a new annotation, @DataSource, that can be directly added to the Service class to achieve database switching:

@Documented
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface DataSource {
    String value();
    
    String core = "core";
    
    String card = "card";
}

Use it like this:

@DataSource(DataSource.card)

In addition, we need to write a Spring AOP to intercept the corresponding service methods and perform data source switching operations. It’s important to note that we need to add @Order(1) to mark its initialization order. The Order value must be smaller than the AOP aspect value of the transaction, so that it can have a higher priority. Otherwise, automatic data source switching will not work.

@Aspect
@Service
@Order(1)
public class DataSourceSwitch {
    @Around("execution(* com.spring.puzzle.others.transaction.example3.CardService.*(..))")
    public void around(ProceedingJoinPoint point) throws Throwable {
        Signature signature = point.getSignature();
        MethodSignature methodSignature = (MethodSignature) signature;
        Method method = methodSignature.getMethod();
        if (method.isAnnotationPresent(DataSource.class)) {
            DataSource dataSource = method.getAnnotation(DataSource.class);
            MyDataSource.setDataSource(dataSource.value());
            System.out.println("Switched data source to: " + MyDataSource.getDatasource());
        }
        point.proceed();
        MyDataSource.clearDataSource();
        System.out.println("Data source has been removed!");
    }
}

Finally, we implemented the logic for issuing a card in the CardService, and declared the database switch in front of the method:

@Service
public class CardService {
    @Autowired
    private CardMapper cardMapper;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    @DataSource(DataSource.card)
    public void createCard(int studentId) throws Exception {
        Card card = new Card();
        card.setStudentId(studentId);
        card.setBalance(50);
        cardMapper.saveCard(card);
    }
}

And in the saveStudent() method, we called the card issuance logic:

@Transactional(rollbackFor = Exception.class)
public void saveStudent(String realname) throws Exception {
    Student student = new Student();
    student.setRealname(realname);
    studentService.doSaveStudent(student);
    try {
        courseService.regCourse(student.getId());
        cardService.createCard(student.getId());
    } catch (Exception e) {
        e.printStackTrace();
    }
}

When executed, everything works fine and the data from both databases can be saved successfully.

Finally, let’s take a look at the call stack of the entire process and go through the process again (here I have omitted the unimportant parts).

After the transaction is created, the corresponding database connection is obtained via DataSourceTransactionManager.doBegin():

protected void doBegin(Object transaction, TransactionDefinition definition) {
   DataSourceTransactionObject txObject = (DataSourceTransactionObject) transaction;
   Connection con = null;

   try {
      if (!txObject.hasConnectionHolder() ||
txObject.getConnectionHolder().isSynchronizedWithTransaction()) {
         Connection newCon = obtainDataSource().getConnection();
         txObject.setConnectionHolder(new ConnectionHolder(newCon), true);
      }

      // omitted non-relevant code
}

Here, obtainDataSource().getConnection() calls AbstractRoutingDataSource.getConnection(), and thus our implementation of the function is successfully invoked.

public Connection getConnection() throws SQLException {
   return determineTargetDataSource().getConnection();
}

Key Highlights #

Through the two case studies above, I believe you now have a profound understanding of Spring’s transaction mechanism. Let’s summarize the key points:

  • Spring has an important attribute called Propagation in transaction processing. It is mainly used to configure how the current method uses transactions and its relationship with other transactions.
  • The default propagation attribute in Spring is REQUIRED, which means that if there is a transactional state, the method will be executed within the transaction. If there is no transactional state, a new transaction will be created.
  • Spring transactions can be applied to multiple data sources. It provides an abstract class called AbstractRoutingDataSource, which allows us to implement customized database switching by implementing this class.

Thought question #

Considering Case 2, please think about the following question: In this case, we declared the transaction propagation attribute on the method of the CardService class as @Transactional(propagation = Propagation.REQUIRES_NEW). Can we use Spring’s default declaration? Why or why not?

Looking forward to your thoughts! Let’s discuss in the comments section!