Що таке 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автоматично перенаправляються на репліки читання БД, розвантажуючи master-вузол. - Захист від випадкового запису: Якщо розробник випадково викличе модифікуючий 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): Для критичних сценаріїв одразу після запису в cookie або сесію користувача виставляється часова мітка
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 та копіювання знімків сутностей у пам’яті 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).