← назад к разделу

Hibernate не сохраняет изменения в базу немедленно — он накапливает их внутри сессии и отправляет в нужный момент. Разобраться, когда именно это происходит и как защититься от одновременных изменений, — это и есть тема статьи.

сессия A в базе сессия B 1. обе сессии прочитали строку — у каждой свой снимок в памятиquantity = 10 · version = 1quantity = 10 · version = 1quantity = 10 · version = 1 2. обе уменьшили количество: изменение в памяти, сброса ещё не былоquantity = 9 · version = 1 — грязнаяquantity = 10 · version = 1quantity = 9 · version = 1 — грязная 3. фиксация A: сброс отправляет UPDATE с проверкой версиисброс измененийUPDATE product SET quantity = 9, version = 2WHERE id = 1 AND version = 1 → 1 строкаquantity = 9 · version = 2quantity = 9 · version = 1 — грязная 4. фиксация B: тот же UPDATE не находит строку — конфликт версийзафиксированаquantity = 9 · version = 2WHERE id = 1 AND version = 1 → 0 строкHibernate бросает OptimisticLockExceptionсброс изменений

Пока идёт транзакция, изменение живёт только в памяти сессии. В базу оно уходит на сбросе (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, режим по умолчанию, и он сбрасывает изменения в базу в двух случаях:

  1. Перед коммитом транзакции.
  2. Перед выполнением 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 подходит транзакции, где много запросов на чтение, а изменения — только в конце.

в памяти в базе, без commit видно всем flush commit как не было rollback

После 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 на уровне базы данных. Любая другая транзакция, которая попытается заблокировать ту же строку, будет ждать.

0 мс A берёт строку for update 5 мс B просит ту же строку 5 мс и дальше B ждёт, а не падает commit A B получает строку lock_timeout B падает с ошибкой

Вторая транзакция не падает и не работает, а стоит в очереди столько, сколько держит строку первая, и без выставленного lock_timeout это ожидание ничем не ограничено.

И вот чего в этой фразе не хватает: ждать она будет столько, сколько понадобится. По умолчанию ожидание бесконечное — в PostgreSQL lock_timeout выключен, и одна застрявшая транзакция держит за собой всю очередь, пока кто-нибудь не вмешается руками. Именно так пессимистичная блокировка и кладёт прод. Границу ставят подсказкой jakarta.persistence.lock.timeout — значение в миллисекундах; 0 означает «не ждать вовсе» (в SQL это NOWAIT): лучше сразу получить ошибку, чем встать в очередь неизвестной длины.

Доступные варианты:

LockModeTypeЧто делает
PESSIMISTIC_READSELECT ... FOR SHARE — другие могут читать, но не писать
PESSIMISTIC_WRITESELECT ... 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 не заменяет уровень изоляции.

Что почитать дальше