Как реализовать оптимистичную блокировку в 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.