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

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

Она явно сообщает Spring Framework, ORM-фреймворку (Hibernate) и драйверу реляционной базы данных, что внутри этого метода будут выполняться исключительно операции чтения данных...


🟢 Junior Level

readonly транзакция — это транзакция, сконфигурированная с атрибутом readOnly = true:

@Transactional(readOnly = true)

Она явно сообщает Spring Framework, ORM-фреймворку (Hibernate) и драйверу реляционной базы данных, что внутри этого метода будут выполняться исключительно операции чтения данных (SELECT), и никаких модификаций (INSERT, UPDATE, DELETE) производиться не будет.

Суть в 30 секундах

Главные выгоды использования readOnly = true:

  1. Мощная оптимизация памяти и CPU (Hibernate): Hibernate отключает механизм отслеживания грязных сущностей (Dirty Checking). Ему больше не нужно делать копии снимков сущностей в памяти и сравнивать каждый геттер/сеттер при коммите.
  2. Оптимизация на уровне СУБД: Драйвер базы данных переключает соединение в режим READ ONLY (SET TRANSACTION READ ONLY), что позволяет СУБД не выделять транзакционные сегменты отката (Undo/WAL на запись) и оптимизировать планы выполнения запросов.
  3. Роутинг на Read Replica: В распределенных системах запросы с readOnly = true автоматически перенаправляются на читающие реплики БД, разгружая мастер.
  4. Защита от случайной записи: Если разработчик случайно вызовет модифицирующий SQL-запрос, база данных заблокирует его и выбросит ошибку.
package com.example.service;

import com.example.dto.UserProfileDto;
import com.example.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class UserQueryService {

    private final UserRepository userRepository;

    // Рекомендуемый стандарт для всех методов чтения:
    @Transactional(readOnly = true)
    public UserProfileDto getUserProfile(Long userId) {
        var user = userRepository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("Пользователь не найден"));
        return new UserProfileDto(user.getId(), user.getUsername(), user.getEmail());
    }
}

🟡 Middle Level

Оптимизации Hibernate: FlushMode.MANUAL

Когда метод аннотирован @Transactional(readOnly = true), Spring переключает текущую сессию Hibernate в режим FlushMode.MANUAL (или FlushMode.NEVER):

  • В стандартном режиме (FlushMode.AUTO) перед каждым запросом и перед коммитом транзакции Hibernate обязан обойти все сущности в PersistenceContext (L1 Cache), сравнить их поля с первоначальными снимками (dirty checking) и отправить пачку UPDATE в базу.
  • При readOnly = true dirty checking полностью отключается. Вызов commit() завершается мгновенно, так как Hibernate не тратит такты процессора на сравнение сущностей и не отправляет SQL UPDATE.
Обычная транзакция (readOnly = false):
SELECT -> Загрузка в память -> Создание копии снимка (Snapshot) ->
Бизнес-логика -> dirty checking (сравнение 10 000 объектов) -> flush() UPDATE -> COMMIT

Транзакция readOnly = true:
SELECT -> Загрузка в память ->
Бизнес-логика -> dirty checking ОТКЛЮЧЕН -> flush() ПРОПУЩЕН -> Мгновенный COMMIT!

⚠️ Коварный нюанс: Если вы измените поле сущности через сеттер внутри readOnly = true: user.setEmail("new@example.com"); Hibernate не выбросит исключение! Он просто молча проигнорирует это изменение и не отправит UPDATE в БД при коммите.

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

При входе в readOnly-транзакцию Spring вызывает метод JDBC-соединения:

connection.setReadOnly(true);
  • PostgreSQL: Выполняет SET TRANSACTION READ ONLY. Любая попытка выполнить модифицирующий SQL (INSERT, UPDATE, DELETE, TRUNCATE, DDL) вызовет фатальную ошибку: ERROR: cannot execute UPDATE in a read-only transaction.
  • MySQL InnoDB: Не блокирует локальные селекты, оптимизирует внутренний учет транзакций движка (не выделяет TRX_ID для транзакции, экономя системные ресурсы).
  • Oracle: Включает уровень транзакционной согласованности чтения (все запросы видят данные строго на момент старта первого SELECT).

🔴 Senior Level

