💾 Розділ 16 · Питання #17

Як реалізувати оптимістичне блокування в JPA

Оптимістичне блокування (Optimistic Locking) — це патерн керування паралельним доступом, заснований на припущенні, що конфлікти конкурентного запису відбуваються відносно рідко....

Оптимістичне блокування (Optimistic Locking) — це патерн керування паралельним доступом, заснований на припущенні, що конфлікти конкурентного запису відбуваються відносно рідко. Замість важкого блокування рядків у базі даних на читання (SELECT FOR UPDATE), оптимістичне блокування дозволяє будь-якій кількості транзакцій вільно читати дані, а перевірку конфліктів переносить на момент збереження через валідацію версійного поля сутності.


🟢 Junior Level

Як влаштоване оптимістичне блокування

Для увімкнення оптимістичного блокування в JPA достатньо додати в клас сутності спеціальне поле з анотацією @jakarta.persistence.Version:

@Entity
@Table(name = "bank_accounts")
public class BankAccount {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private BigDecimal balance;

    @Version
    private Long version; // Hibernate автоматично керує інкрементом цього поля
}

Механіка роботи: SQL і версія

  1. При читанні: Сутність завантажується разом із поточним номером версії (SELECT id, balance, version FROM bank_accounts WHERE id = 1). Припустимо, version = 1.
  2. При зміні в пам’яті: Застосунок змінює баланс.
  3. При записі в БД (flush / commit): Hibernate автоматично формує SQL-запит UPDATE, куди додає перевірку вихідної версії в секцію WHERE і збільшує її на 1:
    UPDATE bank_accounts 
    SET balance = ?, version = version + 1 
    WHERE id = ? AND version = ?;
    
  4. Якщо паралельна транзакція встигла оновити рядок раніше: У таблиці вже version = 2. Наш запит з умовою WHERE id = 1 AND version = 1 оновить 0 рядків (updateCount == 0).
  5. Реакція Hibernate: Виявивши, що оновлено 0 рядків, Hibernate негайно викидає виняток jakarta.persistence.OptimisticLockException (у Spring огортається в ObjectOptimisticLockingFailureException), запобігаючи перезапису чужих даних (проблемі Lost Update).
                      СЦЕНАРІЙ КОНФЛІКТУ ВЕРСІЙ
                      
   Транзакція A (Потік 1)                          Транзакція B (Потік 2)
         │                                                 │
   SELECT version = 1                                SELECT version = 1
   balance = 100                                     balance = 100
         │                                                 │
   balance = 150                                     balance = 200
         │                                                 │
   UPDATE WHERE version = 1                                │
   (Успішно! У БД version = 2)                             │
   COMMIT                                                  │
                                                     UPDATE WHERE version = 1
                                                     (0 рядків оновлено!)
                                                           │
                                                     ❌ OptimisticLockException

🟡 Middle Level

Режими блокувань: LockModeType.OPTIMISTIC vs OPTIMISTIC_FORCE_INCREMENT

У JPA поведінка оптимістичного блокування регулюється через метод entityManager.lock() або анотацію @Lock у Spring Data JPA:

// Spring Data JPA
public interface AccountRepository extends JpaRepository<BankAccount, Long> {
    
    @Lock(LockModeType.OPTIMISTIC)
    Optional<BankAccount> findWithOptimisticLockById(Long id);
    
    @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
    @Query("SELECT a FROM BankAccount a WHERE a.id = :id")
    Optional<BankAccount> findAndForceIncrement(@Param("id") Long id);
}
Режим Поведінка Сценарій використання
OPTIMISTIC (раніше READ) При коміті виконує додатковий SELECT version WHERE id = ?, перевіряючи, що рядок не був змінений іншою транзакцією, навіть якщо сама сутність не модифікувалася. Захист від фантомного або неузгодженого читання даних, на основі яких приймаються бізнес-рішення.
OPTIMISTIC_FORCE_INCREMENT (раніше WRITE) Примусово інкрементує версію (version = version + 1), навіть якщо поля самої сутності не були змінені. Зміна дочірньої колекції сутності (наприклад, додавання позиції в замовлення), коли стан батьківської сутності повинен інвалідуватися.

Патерн автоматичного повтору (Retry Pattern)

Оскільки оптимістичний конфлікт є штатною бізнес-ситуацією конкуренції, застосунок повинен вміти повторити транзакцію зі свіжими даними:

