💾 Раздел 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.

Связанные темы