Wstęp
W jednym z poprzednich wpisów pisałem o tym, czym jest i jak działa @Transactional w Springu. Wiesz już, że opakowuje kod w jedną transakcję i cofa zmiany, gdy coś pójdzie nie tak. Brzmi jak coś, co chcesz mieć wszędzie.
No i tu zaczynają się też problemy. Widziałem sporo serwisów, w których adnotacja wisiała nad każdą metodą „na wszelki wypadek”. Zwykle nie dlatego, że była potrzebna, tylko dlatego, że nikt się nie zastanowił, czy akurat tu ma sens. A ma to jednak swoją cenę: trzyma połączenie do bazy, otwiera i zamyka transakcję, czasem cicho nie robi tego, czego od niej oczekujesz.
Pokażę Ci cztery sytuacje, w których @Transactional bardziej przeszkadza, niż pomaga, i co zrobić zamiast niej.
Gdy metoda tylko czyta albo w ogóle nie dotyka bazy
Najczęstszy przypadek: metoda, która nic nie zapisuje. Pobiera dane i coś na nich liczy, formatuje odpowiedź, sprawdza uprawnienia. Transakcja tu niczego nie chroni, bo nie ma czego cofać.
Jeszcze gorzej wygląda @Transactional na metodzie, która z bazą nie rozmawia w ogóle – jakieś przeliczenie, walidacja, złożenie DTO. Spring i tak otworzy transakcję, pobierze połączenie z puli i po wszystkim je zwolni. Robota za darmo.
Jeśli metoda faktycznie czyta z bazy i chcesz mieć spójny odczyt, użyj wariantu tylko do odczytu:
@Transactional(readOnly = true)
public OrderSummary getSummary(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
return summaryMapper.toSummary(order);
}
readOnly = true daje Hibernate sygnał, że nie będzie zapisów, więc może odpuścić śledzenie zmian encji (dirty checking). Na dużych odczytach to realna oszczędność. Ale jak metoda nie dotyka bazy wcale, po prostu zdejmij adnotację.
Gdy w środku wołasz zewnętrzne API
To pułapka, która lubi wyjść dopiero na produkcji, przy większym ruchu. Wygląda tak:
@Transactional
public void placeOrder(OrderRequest request) {
Order order = orderRepository.save(new Order(request));
paymentClient.charge(order); // wywołanie zewnętrznego API
notificationClient.sendEmail(order); // kolejne wywołanie po sieci
}
Dopóki ta metoda się wykonuje, transakcja jest otwarta, a połączenie z bazą zajęte. Problem w tym, że paymentClient.charge() idzie po sieci do innego systemu i może trwać sekundę, dwie, a przy timeoutach jeszcze dłużej. Przez cały ten czas trzymasz połączenie, którego baza wcale nie potrzebuje.
Pula połączeń jest skończona (domyślnie HikariCP daje 10). Wystarczy kilkanaście równoległych zamówień wiszących na wolno odpowiadającym API płatności i pula się kończy. Kolejne żądania czekają na wolne połączenie, aplikacja zwalnia, a przyczyna jest zupełnie gdzie indziej, niż podpowiada pierwsze spojrzenie.
Zasada, którą sam stosuję: transakcja ma być krótka i obejmować tylko zapis do bazy. Operacje I/O po sieci wyrzuć poza nią. Zapisz zamówienie w transakcji, a płatność i mail obsłuż po jej zamknięciu, najlepiej asynchronicznie albo przez zdarzenie.
Jak to rozbić w praktyce? Sam zapis zamówienia zostaje w krótkiej metodzie z @Transactional, a paymentClient.charge() i wysyłkę maila wołasz dopiero po jej zakończeniu, już poza transakcją. A jeśli zależy Ci, żeby przy nagłym padzie aplikacji żadne zdarzenie nie zginęło, sięgnij po wzorzec outbox: w jednej transakcji zapisujesz i zamówienie, i wpis o zdarzeniu do wysłania, a osobny proces dowozi resztę asynchronicznie. Sam budowałem tak aplikację do transferu danych na Springu z RabbitMQ i przy większym ruchu to się po prostu broni.
Gdy liczysz na rollback, którego nie będzie
Tu adnotacja jest na miejscu, ale nie robi tego, czego się spodziewasz. Dwie klasyczne wpadki.
Pierwsza: wyjątek sprawdzany (checked exception) domyślnie nie cofa transakcji. Spring robi rollback tylko dla RuntimeException i Error. Jak rzucisz własny wyjątek sprawdzany, transakcja się zatwierdzi mimo błędu.
@Transactional
public void archive(Long id) throws IOException {
orderRepository.updateStatus(id, ARCHIVED); // zapisze się
fileStorage.remove(id); // rzuca IOException - a mimo to zmiana wyżej zostaje
}
Jak chcesz cofać także dla sprawdzanych wyjątków, powiedz to wprost:
@Transactional(rollbackFor = IOException.class)
Druga wpadka to samowywołanie. @Transactional działa przez proxy – Spring podstawia obiekt opakowujący Twój serwis. Kiedy wołasz metodę transakcyjną z innej metody tej samej klasy, wywołanie idzie wewnętrznie, z pominięciem proxy. Adnotacja jest wtedy ignorowana i transakcja się nie tworzy.
public void process(OrderRequest request) {
validate(request);
save(request); // wywołanie wewnętrzne - @Transactional nie zadziała
}
@Transactional
public void save(OrderRequest request) {
orderRepository.save(new Order(request));
}
To samo dotyczy metod niepublicznych – na private czy protected proxy się nie założy. Jak potrzebujesz transakcji w takim układzie, wyciągnij metodę do osobnego beana i wstrzyknij go, albo przenieś adnotację na publiczny punkt wejścia. Najczęściej wystarczy to drugie – trzymaj @Transactional na metodzie wołanej z kontrolera, a nie na wewnętrznych metodach pomocniczych.
Gdy owijasz transakcją ciężkie przetwarzanie w pętli
To inna odsłona poprzedniego problemu, tylko przyczyna leży po Twojej stronie, nie w sieci. Import pliku, przeliczenie stanów magazynowych, migracja danych – pętla po tysiącach rekordów zamknięta w jednej transakcji:
@Transactional
public void importOrders(List<OrderRow> rows) {
for (OrderRow row : rows) {
orderRepository.save(new Order(row)); // 50 000 zapisów w jednej transakcji
}
}
Dopóki pętla się kręci, transakcja jest otwarta. Baza trzyma blokady na zapisanych wierszach, a log zmian do cofnięcia (undo log) rośnie z każdym rekordem. Jak błąd wyskoczy na 49-tysięcznym wierszu, cofasz całość od zera. Do tego jedno połączenie z puli zajęte na cały czas przetwarzania – wracamy do problemu z sekcji o zewnętrznym API, tylko tym razem to Twoja pętla trzyma zasób.
Rozbij robotę na porcje i commituj co jakąś partię rekordów. Wtedy blokady schodzą na bieżąco, a błąd pod koniec kosztuje Cię jedną paczkę, nie cały import. Wydziel metodę zapisującą pojedynczą partię z własnym @Transactional, ale pamiętaj o samowywołaniu z poprzedniej sekcji – musi trafić do osobnego beana, inaczej proxy jej nie obejmie. W praktyce do wsadowego przetwarzania i tak sięgniesz po Spring Batch albo saveAll z rozsądnym rozmiarem paczki, ale zasada zostaje ta sama: krótka transakcja na porcję zamiast jednej na wszystko.
Kiedy więc używać @Transactional?
Żeby nie zostało samo „nie rób tego”, krótkie podsumowanie.
Sytuacja | @Transactional? |
Dane logowania | Tak |
Jeden zapis, jedna operacja na bazie | Zwykle nie trzeba (baza sama go otoczy) |
Metoda tylko czyta dane | Tylko readOnly = true, jeśli w ogóle |
Metoda nie dotyka bazy | Nie |
W środku wołasz zewnętrzne API albo wysyłasz mail | Nie wokół całości - wydziel sam zapis |
Przetwarzasz dużą partię danych w pętli | Tak, ale commituj partiami |
Najprościej mówiąc: @Transactional wrzucaj tam, gdzie realnie łączysz kilka operacji zapisu w jedną całość, która ma się wykonać albo w komplecie, albo wcale. Wszędzie indziej najpierw zapytaj, po co jej tam.
Podsumowanie
@Transactional to dobra adnotacja, tylko traktowana jak domyślne „zabezpieczenie” zaczyna szkodzić – trzyma połączenia, wydłuża transakcje i czasem cicho nie robi rollbacku, na który liczysz. Zamiast wrzucać ją z automatu, sprawdź, czy metoda faktycznie łączy kilka zapisów w jedną spójną operację.
A jak u Ciebie w projekcie – trafiłeś na serwis obwieszony @Transactional na każdej metodzie? Daj znać w komentarzu, ciekaw jestem, gdzie ta pula połączeń najczęściej wysiada.