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:
To demonstrate what can happen when transactions are not handled according to business requirements, let’s use the simplest possible data access layer implementation:
|
|
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:
|
|
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
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:
|
|
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 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:
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:
|
|
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:
|
|
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:
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:
|
|
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.
Reference https://vladmihalcea.com/spring-transaction-best-practices/