JavaRush /Курси /Hibernate deep-dive /@Transactional, Sessi...

@Transactional, Session і flush

Hibernate deep-dive
Рівень 17 , Лекція 0
Відкрита

1. Роль @Transactional

Managed- і detached-сутності, dirty checking, flush, lazy loading і mapping-рішення вже показали нам внутрішню механіку Hibernate. Тепер потрібен наступний фрагмент картини: де цей persistence context взагалі починається й закінчується у звичайному Spring-застосунку. Саме від цієї межі залежить, чи побачить Hibernate ваші зміни, коли спрацює flush, і чому одна й та сама сутність в одному місці ще managed, а в іншому вже ні.

Якщо сказати по-людськи, @Transactional — це не «анотація збереження», а межа однієї завершеної операції. У межах цієї межі Spring і Hibernate домовляються: «ось тут у нас один unit of work, один persistence context, і ми разом доведемо справу до кінця». Саме тому в застосунку з інтенсивним використанням Hibernate питання «де поставити @Transactional?» — це не косметика, а частина архітектури.

Щоб це не звучало занадто філософськи, привʼяжімо це до нашого проєкту Commerce Persistence Lab. У ньому є товар (Product), замовлення (PurchaseOrder) і залишки (InventoryItem). Уявіть бізнес-сценарій «перейменувати товар». На перший погляд усе просто: знайшли товар, змінили name, зберегли. Але одразу виникають такі запитання:

  1. Де живе завантажений Product після findById()?
  2. Чому Hibernate взагалі має «помітити», що ми змінили name?
  3. Коли піде UPDATE — одразу на сетері, на save(), на flush() чи на виході з методу?
  4. Що буде, якщо посередині операції станеться помилка?

Відповіді на ці запитання і є тим, що насправді робить @Transactional навколо Hibernate.

2. Що робить Spring навколо методу

Якщо ви раніше уявляли транзакцію як «BEGINCOMMIT у базі», то це лише половина історії. У Spring + Hibernate транзакція — це ще й життєвий цикл persistence context: його треба створити (або привʼязати), дати йому прожити всередині операції та коректно закрити наприкінці. І ось тут @Transactional стає головним диригентом: він визначає, де починається й де закінчується ваш «робочий такт».

Нижче беремо звичайний успішний сценарій: зовнішній виклик public-методу Spring bean-а через Spring proxy. Саме на такій межі Spring може підняти транзакцію і привʼязати до потоку JPA/Hibernate-контекст.

Нам не потрібно зараз занурюватися в деталі Spring AOP і внутрішні класи — достатньо інженерної моделі: перед входом у метод Spring відкриває транзакцію і привʼязує до поточного потоку контекст JPA/Hibernate, усередині методу репозиторії працюють з одним і тим самим контекстом, на виході відбувається flush (якщо потрібно), а потім commit або rollback, після чого контекст закривається. Якщо тримати в голові саме цю послідовність, половина «дивностей Hibernate» раптово перестає бути дивностями.

Нижче — схема, яку зручно подумки прокручувати щоразу, коли ви бачите @Transactional:

sequenceDiagram
    participant C as "Зовнішній виклик (наприклад, controller/test)"
    participant S as "Сервісний метод (@Transactional)"
    participant EM as "EntityManager / Hibernate Session"
    participant DB as PostgreSQL

    C->>S: виклик методу
    S->>DB: "BEGIN (відкриття транзакції)"
    S->>EM: створити/прив'язати persistence context
    S->>EM: "виконання коду методу (find/mutate/query)"
    EM->>DB: "SQL іде під час flush (AUTO/ручного/перед commit)"
    S->>DB: "COMMIT (або ROLLBACK)"
    S->>EM: закрити persistence context
    S-->>C: повернути результат / кинути виняток

Давайте ще зафіксуємо це в більш «табличному» вигляді, щоб мозку було простіше.

Фаза Що ви бачите в коді Що насправді відбувається
До входу просто викликаєте метод транзакції ще немає (у межах цього виклику)
Вхід у @Transactional нічого «особливого» Spring починає транзакцію, готує JPA-контекст
Усередині методу find(), сетері, виклики репозиторіїв сутності стають managed, зміни накопичуються в persistence context
Вихід «успішно» метод завершився Hibernate робить flush (якщо потрібно), потім Spring робить commit
Вихід «з помилкою» вилетів виняток Spring робить rollback, контекст закривається