@Service
@RequiredArgsConstructor
public class PaymentService {

    private final AccountRepository accountRepository;

    @Retryable(
        retryFor = { OptimisticLockException.class, ObjectOptimisticLockingFailureException.class },
        maxAttempts = 3,
        backoff = @Backoff(delay = 100, multiplier = 2)
    )
    @Transactional
    public void deposit(Long accountId, BigDecimal amount) {
        BankAccount account = accountRepository.findById(accountId)
            .orElseThrow(() -> new EntityNotFoundException("Рахунок не знайдено"));

        account.setBalance(account.getBalance().add(amount));
        // При збої Spring повторить транзакцію цілком, заново завантаживши свіжу версію з БД
    }
}

🔴 Senior Level

Оптимістичне блокування без колонки @Version (@OptimisticLocking)

У застарілих (legacy) базах даних часто неможливо змінити схему таблиці і додати колонку version. В Hibernate є фірмове рішення — анотація @OptimisticLocking у поєднанні з @DynamicUpdate:

@Entity
@Table(name = "legacy_contracts")
@org.hibernate.annotations.OptimisticLocking(type = OptimisticLockType.DIRTY)
@org.hibernate.annotations.DynamicUpdate
public class LegacyContract {
    @Id
    private Long id;
    private String terms;
    private BigDecimal amount;
}
  • OptimisticLockType.DIRTY: При скиданні змін в умову WHERE підставляються старі значення тільки змінених колонок:
    UPDATE legacy_contracts 
    SET amount = ? 
    WHERE id = ? AND amount = ?; -- перевіряється старе значення зі знімка loadedState
    
  • OptimisticLockType.ALL: У секцію WHERE включаються абсолютно всі колонки сутності (WHERE id = ? AND terms = ? AND amount = ? ...).

Оптимістичне блокування в REST API («Think Time» та ETag)

У вебзастосунках транзакція БД не може залишатися відкритою, поки користувач заповнює форму на фронтенді (час очікування людини / User Think Time). Для захисту від Lost Update між незалежними HTTP-запитами використовується зв’язка @Version та HTTP-заголовків ETag / If-Match:

 [ Frontend (Browser) ]                               [ Backend (REST Controller) ]
          │                                                         │
          │── GET /api/orders/1 ───────────────────────────────────>│
          │<── 200 OK + Body { id: 1, ... } + ETag: "W/\"v3\"" ─────│ (ETag дорівнює version = 3)
          │                                                         │
    [ Користувач думає ]                                            │
          │                                                         │
          │── PUT /api/orders/1 + Header If-Match: "W/\"v3\"" ─────>│
          │                                                         │
          │                                             Перевірка: if (db.version != 3)
          │                                             throw PreconditionFailedException;
          │<── 412 Precondition Failed ────────────────────────────│

Код контролера:

@PutMapping("/orders/{id}")
@Transactional
public ResponseEntity<OrderDto> updateOrder(
        @PathVariable Long id,
        @RequestHeader(HttpHeaders.IF_MATCH) String ifMatch,
        @RequestBody UpdateOrderRequest request) {

    Long clientVersion = parseVersionFromEtag(ifMatch);
    Order order = orderRepository.findById(id).orElseThrow();

    if (!order.getVersion().equals(clientVersion)) {
        // Конфлікт виявлено ДО зміни даних!
        return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
    }

    order.updateFrom(request);
    return ResponseEntity.ok()
        .eTag("\"v" + (order.getVersion() + 1) + "\"")
        .body(OrderDto.from(order));
}

4 Tricky Questions

1. Припустимо, ми змінили колекцію order.getItems().add(newItem). Поле @Version у сутності Order збільшиться чи залишиться незмінним?

Відповідь:

  1. За замовчуванням при додаванні елемента в дочірню колекцію @OneToMany версія батьківської сутності Order НЕ збільшується.
  2. Змінюється лише дочірня таблиця order_items (виконується INSERT). Поля в таблиці orders не зачіпаються, отже, SQL UPDATE для Order не генерується.
  3. Це створює ризик: дві паралельні транзакції можуть одночасно додати різні товари в одне замовлення, і жодна з них не отримає OptimisticLockException.
  4. Рішення: Явно використовувати режим LockModeType.OPTIMISTIC_FORCE_INCREMENT при завантаженні Order, або викликати entityManager.lock(order, LockModeType.OPTIMISTIC_FORCE_INCREMENT) перед додаванням елементів.

