Question 21 · Section 11

What is readonly transaction?

Like a "read-only" lock on a document — you can view and copy, but can't change the original.

Language versions: English Russian Ukrainian

🟢 Junior Level

Readonly transaction is a transaction with the readOnly = true flag, which tells Spring and the database: “I will only read data, not modify it.”

How to use

@Service
public class UserService {

    @Transactional(readOnly = true)
    public User findById(Long id) {
        return userRepository.findById(id).orElse(null);
    }
}

Analogy

Like a “read-only” lock on a document — you can view and copy, but can’t change the original.

Why it’s needed

  1. Optimization: Hibernate disables dirty checking — the mechanism that, on flush, compares entities with their snapshot to determine which rows to UPDATE. With readOnly = true, Hibernate doesn’t flush UPDATEs.
  2. Safety: Accidental writes will cause an error
  3. Database: Some DBMS apply optimizations for read-only transactions

Common pattern

Mark entire service as readOnly, then override write methods:

@Service
@Transactional(readOnly = true)  // readOnly by default
public class UserService {

    public User findById(Long id) { ... }  // readOnly = true

    @Transactional  // Override — NOT readOnly
    public void update(User user) { ... }
}

When NOT to set readOnly = true

  1. Method might write — even if it doesn’t write now, future changes might add writes. Then you’ll need to change the annotation.
  2. Transaction contains both read and write — readOnly on entire method will break the write part.
  3. Using Sequencenextval() = write, readOnly will block it.

🟡 Middle Level

Optimizations at Hibernate level

With readOnly = true:

  • FlushMode.MANUAL: Hibernate disables automatic dirty checking
  • Memory: Hibernate may skip storing object snapshots (theoretically, depends on version)

Optimizations at JDBC and DBMS level

DBMS What it does
PostgreSQL SET TRANSACTION READ ONLY — execution plan optimization
MySQL Can route to replica — if AbstractRoutingDataSource or framework like Hibernate Shards is configured. readOnly itself doesn’t route, depends on routing configuration.
Oracle Transaction-level read consistency, avoids unnecessary locks

Optimizations — what actually works

Optimization Works? Effectiveness
Hibernate dirty checking disabled ✅ Yes Significant for large objects
DB optimizes execution plan ⚠️ Depends on DBMS Minor in PostgreSQL, more noticeable in Oracle
Read-Replica routing ⚠️ Requires setup Depends on infrastructure
No locks set ✅ Yes Depends on DBMS

Common mistakes

Mistake What happens How to fix
readOnly on write method PSQLException: cannot execute UPDATE in a read-only transaction Override readOnly = false
Expecting exception on setters Hibernate doesn’t throw exception — just doesn’t flush Understand readOnly ≠ immutability
readOnly not applied to connection Connection from pool already used for writes Spring sets readOnly only on new connection
Entity modification in readOnly Setters work, but changes not saved Not a bug — FlushMode.MANUAL

When NOT to use readOnly

Situation Why Alternative
Method reads and writes readOnly blocks writes Regular transaction
Stateless queries (JdbcTemplate) No Hibernate dirty checking anyway Without @Transactional
Very short SELECT by ID Transaction opening overhead > benefit No transaction or no @Transactional

Comparison: readOnly vs no transaction

Aspect readOnly = true Without @Transactional
Dirty checking Disabled No session
Read replica routing Possible No
Consistent snapshot Guaranteed (within tx) Each query — own snapshot
Overhead Low Minimal

🔴 Senior Level

Hibernate readOnly — Internal Behavior

FlushMode Change

// When readOnly = true, Spring sets:
session.setFlushMode(FlushMode.MANUAL);

// This means:
// - No automatic flush before query execution
// - No automatic flush on transaction commit
// - flush() can be called manually

Impact on Dirty Checking:

