💳 Розділ 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 автоматично перенаправляються на репліки читання БД, розвантажуючи master-вузол.
  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): Для критичних сценаріїв одразу після запису в 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) для автоматичного роутингу запитів на читаючі репліки БД».

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

  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).

Посилання на пов’язані теми