Transactions, Caching & Performance

Read-Only Transactions & Optimization

18 min Lesson 4 of 13

Read-Only Transactions & Optimization

Every database call you make happens inside a transaction, whether you asked for one or not. When that call only reads data, locking the rows, tracking dirty state, and preparing a rollback journal is pure overhead — work the database and Hibernate perform with no benefit. The readOnly flag on @Transactional is the signal that lets both layers skip that work and go faster.

What readOnly Actually Does

Setting @Transactional(readOnly = true) on a method triggers optimizations at two distinct layers:

  1. Hibernate / JPA layer — the persistence context switches into flush mode NEVER. This means Hibernate will not perform dirty checking at flush time: it will never scan all managed entities for changes, never compute change-sets, and never generate UPDATE statements. The first-level cache still loads entities, but they are treated as immutable snapshots for the lifetime of the method.
  2. JDBC / database layer — Spring passes the read-only hint down to the JDBC connection (connection.setReadOnly(true)). Many databases (PostgreSQL, MySQL InnoDB, Oracle) use this hint to avoid taking row-level write locks, to skip undo-log entries, or to route the connection to a read replica. The exact behaviour depends on the driver and database, but the benefit is real and measurable under load.
Flush mode NEVER vs COMMIT: In a normal read-write transaction Hibernate uses flush mode AUTO — it flushes dirty state before every query so you always see your own writes. With readOnly = true Hibernate skips that work entirely. If you accidentally mutate a managed entity inside a read-only transaction it will silently not persist — no error, just a lost update. That is a bug, not a feature.

Declaring Read-Only Methods in a Service

A common pattern is to mark the class-level @Transactional as read-only and override individual mutating methods with the write default:

package com.example.shop.service; import com.example.shop.model.Order; import com.example.shop.repository.OrderRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; @Service @Transactional(readOnly = true) // default for all methods: read-only public class OrderQueryService { private final OrderRepository repo; public OrderQueryService(OrderRepository repo) { this.repo = repo; } // Inherits readOnly = true from class — no dirty-check, no flush public List<Order> findByCustomer(Long customerId) { return repo.findByCustomerId(customerId); } public Order findById(Long id) { return repo.findById(id) .orElseThrow(() -> new IllegalArgumentException("Order not found: " + id)); } // Override: this method DOES write, so use the default (readOnly = false) @Transactional public Order createOrder(Order order) { return repo.save(order); } }

The class-level annotation acts as the default. Any method without its own @Transactional annotation inherits the class setting. Any method that needs to write simply adds @Transactional — which defaults to readOnly = false — and overrides the class default.

Split read and write services. Some teams go further and create two separate service classes: a *QueryService (the class is annotated readOnly = true) and a *CommandService (uses write transactions). This keeps the code self-documenting, makes it easy to route reads to a replica, and reduces the risk of accidental writes in query paths.

Read-Only Repositories with Spring Data JPA

Spring Data JPA lets you declare a repository as read-only by extending Repository (not JpaRepository) and annotating query methods:

package com.example.shop.repository; import com.example.shop.model.Product; import org.springframework.data.repository.Repository; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; @Transactional(readOnly = true) public interface ProductReadRepository extends Repository<Product, Long> { Optional<Product> findById(Long id); List<Product> findByCategory(String category); }

By extending the base Repository marker interface instead of JpaRepository, no mutating methods (save, delete, etc.) are available on this interface, which enforces the read-only contract at compile time.

Projections: Fetching Only What You Need

Read-only transactions pair naturally with projections. Instead of loading a full entity graph into the persistence context, a projection returns only the fields the caller needs. This reduces the number of columns fetched, keeps entity objects smaller, and avoids lazy-loading surprises:

// Projection interface — Hibernate generates a proxy at runtime public interface OrderSummary { Long getId(); String getStatus(); java.math.BigDecimal getTotalAmount(); } // In the repository @Transactional(readOnly = true) List<OrderSummary> findSummariesByCustomerId(Long customerId);

Spring Data JPA infers the SELECT list from the interface's getter names. The generated SQL fetches only id, status, and total_amount — not the full ORDER row plus all joined tables.

Measuring the Impact

The gains from readOnly = true are most visible under two conditions:

  • Large entity graphs: if a single query loads hundreds of entities with associations, skipping dirty-checking at flush time saves a measurable CPU cycle count per request.
  • Read-heavy workloads: if 80–90 % of your traffic is reads (typical for most web services), optimising the read path has a multiplicative effect on throughput.

Use Hibernate's statistics or a tool like Datasource-Proxy to log the number of SQL statements per request. You will often see that a naive service method issues N+1 queries or redundant flush checks that readOnly = true plus projections eliminate entirely.

# application.properties — enable Hibernate statistics for profiling spring.jpa.properties.hibernate.generate_statistics=true logging.level.org.hibernate.stat=DEBUG

Sample statistics output after enabling it — note the dirty-check count drops to zero in read-only transactions:

// Write transaction (readOnly = false): // Sessions opened: 1, Flushes: 1, Dirty entities checked: 47 // Read-only transaction: // Sessions opened: 1, Flushes: 0, Dirty entities checked: 0

Common Pitfalls

  • Mutating entities in a read-only transaction: As noted above, changes are silently dropped. If you see a save that is not persisting, check whether the enclosing transaction is read-only.
  • Calling a read-only method from a write-transaction method in the same bean: Spring's proxy-based AOP does not intercept self-calls. If method A (read-write) directly calls this.methodB() (read-only) within the same class, methodB runs inside A's transaction, not a new read-only one. Use propagation or restructure into different beans to avoid this.
  • Assuming all databases honour the hint: H2 (commonly used in tests) ignores the read-only hint. Code that mutates entities inside a readOnly = true transaction may appear to work in tests but fail silently in production.
The silent-write-loss trap: If your application relies on writes inside a readOnly = true transaction to work "sometimes", you have a latent production bug. Always annotate mutating service methods explicitly with @Transactional (defaulting to read-write) and run integration tests against a real database (PostgreSQL / MySQL) that enforces the hint.

Summary

@Transactional(readOnly = true) is not just a hint — it is a contract that tells both Hibernate and the JDBC driver to shed unnecessary work. Hibernate disables dirty-checking and flush, the database avoids write locks and undo entries, and the overall throughput of your read paths improves. Use it as the default on query-only services and repositories, pair it with projections to minimise data transfer, and be aware of the two key pitfalls: silent write loss and self-call bypass.