@Transactional(readOnly = true)
public User findById(Long id) {
    User user = em.find(User.class, id);
    user.setName("changed");  // No exception!
    // But change is NOT saved — flush is MANUAL
    return user;
}

Hibernate does not throw exception on modifications in readOnly transaction — it simply doesn’t flush. The change exists only in L1 cache.

First-Level Cache Implications

@Transactional(readOnly = true)
public List<User> findAll() {
    // Hibernate doesn't need to track snapshots for dirty checking
    // But entities still cached in L1 (Persistence Context)
    return em.createQuery("SELECT u FROM User u", User.class).getResultList();
}

Memory optimization: Theoretically Hibernate can skip snapshot creation. In practice, most Hibernate versions still create snapshots — optimization limited to flush behavior.

JDBC readOnly Flag

// Spring sets this on the connection
connection.setReadOnly(true);

What different DBMS do:

PostgreSQL

SET TRANSACTION READ ONLY;
  • Prohibits INSERT, UPDATE, DELETE, TRUNCATE, CREATE, ALTER, DROP
  • Allows SELECT, EXPLAIN, SHOW
  • Exception: Temporary tables in some configurations
  • Optimization: Avoids some internal locks, slightly reduces overhead

In PostgreSQL, readOnly = true sends a hint to the optimizer, but doesn’t guarantee a read-only transaction at the DBMS level. For truly read-only, use SET TRANSACTION READ ONLY.

MySQL

conn.setReadOnly(true);
  • By default: no SQL-level change
  • With custom routing: can direct to replica
  • HikariCP may use for routing decisions

Oracle

  • setReadOnly(true) → transaction-level read consistency
  • Guarantees all queries see one snapshot
  • Prohibits any DML operations

Spring’s readOnly Implementation

// DataSourceTransactionManager.doBegin()
if (definition.isReadOnly()) {
    if (con.isReadOnly()) {
        // Already read-only
    } else {
        if (txObject.isNewConnection()) {
            con.setReadOnly(true);
        }
    }
}

Important: Spring sets readOnly on connection only if it’s a new connection. If connection reused from pool and was previously used for writes, readOnly may not be set.

Read-Replica Routing Architecture

public class RoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) {
            return "replica";
        }
        return "master";
    }
}

Configuration:

@Bean
public DataSource dataSource() {
    RoutingDataSource routing = new RoutingDataSource();

    Map<Object, Object> targetDataSources = new HashMap<>();
    targetDataSources.put("master", masterDataSource());
    targetDataSources.put("replica", replicaDataSource());

    routing.setTargetDataSources(targetDataSources);
    routing.setDefaultTargetDataSource(masterDataSource());

    return routing;
}

How it works:

  1. @Transactional(readOnly = true) → Spring sets thread-local flag
  2. RoutingDataSource.determineCurrentLookupKey() checks flag
  3. Returns “replica” → query goes to read replica
  4. @Transactional (not readOnly) → returns “master”

Edge Cases (minimum 3)

  1. Modification in readOnly transaction: user.setName("new") doesn’t throw exception — Hibernate just doesn’t flush. But em.createQuery("UPDATE User ...").executeUpdate() throws PSQLException: cannot execute UPDATE in a read-only transaction. Hibernate entity modifications silently ignored, direct SQL throws.

  2. readOnly override doesn’t fire: Class-level @Transactional(readOnly = true) + @Transactional on method = readOnly = false. But if method doesn’t have its own annotation, it inherits readOnly = true. This can lead to unexpected PSQLException if method calls a write operation.

  3. readOnly and Propagation.REQUIRED: readOnly method calls write method with REQUIRED (default) → write method joins readOnly transaction → write fails. Fix: write method needs REQUIRES_NEW for its own non-readOnly transaction.

  4. Connection pool reuse: Spring sets readOnly only on new connection. If connection from pool was previously used for writes, flag may not be set. Depends on pool implementation and resetConnection behavior.

  5. readOnly with @Modifying query: @Modifying query in readOnly = true service → TransactionRequiredException. Repository has its own @Transactional, but service parameter overrides it.

