Як реалізувати песимістичне блокування в 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 / Transactional Outbox) через патерн
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: взаємне блокування (deadlock) або апаратний збій захоплення на стороні 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 по таблиці без індексу» — Це призведе до блокування всієї таблиці.