06 20% of Business Code's Spring Declarative Transactions May Not Be Handled Correctly

06 20% of Business Code’s Spring Declarative Transactions May Not Be Handled Correctly #

Today, I want to talk to you about the pitfalls related to database transactions in business code.

Spring provides a consistent programming model for Java Transaction API (JTA), JDBC, Hibernate, Java Persistence API (JPA), and other transaction APIs. In addition, Spring’s declarative transaction feature offers a convenient way to configure transactions. With the help of Spring Boot’s auto-configuration, most Spring Boot projects only need to annotate methods with the @Transactional annotation to easily enable transactional configuration.

From what I’ve observed, most developers are familiar with the concept of transactions and know that when considering multiple database operations, it’s important to use database transactions to ensure consistency and atomicity. However, in practice, many developers only use the @Transactional annotation without paying attention to whether the transactions are effective or if they are properly rolled back in case of errors. They also do not consider how to handle transactions correctly when dealing with complex business logic involving multiple sub-business processes.

When transactions are not handled correctly, it usually does not significantly affect the normal flow of operations and may not be easily discovered during testing. However, as the system becomes more complex and under increased pressure, it can lead to a lot of data inconsistency issues, followed by a large amount of manual intervention to inspect and repair the data.

Therefore, there is a significant difference in the transaction handling details between a mature business system and a basic functional business system. Ensuring that the transaction configurations meet the requirements of the business functionality is often not just a technical issue, but also involves product processes and architectural design. In my opinion, the 20% mentioned in the title “Approximately 20% of the business code’s Spring declarative transactions may not be handled correctly” is still quite conservative.

Today, I want to share some insights that will help you clarify your thinking on technical issues and avoid the occurrence of numerous sporadic bugs due to improper transaction handling in your business logic.

Be careful, Spring transactions may not take effect #

When using the @Transactional annotation to enable declarative transactions, the first and most easily overlooked problem is that the transaction may not take effect.

To implement the following demo, we need some basic classes. First, define a UserEntity class with ID and name attributes, which represents a user table with two columns:

@Entity
@Data
public class UserEntity {

    @Id
    @GeneratedValue(strategy = AUTO)
    private Long id;

    private String name;

    public UserEntity() { }

    public UserEntity(String name) {
        this.name = name;
    }

}

For easier understanding, I am using Spring JPA for database access. Implement a repository that contains a method for querying all data based on the username:

@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {

    List<UserEntity> findByName(String name);

}

Define a UserService class that is responsible for the business logic. If you are not familiar with the implementation of @Transactional and only consider the code logic, this code snippet looks fine.

Define an entry method createUserWrong1 to call another private method createUserPrivate, which is marked with the @Transactional annotation. If the passed username contains the keyword “test”, it is considered an invalid username and an exception is thrown to make the user creation operation fail, expecting the transaction to be rolled back:

@Service
@Slf4j
public class UserService {

    @Autowired
    private UserRepository userRepository;

    // A public method called by the Controller, which internally calls a method with transactional logic
    public int createUserWrong1(String name) {

        try {
            this.createUserPrivate(new UserEntity(name));
        } catch (Exception ex) {
            log.error("create user failed because {}", ex.getMessage());
        }
        return userRepository.findByName(name).size();
    }

    // A private method marked with @Transactional
    @Transactional
    private void createUserPrivate(UserEntity entity) {

        userRepository.save(entity);
        if (entity.getName().contains("test"))
            throw new RuntimeException("invalid username!");
    }

    // Query the number of users based on the username
    public int getUserCount(String name) {
        return userRepository.findByName(name).size();
    }
}

The following is the implementation of the Controller, which simply calls the entry method createUserWrong1 of the UserService defined earlier:

@Autowired
private UserService userService;

@GetMapping("wrong1")
public int wrong1(@RequestParam("name") String name) {
    return userService.createUserWrong1(name);

}

After calling the interface, it was found that even if the username is invalid, the user can still be created successfully. After refreshing the browser, it was found that there were more than a dozen illegal user registrations.

