Spring Transaction Best Practices

Imagine having a tool that can automatically detect JPA and Hibernate performance issues. Wouldn’t that be just awesome?

Well, Hypersistence Optimizer is that tool! And it works with Spring Boot, Spring Framework, Jakarta EE, Java EE, Quarkus, or Play Framework.

So, enjoy spending your time on the things you love rather than fixing performance issues in your production system on a Saturday night!

Introduction

In this article, I’m going to show you various Spring Transaction Best Practices that can help you achieve the data integrity guarantees required by the underlying business requirements.

Data integrity is of paramount importance because, in the absence of proper transaction handling, your application could be vulnerable to race conditions that could have terrible consequences for the underlying business.

Emulating the Flexcoin race condition

In this article, I explained how Flexcoin went bankrupt because of a race condition that was exploited by some hackers who managed to steal all BTC funds Flexcoin had available.

Our previous implementation was built using plain JDBC, but we can emulate the same scenarios using Spring, which is definitely more familiar to the vast majority of Java developers. This way, we are going to use a real-life problem as an example of how we should handle transactions when building a Spring-based application.

Therefore, we are going to implement our transfer service using the following Service Layer and Data Access Layer components:

TransferService and AccountRepository

To demonstrate what can happen when transactions are not handled according to business requirements, let’s use the simplest possible data access layer implementation:

@Repository
@Transactional(readOnly = true)
public interface AccountRepository extends JpaRepository<Account, Long> {

    @Query(value = """
        SELECT balance
        FROM account
        WHERE iban = :iban
        """,
        nativeQuery = true)
    long getBalance(@Param("iban") String iban);

    @Query(value = """
        UPDATE account
        SET balance = balance + :cents
        WHERE iban = :iban
        """,
        nativeQuery = true)
    @Modifying
    @Transactional
    int addBalance(@Param("iban") String iban, @Param("cents") long cents);
}

Both the getBalance and addBalance methods use the Spring @Query annotation to define the native SQL queries that can read or write a given account balance.

Because there are more read operations than write ones, it’s good practice to define the @Transactional(readOnly = true) annotation on a per-class level.

This way, by default, methods that are not annotated with @Transactional are going to be executed in the context of a read-only transaction, unless an existing read-write transaction has already been associated with the current processing Thread of execution.

However, when we want to change the database state, we can use the @Transactional annotation to mark the read-write transactional method, and, in case no transaction has already been started and propagated to this method call, a read-write transaction context will be created for this method execution.

For more details about the @Transactional annotation, check out this article as well.

Compromising Atomicity

A from ACID stands for Atomicity, which allows a transaction to move the database from one consistent state to another. Therefore, Atomicity allows us to enroll multiple statements in the context of the same database transaction.

In Spring, this can be achieved via the @Transactional annotation, which should be used by all public Service layer methods that are supposed to interact with a relational database.

If you forget to do that, the business method might span over multiple database transactions, therefore compromising Atomicity.

For instance, let’s assume we implement the transfer method like this:

@Service
public class TransferServiceImpl implements TransferService {

    @Autowired
    private AccountRepository accountRepository;

    @Override
    public boolean transfer(
            String fromIban, String toIban, long cents) {
        boolean status = true;

        long fromBalance = accountRepository.getBalance(fromIban);

        if(fromBalance >= cents) {
            status &= accountRepository.addBalance(
                fromIban, (-1) * cents
            ) > 0;
            
            status &= accountRepository.addBalance(
                toIban, cents
            ) > 0;
        }

        return status;
    }
}

Considering we have two users, Alice and Bob:

| iban      | balance | owner |
|-----------|---------|-------|
| Alice-123 | 10      | Alice |
| Bob-456   | 0       | Bob   |

When running the parallel execution test case:

@Test
public void testParallelExecution() 
        throws InterruptedException {
        
    assertEquals(10L, accountRepository.getBalance("Alice-123"));
    assertEquals(0L, accountRepository.getBalance("Bob-456"));

    CountDownLatch startLatch = new CountDownLatch(1);
    CountDownLatch endLatch = new CountDownLatch(threadCount);

    for (int i = 0; i < threadCount; i++) {
        new Thread(() -> {
            try {
                startLatch.await();

                transferService.transfer(
                    "Alice-123", "Bob-456", 5L
                );
            } catch (Exception e) {
                LOGGER.error("Transfer failed", e);
            } finally {
                endLatch.countDown();
            }
        }).start();
    }
    startLatch.countDown();
    endLatch.await();

    LOGGER.info(
        "Alice's balance {}", 
        accountRepository.getBalance("Alice-123")
    );
    LOGGER.info(
        "Bob's balance {}", 
        accountRepository.getBalance("Bob-456")
    );
}

We will get the following account balance log entries:

Alice's balance: -5

Bob's balance: 15

So, we’re in trouble! Bob managed to get more money than Alice originally had in her account.

The reason why we got this race condition is that the transfer method is not executed in the context of a single database transaction.

Since we forgot to add @Transactional to the transfer method, Spring is not going to start a transaction context before calling this method, and, for this reason, we will end up running three consecutive database transactions:

  • one for the getBalance method call that was selecting Alice’s account balance
  • one for the first addBalance call that was debiting Alice’s account
  • and another one for the second addBalance call that was crediting Bob’s account

The reason why the AccountRepository methods are executed transactionally is due to the @Transactional annotations we’ve added to the class and the addBalance method definitions.

The main goal of the Service Layer is to define the transaction boundaries of a given unit of work.

If the service is meant to call several Repository methods, it’s very important to have a single transaction context spanning over the entire unit of work.

Relying on transaction defaults

So, let’s fix the first issue by adding @Transactional annotation to the transfer method:

@Transactional
public boolean transfer(
        String fromIban, String toIban, long cents) {
    boolean status = true;

    long fromBalance = accountRepository.getBalance(fromIban);

    if(fromBalance >= cents) {
        status &= accountRepository.addBalance(
            fromIban, (-1) * cents
        ) > 0;
        
        status &= accountRepository.addBalance(
            toIban, cents
        ) > 0;
    }

    return status;
}

Now, when rerunning the testParallelExecution test case, we will get the following outcome:

Alice's balance: -50

Bob's balance: 60

So, the problem was not fixed even if the read and write operations were done atomically.

The problem we have here is caused by the Lost Update anomaly, which is not prevented by the default isolation level of Oracle, SQL Server, PostgreSQL, or MySQL:

Lost Update Anomaly

While multiple concurrent users can read the account balance of 5, only the first UPDATE will change the balance from 5 to 0. The second UPDATE will believe the account balance was the one it read before, while in reality, the balance has changed by the other transaction that managed to commit.

To prevent the Lost Update anomaly, there are various solutions we could try:

  • we could use optimistic locking, as explained in this article
  • we could use a pessimistic locking approach by locking Alice’s account record using a FOR UPDATE directive, as explained in this article
  • we could use a stricter isolation level

Depending on the underlying relational database system, this is how the Lost Update anomaly could be prevented using a higher isolation level:

| Isolation Level | Oracle | SQL Server | PostgreSQL | MySQL |
|-----------------|--------|------------|------------|-------|
| Read Committed  | Yes    | Yes        | Yes        | Yes   |
| Repeatable Read | N/A    | No         | No         | Yes   |
| Serializable    | No     | No         | No         | No    |

Since we are using PostgreSQL in our Spring example, let’s change the isolation level from the default, which is Read Committed to Repeatable Read.

As I explained in this article, you can set the isolation level at the @Transactional annotation level:

@Transactional(isolation = Isolation.REPEATABLE_READ)
public boolean transfer(
        String fromIban, String toIban, long cents) {
    boolean status = true;

    long fromBalance = accountRepository.getBalance(fromIban);

    if(fromBalance >= cents) {
        status &= accountRepository.addBalance(
            fromIban, (-1) * cents
        ) > 0;
        
        status &= accountRepository.addBalance(
            toIban, cents
        ) > 0;
    }

    return status;
}

And, when running the testParallelExecution integration test, we will see that the Lost Update anomaly is going to be prevented:

Alice's balance: 0

Bob's balance: 10

Just because the default isolation level is fine in many situations, it doesn’t mean you should use it exclusively for any possible use case.

If a given business use case requires strict data integrity guarantees, then you could use a higher isolation level or a more elaborate concurrency control strategy, like the optimistic locking mechanism.

The magic behind the Spring @Transactional annotation

When calling the transfer method from the testParallelExecution integration test, this is how the stack trace looks like:

"Thread-2"@8,005 in group "main": RUNNING
    transfer:23, TransferServiceImpl
    invoke0:-1, NativeMethodAccessorImpl
    invoke:77, NativeMethodAccessorImpl
    invoke:43, DelegatingMethodAccessorImpl
    invoke:568, Method {java.lang.reflect}
    invokeJoinpointUsingReflection:344, AopUtils
    invokeJoinpoint:198, ReflectiveMethodInvocation
    proceed:163, ReflectiveMethodInvocation
    proceedWithInvocation:123, TransactionInterceptor$1
    invokeWithinTransaction:388, TransactionAspectSupport
    invoke:119, TransactionInterceptor
    proceed:186, ReflectiveMethodInvocation
    invoke:215, JdkDynamicAopProxy
    transfer:-1, $Proxy82 {jdk.proxy2}
    lambda$testParallelExecution$1:121

Before the transfer method is called, there is a chain of AOP (Aspect-Oriented Programming) Aspects that get executed, and the most important one for us is the TransactionInterceptor which extends the TransactionAspectSupport class:

Spring TransactionInterceptor

While the entry point of this Spring Aspect is the TransactionInterceptor, the most important actions happen in its base class, the TransactionAspectSupport.

For instance, this is how the transactional context is handled by Spring:

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 TransactionManager tm = determineTransactionManager(txAttr);
    
    ...
        
    PlatformTransactionManager ptm = asPlatformTransactionManager(tm);
    final String joinpointIdentification = methodIdentification(
        method, 
        targetClass, 
        txAttr
    );
        
    TransactionInfo txInfo = createTransactionIfNecessary(
        ptm, 
        txAttr, 
        joinpointIdentification
    );
    
    Object retVal;
    
    try {
        retVal = invocation.proceedWithInvocation();
    }
    catch (Throwable ex) {
        completeTransactionAfterThrowing(txInfo, ex);
        throw ex;
    }
    finally {
        cleanupTransactionInfo(txInfo);
    }
    
    commitTransactionAfterReturning(txInfo);
    
    ...

    return retVal;
}

The service method invocation is wrapped by the invokeWithinTransaction method that starts a new transactional context unless one has already been started and propagated to this transactional method.

If a RuntimeException is thrown, the transaction is rolled back. Otherwise, if everything goes well, the transaction is committed.

If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.

Conclusion

Understanding how Spring transactions work is very important when developing a non-trivial application. First, you need to make sure you declare the transaction boundaries properly around your logical unit of work.

Second, you must know when to use the default isolation level and when it’s the time to use a higher isolation level.

Based on the read-only flag, you can even route transactions to read-only DataSource that connects to replica nodes, instead of the primary node. For more details about transactions routing check out this article.

Transactions and Concurrency Control eBook

Leave a Reply

Your email address will not be published.

This site uses Akismet to reduce spam. Learn how your comment data is processed.