Вопрос 21 · Раздел 11

Что такое readonly транзакция?

Как замок "только для чтения" на документе — вы можете смотреть и копировать, но не можете изменить оригинал.

Версии по языкам: English Russian Ukrainian

🟢 Junior Level

Readonly транзакция — это транзакция с флагом readOnly = true, которая сообщает Spring и базе данных: “я буду только читать данные, не изменять их”.

Как использовать

@Service
public class UserService {

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

Аналогия

Как замок “только для чтения” на документе — вы можете смотреть и копировать, но не можете изменить оригинал.

Зачем это нужно

  1. Оптимизация: Hibernate отключает dirty checking — механизм, который при flush сравнивает entities с snapshot-ом, чтобы определить, какие строки UPDATE-ить. При readOnly = true Hibernate не делает flush для UPDATE.
  2. Безопасность: Случайная запись вызовет ошибку
  3. База данных: Некоторые СУБД применяют оптимизации для read-only транзакций

Частый паттерн

Пометить весь сервис как readOnly, а write-методы переопределить:

@Service
@Transactional(readOnly = true)  // По умолчанию readOnly
public class UserService {

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

    @Transactional  // Переопределяем — НЕ readOnly
    public void update(User user) { ... }
}

Когда НЕ ставить readOnly = true

  1. Метод может писать — даже если сейчас не пишет, будущие изменения могут добавить запись. Тогда придётся менять аннотацию.
  2. Транзакция содержит и read, и write — readOnly на весь метод сломает write-часть.
  3. Используете Sequencenextval() = write, readOnly заблокирует.

🟡 Middle Level

Оптимизации на уровне Hibernate

При readOnly = true:

  • FlushMode.MANUAL: Hibernate отключает автоматический dirty checking
  • Memory: Hibernate может не хранить снимки объектов (теоретически, зависит от версии)

Оптимизации на уровне JDBC и СУБД

СУБД Что делает
PostgreSQL SET TRANSACTION READ ONLY — оптимизация плана выполнения
MySQL Может роутить на реплику — если настроен AbstractRoutingDataSource или фреймворк типа Hibernate Shards. Сам по себе readOnly не роутит, это зависит от конфигурации routing.
Oracle Transaction-level read consistency, избегает лишних блокировок

Оптимизации — что реально работает

Оптимизация Работает? Эффективность
Hibernate dirty checking отключается ✅ Да Значительно для больших объектов
БД оптимизирует план выполнения ⚠️ Зависит от СУБД Minor в PostgreSQL, заметнее в Oracle
Read-Replica routing ⚠️ Требует настройки Зависит от инфраструктуры
Блокировки не ставятся ✅ Да Зависит от СУБД

Распространённые ошибки

Ошибка Что происходит Как исправить
readOnly на write-методе PSQLException: cannot execute UPDATE in a read-only transaction Переопределить readOnly = false
Ожидание exception при set-терах Hibernate не бросает exception — просто не flush-ит Понимать что readOnly ≠ immutability
readOnly не применяется к connection Connection из пула уже был использован для writes Spring ставит readOnly только на new connection
Модификация entity в readOnly Set-теры работают, но изменения не сохраняются Это не баг — FlushMode.MANUAL

Когда НЕ использовать readOnly

Ситуация Почему Альтернатива
Метод читает и пишет readOnly блокирует writes Обычная транзакция
Stateless queries (JdbcTemplate) Нет Hibernate dirty checking anyway Без @Transactional
Очень короткий SELECT по ID Overhead открытия транзакции > выигрыш Без транзакции или без @Transactional

Сравнение: readOnly vs без транзакции

Аспект readOnly = true Без @Transactional
Dirty checking Отключён Нет session
Read replica routing Возможно Нет
Consistent snapshot Гарантирован (в рамках tx) Каждый query — свой snapshot
Overhead Низкий Минимальный

🔴 Senior Level

Hibernate readOnly — Internal Behavior

FlushMode Change

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

// Это значит:
// - Нет automatic flush перед query execution
// - Нет automatic flush при transaction commit
// - flush() можно вызвать вручную

Impact on Dirty Checking:

@Transactional(readOnly = true)
public User findById(Long id) {
    User user = em.find(User.class, id);
    user.setName("changed");  // No exception!
    // Но изменение НЕ сохраняется — flush is MANUAL
    return user;
}

Hibernate не бросает exception при модификациях в readOnly транзакции — он просто не делает flush. Изменение существует только в L1 cache.

First-Level Cache Implications

@Transactional(readOnly = true)
public List<User> findAll() {
    // Hibernate не нужно tracking snapshots для dirty checking
    // Но entities всё ещё кэшируются в L1 (Persistence Context)
    return em.createQuery("SELECT u FROM User u", User.class).getResultList();
}

Memory optimization: Теоретически Hibernate может пропустить создание snapshots. На практике, большинство версий Hibernate всё ещё создают snapshots — оптимация ограничена flush behavior.

JDBC readOnly Flag

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

Что делают разные СУБД:

PostgreSQL

SET TRANSACTION READ ONLY;
  • Запрещает INSERT, UPDATE, DELETE, TRUNCATE, CREATE, ALTER, DROP
  • Разрешает SELECT, EXPLAIN, SHOW
  • Исключение: Temporary tables в некоторых конфигурациях
  • Оптимизация: Избегает некоторых internal locks, slightly снижает overhead

В PostgreSQL readOnly = true посылает hint оптимизатору, но не гарантирует read-only транзакцию на уровне СУБД. Для truly read-only используйте SET TRANSACTION READ ONLY.

MySQL

conn.setReadOnly(true);
  • По умолчанию: no SQL-level change
  • С custom routing: может направлять на replica
  • HikariCP может использовать для routing decisions

Oracle

