
Rollback по умолчанию
Предположим, что у нас есть сервис, который создает трех пользователей в рамках одной транзакции. Если что-то идет не так, выбрасывается java.lang.Exception.
@Service public class PersonService { @Autowired private PersonRepository personRepository; @Transactional public void addPeople(String name) throws Exception { personRepository.saveAndFlush(new Person("Jack", "Brown")); personRepository.saveAndFlush(new Person("Julia", "Green")); if (name == null) { throw new Exception("name cannot be null"); } personRepository.saveAndFlush(new Person(name, "Purple")); } }
А вот простой unit-тест.
@SpringBootTest @AutoConfigureTestDatabase class PersonServiceTest { @Autowired private PersonService personService; @Autowired private PersonRepository personRepository; @BeforeEach void beforeEach() { personRepository.deleteAll(); } @Test void shouldRollbackTransactionIfNameIsNull() { assertThrows(Exception.class, () -> personService.addPeople(null)); assertEquals(0, personRepository.count()); } }
Как думаете, тест завершится успешно, или нет? Логика говорит нам, что Spring должен откатить транзакцию из-за исключения. Следовательно personRepository.count() должен вернуть 0, так ведь? Не совсем.
expected: <0> but was: <2> Expected :0 Actual :2
Здесь требуются некоторые объяснения. По умолчанию Spring откатывает транзакции только в случае непроверяемого исключения. Проверяемые же считаются «восстанавливаемыми» из-за чего Spring вместо rollback делает commit. Поэтому personRepository.count() возращает 2.
Самый простой исправить это — заменить Exception на непроверяемое исключение. Например, NullPointerException. Либо можно переопределить атрибут rollbackFor у аннотации.
Например, оба этих метода корректно откатывают транзакцию.
@Service public class PersonService { @Autowired private PersonRepository personRepository; @Transactional(rollbackFor = Exception.class) public void addPeopleWithCheckedException(String name) throws Exception { addPeople(name, Exception::new); } @Transactional public void addPeopleWithNullPointerException(String name) { addPeople(name, NullPointerException::new); } private <T extends Exception> void addPeople(String name, Supplier<? extends T> exceptionSupplier) throws T { personRepository.saveAndFlush(new Person("Jack", "Brown")); personRepository.saveAndFlush(new Person("Julia", "Green")); if (name == null) { throw exceptionSupplier.get(); } personRepository.saveAndFlush(new Person(name, "Purple")); } }
@SpringBootTest @AutoConfigureTestDatabase class PersonServiceTest { @Autowired private PersonService personService; @Autowired private PersonRepository personRepository; @BeforeEach void beforeEach() { personRepository.deleteAll(); } @Test void testThrowsExceptionAndRollback() { assertThrows(Exception.class, () -> personService.addPeopleWithCheckedException(null)); assertEquals(0, personRepository.count()); } @Test void testThrowsNullPointerExceptionAndRollback() { assertThrows(NullPointerException.class, () -> personService.addPeopleWithNullPointerException(null)); assertEquals(0, personRepository.count()); } }

Rollback при «глушении» исключения
Не все исключения должны быть проброшены вверх по стеку вызовов. Иногда вполне допустимо отловить его внутри метода и залогировать информацию об этом.
Предположим, что у нас есть еще один транзакционный сервис, который проверяет, может ли быть создан пользователь с переданным именем. Если нет, выбрасывается IllegalArgumentException.
@Service public class PersonValidateService { @Autowired private PersonRepository personRepository; @Transactional public void validateName(String name) { if (name == null || name.isBlank() || personRepository.existsByFirstName(name)) { throw new IllegalArgumentException("name is forbidden"); } } }
Давайте добавим валидацию в наш PersonService.
@Service @Slf4j public class PersonService { @Autowired private PersonRepository personRepository; @Autowired private PersonValidateService personValidateService; @Transactional public void addPeople(String name) { personRepository.saveAndFlush(new Person("Jack", "Brown")); personRepository.saveAndFlush(new Person("Julia", "Green")); String resultName = name; try { personValidateService.validateName(name); } catch (IllegalArgumentException e) { log.error("name is not allowed. Using default one"); resultName = "DefaultName"; } personRepository.saveAndFlush(new Person(resultName, "Purple")); } }
Если валидация не проходит, создаем пользователя с именем по умолчанию.
Окей, теперь нужно протестировать новую функциональность.
@SpringBootTest @AutoConfigureTestDatabase class PersonServiceTest { @Autowired private PersonService personService; @Autowired private PersonRepository personRepository; @BeforeEach void beforeEach() { personRepository.deleteAll(); } @Test void shouldCreatePersonWithDefaultName() { assertDoesNotThrow(() -> personService.addPeople(null)); Optional<Person> defaultPerson = personRepository.findByFirstName("DefaultName"); assertTrue(defaultPerson.isPresent()); } }
Однако результат оказывается довольно неожиданным.
Unexpected exception thrown: org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
Странно. Мы отловили исключение. Почему же Spring откатил транзакцию? Прежде всего нужно разобраться с тем, как Spring работает с транзакциями.
Под капотом Spring применяет паттерн аспектно-ориентированного программирования. Опуская сложные детали, идея заключается в том, что bean оборачивается в прокси, который генерируются в процессе старта приложения. Внутри этого прокси выполняется требуемая логика. В нашем случае, управление транзакциями. Когда какой-нибудь bean указывает транзакционный сервис в качестве DI зависимости, Spring на самом деле внедряет прокси.
Ниже представлен workflow вызова вышенаписанного метода addPeople.

