Исправьте импорт, у которого @Transactional на чанк никогда не откатывается
Этот сервис импортирует строки чанками. importChunk помечен @Transactional, поэтому чанк, упавший на середине, должен откатываться целиком. В production этого не происходит: строки, сохранённые до падения, остаются в базе, и откат нигде не логируется. Бин — обычный @Service, управление транзакциями включено, а тот же самый importChunk откатывается корректно, когда его вызывает контроллер.
Ограничения:
- сохраните по одной транзакционной границе на чанк — упавший чанк не должен коммитить частично
- не оборачивайте весь
importAllв одну транзакцию
@Service
public class ImportService {
private final RowRepository repo;
public ImportService(RowRepository repo) {
this.repo = repo;
}
public void importAll(List<List<Row>> chunks) {
for (List<Row> chunk : chunks) {
this.importChunk(chunk);
}
}
@Transactional
public void importChunk(List<Row> chunk) {
for (Row row : chunk) {
repo.save(row); // падает на битой строке
}
}
}
Найдите и исправьте ошибку.
@Transactional применяет прокси, оборачивающий бин, поэтому аннотация действует только на вызов, входящий в бин снаружи. this.importChunk(chunk) — внутренний вызов, который не покидает ImportService, идёт мимо прокси и не открывает транзакцию вовсе — каждый save просто автокоммитится. Исправление — направить вызов через прокси: внедрить бин в самого себя, вынести importChunk в отдельный бин или открыть транзакцию явно через TransactionTemplate.
- ✗Ожидать, что
@Transactionalподействует на метод, который бин вызывает у самого себя - ✗Винить правила отката, когда транзакция вообще не открывалась
- ✗Считать, что один только
REQUIRES_NEWчинит вызов, не доходящий до прокси
- →Почему пометка
importChunkкакprivateилиfinalломает@Transactionalтак же? - →Чем рискованно внедрять бин в самого себя, чтобы направить вызов через собственный прокси?
Исправление
@Service
public class ImportService {
private final RowRepository repo;
private final ImportService self; // ссылка на СЕБЯ через прокси
public ImportService(RowRepository repo, @Lazy ImportService self) {
this.repo = repo;
this.self = self;
}
public void importAll(List<List<Row>> chunks) {
for (List<Row> chunk : chunks) {
self.importChunk(chunk); // ✅ вызов идёт через прокси
}
}
@Transactional
public void importChunk(List<Row> chunk) {
for (Row row : chunk) {
repo.save(row);
}
}
}
Что происходило. @Transactional реализован через AOP-прокси: контейнер отдаёт другим бинам не сам ImportService, а обёртку вокруг него. Транзакцию открывает и закрывает именно обёртка — на входе в метод и на выходе из него.
Вызов this.importChunk(chunk) идёт по обычной ссылке на объект, минуя обёртку. Прокси о нём просто не знает, транзакция не открывается, и каждый repo.save(row) уходит в базу автокоммитом. Первые строки чанка остаются записанными, откатывать нечего — транзакции не было. Тот же метод, вызванный контроллером, приходит через прокси и работает как задумано — отсюда и «в одном месте работает, в другом нет».
Три рабочих исправления:
| Способ | Когда выбирать |
|---|---|
@Lazy self-инъекция (выше) | Точечная правка, класс не хочется резать. @Lazy нужен, чтобы разорвать цикл создания бина |
Вынести importChunk в отдельный бин ChunkImporter | Самое чистое: вызов становится внешним естественным образом |
TransactionTemplate внутри importAll | Когда границу транзакции удобнее задать императивно, без аннотации |
⚠️ Та же ловушка ломает и @Async, @Cacheable, @PreAuthorize — всё, что построено на прокси. И по той же причине @Transactional молча не работает на private- и final-методах: прокси не может их перехватить.