Всем привет! В прошлый раз мы разобрали прокси и исключения. Сегодня поговорим о том, что происходит с транзакцией, когда один сервис вызывает другой.
Представим классическую связку: создание заказа.
@Service
public class OrderService {
private final PaymentService paymentService;
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
@Transactional
public void placeOrder(OrderDto dto) {
orderRepository.save(dto.toEntity());
// Вызываем метод другого бина (PaymentService)
paymentService.chargeMoney(dto.getUserId(), dto.getAmount());
log.info("Заказ успешно создан");
}
}
@Service
public class PaymentService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void chargeMoney(Long userId, BigDecimal amount) {
paymentRepository.withdraw(userId, amount);
if (someBusinessCheckFailed()) {
throw new RuntimeException("Антифрод система заблокировала платеж");
}
}
}
Кажется логичным: если антифрод сработал в chargeMoney(), то вся транзакция должна откатиться. Заказ не должен создаться, ведь деньги списать не удалось.
Но по умолчанию Spring сделает commit для заказа.
В базе останется запись об заказе, а пользователь получит уведомление «Оплата отклонена». Деньги при этом действительно не спишутся (так как у платежа был свой REQUIRES_NEW), но целостность данных нарушена — заказ висит без оплаты.
Почему так?
Spring управляет границами транзакций строго на входе в Proxy-объект.
Когда контроллер зовет orderService.placeOrder():
1. Срабатывает Proxy класса OrderService.
2. Открывается Транзакция №1 (T1).
3. Выполняется код внутри вашего объекта OrderService.
4. Мы заходим в paymentService.chargeMoney(). Это вызов другого бина через внедренную зависимость (@Autowired / конструктор).
5. Срабатывает Proxy класса PaymentService.
6. Текущая T1 ставится на паузу (suspend).
7. Открывается совершенно независимая Транзакция №2 (T2).
8. Если внутри T2 падает RuntimeException, отрабатывает только rollback для T2.
9. Управление возвращается в OrderService. Прокси PaymentService отработал и умер вместе со своей транзакцией.
10. Код OrderService продолжает выполнение до конца метода.
11. Достигается закрывающая скобка метода placeOrder(). Прокси OrderService видит штатный выход из метода и делает commit для T1.
Прокси первого сервиса ничего не знает о падении внутреннего прокси второго. Для него всё прошло успешно.
Как починить?
Нужно научить родительскую транзакцию реагировать на ошибки дочерней. Есть два основных способа:
1. Параметр rollbackFor. Самый простой вариант. Мы явно говорим родителю: «Если мой коллега упал вот с такой ошибкой — убей и мою транзакцию тоже».
@Transactional(rollbackFor = PaymentException.class)
public void placeOrder(OrderDto dto) {
// ... логика ...
paymentService.chargeMoney(...); // Кинет PaymentException
}
Важное условие: исключение должно вылететь наружу из chargeMoney непойманным, дойти до границы placeOrder и быть перечисленным в rollbackFor.
2. Глобальный аспект @AfterThrowing. Если таких сервисов много, прописывать rollbackFor везде — плохая идея. Можно написать один общий Aspect, который будет слушать падение определенных методов и программно помечать текущую транзакцию под откат:
@Aspect
@Component
public class TransactionRollbackAspect {
@AfterThrowing(
pointcut = "execution(* com.example..PaymentService.*(..))",
throwing = "ex")
public void markRollbackOnPaymentError(RuntimeException ex) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}
Теперь любая ошибка в любом методе PaymentService автоматически заставит откатиться любую внешнюю транзакцию, откуда бы его ни вызвали.