Параметр propagation у @Transactional по умолчанию имеет значение REQUIRED. Это значит, что новая транзакция создается, если она отсутствует. Иначе выполнение продолжается в текущей. Так что в нашем случае весь запрос выполняется в рамках единственной транзакции.
Однако здесь есть нюанс. Если RuntimeException был выброшен из-за границ transactional proxy, то Spring отмечает текущую транзакцию как rollback-only. Здесь у нас именно такой случай. PersonValidateService.validateName выбросил IllegalArgumentException. Transactional proxy выставил флаг rollback. Дальнейшие операции уже не имеют значения, так как в конечном итоге транзакция не закоммитится.
Каково решение проблемы? Вообще их несколько. Например, мы можем добавить атрибут noRollbackFor в PersonValidateService.
@Service public class PersonValidateService { @Autowired private PersonRepository personRepository; @Transactional(noRollbackFor = IllegalArgumentException.class) public void validateName(String name) { if (name == null || name.isBlank() || personRepository.existsByFirstName(name)) { throw new IllegalArgumentException("name is forbidden"); } } }
Есть вариант поменять propagation на REQUIRES_NEW. В этом случае PersonValidateService.validateName будет выполнен в отдельной транзакции. Так что родительская не будет отменена.
@Service public class PersonValidateService { @Autowired private PersonRepository personRepository; @Transactional(propagation = Propagation.REQUIRES_NEW) public void validateName(String name) { if (name == null || name.isBlank() || personRepository.existsByFirstName(name)) { throw new IllegalArgumentException("name is forbidden"); } } }
Возможные проблемы с Kotlin
У Kotlin много схожестей с Java. Но управление исключениями не является одной из них.
Kotlin убрал понятия проверяемых и непроверяемых исключений. Строго говоря, все исключения в Kotlin непроверяемые, потому что нам не требуется указывать конструкции throws SomeException в сигнатурах методов, а также оборачивать их вызовы в try-catch. Обсуждение плюсов и минусов такого решения — тема для отдельной статьи. Но сейчас я хочу продемонстрировать вам проблемы, которые могут из-за этого возникнуть при использовании Spring Data.
Давайте перепишем самый первый пример с java.lang.Exception на Kotlin.
@Service class PersonService( @Autowired private val personRepository: PersonRepository ) { @Transactional fun addPeople(name: String?) { personRepository.saveAndFlush(Person("Jack", "Brown")) personRepository.saveAndFlush(Person("Julia", "Green")) if (name == null) { throw Exception("name cannot be null") } personRepository.saveAndFlush(Person(name, "Purple")) } }
@SpringBootTest @AutoConfigureTestDatabase internal class PersonServiceTest { @Autowired lateinit var personRepository: PersonRepository @Autowired lateinit var personService: PersonService @BeforeEach fun beforeEach() { personRepository.deleteAll() } @Test fun `should rollback transaction if name is null`() { assertThrows(Exception::class.java) { personService.addPeople(null) } assertEquals(0, personRepository.count()) } }
Тест падает как в Java.
expected: <0> but was: <2> Expected :0 Actual :2
Здесь нет ничего удивительного. Spring управляет транзакциями в Kotlin ровно так же, как и в Java. Но в Java мы не можем вызвать метод, который выбрасывает java.lang.Exception, не оборачивая инструкцию в try-catch или не пробрасывая исключение дальше. Kotlin же позволяет. Это может привести к непредвиденным ошибкам и трудно уловимым багам. Так что к таким вещам следует относиться вдвойне внимательнее.
Строго говоря, в Java есть хак, который позволяет выбросить checked исключения, не указывая
throwsв сигнатуре.
Заключение
Это все, что я хотел рассказать о @Transactionalв Spring. Если у вас есть какие-то вопросы или пожелания, пожалуйста, оставляйте комментарии. Спасибо за чтение!
Spring — самый популярный фреймворк в мире Java. Разработчикам из «коробки» доступны инструменты для API, ролевой модели, кэширования и доступа к данным. Spring Data в особенности делает жизнь программиста гораздо легче. Нам больше не нужно беспокоиться о соединениях к базе данных и управлении транзакциями. Фреймворк сделает все за нас. Однако тот факт, что многие детали остаются для нас скрытыми, может привести к трудно отлавливаемым багам и ошибкам. Так что давайте глубже погрузимся в аннотацию @Transaсtional и узнаем, что же там происходит.
