Блокировки в 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 уже невалиден, и повтор в той же транзакции бессмыслен.
// вариант 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.