Як реалізувати оптимістичне блокування в 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 і версія
- При читанні: Сутність завантажується разом із поточним номером версії (
SELECT id, balance, version FROM bank_accounts WHERE id = 1). Припустимо,version = 1. - При зміні в пам’яті: Застосунок змінює баланс.
- При записі в БД (
flush/commit): Hibernate автоматично формує SQL-запитUPDATE, куди додає перевірку вихідної версії в секціюWHEREі збільшує її на 1:UPDATE bank_accounts SET balance = ?, version = version + 1 WHERE id = ? AND version = ?; - Якщо паралельна транзакція встигла оновити рядок раніше: У таблиці вже
version = 2. Наш запит з умовоюWHERE id = 1 AND version = 1оновить 0 рядків (updateCount == 0). - Реакція 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 = ?; -- перевіряється старе значення зі знімка loadedStateOptimisticLockType.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 збільшиться чи залишиться незмінним?
Відповідь:
- За замовчуванням при додаванні елемента в дочірню колекцію
@OneToManyверсія батьківської сутностіOrderНЕ збільшується. - Змінюється лише дочірня таблиця
order_items(виконуєтьсяINSERT). Поля в таблиціordersне зачіпаються, отже, SQLUPDATEдляOrderне генерується. - Це створює ризик: дві паралельні транзакції можуть одночасно додати різні товари в одне замовлення, і жодна з них не отримає
OptimisticLockException. - Рішення: Явно використовувати режим
LockModeType.OPTIMISTIC_FORCE_INCREMENTпри завантаженніOrder, або викликатиentityManager.lock(order, LockModeType.OPTIMISTIC_FORCE_INCREMENT)перед додаванням елементів.
2. Чому використання java.time.Instant або java.sql.Timestamp для поля @Version вважається небезпечним антипатерном у високонавантажених системах?
Відповідь:
- Точність СУБД: Різні бази даних мають різну дискретність збереження часу (MySQL до 5.6.4 зберігав секунди без мілісекунд; багато СУБД округлюють до мікросекунд, тоді як JVM зберігає наносекунди).
- Ризик пропущеного конфлікту: Якщо дві паралельні транзакції встигнуть прочитати, змінити і закомітити сутність у межах однієї й тієї самої мілісекунди, поле
timestampзалишиться ідентичним! У результаті друга транзакція успішно запише свої дані, і конфлікт версій не буде виявлений. - Синхронізація часу: Стрибки часу через NTP-демони можуть зрушити системний годинник назад.
- Стандарт індустрії: Завжди використовувати цілочисельні типи:
IntegerабоLong.
3. Чи можна зловити OptimisticLockException у блоці catch прямо всередині транзакційного методу і тут же виконати повторну спробу запису?
Відповідь:
- Категорично ні. Виняток оптимістичного блокування викидається Hibernate в момент фази
flush(). - При падінні
flush()поточний контекст персистентності (PersistenceContext) переходить у пошкоджений, неконсистентний стан, а сама транзакція Spring/JPA позначається якRollback-Only. - Будь-які подальші виклики
entityManager.find()або спроби скидання в цьому ж методі призведуть доTransactionSystemException: Transaction marked as rollback-only. - Правило: Обробка
OptimisticLockExceptionта retry-логіка завжди повинні знаходитися на рівень вище транзакційного методу (наприклад, через Spring@Retryableабо зовнішній фасад).
4. Як оптимістичне блокування взаємодіє з JDBC Batching (hibernate.jdbc.batch_size)?
Відповідь:
- При батчингу драйвер JDBC виконує пачку запитів через
PreparedStatement.executeBatch(), повертаючи масив лічильників оновлених рядківint[] updateCounts. - Hibernate обходить цей масив: якщо хоча б один елемент дорівнює
0(абоStatement.SUCCESS_NO_INFOу деяких драйверах при незбігу), Hibernate негайно перериває батч і викидаєStaleObjectStateException/OptimisticLockException. - Проте при використанні
IDENTITYстратегії генерації ключів та деяких специфічних діалектах СУБД батчинг з оптимістичним блокуванням може вимикатися, тому перевагу слід надавати генераторамSEQUENCE.
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь (Elevator Pitch)
«Оптимістичне блокування в JPA реалізується через анотацію
@Versionна числовому полі (LongабоInteger). При кожномуUPDATEHibernate додає умову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.