Ключовий момент: усередині транзакційного методу живе один persistence context, і це прямо впливає на dirty checking, кеш першого рівня, flush і lazy loading.

3. Один @Transactional — один persistence context

Коли кажуть «у транзакції один persistence context», це звучить так, ніби з підручника. Але насправді це дуже практична річ: ви раптово отримуєте гарантію, що всередині операції Hibernate поводиться як нормальний менеджер обʼєктів. Він не створює нові копії однієї й тієї самої сутності без причини, не забуває, що вже завантажив, і може правильно порівняти «як було» і «як стало». І саме це є фундаментом для dirty checking, а не якась «магія save».

Щоб побачити це руками, візьмемо найпростіший приклад з нашого проєкту: двічі читаємо один і той самий Product за id у межах одного сервісного методу.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogService {

    private final ProductRepository productRepository;

    public CatalogService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public void loadSameProductTwice(Long productId) {
        // Обидва читання виконуються в межах однієї транзакції та одного persistence context
        Product p1 = productRepository.findById(productId).orElseThrow();

        // Другий findById за тим самим id поверне той самий managed-об'єкт із L1-кешу
        Product p2 = productRepository.findById(productId).orElseThrow();

        // Важливо: == порівнює посилання (тобто чи це той самий об'єкт у пам'яті)
        System.out.println(p1 == p2); // true
    }
}

Що тут важливо помітити. У Java оператор == порівнює посилання, тобто «це той самий обʼєкт у памʼяті?». І в межах однієї транзакції відповідь буде true, тому що persistence context працює як identity map: один рядок таблиці → один managed-обʼєкт.

Це не просто «круто». Це означає, що Hibernate може безпечно робити такі речі:

Він бере snapshot стану p1, потім ви змінюєте p1.setName(...), і наприкінці операції Hibernate порівнює snapshot і поточний стан саме того самого обʼєкта. Якби у вас раптом виявилися два різні обʼєкти «про один і той самий товар», усе стало б набагато менш передбачуваним: які зміни вважати справжніми, який обʼєкт оновлювати, як не втратити частину змін.

І ось тут можна зробити дуже корисний висновок: @Transactional — це спосіб сказати Hibernate «тримай контекст цілком на операцію, а не на один випадковий виклик репозиторію».

4. Dirty checking: save() не обовʼязковий

Після теми dirty checking багато хто починає підозрювати Hibernate в телепатії. «Я ж не викликав save(), чому він відправив UPDATE?» — звучить як початок хорошої історії біля вогнища, але насправді це просто наслідок того, що сутність була managed усередині persistence context, а транзакція дала Hibernate можливість довести unit of work до фіналу.

Візьмемо приклад «перейменувати товар» — майже те, що у нас у day-plan.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogService {

    private final ProductRepository productRepository;

    public CatalogService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public void renameProduct(Long productId, String newName) {
        // Після findById сутність стає managed у поточному persistence context
        Product product = productRepository.findById(productId).orElseThrow();

        // Змінюємо поле — Hibernate запам'ятає зміну завдяки dirty checking
        product.setName(newName);

        // save() не потрібен, якщо сутність managed: UPDATE піде під час flush/commit
    }
}

Тут немає productRepository.save(product). І часто новачка це лякає: «А воно збережеться?» Так, якщо Product — managed (а всередині @Transactional він стає managed після findById()), то Hibernate побачить зміну під час завершення операції і виконає UPDATE.

У SQL-лозі це зазвичай виглядає приблизно так (спрощено):

-- Спочатку Hibernate читає рядок і створює managed-сутність
select p.id, p.name, p.status
from product p
where p.id = ?;

-- Потім (зазвичай на flush перед commit) надсилає UPDATE за накопиченими змінами
update product
set name = ?
where id = ?;

Зверніть увагу на важливу думку: UPDATE не зобовʼязаний піти одразу після setName(). Він піде тоді, коли Hibernate вирішить синхронізуватися з БД — найчастіше на flush перед commit. І саме транзакція дає Hibernate момент «закриття операції», у який він може спокійно виконати цю синхронізацію.

