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:
To demonstrate what can happen when transactions are not handled according to business requirements, let’s use the simplest possible data access layer implementation:
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
@Transactionalare 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
@Transactionalannotation 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
@Transactionalannotation, check out this article as well.
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:
Considering we have two users, Alice and Bob:
When running the parallel execution test case:
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
getBalancemethod call that was selecting Alice’s account balance
- one for the first
addBalancecall that was debiting Alice’s account
- and another one for the second
addBalancecall 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
Repositorymethods, 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
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:
While multiple concurrent users can read the account balance of
5, only the first
UPDATE will change the balance from
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 UPDATEdirective, 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:
Since we are using PostgreSQL in our Spring example, let’s change the isolation level from the default, which is
Read Committed to
As I explained in this article, you can set the isolation level at the
@Transactional annotation level:
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:
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
While the entry point of this Spring Aspect is the
TransactionInterceptor, the most important actions happen in its base class, the
For instance, this is how the transactional context is handled by Spring:
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.
RuntimeException is thrown, the transaction is rolled back. Otherwise, if everything goes well, the transaction is committed.