Here, I provide the principle of @Transactional effectiveness 1. Unless there is special configuration (such as using AspectJ static weaving to implement AOP), only @Transactional defined on public methods can take effect. The reason is that Spring implements AOP by default using dynamic proxy, and private methods cannot be delegated to, so Spring naturally cannot dynamically enhance transaction processing logic.

You may say that the fix is simple, just change the createUserPrivate method marked with the transaction annotation to public. In the UserService, create a new entry method createUserWrong2 to call this public method again:

public int createUserWrong2(String name) {
    
    try {
        this.createUserPublic(new UserEntity(name));
    } catch (Exception ex) {
        log.error("create user failed because {}", ex.getMessage());
    }
  return userRepository.findByName(name).size();
}

//marked with @Transactional public method
@Transactional
public void createUserPublic(UserEntity entity) {
    userRepository.save(entity);
    if (entity.getName().contains("test"))
        throw new RuntimeException("invalid username!");
}

Testing found that the transaction of the new createUserWrong2 method is still not effective. Here, I give the principle of @Transactional effectiveness 2, which must call the target method from the proxied class externally in order to take effect.

Spring enhances the method through AOP technology. To invoke the enhanced method, it must be the object after calling the proxy. Let’s try modifying the code of UserService, inject a self, and then call the createUserPublic method marked with @Transactional annotation through the self instance. You can see that the self is a class enhanced by Spring through CGLIB by setting breakpoints:

CGLIB implements the proxy class through inheritance, and private methods are not visible in the subclass, so it cannot be enhanced with transactions;

The this pointer represents the object itself, and Spring cannot inject this, so accessing methods through this is definitely not a proxy.

img

After changing this to self and testing, it was found that calling the createUserRight method in the Controller can verify that the transaction is effective, and the illegal user registration operation can be rolled back.

Although injecting itself and calling its own createUserPublic in the UserService can correctly implement transactions, a more reasonable implementation is to directly call the previously defined createUserPublic method of the UserService in the Controller, because injecting itself and calling itself is strange and does not conform to the specification of layered implementation:

@GetMapping("right2")
public int right2(@RequestParam("name") String name) {

    try {
        userService.createUserPublic(new UserEntity(name));
    } catch (Exception ex) {
        log.error("create user failed because {}", ex.getMessage());
    }

    return userService.getUserCount(name);
}

Let us review the differences between the three implementations of calling this self, calling through self, and calling the UserService in the Controller through the following image:

img

By calling this self, there is no chance to go to the Spring proxy class; the last two improvement methods call the UserService injected by Spring, and dynamic enhancement can be performed by calling the proxy.

Here, I have another tip. I highly recommend that you open the relevant Debug logs during development, in order to understand the details of Spring transaction implementation and judge the execution of transactions in a timely manner.

Our demo code uses JPA for database access. You can enable Debug logs like this:

logging.level.org.springframework.orm.jpa=DEBUG

After enabling the logs, let’s compare the differences between calling createUserPublic through this in the UserService and calling it through the injected UserService Bean in the Controller. Obviously, calling through this does not go through the proxy, and the transaction does not take effect on the createUserPublic method, only at the level of the save method of the Repository:

//calling public createUserPublic through this in UserService
[10:10:19.913] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :370 ] - Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT

//calling createUserPublic through the injected UserService Bean in Controller
[10:10:47.750] [http-nio-45678-exec-6] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :370 ] - Creating new transaction with name [org.geekbang.time.commonmistakes.transaction.demo1.UserService.createUserPublic]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT

You may also consider another issue that handling exceptions directly in the Controller in this implementation seems a bit cumbersome. It is better to directly add the @Transactional annotation to the createUserWrong2 method and then call this method directly in the controller. In this way, you can call the methods in the UserService from externally (in the controller), and the methods are public and can be dynamically enhanced by AOP.

You can try this method, but it is easy to encounter the second pitfall, which is that the transaction may not necessarily roll back even if it takes effect because the exception is not handled properly.

Transactions may not be rolled back even if they are effective #

Implementing transaction processing through AOP can be understood as using try…catch… to wrap methods annotated with @Transactional. When an exception occurs in the method and certain conditions are met, we can set the transaction to roll back in the catch section, or the transaction will be committed directly if there is no exception.