Якщо ваш мозок просить аналогію, то це як робота в текстовому редакторі: ви друкуєте текст (змінюєте обʼєкт), але файл на диск не зобовʼязаний записуватися після кожного натискання клавіші. Він буде збережений або коли ви натиснете «Зберегти», або коли редактор сам вирішить зробити автозбереження, або коли ви закриєте документ. У Hibernate @Transactional — це «документ», а flush — це «момент збереження чернетки у файл», причому ще не обовʼязково фінального.

5. flush усередині транзакції

Сам flush нам уже знайомий, але зараз важливо повʼязати його з транзакцією саме на рівні життєвого циклу. Найчастіша проблема тут у термінології: люди кажуть «зафлашилося» й мають на увазі «збереглося назавжди». А потім дивуються, що після помилки даних немає в базі. Отже: flush — це відправка SQL у базу, а commit — це закріплення транзакції.

Покажемо це на короткому прикладі: ми змінюємо імʼя товару та явно викликаємо flush().

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogService {

    private final ProductRepository productRepository;
    private final EntityManager entityManager;

    public CatalogService(ProductRepository productRepository, EntityManager entityManager) {
        this.productRepository = productRepository;
        this.entityManager = entityManager;
    }

    @Transactional
    public void renameAndFlush(Long productId, String newName) {
        // Сутність буде managed у межах поточної транзакції
        Product product = productRepository.findById(productId).orElseThrow();
        product.setName(newName);

        // flush = "надішли SQL зараз", але НЕ "зафіксуй назавжди"
        entityManager.flush(); // SQL піде в БД просто зараз

        // Важливо: commit/rollback буде пізніше — на виході з @Transactional
    }
}

Що тут станеться. Hibernate виконає dirty checking і відправить UPDATE у БД ще до завершення методу. У SQL-лозі ви це побачите одразу: update product set name=? ... зʼявиться посеред виконання, а не «десь потім».

Але ось важлива частина: транзакція ще не завершена. Якщо після flush() станеться помилка і буде rollback, то «вже відправлений SQL» не стане постійною зміною даних. Він був виконаний у межах транзакції — а транзакцію можна відкотити.

Навіщо взагалі потрібен ручний flush()? У реальному житті — не тому, що «так надійніше», а тому, що іноді вам важливо, щоб БД уже бачила зміни всередині поточної транзакції (наприклад, щоб наступний запит у межах цього ж методу працював з актуальним станом і не суперечив вашій логіці). Але важливо памʼятати: flush — це інструмент синхронізації, а не «поставити печатку на документ».

6. Rollback: база vs памʼять

Слово rollback часто звучить так, ніби це кнопка «скасувати все, ніби нічого й не було». І в базі даних це майже так і працює (спрощено): транзакцію відкочено — зміни не зафіксовано. Але в Java-обʼєктах усе не так казково: якщо ви встигли змінити поля обʼєкта в памʼяті, вони не повернуться до початкового стану автоматично лише тому, що транзакцію відкочено.

Це дуже важливий момент для Hibernate-мислення: стан бази й стан ваших обʼєктів — різні речі, які синхронізуються через flush, але не перетворюються на одну й ту саму «матерію».

Подивімося на приклад з аварією:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogService {

    private final ProductRepository productRepository;

    public CatalogService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public void renameThenFail(Long productId) {
        // Сутність managed, зміни накопичуються в persistence context
        Product product = productRepository.findById(productId).orElseThrow();
        product.setName("Broken name");

        // RuntimeException зазвичай позначає транзакцію як rollback-only
        throw new IllegalStateException("stop");
    }
}

Що важливо зрозуміти без занурення в тонкі правила: якщо метод падає з RuntimeExceptionIllegalStateException якраз така), Spring зазвичай позначає транзакцію як rollback-only і робить ROLLBACK. У БД зміни не збережуться.

Але обʼєкт product у памʼяті вже змінив name. Він не стане назад «правильним» автоматично. Ба більше: після виходу з транзакції persistence context буде закрито, і цей обʼєкт фактично стане detached. Тобто ви можете тримати посилання на нього (наприклад, зловити виняток зовні й десь його залогувати), але Hibernate вже не керує цим обʼєктом.

