💾 Раздел 16 · Вопрос #18

Как реализовать пессимистичную блокировку в JPA

Пессимистичная блокировка (Pessimistic Locking) — это стратегия управления параллельным доступом, которая предполагает высокую вероятность конфликтов конкурентной записи. Вместо...

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


🟢 Junior Level

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

В отличие от оптимистичной блокировки, пессимистичная опирается на встроенные механизмы СУБД (Row-Level Locking). Транзакция, запросившая блокировку, гарантирует, что ни одна параллельная транзакция не сможет изменить или заблокировать эти же строки до тех пор, пока текущая транзакция не завершится.

В JPA блокировка накладывается через EntityManager.find(), EntityManager.lock() или аннотацию @Lock в Spring Data JPA:

@Transactional
public void withdrawMoney(Long accountId, BigDecimal amount) {
    // Выполняет SQL: SELECT * FROM accounts WHERE id = ? FOR UPDATE;
    Account account = entityManager.find(
        Account.class, 
        accountId, 
        LockModeType.PESSIMISTIC_WRITE
    );

    account.setBalance(account.getBalance().subtract(amount));
    // При выходе из метода Spring зафиксирует транзакцию (COMMIT),
    // и СУБД автоматически снимет блокировку со строки.
}
                 ХРОНОЛОГИЯ ПЕССИМИСТИЧНОЙ БЛОКИРОВКИ
                 
  Транзакция 1 (Поток A)                          Транзакция 2 (Поток B)
        │                                                 │
  SELECT ... FOR UPDATE                                   │
  (Захват Exclusive Lock в БД)                           │
        │                                                 │
  Бизнес-логика...                                  SELECT ... FOR UPDATE
        │                                           (БД приостанавливает поток B!
        │                                            Ожидание снятия блокировки)
        │                                                 │ ⏳
  UPDATE accounts SET balance = ...                       │ ⏳
  COMMIT (Блокировка снята в БД) ──────────────────────> │ 
                                                    (Поток B просыпается и захватывает
                                                     уже обновленную строку!)

Когда использовать пессимистичную блокировку

  • Высококонкурентные операции списания баланса, бронирования билетов, распределения складских остатков.
  • Когда частота конфликтов высока (> 5–10%), и повторы транзакций оптимистичной блокировки перегружают CPU.
  • Для реализации очередей задач в БД (Job Queues) через паттерн SKIP LOCKED.

🟡 Middle Level

Режимы пессимистичных блокировок (LockModeType)

Режим JPA Трансляция в SQL (PostgreSQL / MySQL) Описание поведения
PESSIMISTIC_READ SELECT ... FOR SHARE (Postgres) / LOCK IN SHARE MODE (MySQL) Разделяемая блокировка (Shared Lock). Другие транзакции могут читать строку через FOR SHARE, но попытки UPDATE, DELETE или FOR UPDATE блокируются.
PESSIMISTIC_WRITE SELECT ... FOR UPDATE Эксклюзивная блокировка (Exclusive Lock). Блокирует любые параллельные попытки захвата блокировки на чтение или запись.
PESSIMISTIC_FORCE_INCREMENT SELECT ... FOR UPDATE + инкремент @Version Захватывает эксклюзивную блокировку в СУБД и принудительно увеличивает поле @Version сущности при коммите.

Управление временем ожидания (Lock Timeouts)

По умолчанию, если строка уже заблокирована, транзакция встает в бесконечное ожидание (до системного таймаута СУБД). Это может привести к исчерпанию пула соединений HikariCP.

В JPA таймаут настраивается в миллисекундах через свойство jakarta.persistence.lock.timeout:

// 1. Ждать блокировку максимум 3 секунды (3000 мс)
Map<String, Object> hints = Map.of("jakarta.persistence.lock.timeout", 3000);
Account account = entityManager.find(Account.class, 1L, LockModeType.PESSIMISTIC_WRITE, hints);

// 2. Режим NOWAIT (0 мс): не ждать вообще, сразу выбросить исключение
Map<String, Object> nowaitHints = Map.of("jakarta.persistence.lock.timeout", 0);
// Транслируется в SQL: SELECT ... FOR UPDATE NOWAIT
Account fastFailAccount = entityManager.find(Account.class, 1L, LockModeType.PESSIMISTIC_WRITE, nowaitHints);

В Spring Data JPA:

public interface AccountRepository extends JpaRepository<Account, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")})
    @Query("SELECT a FROM Account a WHERE a.id = :id")
    Optional<Account> findByIdForUpdate(@Param("id") Long id);
}

Исключения при сбое захвата блокировок

  • jakarta.persistence.PessimisticLockException: общая ошибка при невозможности получить замок.
  • jakarta.persistence.LockTimeoutException: истек заданный таймаут ожидания.
  • org.hibernate.exception.LockAcquisitionException: дедлок или аппаратный сбой захвата на стороне JDBC.

🔴 Senior Level

Паттерн конкурентной очереди задач: SKIP LOCKED

В высоконагруженных распределенных системах реляционную БД часто используют как надежный брокер очередей (Transactional Outbox / Task Queue). Если несколько воркеров вызовут обычный SELECT ... FOR UPDATE, они заблокируют друг друга.

Решение — использование модификатора SKIP LOCKED, который приказывает СУБД пропустить строки, уже захваченные другими потоками:

public interface TaskQueueRepository extends JpaRepository<ScheduledTask, Long> {

    // В Hibernate значение таймаута -2 транслируется в конструкцию SKIP LOCKED
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "-2")})
    @Query("SELECT t FROM ScheduledTask t WHERE t.status = 'PENDING' ORDER BY t.createdAt ASC")
    List<ScheduledTask> fetchNextBatch(Pageable pageable);
}
-- Генерируемый SQL в PostgreSQL / MySQL 8+:
SELECT * FROM scheduled_tasks 
WHERE status = 'PENDING' 
ORDER BY created_at ASC 
LIMIT 10 
FOR UPDATE SKIP LOCKED;

Каждый воркер мгновенно забирает свою уникальную пачку задач без какого-либо ожидания и дедлоков!

Предотвращение взаимных блокировок (Deadlock Prevention)

Классический сценарий дедлока при банковском переводе между счетами $A$ и $B$:

  • Поток 1 переводит со счета 1 на счет 2 (блокирует 1, затем пытается блокировать 2).
  • Поток 2 одновременно переводит со счета 2 на счет 1 (блокирует 2, затем пытается блокировать 1).
  • СУБД фиксирует Deadlock и принудительно убивает одну из транзакций.
                      СХЕМА ВЗАИМНОЙ БЛОКИРОВКИ (DEADLOCK)
                      
     Поток 1: LOCK(Account 1) ──[ждет]──> Заблокирован Потоком 2 (Account 2)
                   ▲                                │
                   │                                ▼
     Поток 2: Заблокирован Потоком 1 <──[ждет]── LOCK(Account 2)

Решение на уровне архитектуры — Строгий порядок блокировок (Lock Ordering): Всегда захватывать блокировки на ресурсы в строго детерминированном порядке (например, по возрастанию первичных ключей):

@Transactional
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
    Long firstId = fromId < toId ? fromId : toId;
    Long secondId = fromId < toId ? toId : fromId;

    // Блокируем строго в порядке возрастания ID:
    Account first = accountRepository.findByIdForUpdate(firstId).orElseThrow();
    Account second = accountRepository.findByIdForUpdate(secondId).orElseThrow();

    Account from = fromId.equals(first.getId()) ? first : second;
    Account to = toId.equals(first.getId()) ? first : second;

    from.debit(amount);
    to.credit(amount);
    // Дедлок математически невозможен!
}

4 Tricky Questions

1. Если транзакция захватила строку через SELECT ... FOR UPDATE, сможет ли другая параллельная транзакция прочитать эту строку обычным SELECT * FROM table WHERE id = ? (без блокировки)?

Ответ:

  1. Да, сможет! В современных СУБД (PostgreSQL, MySQL InnoDB, Oracle) используется механизм многоверсионности MVCC (Multi-Version Concurrency Control).
  2. Обычный SELECT читает согласованный снимок данных на момент начала транзакции/запроса (Consistent Read) из undo-логов (WAL / Undo Segments) и никогда не блокируется эксклюзивными замками строк.
  3. SELECT ... FOR UPDATE блокирует только те параллельные транзакции, которые тоже пытаются:
    • Выполнить SELECT ... FOR UPDATE или FOR SHARE.
    • Выполнить UPDATE или DELETE этой же строки.