The “certain conditions” here mainly include two points.

First, the transaction can only be rolled back if the exception is propagated from the method annotated with @Transactional. In the TransactionAspectSupport of Spring, there is a method called invokeWithinTransaction that handles transaction logic. It can be seen that the subsequent transaction processing can only be done if the exception is caught:

try {

   // This is an around advice: Invoke the next interceptor in the chain.
   // This will normally result in a target object being invoked.
   retVal = invocation.proceedWithInvocation();
} catch (Throwable ex) {

   // target invocation exception
   completeTransactionAfterThrowing(txInfo, ex);
   throw ex;
} finally {
   cleanupTransactionInfo(txInfo);
}

Second, by default, the transaction will be rolled back only when a RuntimeException (unchecked exception) or Error occurs.

By opening the DefaultTransactionAttribute class of Spring, you can see the following code block, which provides evidence of this. The comments also explain the reason why Spring does this. Generally, checked exceptions are considered business exceptions or alternative return values similar to another method, so the business may still be completed when such exceptions occur and the rollback will not be triggered. On the other hand, Error or RuntimeException represents unexpected results, so the transaction should be rolled back:

/**
 * The default behavior is as with EJB: rollback on unchecked exception
 * ({@link RuntimeException}), assuming an unexpected outcome outside of any
 * business rules. Additionally, we also attempt to rollback on {@link Error} which
 * is clearly an unexpected outcome as well. By contrast, a checked exception is
 * considered a business exception and therefore a regular expected outcome of the
 * transactional business method, i.e. a kind of alternative return value which
 * still allows for regular completion of resource operations.
 * <p>This is largely consistent with TransactionTemplate's default behavior,
 * except that TransactionTemplate also rolls back on undeclared checked exceptions
 * (a corner case). For declarative transactions, we expect checked exceptions to be
 * intentionally declared as business exceptions, leading to a commit by default.
 * @see org.springframework.transaction.support.TransactionTemplate#execute
 */

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

Next, I will share two counterexamples with you.

Let’s re-implement the register user operation in the UserService:

In the method createUserWrong1, a RuntimeException is thrown, but since all exceptions are caught within the method, the exception cannot be propagated from the method, so the transaction cannot be rolled back.

In the method createUserWrong2, there is a file reading operation during user registration. If the file reading fails, we hope the database operation of user registration will be rolled back. Although no exceptions are caught here, the method createUserWrong2 propagates a checked exception because the otherTask method throws a checked exception. Therefore, the transaction will not be rolled back.

@Service
@Slf4j
public class UserService {

    @Autowired
    private UserRepository userRepository;

    // The exception cannot be propagated from the method, resulting in transaction rollback failure
    @Transactional
    public void createUserWrong1(String name) {

        try {
            userRepository.save(new UserEntity(name));
            throw new RuntimeException("error");
        } catch (Exception ex) {
            log.error("create user failed", ex);
        }
    }

    // The transaction will not be rolled back even if a checked exception occurs
    @Transactional
    public void createUserWrong2(String name) throws IOException {

        userRepository.save(new UserEntity(name));
        otherTask();
    }

    // Throws an IOException because the file does not exist
    private void otherTask() throws IOException {
        Files.readAllLines(Paths.get("file-that-not-exist"));
    }

}

In the implementation in the Controller, we only call the methods createUserWrong1 and createUserWrong2 in the UserService. Although these two methods completely avoid the pitfalls of ineffective transactions, improper exception handling causes the program to fail to roll back the transaction when we expect file operations to fail.

Now, let’s take a look at the fix and how to verify if we have fixed it. The corresponding fixes for these two situations are as follows:

First, if you want to catch exceptions for processing yourself, it’s okay. You can manually set the current transaction to be in a rollback state:

@Transactional
public void createUserRight1(String name) {

    try {
        userRepository.save(new UserEntity(name));
        throw new RuntimeException("error");
    } catch (Exception ex) {
        log.error("create user failed", ex);
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
    }
}

After running, you can see the “Rolling back” message in the log, confirming that the transaction has been rolled back. At the same time, we also noticed the prompt “Transactional code has requested rollback”, indicating that a rollback has been requested manually:

[22:14:49.352] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :698 ] - Transactional code has requested rollback
[22:14:49.353] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :834 ] - Initiating transaction rollback
[22:14:49.353] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :555 ] - Rolling back JPA transaction on EntityManager [SessionImpl(1906719643<open>)]

Second, declare in the annotation that you expect all Exception to trigger transaction rollback (to break the default limitation of not rolling back checked exceptions):

@Transactional(rollbackFor = Exception.class)
public void createUserRight2(String name) throws IOException {
    userRepository.save(new UserEntity(name));
    otherTask();
}

After running, you can also see the rollback prompt in the log:

[22:10:47.980] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :834 ] - Initiating transaction rollback
[22:10:47.981] [http-nio-45678-exec-4] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :555 ] - Rolling back JPA transaction on EntityManager [SessionImpl(1419329213<open>)]

In this example, we demonstrate a complex business logic that involves database operations and IO operations. We want the database transaction to be rolled back when an IO operation fails to ensure the consistency of the logic. In some business logics, multiple database operations may be included, and we may not want to treat the two operations as one transaction. In this case, the configuration of transaction propagation needs to be carefully considered, otherwise we may also encounter pitfalls.

Please confirm whether the transaction propagation configuration matches your business logic #

Here’s the scenario: There’s an operation for user registration, which involves inserting a main user into the user table and registering an associated sub-user. We want to handle the database operation for the sub-user registration as a separate transaction, so even if it fails, it won’t affect the main registration process, meaning it won’t affect the registration of the main user.

Next, we will simulate a UserService implementation that has similar business logic:

@Autowired
private UserRepository userRepository;

@Autowired
private SubUserService subUserService;

@Transactional
public void createUserWrong(UserEntity entity) {
    createMainUser(entity);
    subUserService.createSubUserWithExceptionWrong(entity);
}

private void createMainUser(UserEntity entity) {
    userRepository.save(entity);
    log.info("createMainUser finish");
}

The implementation of createSubUserWithExceptionWrong in the SubUserService is as its name suggests; we throw a runtime exception at the end, with the error reason being an invalid user status, so the registration of the sub-user is definitely going to fail. We expect the registration of the sub-user to be rolled back as a separate transaction, without affecting the registration of the main user. Is this logic achievable?

@Service
@Slf4j
public class SubUserService {

    @Autowired
    private UserRepository userRepository;

    @Transactional
    public void createSubUserWithExceptionWrong(UserEntity entity) {

        log.info("createSubUserWithExceptionWrong start");
        userRepository.save(entity);
        throw new RuntimeException("invalid status");
    }
}

In the controller, we implement a piece of test code that calls the UserService:

@GetMapping("wrong")
public int wrong(@RequestParam("name") String name) {

    try {
        userService.createUserWrong(new UserEntity(name));
    } catch (Exception ex) {
        log.error("createUserWrong failed, reason:{}", ex.getMessage());
    }

    return userService.getUserCount(name);
}

After calling it, we can see the following information in the logs. It is clear that the transaction is rolled back, and the controller logs the runtime exception that was thrown during the creation of the sub-user:

[22:50:42.866] [http-nio-45678-exec-8] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :555 ] - Rolling back JPA transaction on EntityManager [SessionImpl(103972212<open>)]
[22:50:42.869] [http-nio-45678-exec-8] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :620 ] - Closing JPA EntityManager [SessionImpl(103972212<open>)] after transaction
[22:50:42.869] [http-nio-45678-exec-8] [ERROR] [t.d.TransactionPropagationController:23  ] - createUserWrong failed, reason:invalid status

You will immediately realize that something is wrong, because the runtime exception escaped from the createUserWrong method marked with the @Transactional annotation, so Spring will naturally roll back the transaction. If we want the main method to not roll back, we should catch the exception thrown by the sub-method.

Here’s how we can change it - add a catch block wrapping the subUserService.createSubUserWithExceptionWrong call, so that the outer main method won’t experience an exception:

@Transactional
public void createUserWrong2(UserEntity entity) {
    createMainUser(entity);

    try{
        subUserService.createSubUserWithExceptionWrong(entity);
    } catch (Exception ex) {
        // Although the exception is caught, since we didn't start a new transaction, and the current transaction has been marked for rollback due to the exception, it will still be rolled back in the end.
        log.error("create sub user error:{}", ex.getMessage());
    }
}

After running the program, you can see the following logs:

[22:57:21.722] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :370 ] - Creating new transaction with name [org.geekbang.time.commonmistakes.transaction.demo3.UserService.createUserWrong2]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
[22:57:21.739] [http-nio-45678-exec-3] [INFO ] [t.c.transaction.demo3.SubUserService:19  ] - createSubUserWithExceptionWrong start
[22:57:21.739] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :356 ] - Found thread-bound EntityManager [SessionImpl(1794007607<open>)] for JPA transaction
[22:57:21.739] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :471 ] - Participating in existing transaction
[22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :843 ] - Participating transaction failed - marking existing transaction as rollback-only
[22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :580 ] - Setting JPA transaction on EntityManager [SessionImpl(1794007607<open>)] rollback-only
[22:57:21.740] [http-nio-45678-exec-3] [ERROR] [.g.t.c.transaction.demo3.UserService:37  ] - create sub user error:invalid status
[22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :741 ] - Initiating transaction commit
[22:57:21.740] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :529 ] - Committing JPA transaction on EntityManager [SessionImpl(1794007607<open>)]
[22:57:21.743] [http-nio-45678-exec-3] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :620 ] - Closing JPA EntityManager [SessionImpl(1794007607<open>)] after transaction
[22:57:21.743] [http-nio-45678-exec-3] [ERROR] [t.d.TransactionPropagationController:33  ] - createUserWrong2 failed, reason:Transaction silently rolled back because it has been marked as rollback-only
org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
...

The following points should be noted:

As shown in line 1, exception handling has been enabled for the createUserWrong2 method;

As shown in line 5, the child method marks the current transaction as rollback due to a runtime exception;

As shown in line 7, the main method catches the exception and prints “create sub user error”;

As shown in line 9, the main method commits the transaction;

However, lines 11 and 12 show an UnexpectedRollbackException in the Controller, indicating that the transaction was rolled back without any exception. This is considered a silent rollback. Here, the createUserWrong2 method itself did not throw an exception, but after the submission, it was found that the child method had already marked the current transaction as rollback, so the transaction cannot be committed.

This is quite counterintuitive. As mentioned earlier, not all exceptions will lead to a transaction rollback, but in this case, even without an exception, the transaction may not be able to be committed. The reason is that the logic of registering both the main user and the sub-user in the main method belongs to the same transaction. When the sub-logic marks the transaction for rollback, the main logic cannot be committed naturally.

With that in mind, the fix is clear. We need to find a way to run the sub-logic in a separate transaction. To achieve this, we can modify the createSubUserWithExceptionRight method in the SubUserService class by adding the annotation @Transactional(propagation = Propagation.REQUIRES_NEW). This sets the transaction propagation strategy to REQUIRES_NEW, which means that a new transaction should be started when executing this method, suspending the current transaction:

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createSubUserWithExceptionRight(UserEntity entity) {
    log.info("createSubUserWithExceptionRight start");
    userRepository.save(entity);
    throw new RuntimeException("invalid status");
}

The main method is not changed much. It still needs to catch the exception to prevent the main transaction from rolling back. We can rename it as createUserRight:

@Transactional
public void createUserRight(UserEntity entity) {
    createMainUser(entity);
    try {
        subUserService.createSubUserWithExceptionRight(entity);
    } catch (Exception ex) {
        // Catch the exception to prevent the main method from rolling back
        log.error("create sub user error:{}", ex.getMessage());
    }
}

After the modification, when we run the program again, we can see the following key logs:

The log in line 1 indicates that the transaction for the createUserRight method has been started;

The log in line 2 indicates that the creation of the main user has been completed;

The log in line 3 shows that the main transaction has been suspended and a new transaction has been started for the createSubUserWithExceptionRight method, which is the logic for creating the sub-user;