Benchmarking readOnly Impact

Scenario: Fetch 10,000 entities with Hibernate

Configuration              | Time (ms) | Memory (MB)
---------------------------|-----------|------------
Default                    | 850       | 120
readOnly = true            | 620       | 85
readOnly + stateless session| 450       | 30

readOnly gives ~27% time improvement and ~30% memory savings for large read operations.

Performance Numbers

Operation Default readOnly
Dirty checking (per entity) ~0.5 μs 0 (disabled)
Snapshot creation (per entity) ~1 μs ~1 μs (may not be skipped)
Flush at commit ~5-10 ms (for dirty entities) 0 (not needed)

Memory Implications

  • readOnly transaction: Hibernate can skip snapshot creation → memory savings proportional to entity count
  • L1 cache still active — entities loaded into Persistence Context
  • For 10,000 entities: ~35 MB savings (from benchmark)
  • Stateless session: minimal memory footprint, no L1 cache

Thread Safety

readOnly flag stored in ThreadLocal (TransactionSynchronizationManager.currentTransactionReadOnly). Each thread isolated. Singleton service thread-safe.

Production War Story

Reporting service with @Transactional(readOnly = true) called @Modifying method for temporary data cleanup. Got PSQLException: cannot execute UPDATE in a read-only transaction. Entire nightly batch failed. Fix: cleanup moved to separate service with REQUIRES_NEW.

Monitoring

Debug logging:

logging:
  level:
    org.springframework.transaction: DEBUG
    org.hibernate.SQL: DEBUG

Programmatically check readOnly:

boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();

Actuator: connection pool metrics show active connections for read vs write ops.

Highload Best Practices

  1. readOnly on all query methods: Baseline for any method that doesn’t write. Dirty checking savings significant for large result sets.
  2. Class-level readOnly = true: For read-only services — one annotation per class.
  3. Read replica routing: AbstractRoutingDataSource + isCurrentTransactionReadOnly() = automatic routing to replicas.
  4. Stateless session for large result sets: Session#doStatelessWork() — no L1 cache, minimal memory.
  5. Avoid readOnly + write in same tx: Don’t mix. If method reads and writes — regular transaction.
  6. Monitor replica lag: With read replica routing — replica lag may cause stale reads. Acceptable for reports, not for real-time data.

🎯 Interview Cheat Sheet

Must know:

  • readOnly = true tells Spring and DB: “read only, no writes”
  • Hibernate disables dirty checking (FlushMode.MANUAL) — ~27% time savings, ~30% memory savings
  • readOnly ≠ immutability: entities can be modified, but changes not flushed to DB
  • PostgreSQL: SET TRANSACTION READ ONLY — prohibits INSERT/UPDATE/DELETE, optimizes plan
  • Read replica routing: AbstractRoutingDataSource + isCurrentTransactionReadOnly() → replica
  • Direct SQL (UPDATE/DELETE) in readOnly transaction throws PSQLException

Common follow-up questions:

  • Will Hibernate throw exception on user.setName() in readOnly? — No, just doesn’t flush (FlushMode.MANUAL)
  • What about JPQL UPDATE in readOnly? — PSQLException: cannot execute UPDATE in a read-only transaction
  • When is readOnly not set on connection? — If connection reused from pool and previously used for writes
  • readOnly + REQUIRED calling write method? — Write joins readOnly transaction → write fails

Red flags (DO NOT say):

  • “readOnly = prohibits entity modification” — setters work, just no flush
  • “readOnly always routes to replica” — only if AbstractRoutingDataSource configured
  • “readOnly = zero overhead” — opening transaction still has cost

Related topics:

  • [[17. At what level can you use @Transactional]]
  • [[16. What is @Transactional annotation]]
  • [[13. What is Propagation in Spring]]