Перейти к содержанию

Блокировки в Spring Data

См. Блокировки.


Optimistic locking

Optimistic locking в Spring - поле @Version на сущности (см. Optimistic locking) и retry на конфликт.

  • Spring переводит исключения JPA в свою иерархию DataAccessException. Конфликт версий из репозитория или при коммите приходит как ObjectOptimisticLockingFailureException - наследник OptimisticLockingFailureException
  • поэтому retry настраивают на OptimisticLockingFailureException, а не на jakarta.persistence.OptimisticLockException
@Service
public class AccountService {

    @Retryable(
            retryFor = OptimisticLockingFailureException.class,
            maxAttempts = 3,
            backoff = @Backoff(delay = 100, multiplier = 2)
    )
    @Transactional
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        Account from = accountRepo.findById(fromId).orElseThrow();
        Account to = accountRepo.findById(toId).orElseThrow();
        from.debit(amount);
        to.credit(amount);
        // конфликт версий -> OptimisticLockingFailureException -> новая попытка
    }
}

См. Порядок @Retryable и @Transactional.

Note

В исходной версии retry был настроен на OptimisticLockException. Через репозиторий Spring Data и JpaTransactionManager это исключение приходит переведённым в ObjectOptimisticLockingFailureException, и retryFor = OptimisticLockException.class его не поймает.


Pessimistic locking

Pessimistic locking в Spring Data - аннотация @Lock на методе репозитория.

public interface AccountRepository extends JpaRepository<Account, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)   // SELECT ... FOR UPDATE - эксклюзивная блокировка
    @Query("SELECT a FROM Account a WHERE a.id = :id")
    Account findByIdForUpdate(@Param("id") Long id);

    @Lock(LockModeType.PESSIMISTIC_READ)    // SELECT ... FOR SHARE - разделяемая блокировка
    @Query("SELECT a FROM Account a WHERE a.id = :id")
    Account findByIdWithSharedLock(@Param("id") Long id);
}

Таймаут ожидания блокировки - обязателен на проде, иначе поток висит, пока блокировку не отпустят:

@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))   // мс
@Lock(LockModeType.PESSIMISTIC_WRITE)
Account findByIdForUpdate(Long id);

Дедлоки - блокируй строки в одном порядке, например по возрастанию id (сценарий - в Pessimistic locking).


Retry

Retry нужен для временных ошибок: конфликт оптимистической блокировки, ошибка сериализации, жертва дедлока, сбой сети, временные проблемы брокера или БД.

Правила:

  • ограниченное число попыток
  • backoff, желательно с jitter
  • классификация исключений: что повторять, а что нет
  • повторять можно только идемпотентные операции или с дедупликацией (см. Идемпотентность)

Нельзя повторять: ошибки валидации и бизнес-ошибки, несогласованные побочные эффекты, уже выполненные неидемпотентные внешние вызовы без idempotency key.


Spring Retry - декларативный retry. Проект в режиме maintenance only: его заменяют встроенные механизмы устойчивости Spring Framework 7.

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>
@Configuration
@EnableRetry
public class RetryConfig {
}

@Service
public class PaymentService {

    @Retryable(
            retryFor = {OptimisticLockingFailureException.class, TransientDataAccessException.class},
            noRetryFor = {IllegalArgumentException.class},
            maxAttempts = 3,
            backoff = @Backoff(
                    delay = 200,       // начальная задержка, мс
                    multiplier = 2.0,  // экспоненциальный рост
                    maxDelay = 5000    // потолок задержки, мс
            )
    )
    @Transactional
    public void processPayment(Payment payment) {
        // бизнес-логика
    }

    @Recover
    public void recoverPayment(OptimisticLockingFailureException e, Payment payment) {
        alertService.notifyFailure(payment, e);   // вызывается, когда попытки кончились
    }
}

Порядок @Retryable и @Transactional

@Retryable стоит НАД @Transactional: retry оборачивает транзакцию целиком. Если повторять внутри транзакции, после OptimisticLockException EntityManager уже невалиден, и повтор в той же транзакции бессмыслен.

@Retryable
    └── @Transactional (новая чистая транзакция на каждую попытку)
            └── бизнес-логика
// вариант 1: оба на одном методе - прокси retry оборачивает транзакционный прокси
@Retryable(retryFor = OptimisticLockingFailureException.class)
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) { ...}

// вариант 2: два бина - надёжнее
@Service
public class TransferFacade {
    @Retryable(retryFor = OptimisticLockingFailureException.class, maxAttempts = 3)
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        transferService.doTransfer(fromId, toId, amount);
    }
}

@Service
public class TransferService {
    @Transactional
    public void doTransfer(Long fromId, Long toId, BigDecimal amount) { ...}
}

RetryTemplate - программный retry (Spring Retry):

@Bean
public RetryTemplate retryTemplate() {
    return RetryTemplate.builder()
            .maxAttempts(3)
            .exponentialBackoff(100, 2.0, 5000)
            .retryOn(TransientDataAccessException.class)
            .build();
}

retryTemplate.execute(context -> {
    txTemplate.execute(status -> {   // новая транзакция на каждую попытку
        processPayment(payment);
        return null;
    });
    return null;
});

Retry в Spring Framework 7 - свой org.springframework.core.retry.RetryTemplate в ядре. По умолчанию операция выполняется один раз и повторяется до 3 раз с фиксированной паузой 1 секунда. Декларативный вариант - @Retryable из org.springframework.resilience.annotation, включается @EnableResilientMethods.

Resilience4j Retry - альтернатива:

@Retry(name = "paymentRetry", fallbackMethod = "fallback")
@Transactional
public void processPayment(Payment payment) { ...}
resilience4j:
  retry:
    instances:
      paymentRetry:
        maxAttempts: 3
        waitDuration: 200ms
        retryExceptions:
          - org.springframework.dao.OptimisticLockingFailureException
          - org.springframework.dao.TransientDataAccessException

Идемпотентность

Идемпотентность - повтор операции даёт тот же результат, что и первый вызов. Без неё любой retry и любая распределённая система дублируют операции.

@Entity
public class IdempotencyKey {
    @Id
    private String key;          // UUID, его генерирует клиент
    private String response;     // сериализованный ответ
    private Instant createdAt;
}

@Transactional
public PaymentResult processPayment(String idempotencyKey, PaymentRequest req) {
    Optional<IdempotencyKey> existing = idempotencyRepo.findById(idempotencyKey);
    if (existing.isPresent()) {
        return deserialize(existing.get().getResponse());   // уже обрабатывали - отдаём сохранённый ответ
    }

    PaymentResult result = executePayment(req);

    idempotencyRepo.save(new IdempotencyKey(idempotencyKey, serialize(result), Instant.now()));   // в той же транзакции
    return result;
}

Warning

Два одновременных запроса с одним ключом оба не найдут запись и оба выполнят платёж. Защита - уникальность ключа в БД (первичный ключ уже даёт её): второй INSERT упадёт, его транзакция откатится, и клиент получит сохранённый ответ на повторе. Внешний вызов (executePayment в платёжный шлюз) тоже должен получать idempotency key.