2. Чому використання java.time.Instant або java.sql.Timestamp для поля @Version вважається небезпечним антипатерном у високонавантажених системах?

Відповідь:

  1. Точність СУБД: Різні бази даних мають різну дискретність збереження часу (MySQL до 5.6.4 зберігав секунди без мілісекунд; багато СУБД округлюють до мікросекунд, тоді як JVM зберігає наносекунди).
  2. Ризик пропущеного конфлікту: Якщо дві паралельні транзакції встигнуть прочитати, змінити і закомітити сутність у межах однієї й тієї самої мілісекунди, поле timestamp залишиться ідентичним! У результаті друга транзакція успішно запише свої дані, і конфлікт версій не буде виявлений.
  3. Синхронізація часу: Стрибки часу через NTP-демони можуть зрушити системний годинник назад.
  4. Стандарт індустрії: Завжди використовувати цілочисельні типи: Integer або Long.

3. Чи можна зловити OptimisticLockException у блоці catch прямо всередині транзакційного методу і тут же виконати повторну спробу запису?

Відповідь:

  1. Категорично ні. Виняток оптимістичного блокування викидається Hibernate в момент фази flush().
  2. При падінні flush() поточний контекст персистентності (PersistenceContext) переходить у пошкоджений, неконсистентний стан, а сама транзакція Spring/JPA позначається як Rollback-Only.
  3. Будь-які подальші виклики entityManager.find() або спроби скидання в цьому ж методі призведуть до TransactionSystemException: Transaction marked as rollback-only.
  4. Правило: Обробка OptimisticLockException та retry-логіка завжди повинні знаходитися на рівень вище транзакційного методу (наприклад, через Spring @Retryable або зовнішній фасад).

4. Як оптимістичне блокування взаємодіє з JDBC Batching (hibernate.jdbc.batch_size)?

Відповідь:

  1. При батчингу драйвер JDBC виконує пачку запитів через PreparedStatement.executeBatch(), повертаючи масив лічильників оновлених рядків int[] updateCounts.
  2. Hibernate обходить цей масив: якщо хоча б один елемент дорівнює 0 (або Statement.SUCCESS_NO_INFO у деяких драйверах при незбігу), Hibernate негайно перериває батч і викидає StaleObjectStateException / OptimisticLockException.
  3. Проте при використанні IDENTITY стратегії генерації ключів та деяких специфічних діалектах СУБД батчинг з оптимістичним блокуванням може вимикатися, тому перевагу слід надавати генераторам SEQUENCE.

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь (Elevator Pitch)

«Оптимістичне блокування в JPA реалізується через анотацію @Version на числовому полі (Long або Integer). При кожному UPDATE Hibernate додає умову WHERE id = ? AND version = ? та інкрементує версію. Якщо рядок був змінений іншою транзакцією, запит оновлює 0 рядків, і Hibernate викидає OptimisticLockException. На відміну від песимістичного блокування, оптимістичне не утримує замки в СУБД при читанні, забезпечуючи максимальну пропускну здатність. Для обробки конфліктів використовується патерн Retry на рівні сервісу (Spring @Retryable), а для пов’язаних колекцій — OPTIMISTIC_FORCE_INCREMENT».

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

  • OptimisticLockException Rate: відсоток транзакцій, що падають через конфлікт версій (зростання вище 1–2% — сигнал до перегляду логіки або переходу на песимістичні блокування).
  • Retry Success Rate: частка успішних повторів після оптимістичного збою.

Червоні прапорці (Чого говорити НЕ МОЖНА)

  • ❌ «Оптимістичне блокування виконує SQL SELECT FOR UPDATE» — Це песимістичне блокування. Оптимістичне блокувань у СУБД не ставить.
  • ❌ «Я використовую Timestamp як @Version для надійності» — Антипатерн через мілісекундні колізії; потрібно використовувати Long/Integer.
  • ❌ «Ловлю OptimisticLockException всередині @Transactional методу в try-catch і викликаю em.persist()» — Транзакція переведена в Rollback-Only, retry можливий лише зовні.
  • ❌ «@Version автоматично оновлюється при зміні дочірньої колекції @OneToMany» — Ні, потрібно явно використовувати LockModeType.OPTIMISTIC_FORCE_INCREMENT.

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