Как реализовать пессимистичную блокировку в 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 = ? (без блокировки)?
Ответ:
- Да, сможет! В современных СУБД (PostgreSQL, MySQL InnoDB, Oracle) используется механизм многоверсионности MVCC (Multi-Version Concurrency Control).
- Обычный
SELECTчитает согласованный снимок данных на момент начала транзакции/запроса (Consistent Read) из undo-логов (WAL / Undo Segments) и никогда не блокируется эксклюзивными замками строк. SELECT ... FOR UPDATEблокирует только те параллельные транзакции, которые тоже пытаются:- Выполнить
SELECT ... FOR UPDATEилиFOR SHARE. - Выполнить
UPDATEилиDELETEэтой же строки.
- Выполнить
2. Что произойдет, если выполнить PESSIMISTIC_WRITE запрос по неиндексированному полю (например, WHERE status = 'NEW') в таблице с миллионом строк?
Ответ:
- Чтобы найти строки, СУБД будет вынуждена выполнить полное сканирование таблицы (Full Table Scan).
- В процессе сканирования СУБД наложит блокировку на абсолютно каждую прочитанную строку таблицы (а в некоторых СУБД, таких как MySQL InnoDB, применит предикатные блокировки диапазонов
Gap Locksили эскалирует блокировку до уровня всей таблицыTable Lock). - Вся база данных фактически окажется заблокированной для любых параллельных операций записи.
- Правило: Пессимистичные блокировки допустимо использовать только по индексированным колонкам (в идеале — строго по первичному ключу).
3. Транзакция попыталась захватить строку с jakarta.persistence.lock.timeout = 1000 и упала по LockTimeoutException. Можно ли перехватить исключение в блоке catch и продолжить транзакцию в PostgreSQL?
Ответ:
- В PostgreSQL — категорически нет.
- В PostgreSQL любая ошибка выполнения SQL-стейтмента (включая таймаут блокировки) переводит транзакцию на уровне базы данных в состояние ошибки (
ERROR: current transaction is aborted, commands ignored until end of transaction block). - Текущая сессия JPA помечается как
Rollback-Only. Любой последующий запрос приведет к отказу. - Вся транзакция должна быть немедленно завершена откатом (
ROLLBACK), а повтор должен инициироваться заново из внешнего слоя.
4. В чем отличие LockModeType.PESSIMISTIC_WRITE от LockModeType.PESSIMISTIC_FORCE_INCREMENT?
Ответ:
PESSIMISTIC_WRITEзахватывает замокFOR UPDATEи изменяет версию сущности только в том случае, если приложение физически модифицировало поля самой сущности.PESSIMISTIC_FORCE_INCREMENTне только захватывает замокFOR UPDATE, но и в любом случае принудительно увеличивает версионное поле@Versionпри коммите, даже если поля сущности не менялись (генерируется явныйUPDATE table SET version = version + 1 WHERE id = ?).- Это необходимо для оповещения внешних систем и других потоков, использующих оптимистичные проверки, о том, что контекст сущности был монопольно изменен (например, была модифицирована связанная дочерняя коллекция).
🎯 Шпаргалка для интервью
30-секундный ответ (Elevator Pitch)
«Пессимистичная блокировка в JPA захватывает физические замки строк в базе данных на уровне СУБД в момент чтения.
PESSIMISTIC_WRITEтранслируется в SQLSELECT ... 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 по таблице без индекса» — Это приведет к блокировке всей таблицы.