HibernateMiddleSystem design

Что такое пессимистическая блокировка в Hibernate и когда её следует использовать?

Пессимистическая блокировка захватывает строку на уровне СУБД (SELECT FOR UPDATE) при чтении, предотвращая параллельные изменения. Используется когда конфликты вероятны или когда нельзя допустить потерянных обновлений (финансовые операции, инвентаризация).

Концепция пессимистической блокировки

Пессимистическая блокировка исходит из того, что конфликты вероятны, и блокирует ресурс немедленно при чтении. СУБД выполняет SELECT ... FOR UPDATE (или FOR SHARE), удерживая row-level lock до завершения транзакции. Другие транзакции не могут изменить заблокированные строки, пока транзакция не зафиксируется или откатится.

Применяется когда: бизнес-логика требует гарантии, что данные не изменятся между чтением и записью; конфликты часты; retry невозможен или дорог; операция финансовая и нет права на ошибку.

LockModeType в JPA

JPA определяет несколько режимов блокировки через jakarta.persistence.LockModeType:

  • PESSIMISTIC_READ — shared lock (SELECT FOR SHARE), другие читают, но не пишут.
  • PESSIMISTIC_WRITE — exclusive lock (SELECT FOR UPDATE), другие не читают и не пишут.
  • PESSIMISTIC_FORCE_INCREMENT — exclusive lock + инкремент @Version, если она есть.

Использование через EntityManager

@Transactional
public void transferFunds(Long fromId, Long toId, BigDecimal amount) {
    // Блокируем обе записи в предсказуемом порядке (избегаем deadlock)
    Long first = Math.min(fromId, toId);
    Long second = Math.max(fromId, toId);

    BankAccount from = em.find(
        BankAccount.class, first,
        LockModeType.PESSIMISTIC_WRITE
    );
    BankAccount to = em.find(
        BankAccount.class, second,
        LockModeType.PESSIMISTIC_WRITE
    );

    if (from.getId().equals(toId)) {
        BankAccount tmp = from; from = to; to = tmp;
    }

    from.debit(amount);
    to.credit(amount);
    // Блокировки освобождаются при коммите транзакции
}

Использование через Spring Data JPA

public interface InventoryRepository extends JpaRepository<Inventory, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT i FROM Inventory i WHERE i.productId = :productId")
    Optional<Inventory> findByProductIdForUpdate(@Param("productId") Long productId);

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(@QueryHint(name = "javax.persistence.lock.timeout", value = "3000"))
    Optional<Inventory> findById(Long id); // Override базового метода с блокировкой
}

Timeout для блокировки

Важно всегда устанавливать timeout, чтобы избежать бесконечного ожидания. В PostgreSQL это lock_timeout:

Map<String, Object> hints = new HashMap<>();
hints.put("jakarta.persistence.lock.timeout", 5000); // 5 секунд
Product product = em.find(Product.class, productId, LockModeType.PESSIMISTIC_WRITE, hints);

Через JPQL с hint:

TypedQuery<Seat> query = em.createQuery(
    "SELECT s FROM Seat s WHERE s.flightId = :fid AND s.status = 'AVAILABLE'",
    Seat.class
);
query.setParameter("fid", flightId);
query.setMaxResults(1);
query.setLockMode(LockModeType.PESSIMISTIC_WRITE);
query.setHint("jakarta.persistence.lock.timeout", 3000);
Seat seat = query.getSingleResult();

SELECT FOR UPDATE SKIP LOCKED

Для реализации очередей задач (job queue) PostgreSQL поддерживает SKIP LOCKED — пропустить уже заблокированные строки. Через нативный запрос:

@Query(value = "SELECT * FROM tasks WHERE status = 'PENDING' LIMIT 1 FOR UPDATE SKIP LOCKED",
       nativeQuery = true)
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Task> findNextPendingTask();

Подводные камни

  • Deadlock возможен если две транзакции блокируют строки в разном порядке. Решение: всегда блокировать строки в одном детерминированном порядке (например, по ID по возрастанию).
  • Пессимистическая блокировка не работает корректно без транзакции — @Transactional обязателен, иначе lock снимается сразу после SELECT.
  • Длинные транзакции с пессимистическими блокировками создают высокое lock-contention — избегайте операций ввода-вывода (HTTP-вызовы, файловые операции) внутри заблокированной транзакции.
  • В кластере с несколькими репликами чтения пессимистические блокировки работают только на мастере — убедитесь, что запрос направлен на primary.
  • Hint jakarta.persistence.lock.timeout=0 означает NOWAIT (немедленно выбросить исключение если lock недоступен) — не означает отключение timeout.
  • При использовании connection pool блокировка держит соединение до конца транзакции — при высоком параллелизме это быстро исчерпывает пул соединений.
  • Hibernate может делать дополнительный SELECT для получения блокировки на уже загруженную в L1-кэш сущность — при необходимости используйте em.refresh(entity, LockModeType.PESSIMISTIC_WRITE).

Common mistakes

  • Путать термин «pessimistic locking» с соседним механизмом Hibernate.
  • Не называть границу lifecycle, transaction, thread или request для «pessimistic locking».
  • Игнорировать production-эффекты «pessimistic locking»: latency, SQL shape, memory, security или observability.

What the interviewer is testing

  • Попросить объяснить механизм «pessimistic locking» на минимальном примере.
  • Проверить, видит ли кандидат failure mode и диагностику для «pessimistic locking».
  • Уточнить, какие настройки или API меняют «pessimistic locking» в реальном сервисе.

Sources

Related topics