Архитектура динамического роутинга на Read Replica

В высоконагруженных системах с архитектурой Master-Replica аннотация @Transactional(readOnly = true) является триггером для перенаправления сетевого трафика на реплики чтения через AbstractRoutingDataSource.

                  ┌──────────────────────────────────────────────┐
                  │          Spring @Transactional Proxy         │
                  │  readOnly = false      │     readOnly = true │
                  └──────────────┬─────────┴─────────────┬───────┘
                                 │                       │
                                 v                       v
                         [ Master DataSource ]   [ Replica DataSource ]
                                 │                       │
                                 v                       v
                          Primary DB (Write)      Read Replica (Read-only)

Реализация на Spring:

package com.example.datasource;

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.transaction.support.TransactionSynchronizationManager;

public class DynamicRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        // Проверяем флаг текущего потока в ThreadLocal:
        boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        return isReadOnly ? "REPLICA" : "MASTER";
    }
}

Критическая ловушка: LazyConnectionDataSourceProxy

Если просто настроить DynamicRoutingDataSource, роутинг на реплику работать не будет! Почему: Spring Framework по умолчанию захватывает физическое соединение из DataSource до того, как интерцептор транзакции успевает выставить флаг currentTransactionReadOnly в TransactionSynchronizationManager. Метод determineCurrentLookupKey() вызывается слишком рано, видит readOnly = false и всегда возвращает MASTER.

Senior Решение: Обернуть DataSource в LazyConnectionDataSourceProxy:

@Bean
public DataSource dataSource(DynamicRoutingDataSource routingDataSource) {
    // Откладывает реальный запрос физического коннекта из пула
    // до момента выполнения первого реального SQL Statement!
    return new LazyConnectionDataSourceProxy(routingDataSource);
}

С LazyConnectionDataSourceProxy коннект запрашивается только в момент отправки SELECT, когда флаг isCurrentTransactionReadOnly() == true уже гарантированно установлен в ThreadLocal.

Опасность Replica Lag (Отставание репликации)

При использовании read-реплик неизбежно возникает аномалия Replica Lag (асинхронная репликация отстает на 50–500 мс):

  1. Пользователь обновляет свой профиль: POST /profile (@Transactional $\rightarrow$ пишет в Master).
  2. Пользователь сразу же перенаправляется на чтение: GET /profile (@Transactional(readOnly = true) $\rightarrow$ читает с Replica).
  3. Результат: Реплика еще не успела применить binlog/WAL мастера. Пользователь видит свои старые данные!
  4. Паттерн решения (Read-Your-Own-Writes Consistency): Для критичных сценариев сразу после записи в куки или сессию пользователя выставляется временная метка last_write_timestamp. Если запрос на чтение пришел раньше истечения окна синхронизации (например, 2 секунды), сервис принудительно направляет запрос на Master, игнорируя readOnly = true.

4 Tricky Questions

1. Выбросит ли Hibernate исключение, если внутри @Transactional(readOnly = true) изменить поле сущности через вызов сеттера?

Ответ: Нет, Hibernate не выбросит никакого исключения! В режиме readOnly = true Hibernate переводит сессию в FlushMode.MANUAL. Значение поля объекта в памяти JVM изменится, но Hibernate не станет выполнять автоматический dirty checking и не сгенерирует SQL UPDATE при завершении транзакции. Однако если внутри этого же метода разработчик явно вызовет entityManager.flush(), Hibernate будет вынужден отправить накопившиеся изменения в базу данных через SQL UPDATE. В этот момент уже сама СУБД (например, PostgreSQL с активным SET TRANSACTION READ ONLY) прервет выполнение с ошибкой PSQLException: ERROR: cannot execute UPDATE in a read-only transaction.

2. Что произойдет, если родительский метод с @Transactional(readOnly = true) вызовет дочерний метод другого сервиса с @Transactional (без параметров, т.е. Propagation.REQUIRED), который выполняет запись в БД?