2. Что произойдет, если выполнить PESSIMISTIC_WRITE запрос по неиндексированному полю (например, WHERE status = 'NEW') в таблице с миллионом строк?

Ответ:

  1. Чтобы найти строки, СУБД будет вынуждена выполнить полное сканирование таблицы (Full Table Scan).
  2. В процессе сканирования СУБД наложит блокировку на абсолютно каждую прочитанную строку таблицы (а в некоторых СУБД, таких как MySQL InnoDB, применит предикатные блокировки диапазонов Gap Locks или эскалирует блокировку до уровня всей таблицы Table Lock).
  3. Вся база данных фактически окажется заблокированной для любых параллельных операций записи.
  4. Правило: Пессимистичные блокировки допустимо использовать только по индексированным колонкам (в идеале — строго по первичному ключу).

3. Транзакция попыталась захватить строку с jakarta.persistence.lock.timeout = 1000 и упала по LockTimeoutException. Можно ли перехватить исключение в блоке catch и продолжить транзакцию в PostgreSQL?

Ответ:

  1. В PostgreSQL — категорически нет.
  2. В PostgreSQL любая ошибка выполнения SQL-стейтмента (включая таймаут блокировки) переводит транзакцию на уровне базы данных в состояние ошибки (ERROR: current transaction is aborted, commands ignored until end of transaction block).
  3. Текущая сессия JPA помечается как Rollback-Only. Любой последующий запрос приведет к отказу.
  4. Вся транзакция должна быть немедленно завершена откатом (ROLLBACK), а повтор должен инициироваться заново из внешнего слоя.

4. В чем отличие LockModeType.PESSIMISTIC_WRITE от LockModeType.PESSIMISTIC_FORCE_INCREMENT?

Ответ:

  1. PESSIMISTIC_WRITE захватывает замок FOR UPDATE и изменяет версию сущности только в том случае, если приложение физически модифицировало поля самой сущности.
  2. PESSIMISTIC_FORCE_INCREMENT не только захватывает замок FOR UPDATE, но и в любом случае принудительно увеличивает версионное поле @Version при коммите, даже если поля сущности не менялись (генерируется явный UPDATE table SET version = version + 1 WHERE id = ?).
  3. Это необходимо для оповещения внешних систем и других потоков, использующих оптимистичные проверки, о том, что контекст сущности был монопольно изменен (например, была модифицирована связанная дочерняя коллекция).

🎯 Шпаргалка для интервью

30-секундный ответ (Elevator Pitch)

«Пессимистичная блокировка в JPA захватывает физические замки строк в базе данных на уровне СУБД в момент чтения. PESSIMISTIC_WRITE транслируется в SQL SELECT ... FOR UPDATE, предотвращая любые параллельные изменения данных до фиксации текущей транзакции. PESSIMISTIC_READ транслируется в FOR SHARE, разрешая параллельное чтение, но блокируя запись. Для предотвращения зависаний пула соединений обязательно настраивается jakarta.persistence.lock.timeout (или 0 для NOWAIT). Для организации очередей задач используется SKIP LOCKED. Главные риски пессимистичных блокировок — дедлоки (решаются строгим порядком захвата по ID) и деградация пропускной способности при длительных транзакциях».

Ключевые метрики для Production-мониторинга

  • Количество ожидающих блокировок (Row Lock Wait Time / Lock Contention): рост говорит о слишком длинных транзакциях.
  • Deadlock Count в СУБД (PostgreSQL pg_stat_database.deadlocks): всплеск сигнализирует о нарушении порядка захвата блокировок.
  • Длительность активной транзакции с блокировкой: должна удерживаться не более 10–50 мс.

Красные флаги (Чего говорить НЕЛЬЗЯ)

  • ❌ «Пессимистичная блокировка блокирует обычные SELECT-запросы других пользователей» — При MVCC обычные SELECT не блокируются и читают данные из снимков (undo log).
  • ❌ «Использую пессимистичную блокировку без таймаута для всех запросов сервиса» — Гарантированный способ положить приложение под нагрузкой из-за исчерпания пула соединений.
  • ❌ «Внутри транзакции с FOR UPDATE выполняю долгий сетевой вызов по HTTP во внешний сервис» — Грубейший антипаттерн, приводящий к каскадному простою всей базы данных.
  • ❌ «Выполняю SELECT FOR UPDATE по таблице без индекса» — Это приведет к блокировке всей таблицы.

Связанные темы