The log in line 4 indicates that the transaction for the sub-method has been rolled back;

The log in line 5 indicates that the transaction for the sub-method has been completed, and the suspended transaction for the main method is resumed;

The log in line 6 indicates that the main method has caught the exception thrown by the sub-method;

The log in line 8 indicates that the transaction for the main method has been committed, and we no longer see the silent rollback exception in the Controller.

[23:17:20.935] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :370 ] - Creating new transaction with name [org.geekbang.time.commonmistakes.transaction.demo3.UserService.createUserRight]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
[23:17:21.079] [http-nio-45678-exec-1] [INFO ] [.g.t.c.transaction.demo3.UserService:55  ] - createMainUser finish
[23:17:21.082] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :420 ] - Suspending current transaction, creating new transaction with name [org.geekbang.time.commonmistakes.transaction.demo3.SubUserService.createSubUserWithExceptionRight]
[23:17:21.153] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :834 ] - Initiating transaction rollback
[23:17:21.160] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :1009] - Resuming suspended transaction after completion of inner transaction
[23:17:21.161] [http-nio-45678-exec-1] [ERROR] [.g.t.c.transaction.demo3.UserService:49  ] - create sub user error:invalid status
[23:17:21.161] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :741 ] - Initiating transaction commit
[23:17:21.161] [http-nio-45678-exec-1] [DEBUG] [o.s.orm.jpa.JpaTransactionManager       :529 ] - Committing JPA transaction on EntityManager [SessionImpl(396441411<open>)]

When running the test program, we can see that the user count obtained by getUserCount is 1, indicating that only one user, which is the main user, has been registered. This is as expected:

img

Key Review #

Today, I summarized the three common pitfalls that may be encountered when using the Spring declarative transaction, which is the most common way of using database transactions in business code:

Firstly, the transaction on the method is not effective due to incorrect configuration. We need to ensure that the method marked with the @Transactional annotation is public and called through a Spring-injected bean.

Secondly, the transaction is effective but not rolled back when an exception occurs due to incorrect exception handling. By default, Spring only rolls back when a method marked with the @Transactional annotation encounters RuntimeException and Error. If we catch the exception in our method, we need to handle the transaction rollback manually. If we want Spring to roll back for other exceptions as well, we can override the default settings by configuring the rollbackFor and noRollbackFor properties of the @Transactional annotation accordingly.

Thirdly, if a method involves multiple database operations and we want to commit or roll back them as independent transactions, we need to further refine the configuration of the transaction propagation, which is the Propagation property of the @Transactional annotation.

It can be seen that correctly configuring transactions can improve the robustness of business projects. However, because the robustness issues often arise in exceptional cases or in some detailed handling, it is difficult to discover them in the execution and testing of the main process, resulting in the transaction handling logic of the business code being easily overlooked. Therefore, I have always paid close attention to whether the transaction is correctly handled during code review.

If you are unable to confirm whether the transaction is truly effective and follows the expected logic, you can try opening some debug logs of Spring to verify through the operational details of the transaction. It is also recommended to cover as many exceptional scenarios as possible in unit tests so that transaction failure issues caused by adjustments to method invocation and exception handling logic can be promptly discovered during refactoring.

I have put the code used today on GitHub, and you can click on this link to view it.

Reflection and Discussion #

Considering the simplicity of the demo, all data access in the article is done using Spring Data JPA. However, most internet projects in China use MyBatis for data access. If you want to use MyBatis in combination with Spring’s declarative transaction management, you should also pay attention to the points mentioned in the article. You can try modifying today’s demo to use MyBatis for data access and see if the logs can reflect these pitfalls.

In the first section, we mentioned that if you want to enable transactions for private methods, dynamic proxy-based AOP won’t work and you need to use static weaving-based AOP. This means weaving the transaction enhancement code during compilation. You can refer to the Spring documentation on “Using @Transactional with AspectJ” to try it out. Note: AspectJ may have some pitfalls when used together with Lombok.

Have you encountered any other pitfalls related to database transactions? I am Zhu Ye. Feel free to leave your comments in the comment section and share this article with your friends or colleagues for further discussion.