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-heavy приложении вопрос “где поставить @Transactional?” — это не косметика, а часть архитектуры.
Чтобы не звучало слишком философски, давайте привяжем к нашему проекту Commerce Persistence Lab. В нём есть товар (Product), заказ (PurchaseOrder) и остатки (InventoryItem). Представьте бизнес-сценарий “переименовать товар”. Наивно кажется, что это просто: нашли товар, поменяли name, сохранили. Но вот вопросы, которые тут появляются сразу:
- Где живёт загруженный Product после findById()?
- Почему Hibernate вообще должен “заметить”, что мы поменяли name?
- Когда уйдёт UPDATE — сразу на сеттере, на save(), на flush() или на выходе из метода?
- Что будет, если в середине операции случится ошибка?
Ответы на эти вопросы и есть “что реально делает @Transactional вокруг Hibernate”.
2. Что делает Spring вокруг метода
Если вы раньше представляли транзакцию как “BEGIN…COMMIT в базе”, то это только половина истории. В Spring + Hibernate транзакция — это ещё и жизненный цикл persistence context: его надо создать (или привязать), дать ему прожить внутри операции и корректно закрыть в конце. И вот тут @Transactional становится главным дирижёром: он определяет, где начинается и где заканчивается ваш “рабочий такт”.
Ниже берём обычный happy-path: внешний вызов 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 "Service-метод (@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 увидит изменение при завершении unit of work и выполнит 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");
}
}
Что важно понять без углубления в тонкие правила: если метод падает с RuntimeException (а IllegalStateException как раз такой), 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 ещё “жив”), а снаружи — нет.
Вот честный и безопасный read-use-case (да, даже для чтения транзакция бывает полезной, но сегодня мы не углубляемся в 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.
В нашем baseline persistence context короткоживущий. Как только метод завершился, объект становится detached, и любые ожидания “Hibernate сейчас сам догрузит ещё пару полей” превращаются в риск LazyInitializationException или (хуже) в неявные попытки чинить это архитектурно неправильными костылями.
Ошибка №5: механически вызывать save() “на всякий случай”.
После темы dirty checking очень хочется просто запомнить: “меняю — не save’ю”. Но в реальном коде важнее другое: понимать состояние объекта. Если объект managed — обычно save() действительно лишний. Если detached — это уже другой разговор (и мы его уже обсуждали на дне про merge()), но привычка “save везде” почти всегда мешает увидеть настоящую картину происходящего.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