Транзакции
Транзакция — это группа действий с базой, которая выполняется целиком или не выполняется вовсе. Если на середине случилась ошибка, база откатывается к состоянию до начала группы. В учебнике вы управляли транзакциями через JDBC: setAutoCommit(false), commit, rollback. В Spring достаточно аннотации @Transactional над методом сервиса.
Аннотации в сервисе
// src/main/java/com/example/blog/PostService.java
@Service
@Transactional(readOnly = true)
public class PostService {
// ...
@Transactional
public PostResponse create(NewPostRequest request) {
User author = users.findById(request.authorId())
.orElseThrow(() -> new IllegalArgumentException("Неизвестный автор " + request.authorId()));
Post post = new Post(request.title(), request.body(), author, Instant.now(clock));
return PostResponse.from(posts.save(post));
}
}Аннотация над классом действует на все его публичные методы: каждый работает в транзакции «только для чтения» — база и Hibernate могут её ускорить. Методы, которые меняют данные, помечены просто @Transactional: аннотация над методом важнее аннотации над классом. Обработчик IllegalArgumentException с кодом 400 добавьте в ApiExceptionHandler так же, как обработчик для PostNotFoundException.
Всё или ничего
Добавим создание нескольких статей одним запросом:
// src/main/java/com/example/blog/PostService.java
@Transactional
public List<PostResponse> createAll(List<NewPostRequest> requests) {
return requests.stream().map(this::create).toList();
}// src/main/java/com/example/blog/PostController.java
@PostMapping("/batch")
@ResponseStatus(HttpStatus.CREATED)
public List<PostResponse> createAll(@RequestBody List<@Valid NewPostRequest> requests) {
return postService.createAll(requests);
}Отправим две статьи, у второй — несуществующий автор:
curl -X POST http://localhost:8080/api/posts/batch -H "Content-Type: application/json" \
-d '[{"title":"Один","body":"1","authorId":1},{"title":"Два","body":"2","authorId":42}]'{"detail":"Неизвестный автор 42","instance":"/api/posts/batch","status":400,"title":"Bad Request"}Первая статья успела записаться в базу, но исключение на второй откатило всю транзакцию. curl http://localhost:8080/api/posts покажет прежнее число статей — "totalElements":1. С правильными авторами придут обе статьи, причём с номерами 3 и 4: номер 2 база выдала откаченной вставке, и он не возвращается.
Изменения без save
Внутри транзакции Hibernate следит за загруженными сущностями. Если поменять поле, при завершении транзакции он сам выполнит update:
// src/main/java/com/example/blog/PostService.java
@Transactional
public PostResponse update(Long id, NewPostRequest request) {
Post post = getPost(id);
post.setTitle(request.title());
post.setBody(request.body());
return PostResponse.from(post);
}Вызова save здесь нет, но в журнале видно:
Hibernate: select p1_0.id,p1_0.author_id,p1_0.body,p1_0.created_at,p1_0.title from posts p1_0 where p1_0.id=?
Hibernate: select u1_0.id,u1_0.username from users u1_0 where u1_0.id=?
Hibernate: update posts set author_id=?,body=?,created_at=?,title=? where id=?Этот механизм называется отслеживанием изменений (dirty checking).
Правила @Transactional
| Правило | Что значит |
|---|---|
| Ставьте на сервис | Транзакция охватывает всю операцию, а не один запрос |
| Работает только на публичных методах бина | Spring оборачивает бин посредником (proxy) |
| Откат — при непроверяемых исключениях | RuntimeException и наследники; для проверяемых нужно rollbackFor |
| Вызов из того же класса идёт мимо посредника | Аннотация на вызываемом методе в этом случае не действует |
Частая ошибка
Транзакция откатывается только при непроверяемых исключениях. Если метод сервиса бросит проверяемое, например IOException при чтении файла, Spring зафиксирует всё, что успело записаться, и в базе останутся половинчатые данные. Либо бросайте наследников RuntimeException, как PostNotFoundException, либо укажите откат явно: @Transactional(rollbackFor = Exception.class).