Hibernate не сохраняет изменения в базу немедленно — он накапливает их внутри сессии и отправляет в нужный момент. Разобраться, когда именно это происходит и как защититься от одновременных изменений, — это и есть тема статьи.
Что такое граница транзакции и почему она важна
Когда вы вызываете entityManager.persist(entity) или меняете поле у уже загруженной сущности — в базу данных ещё ничего не уходит. Hibernate запоминает изменения в persistence context (он же «первый уровень кэша» — подробнее про него в статье о persistence context). В базу всё попадёт только при flush.
Транзакция — это граница, внутри которой Hibernate гарантирует, что все изменения будут отправлены вместе (или не отправлены совсем при откате). Типичная схема в Spring:
@Transactional
public void transferFunds(long fromId, long toId, BigDecimal amount) {
Account from = entityManager.find(Account.class, fromId);
Account to = entityManager.find(Account.class, toId);
from.debit(amount);
to.credit(amount);
// flush произойдёт автоматически перед коммитом
}
Spring открывает транзакцию на входе в метод и закрывает (коммит или откат) на выходе. Hibernate видит окончание транзакции и выполняет flush — отправляет накопленные UPDATE/INSERT в базу.
Когда именно происходит flush
По умолчанию Hibernate работает в режиме FlushMode.AUTO. Это означает:
- Перед коммитом транзакции.
- Перед выполнением JPQL/SQL-запроса, если Hibernate обнаруживает, что «грязные» (изменённые) сущности могут повлиять на результат запроса.
Второй пункт — частый источник удивления. Вот пример:
@Transactional
public List<Order> updateAndSearch(long orderId, String newStatus) {
Order order = entityManager.find(Order.class, orderId);
order.setStatus(newStatus); // сущность стала «грязной»
// Hibernate сделает flush ПЕРЕД этим запросом,
// потому что запрос обращается к таблице Orders
return entityManager.createQuery(
"SELECT o FROM Order o WHERE o.status = :s", Order.class)
.setParameter("s", newStatus)
.getResultList();
}
Hibernate понимает, что незакоммиченное изменение order.status влияет на результат запроса, и выполняет flush заранее. Это полезно, но иногда приводит к неожиданным UPDATE в середине метода.
Другие доступные режимы:
| FlushMode | Когда flush |
|---|---|
AUTO (по умолчанию) | перед коммитом и перед запросами (если нужно) |
COMMIT | только перед коммитом |
ALWAYS | перед каждым запросом |
MANUAL | только явный вызов flush() |
COMMIT иногда используют для оптимизации в сценариях, где в одной транзакции выполняется много запросов на чтение, а изменения — только в конце.
Оптимистичная блокировка через @Version
Представьте: двое пользователей одновременно открыли карточку товара, оба видят количество = 10, оба уменьшают его на 1 и сохраняют. В итоге в базе 9, хотя должно быть 8. Это «потерянное обновление» (lost update).
Оптимистичная блокировка решает эту проблему без физической блокировки строки в базе. Идея: добавить к сущности поле-версию, которое Hibernate автоматически проверяет и увеличивает при каждом обновлении.
@Entity
public class Product {
@Id
@GeneratedValue
private Long id;
private String name;
private int quantity;
@Version
private int version; // Hibernate управляет этим полем сам
}
Когда Hibernate отправляет UPDATE, он добавляет в условие текущую версию:
UPDATE product
SET quantity = 9, version = 2
WHERE id = 1 AND version = 1;
Если другой поток уже успел обновить запись (версия в базе уже 2), WHERE-условие не найдёт строку — UPDATE затронет 0 строк. Hibernate это заметит и выбросит OptimisticLockException.
Обрабатывать это исключение нужно явно — обычно повторной попыткой или сообщением пользователю о конфликте:
@Transactional
public void decreaseQuantity(long productId, int delta) {
try {
Product product = entityManager.find(Product.class, productId);
product.setQuantity(product.getQuantity() - delta);
} catch (OptimisticLockException e) {
// конфликт версий — другой поток успел раньше
throw new ConcurrentModificationException("Товар был изменён параллельно", e);
}
}
Короткая формула: @Version — это «проверить перед записью, а не блокировать заранее».
Пессимистичная блокировка: SELECT FOR UPDATE
Иногда оптимистичная блокировка не подходит. Например, вы списываете деньги со счёта и не хотите работать со значением, которое кто-то может изменить прямо сейчас. В таком случае используют пессимистичную блокировку — строка в базе физически блокируется на время транзакции.
В JPA это делается через LockModeType:
@Transactional
public void reserveFunds(long accountId, BigDecimal amount) {
Account account = entityManager.find(
Account.class,
accountId,
LockModeType.PESSIMISTIC_WRITE // → SELECT ... FOR UPDATE
);
if (account.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException();
}
account.setBalance(account.getBalance().subtract(amount));
}
Hibernate переводит PESSIMISTIC_WRITE в SELECT ... FOR UPDATE на уровне базы данных. Любая другая транзакция, которая попытается заблокировать ту же строку, будет ждать.
Доступные варианты:
| LockModeType | Что делает |
|---|---|
PESSIMISTIC_READ | SELECT ... FOR SHARE — другие могут читать, но не писать |
PESSIMISTIC_WRITE | SELECT ... FOR UPDATE — исключительная блокировка |
PESSIMISTIC_FORCE_INCREMENT | FOR UPDATE + принудительно увеличивает @Version |
Детали того, как PostgreSQL реализует эти блокировки на уровне MVCC и что происходит при конкурентном доступе, разобраны в статье о блокировках PostgreSQL.
Оптимистичная vs пессимистичная: когда что выбрать
Оптимистичная (@Version) подходит, когда:
- Конфликты редки (большинство операций не пересекаются).
- Пользователь может увидеть сообщение «данные изменились, попробуйте снова» и это приемлемо.
- Нагрузка высокая — физические блокировки стали бы узким местом.
Пессимистичная (PESSIMISTIC_WRITE) подходит, когда:
- Конфликты часты и дорого стоят (финансовые операции, инвентарь).
- Нельзя допустить, чтобы два потока работали с одним объектом одновременно.
- Транзакции короткие — блокировка не будет удерживаться долго.
Смешивать оба подхода в одном приложении — нормально: разные сущности требуют разных стратегий.
Коротко
- Hibernate не отправляет изменения в базу мгновенно — они накапливаются в persistence context и уходят при flush.
- По умолчанию flush происходит перед коммитом и перед JPQL-запросами, которые могут быть затронуты «грязными» сущностями (
FlushMode.AUTO). @Versionдобавляет поле-версию: Hibernate проверяет его при каждомUPDATEи выбрасываетOptimisticLockExceptionпри конфликте.LockModeType.PESSIMISTIC_WRITEблокирует строку черезSELECT FOR UPDATE— другие транзакции ждут.- Оптимистичная блокировка — для редких конфликтов и высокой нагрузки; пессимистичная — для частых конфликтов и критичных операций.
Что почитать дальше
- Persistence context и жизненный цикл сущности — как Hibernate отслеживает изменения внутри сессии.
- Кэширование в Hibernate — первый и второй уровень кэша, когда что использовать.
- Транзакции в Spring: @Transactional изнутри — как Spring управляет границами транзакций и что происходит при вложенных вызовах.
- Блокировки в PostgreSQL — MVCC, уровни изоляции и
SELECT FOR UPDATEна уровне базы данных.