Ответ: Произойдет ошибка базы данных при попытке записи:

  • В PostgreSQL: PSQLException: ERROR: cannot execute UPDATE in a read-only transaction.
  • Либо TransactionRequiredException в Spring Data JPA при @Modifying. Причина: По умолчанию дочерний метод с REQUIRED входит в уже открытую родительскую транзакцию. Атрибут readOnly = true, выставленный родителем, распространяется на всю физическую сессию соединения. Внутренний метод не создает новой транзакции и наследует режим только для чтения. Чтобы внутренний метод смог успешно записать данные, он обязан использовать Propagation.REQUIRES_NEW, чтобы открыть отдельное независимое физическое соединение с базой без флага readOnly.

3. Дает ли аннотация @Transactional(readOnly = true) прирост производительности при работе через JdbcTemplate или JOOQ (без Hibernate)?

Ответ: Прирост производительности в чистом JDBC / JdbcTemplate минимален или практически отсутствует:

  • Основной колоссальный выигрыш в Spring приложениях дает Hibernate за счет отключения ресурсоемкого dirty checking и snapshot-копирования сущностей в памяти JVM.
  • Для JdbcTemplate этого оверхеда нет изначально. Единственный эффект readOnly = true в данном случае — отправка подсказки драйверу connection.setReadOnly(true) (что в PostgreSQL инициирует SET TRANSACTION READ ONLY). В некоторых СУБД это помогает оптимизатору блокировок, но в целом latency выполнения одиночного SELECT остается прежним. Главная польза readOnly для JdbcTemplate — безопасность (защита от случайных DML) и возможность роутинга на read-реплики.

4. Почему в PostgreSQL для тяжелых аналитических отчетов в транзакциях Repeatable Read / Serializable рекомендуется указывать режим READ ONLY DEFERRABLE?

Ответ: В PostgreSQL на высоких уровнях изоляции (Repeatable Read и Serializable) длинная аналитическая транзакция чтения может приводить к ошибкам сериализации (serialization_failure, SQLSTATE 40001) из-за конфликтов с параллельно пишущими транзакциями. Специальный модификатор DEFERRABLE (SET TRANSACTION READ ONLY DEFERRABLE):

  • Приостанавливает старт транзакции чтения ровно до того момента, пока в кластере не наступит состояние, гарантирующее, что транзакция сможет завершиться без каких-либо конфликтов с писателями.
  • После того как транзакция DEFERRABLE стартовала, она гарантированно никогда не упадет с ошибкой сериализации и не сможет заблокировать ни одного конкурентного писателя.

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

30-секундный ответ

«@Transactional(readOnly = true) декларирует транзакцию, предназначенную исключительно для чтения данных. Главная оптимизация происходит в Hibernate: отключается dirty checking (режим FlushMode.MANUAL), что экономит память и процессорное время на сканировании сущностей при коммите. На уровне JDBC драйвер выполняет connection.setReadOnly(true), предотвращая случайные операции записи. В распределенных архитектурах этот флаг используется в AbstractRoutingDataSource (в связке с LazyConnectionDataSourceProxy) для автоматического роутинга запросов на читающие реплики БД».

Чек-лист ключевых понятий

  1. Hibernate Optimization: Отключение dirty checking, пропуск автоматического flush().
  2. JDBC Optimization: Вызов connection.setReadOnly(true) $\rightarrow$ SET TRANSACTION READ ONLY в СУБД.
  3. Read Replica Routing: AbstractRoutingDataSource переключает чтение на слейвы по флагу TransactionSynchronizationManager.isCurrentTransactionReadOnly().
  4. Обязательный компонент: LazyConnectionDataSourceProxy необходим для корректного роутинга до захвата соединения.
  5. Мутации в памяти: Сеттеры сущностей в readOnly не бросают исключений, но изменения не попадут в БД, пока не вызван явный flush().

Красные флаги (чего ни в коем случае нельзя говорить)

  • ❌ «readOnly = true гарантирует неизменяемость (immutability) Java-объектов» (Сеттеры работают в памяти JVM, просто изменения не сохраняются в БД).
  • ❌ «readOnly = true автоматически перенаправляет запросы на реплику без дополнительных настроек» (Требуется ручная настройка AbstractRoutingDataSource).
  • ❌ «Можно спокойно вызывать пишущие методы из сервиса с readOnly = true» (Запись упадет с ошибкой СУБД или TransactionRequiredException).

Ссылки на связанные темы