Hibernate не сохраняет изменения в базу немедленно — он накапливает их внутри сессии и отправляет в нужный момент. Разобраться, когда именно это происходит и как защититься от одновременных изменений, — это и есть тема статьи.
Пока идёт транзакция, изменение живёт только в памяти сессии. В базу оно уходит на сбросе (flush) — и уходит не голым UPDATE, а с условием по версии. Первому сбросу условие подходит, версия становится двойкой; второму — уже нет, база отвечает «затронуто 0 строк», и Hibernate превращает этот ноль в OptimisticLockException.
Что такое граница транзакции и почему она важна
Когда вы вызываете 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
Метод меняет статус заказа, следующей строкой делает запрос по той же таблице, и в логе SQL посреди метода появляется UPDATE, которого никто не вызывал. Это FlushMode.AUTO, режим по умолчанию, и он сбрасывает изменения в базу в двух случаях:
- Перед коммитом транзакции.
- Перед выполнением JPQL/SQL-запроса, если Hibernate обнаруживает, что «грязные» (изменённые) сущности могут повлиять на результат запроса.
Второй случай и есть тот UPDATE посреди метода:
@Transactional
public List<Order> updateAndSearch(long orderId, OrderStatus 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 | перед каждым запросом (режим Hibernate, не JPA) |
MANUAL | только явный вызов flush() (режим Hibernate, не JPA) |
COMMIT подходит транзакции, где много запросов на чтение, а изменения — только в конце.
После 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.
Ту же проверку можно посмотреть без базы: строка лежит в обычной Map, а «обновление» проходит только при совпадении версии.
живой пример
import java.util.HashMap;
import java.util.Map;
public class VersionCheck {
static final Map<String, Integer> row = new HashMap<>(Map.of("quantity", 10, "version", 1));
static int update(int quantity, int expectedVersion) {
if (row.get("version") != expectedVersion) {
return 0;
}
row.put("quantity", quantity);
row.put("version", expectedVersion + 1);
return 1;
}
static void show(String who, int rows) {
System.out.println(who + ": затронуто строк " + rows + ", в базе " + row);
}
public static void main(String[] args) {
int quantityA = row.get("quantity"), versionA = row.get("version");
int quantityB = row.get("quantity"), versionB = row.get("version");
show("A", update(quantityA - 1, versionA));
show("B", update(quantityB - 1, versionB));
show("B после перечитывания", update(row.get("quantity") - 1, row.get("version")));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Второй вызов возвращает ноль затронутых строк — это и есть конфликт версий; повтор после перечитывания проходит.
Здесь легко расставить try/catch не там, где надо. Изменение сущности само по себе в базу ничего не пишет: UPDATE с проверкой версии уходит при сбросе. Ни запроса, ни явного flush() в методе ниже нет, поэтому сброс случится только на фиксации транзакции — уже после выхода из метода с @Transactional, и блок catch внутри него не сработает.
Ловить конфликт нужно снаружи транзакции:
@Transactional
public void decreaseQuantity(long productId, int delta) {
Product product = entityManager.find(Product.class, productId);
product.setQuantity(product.getQuantity() - delta);
} // <-- здесь уходит UPDATE и проверяется версия
// вызывающий код: повтор снаружи транзакции
@Retryable(retryFor = ObjectOptimisticLockingFailureException.class, maxAttempts = 3)
public void decreaseQuantityWithRetry(long productId, int delta) {
inventory.decreaseQuantity(productId, delta);
}
Spring оборачивает OptimisticLockException из JPA в своё ObjectOptimisticLockingFailureException — ловить в приложении нужно именно его.
У самой @Retryable есть два условия, без которых аннотация превращается в комментарий. Первое: в конфигурации должен стоять @EnableRetry, иначе прокси не создастся и повторов не будет. Второе: метод надо вызывать снаружи бина. Вызов decreaseQuantityWithRetry(...) из соседнего метода того же класса идёт мимо прокси — это самый частый способ промахнуться: аннотация на месте, повторов нет, конфликт улетает наружу.
Отдельно стоит сказать, что для счётчика остатка вся эта конструкция избыточна. Версия с повторами нужна там, где решение принимают по прочитанному значению. А уменьшить число на единицу база умеет сама, одним запросом и без чтения:
UPDATE product SET quantity = quantity - :n WHERE id = :id AND quantity >= :n
Ноль затронутых строк здесь и означает «остатка не хватило», и никакие повторы не нужны. На горячем товаре разница заметная: @Version с повторами выстроит параллельные попытки в очередь, где каждая следующая перечитывает строку и пробует заново.
Версия на отсоединённой сущности: главный случай @Version
Всё, что описано выше, происходит внутри одной транзакции — и там потерянное обновление случается редко, потому что окно между чтением и записью крошечное. Настоящий случай другой: форма открыта у человека.
Заказ отдали клиенту, тот десять минут думал, поправил и нажал «сохранить». За это время строку успел изменить кто-то другой — оператор поддержки, ночное задание, второй пользователь. Если просто применить пришедшие поля, чужая правка исчезнет молча, и никто об этом не узнает.
@Version закрывает это ровно тогда, когда версия ездит вместе с объектом: уходит клиенту (скрытое поле формы или поле в ответе), возвращается обратно и попадает в сущность перед сохранением.
@Transactional
public void update(long id, OrderForm form) {
Order order = orders.findById(id).orElseThrow();
if (order.getVersion() != form.version()) {
throw new StaleDataException("заказ изменили, пока вы редактировали");
}
order.apply(form);
}
Проверка руками здесь нужна не всегда: если объект собирают через merge с проставленной версией, Hibernate сам сравнит её при UPDATE и бросит OptimisticLockException. Ручная проверка удобна тем, что ошибка приходит до изменений и её проще превратить в понятный ответ клиенту: «данные изменились, обновите страницу и проверьте».
Чего делать нельзя — брать версию из свежезагруженного объекта (тогда проверка ничего не проверяет) и хранить её на сервере в сессии (тогда две вкладки одного пользователя ломают друг друга).
Что происходит после конфликта
Тонкость, из-за которой обработка конфликтов часто написана неправильно: после OptimisticLockException транзакцию нельзя продолжать. Исключение бросается на flush, то есть часть изменений уже отправлена в базу; Hibernate помечает контекст как непригодный, и любая следующая операция в нём даст IllegalStateException или ту же ошибку снова. Транзакция помечена «только на откат», и COMMIT сработает как откат.
Поэтому обработка выглядит так: транзакцию завершают (откатывают), создают новую и в ней перезагружают данные. Ловить исключение внутри того же @Transactional-метода и пытаться там что-то исправить бессмысленно — это тот же случай, что UnexpectedRollbackException из статьи про @Transactional.
Политика повторов
Раз конфликт — штатная ситуация, у неё должна быть политика, а не случайный catch.
Сколько раз. Три попытки закрывают почти все реальные случаи. Больше пяти означает, что конкуренция за строку такая, что оптимистичная схема не подходит.
С какой паузой. Нарастающая с разбросом: 50 мс, 100, 200, каждая умноженная на случайный множитель. Без разброса конкуренты отступают синхронно и сталкиваются снова.
Что повторять. Всю операцию целиком, снаружи транзакции — то есть повтор навешивают на метод, который открывает транзакцию, а не внутрь него. В Spring это @Retryable на внешнем методе плюс @Transactional на внутреннем, и никогда наоборот.
Что нельзя повторять. Операцию с внешними эффектами: письмо, платёж, сообщение в брокер. Откат базы их не отменяет, и повтор отправит второй раз. Внешние вызовы выносят за границу транзакции и делают идемпотентными.
Когда конфликт повторяется всегда. Это горячая строка: общий счётчик, остаток популярного товара, баланс. Оптимистичная схема здесь вырождается в поток бесполезных повторов, и честнее взять пессимистичную блокировку или вообще убрать общую строку — считать инкрементом в самой базе (UPDATE … SET qty = qty - 1 WHERE qty > 0), а не читать-считать-писать.
@Retryable(retryFor = OptimisticLockingFailureException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 50, multiplier = 2, random = true))
public void reserve(long orderId) {
reservationService.reserveInTransaction(orderId); // отдельный бин с @Transactional
}
Пессимистичная блокировка: 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 на уровне базы данных. Любая другая транзакция, которая попытается заблокировать ту же строку, будет ждать.
Вторая транзакция не падает и не работает, а стоит в очереди столько, сколько держит строку первая, и без выставленного lock_timeout это ожидание ничем не ограничено.
И вот чего в этой фразе не хватает: ждать она будет столько, сколько понадобится. По умолчанию ожидание бесконечное — в PostgreSQL lock_timeout выключен, и одна застрявшая транзакция держит за собой всю очередь, пока кто-нибудь не вмешается руками. Именно так пессимистичная блокировка и кладёт прод. Границу ставят подсказкой jakarta.persistence.lock.timeout — значение в миллисекундах; 0 означает «не ждать вовсе» (в SQL это NOWAIT): лучше сразу получить ошибку, чем встать в очередь неизвестной длины.
Доступные варианты:
| LockModeType | Что делает |
|---|---|
PESSIMISTIC_READ | SELECT ... FOR SHARE — другие могут читать, но не писать |
PESSIMISTIC_WRITE | SELECT ... FOR UPDATE — исключительная блокировка |
PESSIMISTIC_FORCE_INCREMENT | сразу шлёт отдельный UPDATE поля @Version — блокировка берётся этим увеличением, FOR UPDATE тут нет; сущность без @Version этот режим не примет |
OPTIMISTIC | проверит @Version в конце транзакции даже у сущности, которую вы только читали |
OPTIMISTIC_FORCE_INCREMENT | поднимет @Version у корня, когда изменилась дочерняя строка: заказ «постарел» оттого, что поменялась его позиция |
Правый столбец — про PostgreSQL. В SQL режим переводит диалект, и в другой базе тот же PESSIMISTIC_READ превратится в другой синтаксис.
Как PostgreSQL реализует эти блокировки на уровне MVCC — в статье о блокировках PostgreSQL.
Тупики и порядок захвата
Следующий вопрос после «кто ждёт» — что будет, если ждут друг друга. Две транзакции взяли по одной строке и просят вторую: первая держит заказ A и просит B, вторая держит B и просит A. Это взаимная блокировка, и база её разрешает сама — убивает одну из транзакций с ошибкой deadlock detected (SQLSTATE 40P01), которая в Spring приезжает как CannotAcquireLockException.
Лечится это не настройкой, а порядком. Строки захватывают в одном и том же порядке во всех сценариях — обычно по возрастанию идентификатора:
List<Long> ids = List.of(fromId, toId).stream().sorted().toList();
for (long id : ids) {
em.find(Account.class, id, LockModeType.PESSIMISTIC_WRITE);
}
Если в одном месте кода переводят деньги «со счёта на счёт», а в другом «на счёт со счёта», тупик неизбежен под нагрузкой и невоспроизводим на стенде. Сортировка снимает его полностью.
Три практических добавления. lock_timeout (или jakarta.persistence.lock.timeout) ограничивает ожидание, чтобы вместо тупика получить понятную ошибку. Сам тупик обычно редок, поэтому @Retryable на CannotAcquireLockException с парой попыток — законное лечение остатка. И чем короче транзакция, тем меньше шанс: тупики любят долгие транзакции с внешними вызовами внутри.
Изоляция — отдельная настройка
И последнее, что стоит развести. @Version защищает от потерянного обновления — того случая, когда две транзакции перезаписывают результат друг друга. От аномалий чтения он не защищает вовсе: неповторяемое чтение и фантомы — это про уровень изоляции, и он настраивается отдельно, атрибутом isolation у @Transactional или на стороне базы.
Практически в PostgreSQL на уровне по умолчанию (READ COMMITTED) оптимистичная блокировка работает как надо и большего обычно не требуется. Поднимать уровень стоит, когда правило проверяется по выборке строк, а не по одной строке («не больше пяти активных заказов на клиента») — там ни версия, ни блокировка одной строки не помогут. Полный разбор — в статьях про уровни изоляции и блокировки.
Оптимистичная vs пессимистичная: когда что выбрать
Оптимистичная (@Version) подходит, когда:
- Конфликты редки (большинство операций не пересекаются).
- Пользователь может увидеть сообщение «данные изменились, попробуйте снова» и это приемлемо.
- Нагрузка высокая — физические блокировки стали бы узким местом.
Пессимистичная (PESSIMISTIC_WRITE) подходит, когда:
- Конфликты часты и дорого стоят (финансовые операции, инвентарь).
- Нельзя допустить, чтобы два потока работали с одним объектом одновременно.
- Транзакции короткие — блокировка не будет удерживаться долго.
Смешивать оба подхода в одном приложении — нормально: разные сущности требуют разных стратегий.
Глубже: SKIP LOCKED, NOWAIT и lock_timeout из JPAрасширенное
Пессимистичная блокировка выше это SELECT ... FOR UPDATE, который ждёт. У PostgreSQL есть два варианта, которые не ждут, и оба доступны из JPA через подсказку таймаута:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "-2")) // SKIP LOCKED
@Query("select t from OutboxTask t where t.status = 'NEW' order by t.createdAt")
List<OutboxTask> nextBatch(Pageable page);
Значение -2 Hibernate переводит в FOR UPDATE SKIP LOCKED: строки, которые держит другая транзакция, пропускаются, и десять экземпляров сервиса разбирают одну таблицу задач, не мешая друг другу. Значение 0 даёт NOWAIT: если строка занята, запрос падает сразу с PessimisticLockException, и приложение решает, что делать, вместо того чтобы держать поток. Положительный таймаут в миллисекундах PostgreSQL в самом запросе не понимает; для него ставят lock_timeout на роль или соединение, и по истечении Hibernate поднимет LockTimeoutException.
Тупик, когда две транзакции ждут друг друга, PostgreSQL находит сам через секунду (deadlock_timeout) и убивает одну из них; в приложении это CannotAcquireLockException от Spring поверх deadlock detected. Лечится порядком: обе транзакции берут строки в одном и том же порядке, например по возрастанию идентификатора, и тогда круг не замкнётся. Повтор упавшей транзакции целиком, а не одной команды, второе лекарство. Механика самих блокировок, pg_locks и кто кого держит, разобрана в статье про блокировки в разделе PostgreSQL, эта статья лишь показывает, как до них дотянуться из JPA.
Коротко
- Hibernate не отправляет изменения в базу мгновенно — они накапливаются в persistence context и уходят при flush.
- По умолчанию flush происходит перед коммитом и перед JPQL-запросами, которые могут быть затронуты «грязными» сущностями (
FlushMode.AUTO). @Versionдобавляет поле-версию: Hibernate проверяет его при каждомUPDATEи выбрасываетOptimisticLockExceptionпри конфликте.LockModeType.PESSIMISTIC_WRITEблокирует строку черезSELECT FOR UPDATE— другие транзакции ждут.- Оптимистичная блокировка — для редких конфликтов и высокой нагрузки; пессимистичная — для частых конфликтов и критичных операций.
- Из JPA
SKIP LOCKEDэто подсказкаjakarta.persistence.lock.timeout = -2,NOWAITэто0; тупик база убивает сама, лечится единым порядком захвата и повтором транзакции. - Главный случай
@Version— форма у человека: версия уходит клиенту и возвращается в сохранение, иначе чужая правка теряется молча. - После
OptimisticLockExceptionтранзакцию продолжать нельзя: откат, новая транзакция, перезагрузка данных; повтор навешивают снаружи, с разбросом паузы и только для операций без внешних эффектов. - Тупики лечит одинаковый порядок захвата строк (по возрастанию идентификатора) плюс
lock_timeout;@Versionне заменяет уровень изоляции.
Что почитать дальше
- Persistence context и жизненный цикл сущности — как Hibernate отслеживает изменения внутри сессии.
- Кэширование в Hibernate — первый и второй уровень кэша, когда что использовать.
- Транзакции в Spring: @Transactional изнутри — как Spring управляет границами транзакций и что происходит при вложенных вызовах.
- Блокировки в PostgreSQL — MVCC, уровни изоляции и
SELECT FOR UPDATEна уровне базы данных.