  • setReadOnly(true) → transaction-level read consistency
  • Гарантирует что все query видят один snapshot
  • Запрещает любые DML операции

Spring’s readOnly Implementation

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

Important: Spring ставит readOnly на connection только если это new connection. Если connection reused из пула и ранее использовался для writes, readOnly может не установиться.

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;
}

Как работает:

  1. @Transactional(readOnly = true) → Spring ставит thread-local flag
  2. RoutingDataSource.determineCurrentLookupKey() проверяет flag
  3. Возвращает “replica” → query идёт на read replica
  4. @Transactional (not readOnly) → возвращает “master”

Edge Cases (минимум 3)

  1. Модификация в readOnly транзакции: user.setName("new") не бросает exception — Hibernate просто не flush-ит. Но em.createQuery("UPDATE User ...").executeUpdate() бросает PSQLException: cannot execute UPDATE in a read-only transaction. Hibernate entity modifications silently ignored, direct SQL throws.

  2. readOnly override не срабатывает: Классовая @Transactional(readOnly = true) + @Transactional на методе = readOnly = false. Но если метод не имеет свою аннотацию, он наследует readOnly = true. Это может привести к неожиданному PSQLException если метод вызывает write-операцию.

  3. readOnly и Propagation.REQUIRED: readOnly метод вызывает write метод с REQUIRED (default) → write метод join-ит readOnly транзакцию → write fails. Fix: write методу нужен REQUIRES_NEW для собственной non-readOnly транзакции.

  4. Connection pool reuse: Spring ставит readOnly только на new connection. Если connection из пула ранее использовался для writes, flag может не установиться. Зависит от реализации пула и resetConnection behavior.

  5. readOnly с @Modifying запросом: @Modifying query в readOnly = true сервисе → TransactionRequiredException. Репозиторий имеет свою @Transactional, но параметр сервиса override-ит его.

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 даёт ~27% улучшение по времени и ~30% экономии памяти для больших read-операций.

Performance Numbers

Операция Default readOnly
Dirty checking (per entity) ~0.5 μs 0 (отключён)
Snapshot creation (per entity) ~1 μs ~1 μs (может не skipped)
Flush at commit ~5-10 ms (для dirty entities) 0 (not needed)

Memory Implications

  • readOnly транзакция: Hibernate может skip snapshot creation → экономия памяти пропорциональна количеству entities
  • L1 cache всё ещё активен — entities загружаются в Persistence Context
  • Для 10,000 entities: ~35 MB экономии (из benchmark)
  • Stateless session: минимальная memory footprint, no L1 cache

Thread Safety

readOnly flag хранится в ThreadLocal (TransactionSynchronizationManager.currentTransactionReadOnly). Каждый поток изолирован. Singleton сервис thread-safe.

Production War Story

Отчётный сервис с @Transactional(readOnly = true) вызывал @Modifying метод для cleanup временных данных. Получил PSQLException: cannot execute UPDATE in a read-only transaction. Весь nightly batch упал. Fix: cleanup вынесен в отдельный сервис с 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 показывают active connections для read vs write ops.

Highload Best Practices

  1. readOnly на всех query-методах: Baseline для любого метода, который не пишет. Dirty checking savings значительны при больших result sets.
  2. Class-level readOnly = true: Для сервисов, которые только читают — одна аннотация на класс.
  3. Read replica routing: AbstractRoutingDataSource + isCurrentTransactionReadOnly() = автоматический routing на реплики.
  4. Stateless session для больших result sets: Session#doStatelessWork() — no L1 cache, минимальная memory.
  5. Avoid readOnly + write in same tx: Не смешивать. Если метод и читает, и пишет — обычная транзакция.
  6. Monitor replica lag: При read replica routing — replica lag может вызвать stale reads. Acceptable для отчётов, не для реальных данных.

🎯 Шпаргалка для интервью

Обязательно знать:

  • readOnly = true сообщает Spring и БД: “только чтение, без записи”
  • Hibernate отключает dirty checking (FlushMode.MANUAL) — экономия ~27% времени, ~30% памяти
  • readOnly ≠ immutability: entity можно модифицировать, но изменения не flush-ятся в БД
  • PostgreSQL: SET TRANSACTION READ ONLY — запрещает INSERT/UPDATE/DELETE, оптимизирует план
  • Read replica routing: AbstractRoutingDataSource + isCurrentTransactionReadOnly() → replica
  • Прямой SQL (UPDATE/DELETE) в readOnly транзакции бросает PSQLException

Частые уточняющие вопросы:

  • Бросит ли Hibernate exception при user.setName() в readOnly? — Нет, просто не flush-ит (FlushMode.MANUAL)
  • А что будет при JPQL UPDATE в readOnly? — PSQLException: cannot execute UPDATE in a read-only transaction
  • Когда readOnly не устанавливается на connection? — Если connection reused из пула и ранее был used for writes
  • readOnly + REQUIRED вызов write метода? — Write join-ит readOnly транзакцию → write fails

Красные флаги (НЕ говорить):

  • “readOnly = запрет на модификацию entities” — set-теры работают, просто нет flush
  • “readOnly всегда роутит на реплику” — только если настроен AbstractRoutingDataSource
  • “readOnly = нулевой overhead” — открытие транзакции всё ещё имеет стоимость

Связанные темы:

  • [[17. На каком уровне можно использовать @Transactional]]
  • [[16. Что такое аннотация @Transactional]]
  • [[13. Что такое Propagation в Spring]]