Це місце, де багато хто вперше стикається з думкою: транзакція — це не машина часу для Java-памʼяті. Транзакція відповідає за узгодженість запису в БД. А ваші обʼєкти в памʼяті — це ваші обʼєкти. Якщо ви хочете «скинути» їхній стан, це окрема задача (і найчастіше правильніше взагалі не тягнути такі обʼєкти назовні з сервісу).

7. @Transactional і lazy loading

Ми вже бачили причину LazyInitializationException: ви намагаєтеся ініціалізувати LAZY-звʼязок, коли persistence context закритий. Сьогодні важливо побачити, що @Transactional — це якраз той механізм, який створює «вікно часу», коли lazy loading є безпечним і передбачуваним.

Уявімо, що в PurchaseOrder позиції (items) завантажуються ліниво. Тоді всередині транзакції ми можемо звернутися до items (тому що Hibernate ще «живий»), а зовні — ні.

Ось чесний і безпечний сценарій читання (так, навіть для читання транзакція буває корисною, але сьогодні ми не заглиблюємося в readOnly=true — просто тримаємо приклад простим):

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderQueryService {

    private final PurchaseOrderRepository orderRepository;

    public OrderQueryService(PurchaseOrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional
    public int countItems(Long orderId) {
        // Замовлення буде managed, а LAZY-колекції можна дозавантажувати, поки контекст відкритий
        PurchaseOrder order = orderRepository.findById(orderId).orElseThrow();

        // У цей момент Hibernate може виконати додатковий SELECT для items
        return order.getItems().size();
    }
}

order.getItems().size() виглядає безпечно, але ми вже знаємо: це може бути тригером ініціалізації persistent collection, а отже — SQL-запиту. І саме @Transactional робить цей запит можливим: сесію відкрито, контекст активний, Hibernate може сходити в БД і дозавантажити елементи.

Цей звʼязок транзакційної межі з lazy loading — одна з причин, чому в курсі так наполягаємо на open-in-view=false. Ми хочемо, щоб «безпечна зона» була там, де їй місце: у сервісній операції, а не десь у представленні, логуванні чи випадковому toString().

8. Типові помилки під час використання @Transactional

Помилка №1: сприймати @Transactional як «автозбереження».
Дуже поширена ментальна модель: «якщо повісив @Transactional, значить Hibernate сам збереже все, що я хочу». На практиці Hibernate зберігає не «те, що ви хочете», а те, що ви реально змінили в managed-сутностях усередині persistence context. Тому корисна звичка — думати не «збереже / не збереже», а «які сутності managed, які поля реально змінилися, коли буде flush».

Помилка №2: плутати flush і commit.
У розмовній мові розробники іноді кажуть: «у нас усе флашиться на виході, значить коміт». Це небезпечно: flush() може відбутися до кінця методу (наприклад, вручну або перед запитом), але транзакцію все ще можна відкотити. Якщо ви в голові ставите знак рівності flush == commit, ви неминуче почнете неправильно інтерпретувати SQL-лог і поведінку під час помилок.

Помилка №3: розраховувати, що rollback «відкотить» Java-обʼєкти.
Після rollback база не змінюється — це добре. Але обʼєкт у памʼяті залишається зміненим — і це нормально, просто це інший рівень реальності. Коли розробник цього не очікує, зʼявляються дивні баги: «чому в логах обʼєкт уже зі статусом CONFIRMED, хоча транзакцію відкочено?». Відповідь: тому що ви змінили поле, а rollback не зобовʼязаний переписувати вашу памʼять.

Помилка №4: продовжувати використовувати сутність після виходу з транзакції так, ніби вона все ще managed.
У нашому базовому варіанті persistence context короткоживучий. Щойно метод завершився, обʼєкт стає detached, і будь-які очікування «Hibernate зараз сам дозавантажить ще пару полів» перетворюються на ризик LazyInitializationException або, ще гірше, на неявні спроби виправити це архітектурно невдалими костилями.

Помилка №5: механічно викликати save() «про всяк випадок».
Після теми dirty checking дуже хочеться просто запамʼятати: «змінюю — отже, не викликаю save()». Але в реальному коді важливіше інше: розуміти стан обʼєкта. Якщо обʼєкт managed — зазвичай save() справді зайвий. Якщо detached — це вже інша розмова (і ми її вже розглядали в темі про merge()), але звичка «save всюди» майже завжди заважає побачити справжню картину того, що відбувається.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