Транзакции и @Transactional
Транзакция — группа изменений в базе, которая выполняется целиком или не выполняется вовсе. Если импорт десяти записей упал на седьмой, в базе не должно остаться шести «половинчатых». В Jakarta EE транзакциями управляет сервер (JTA — Jakarta Transactions): вы ставите на метод аннотацию @Transactional, а сервер открывает транзакцию перед вызовом и фиксирует или откатывает её после.
Как это работает
| Что произошло в методе | Что сделает сервер |
|---|---|
| Метод завершился нормально | Фиксирует транзакцию (commit) |
Выброшено непроверяемое исключение (RuntimeException) | Откатывает транзакцию (rollback) |
Выброшено проверяемое исключение (Exception) | Фиксирует, если не указано иное |
@Transactional работает только на бинах CDI и только при вызове снаружи: вызов метода из того же объекта (this.save(...)) мимо прокси транзакцию не откроет.
Импорт записей
Добавим в сервис импорт сразу нескольких записей. Полный src/main/java/com/example/blog/PostService.java:
// src/main/java/com/example/blog/PostService.java
package com.example.blog;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Event;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
@ApplicationScoped
public class PostService {
@Inject
PostRepository repository;
@Inject
Event<PostCreated> postCreated;
@Inject
Clock clock;
public Post publish(NewPost data) {
Post post = repository.save(new Post(data.title(), data.body(), LocalDate.now(clock)));
PostCreated event = new PostCreated(post.getId(), post.getTitle());
postCreated.fire(event);
postCreated.fireAsync(event);
return post;
}
@Transactional
public int importPosts(List<NewPost> items) {
for (NewPost item : items) {
if (item.title() == null || item.title().isBlank()) {
throw new IllegalArgumentException("Запись без заголовка в импорте");
}
repository.save(new Post(item.title(), item.body(), LocalDate.now(clock)));
}
return items.size();
}
}importPosts открывает транзакцию. Метод repository.save тоже помечен @Transactional, но по умолчанию он присоединяется к уже открытой транзакции, а не создаёт свою. Поэтому все записи импорта сохраняются вместе.
Ресурс
// src/main/java/com/example/blog/ImportResult.java
package com.example.blog;
public record ImportResult(int imported) {
}// src/main/java/com/example/blog/PostResource.java (фрагмент)
@POST
@Path("/import")
@Consumes(MediaType.APPLICATION_JSON)
public ImportResult importPosts(List<NewPost> items) {
return new ImportResult(service.importPosts(items));
}Проверка отката
Сначала успешный импорт двух записей, затем импорт, где у второй записи пустой заголовок:
curl http://localhost:8080/blog/stats
curl -H "Content-Type: application/json" -d '[{"title":"Импорт один","body":"Текст первой записи"},{"title":"Импорт два","body":"Текст второй записи"}]' http://localhost:8080/blog/api/posts/import
curl http://localhost:8080/blog/stats
curl -i -H "Content-Type: application/json" -d '[{"title":"Импорт три","body":"Текст третьей записи"},{"title":"","body":"Без заголовка"}]' http://localhost:8080/blog/api/posts/import
curl http://localhost:8080/blog/statsЗаписей в блоге: 2
{"imported":2}
Записей в блоге: 4
HTTP/1.1 500 Internal Server Error
Запись без заголовка в импорте
Записей в блоге: 4
Запись «Импорт три» успела пройти через save, но после исключения транзакция откатилась, и в базе её нет — счётчик остался 4. В настоящем API это исключение стоит превратить в ответ 400 обработчиком, как в главе про REST.
Настройки аннотации
| Параметр | Что меняет |
|---|---|
value = TxType.REQUIRED | По умолчанию: присоединиться к транзакции или открыть новую |
TxType.REQUIRES_NEW | Всегда новая транзакция, внешняя приостанавливается |
TxType.MANDATORY | Транзакция обязана быть, иначе исключение |
TxType.NEVER | Транзакции быть не должно |
rollbackOn = IOException.class | Откатывать и при этом проверяемом исключении |
dontRollbackOn = ... | Не откатывать при этом исключении |
Например, @Transactional(TxType.REQUIRES_NEW) подходит для журнала аудита: запись в журнал должна сохраниться, даже если основная операция откатится.
Частая ошибка
Вызвать persist без транзакции. Если убрать @Transactional у PostRepository.save, создание записи падает:
jakarta.persistence.TransactionRequiredException: No active transaction for PuId=blog#blog.war#blog
Читать данные можно без транзакции, а изменять — нет: persist, merge и remove требуют, чтобы метод выше по цепочке вызовов был помечен @Transactional.