💾 Розділ 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 / 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 = ? (без блокування)?

Відповідь:

  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 по таблиці без індексу» — Це призведе до блокування всієї таблиці.

Пов’язані теми