Understanding the Performance Impact of @Transactional(readOnly=true) in Spring
Today, I want to discuss the @Transactional(readOnly = true) annotation provided by Spring.
I'm bringing this up because there are many instances of @Transactional(readOnly = true) in my company's project code, and colleagues who have used it say that @Transactional(readOnly = true) improves performance. Let's first consider the following points:
- How does
@Transactional(readOnly = true)work, and why does using it enhance performance? - When using JPA, should we always add
@Transactional(readOnly = true)to read-only methods at the service layer? What are the trade-offs?
Before we begin, note that we are using Hibernate to implement JPA.
1. How does @Transactional(readOnly = true) work, and why does using it enhance performance?
First, let's look at the transaction interface.
/**
* A boolean flag that can be set to {@code true} if the transaction is
* effectively read-only, allowing for corresponding optimizations at runtime.
* <p>Defaults to {@code false}.
* <p>This just serves as a hint for the actual transaction subsystem;
* it will <i>not necessarily</i> cause failure of write access attempts.
* A transaction manager which cannot interpret the read-only hint will
* <i>not</i> throw an exception when asked for a read-only transaction
* but rather silently ignore the hint.
* @see org.springframework.transaction.interceptor.TransactionAttribute#isReadOnly()
* @see org.springframework.transaction.support.TransactionSynchronizationManager#isCurrentTransactionReadOnly()
*/
boolean readOnly() default false;
We can see that the readOnly = true option allows for optimizations. The transaction manager will use the read-only option as a hint. Let's look at JpaTransactionManager, which is used to transaction management.
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
JpaTransactionObject txObject = (JpaTransactionObject) transaction;
// ...
// Delegate to JpaDialect for actual transaction begin.
int timeoutToUse = determineTimeout(definition);
Object transactionData = getJpaDialect().beginTransaction(em,
new JpaTransactionDefinition(definition, timeoutToUse, txObject.isNewEntityManagerHolder()));
//...
}
In JpaTransactionManager, the doBegin method delegates to JpaDialect to start the actual transaction, and beginTransaction is called within JpaDialect. Let's examine the HibernateJpaDialect class.
@Override
public Object beginTransaction(EntityManager entityManager, TransactionDefinition definition)
throws PersistenceException, SQLException, TransactionException {
// ...
// Adapt flush mode and store previous isolation level, if any.
FlushMode previousFlushMode = prepareFlushMode(session, definition.isReadOnly());
if (definition instanceof ResourceTransactionDefinition &&
((ResourceTransactionDefinition) definition).isLocalResource()) {
// As of 5.1, we explicitly optimize for a transaction-local EntityManager,
// aligned with native HibernateTransactionManager behavior.
previousFlushMode = null;
if (definition.isReadOnly()) {
session.setDefaultReadOnly(true);
}
}
// ...
}
protected FlushMode prepareFlushMode(Session session, boolean readOnly) throws PersistenceException {
FlushMode flushMode = session.getHibernateFlushMode();
if (readOnly) {
// We should suppress flushing for a read-only transaction.
if (!flushMode.equals(FlushMode.MANUAL)) {
session.setHibernateFlushMode(FlushMode.MANUAL);
return flushMode;
}
}
else {
// We need AUTO or COMMIT for a non-read-only transaction.
if (flushMode.lessThan(FlushMode.COMMIT)) {
session.setHibernateFlushMode(FlushMode.AUTO);
return flushMode;
}
}
// No FlushMode change needed...
return null;
}
In JpaDialect, we can see that JpaDialect uses the read-only option to prepare the flush mode. When readOnly = true, JpaDialect disables flushing. Additionally, you can see that after preparing the flush mode, session.setDefaultReadOnly(true) sets the session's read-only property to true.
/**
* Change the default for entities and proxies loaded into this session
* from modifiable to read-only mode, or from modifiable to read-only mode.
*
* Read-only entities are not dirty-checked and snapshots of persistent
* state are not maintained. Read-only entities can be modified, but
* changes are not persisted.
*
* When a proxy is initialized, the loaded entity will have the same
* read-only/modifiable setting as the uninitialized
* proxy has, regardless of the session's current setting.
*
* To change the read-only/modifiable setting for a particular entity
* or proxy that is already in this session:
* @see Session#setReadOnly(Object,boolean)
*
* To override this session's read-only/modifiable setting for entities
* and proxies loaded by a Query:
* @see Query#setReadOnly(boolean)
*
* @param readOnly true, the default for loaded entities/proxies is read-only;
* false, the default for loaded entities/proxies is modifiable
*/
void setDefaultReadOnly(boolean readOnly);
In the Session interface, by setting the read-only property to true, dirty checking will not be performed on read-only entities, and snapshots of persistent state will not be maintained. Additionally, changes to read-only entities will not be persisted.
In summary, these are the results obtained by using @Transactional(readOnly = true) in Hibernate:
- Performance improvement: Read-only entities do not undergo dirty checking.
- Memory saving: Snapshots of persistent state are not maintained.
- Data consistency: Changes to read-only entities are not persisted.
- When using master-slave or read-write replica sets (or clusters),
@Transactional(readOnly = true)allows us to connect to read-only databases.
2. When using JPA, should we always add @Transactional(readOnly = true) to read-only methods at the service layer? What are the trade-offs?
I see that when using @Transactional(readOnly = true), we can have many advantages. However, is it appropriate to add @Transactional(readOnly = true) to read-only methods at the service layer? Here are my concerns:
- Unlimtied use of transactions may lead to database deadlocks, performance degradation, and reduced throughput.
- Since a transaction occupies a DB connection, adding
@Transactional(readOnly = true)to methods at the service layer may cause DB connection starvation.
The first issue is difficult to reproduce, so I conducted some tests to check the second issue.
@Transactional(readOnly = true)
public List<UserDto> transactionalReadOnlyOnService(){
List<UserDto> userDtos = userRepository.findAll().stream()
.map(userMapper::toDto)
.toList();
timeSleepAndPrintConnection();
return userDtos;
}
public List<UserDto> transactionalReadOnlyOnRepository(){
List<UserDto> userDtos = userRepository.findAll().stream()
.map(userMapper::toDto)
.toList();
timeSleepAndPrintConnection();
return userDtos;
}
I tested two methods at the service layer: one with @Transactional(readOnly = true) and another with @Transactional(readOnly = true) at the repository layer (in SimpleJpaRepository, wich is the default implementation of JpaRepository, there is @Transactional(readOnly = true) at the top of the class, so the findAll() method has @Transactional(readOnly = true) by default).
I retrieved userInfo from the DB and kept the thread for 5 seconds, then checked when the method releases the connection.
The results are as follows:
For @Transactional(readOnly = true) at the service layer method:
activeConnections:0, IdleConnections:10, TotalConnections:10
start transactionalReadOnlyOnService!!
Hibernate:
select
u1_0.id,
u1_0.email,
u1_0.name,
u1_0.profile_file_name
from
users u1_0
activeConnections:1, IdleConnections:9, TotalConnections:10
activeConnections:1, IdleConnections:9, TotalConnections:10
activeConnections:1, IdleConnections:9, TotalConnections:10
activeConnections:1, IdleConnections:9, TotalConnections:10
activeConnections:1, IdleConnections:9, TotalConnections:10
end transactionalReadOnlyOnService!!
activeConnections:0, IdleConnections:10, TotalConnections:10
For @Transactional(readOnly = true) at the repository layer method:
activeConnections:0, IdleConnections:10, TotalConnections:10
start transactionalReadOnlyOnRepository!!
Hibernate:
select
u1_0.id,
u1_0.email,
u1_0.name,
u1_0.profile_file_name
from
users u1_0
activeConnections:0, IdleConnections:10, TotalConnections:10
activeConnections:0, IdleConnections:10, TotalConnections:10
activeConnections:0, IdleConnections:10, TotalConnections:10
activeConnections:0, IdleConnections:10, TotalConnections:10
activeConnections:0, IdleConnections:10, TotalConnections:10
end transactionalReadOnlyOnRepository!!
activeConnections:0, IdleConnections:10, TotalConnections:10
As you can see, @Transactional(readOnly = true) at the repository layer releases the connection once the query results arrive.
However, @Transactional(readOnly = true) at the service layer method does not release the connection until the service layer method ends.
Therefore, be cautious when service layer methods have logic that requires a lot of time, as they can hold database connections for extended periods, potentially leading to database connection starvation.
3. Review
Clearly, @Transactional(readOnly = true) has many advantages.
- Performance improvement: Read-only entities do not undergo dirty checking.
- Memory saving: Snapshots of persistent state are not maintained.
- Data consistency: Changes to read-only entities are not persisted.
- When using master-slave or read-write replica sets (or clusters),
@Transactional(readOnly = true)allows us to connect to read-only databases.
How ever, you should also remember that @Transactional(readOnly = true) at the service layer method may lead to database deadlocks, performance degradation, and database connection starvation!
When you need to execute read-only queries only as a transaction, do not hesitate to use @Transactional(readOnly = true) at the service layer method, but if your service layer method has a lot of other logic, you need to make trade-offs!