Что такое readonly транзакция
Она явно сообщает Spring Framework, ORM-фреймворку (Hibernate) и драйверу реляционной базы данных, что внутри этого метода будут выполняться исключительно операции чтения данных...
🟢 Junior Level
readonly транзакция — это транзакция, сконфигурированная с атрибутом readOnly = true:
@Transactional(readOnly = true)
Она явно сообщает Spring Framework, ORM-фреймворку (Hibernate) и драйверу реляционной базы данных, что внутри этого метода будут выполняться исключительно операции чтения данных (SELECT), и никаких модификаций (INSERT, UPDATE, DELETE) производиться не будет.
Суть в 30 секундах
Главные выгоды использования readOnly = true:
- Мощная оптимизация памяти и CPU (Hibernate): Hibernate отключает механизм отслеживания грязных сущностей (Dirty Checking). Ему больше не нужно делать копии снимков сущностей в памяти и сравнивать каждый геттер/сеттер при коммите.
- Оптимизация на уровне СУБД: Драйвер базы данных переключает соединение в режим
READ ONLY(SET TRANSACTION READ ONLY), что позволяет СУБД не выделять транзакционные сегменты отката (Undo/WAL на запись) и оптимизировать планы выполнения запросов. - Роутинг на Read Replica: В распределенных системах запросы с
readOnly = trueавтоматически перенаправляются на читающие реплики БД, разгружая мастер. - Защита от случайной записи: Если разработчик случайно вызовет модифицирующий 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 = truedirty 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 мс):
- Пользователь обновляет свой профиль:
POST /profile(@Transactional$\rightarrow$ пишет в Master). - Пользователь сразу же перенаправляется на чтение:
GET /profile(@Transactional(readOnly = true)$\rightarrow$ читает с Replica). - Результат: Реплика еще не успела применить binlog/WAL мастера. Пользователь видит свои старые данные!
- Паттерн решения (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) для автоматического роутинга запросов на читающие реплики БД».
Чек-лист ключевых понятий
- Hibernate Optimization: Отключение dirty checking, пропуск автоматического
flush(). - JDBC Optimization: Вызов
connection.setReadOnly(true)$\rightarrow$SET TRANSACTION READ ONLYв СУБД. - Read Replica Routing:
AbstractRoutingDataSourceпереключает чтение на слейвы по флагуTransactionSynchronizationManager.isCurrentTransactionReadOnly(). - Обязательный компонент:
LazyConnectionDataSourceProxyнеобходим для корректного роутинга до захвата соединения. - Мутации в памяти: Сеттеры сущностей в
readOnlyне бросают исключений, но изменения не попадут в БД, пока не вызван явныйflush().
Красные флаги (чего ни в коем случае нельзя говорить)
- ❌ «readOnly = true гарантирует неизменяемость (immutability) Java-объектов» (Сеттеры работают в памяти JVM, просто изменения не сохраняются в БД).
- ❌ «readOnly = true автоматически перенаправляет запросы на реплику без дополнительных настроек» (Требуется ручная настройка
AbstractRoutingDataSource). - ❌ «Можно спокойно вызывать пишущие методы из сервиса с readOnly = true» (Запись упадет с ошибкой СУБД или TransactionRequiredException).