Read-only Transactions

Mark query methods with @Transactional(readOnly = true) so Hibernate skips dirty checking and the database can route and optimise the work.

Syntax@Transactional(readOnly = true) public List<Dto> query(...) { ... } // class default read-only, writes opt back in @Transactional(readOnly = true) class Service { @Transactional void write() {} }

@Transactional(readOnly = true) tells Spring that a method only reads. It is a hint, not a security boundary, but with JPA it changes real behaviour and is worth adding to every query method in a service.

What the flag does

  • Hibernate stops flushing the session for that transaction, and Spring also marks the session read-only, so entities are loaded without keeping the snapshot copies used for dirty checking. Reading 10,000 rows needs noticeably less memory and no comparison work at the end.
  • The JDBC connection is marked with Connection.setReadOnly(true). The PostgreSQL driver then starts the transaction as read-only, so an accidental UPDATE fails instead of committing.
  • Routing: a data source that sends read-only work to a replica can use the flag to decide where the query goes.

Spring Data's built-in repository methods such as findById and findAll already run read-only. Your own service methods do not, unless you say so.

A common pattern

Annotate the service class with @Transactional(readOnly = true) and override it with a plain @Transactional on the methods that write. New query methods are then read-only without anyone remembering to add the annotation, and every write method is visibly marked.

@Service
@Transactional(readOnly = true)
public class ProductQueries {
    public List<ProductSummary> search(String term) { ... }

    @Transactional
    public void rename(long id, String name) { ... }
}

Mistakes to avoid

The flag only takes effect when the method starts the transaction. A read-only method called from inside a read-write transaction joins it and the flag is ignored. The opposite case is the dangerous one: a write method with REQUIRED called from inside a read-only transaction joins the read-only one. With JPA the change is never flushed, the method returns normally and the update quietly disappears.

Do not use read-only transactions to fetch entities you plan to change and save in a later request. Load, change and commit in one read-write method.

Read-only does not mean consistent across statements. Two queries in the same read-only transaction at the default READ COMMITTED isolation can see different data if another transaction commits in between. When a report must see one snapshot, ask for isolation = Isolation.REPEATABLE_READ as well.

Example

Example · java
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Entity
class Product {
    @Id
    private Long id;
    private String name;
    private BigDecimal price;

    protected Product() {
    }

    void rename(String newName) {
        this.name = newName;
    }
}

record ProductSummary(Long id, String name, BigDecimal price) {
}

interface ProductRepository extends JpaRepository<Product, Long> {

    // Spring Data maps each row onto the record's constructor: no entities are loaded.
    List<ProductSummary> findByNameContainingIgnoreCase(String term);
}

@Service
@Transactional(readOnly = true)
public class ProductService {

    private final ProductRepository products;

    ProductService(ProductRepository products) {
        this.products = products;
    }

    public List<ProductSummary> search(String term) {
        return products.findByNameContainingIgnoreCase(term);
    }

    @Transactional
    public void rename(long id, String newName) {
        products.findById(id).orElseThrow().rename(newName);
    }
}

When to use it

  • Cut memory use on a search endpoint that loads thousands of entities just to map them to DTOs.
  • Send product listing queries to a read replica while checkout writes go to the primary.
  • Produce a daily sales summary whose count and revenue figures come from the same snapshot.

More examples

A consistent read for a report

Without the isolation level, an order committed between the two queries would be counted in one total and missing from the other. <code>REPEATABLE_READ</code> on PostgreSQL takes one snapshot for the whole transaction. The timeout stops a heavy report holding that snapshot open for minutes, which on PostgreSQL also delays vacuum cleaning up old row versions.

Example · java
import java.math.BigDecimal;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

record DailyTotals(long orders, BigDecimal revenue) {
}

@Service
public class ReportService {

    private final JdbcClient jdbc;

    ReportService(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    // Both queries see the same snapshot, so the totals always agree.
    @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ, timeout = 20)
    public DailyTotals today() {
        long count = jdbc.sql("select count(*) from orders where created_at >= current_date")
                .query(Long.class)
                .single();
        BigDecimal revenue = jdbc.sql("select coalesce(sum(total), 0) from orders where created_at >= current_date")
                .query(BigDecimal.class)
                .single();
        return new DailyTotals(count, revenue);
    }
}

Routing read-only transactions to a replica

<code>LazyConnectionDataSourceProxy</code> delays choosing a connection until it is actually used, and <code>setReadOnlyDataSource</code> sends read-only transactions to the replica pool. The <code>app.db.*</code> values come from environment variables in production. Remember replication lag: a client that writes and then immediately reads through a read-only method may not see its own write yet.

Example · java
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;

@Configuration
public class DataSourceConfig {

    @Bean
    @Primary
    DataSource dataSource(@Value("${app.db.primary-url}") String primaryUrl,
                          @Value("${app.db.replica-url}") String replicaUrl,
                          @Value("${app.db.username}") String username,
                          @Value("${app.db.password}") String password) {
        HikariDataSource primary = pool("primary", primaryUrl, username, password);
        HikariDataSource replica = pool("replica", replicaUrl, username, password);

        // Fetches a real connection only when the first statement runs, by which
        // time the transaction's read-only flag is known.
        LazyConnectionDataSourceProxy proxy = new LazyConnectionDataSourceProxy(primary);
        proxy.setReadOnlyDataSource(replica);
        return proxy;
    }

    private static HikariDataSource pool(String name, String url, String username, String password) {
        HikariDataSource ds = new HikariDataSource();
        ds.setPoolName(name);
        ds.setJdbcUrl(url);
        ds.setUsername(username);
        ds.setPassword(password);
        return ds;
    }
}

The silent lost update

<code>VisitCounter.count</code> is correct on its own, and its tests pass. Called from <code>showProfile</code> it joins a read-only transaction, Hibernate never flushes, and the visit count never moves. Fix it by annotating <code>showProfile</code> with a plain <code>@Transactional</code>, or by giving <code>count</code> <code>REQUIRES_NEW</code> if the counter really is independent of the read.

Example · java
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Entity
class Customer {
    @Id
    private Long id;
    private int visits;

    protected Customer() {
    }

    void countVisit() {
        visits++;
    }
}

interface CustomerRepository extends JpaRepository<Customer, Long> {
}

@Service
class VisitCounter {

    private final CustomerRepository customers;

    VisitCounter(CustomerRepository customers) {
        this.customers = customers;
    }

    @Transactional
    public void count(long customerId) {
        customers.findById(customerId).orElseThrow().countVisit();
    }
}

@Service
@Transactional(readOnly = true)
public class ProfileService {

    private final VisitCounter counter;

    ProfileService(VisitCounter counter) {
        this.counter = counter;
    }

    // BUG: count() joins this read-only transaction, so its change is never flushed.
    public void showProfile(long customerId) {
        counter.count(customerId);
    }
}

Discussion

  • Be the first to comment on this lesson.