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

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. Это означает:

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