Użycie adnotacji @Transactional stało się na tyle powszechne, że często nie wiemy, po co to robimy. Transakcje w Springu są dostarczone domyślnie i jako użytkownicy frameworka nie martwimy się jak działają pod spodem. Zdarza się jednak, że niewiedza działa na naszą niekorzyść i nie inaczej jest w tym przypadku. Transakcje są czymś niewidocznym. Niby wiemy jak to działa, lub powinno działać, ale mimo wszystko gdzieś tam pod spodem dzieje się coś nieznanego. W tym artykule przybliżymy i odkryjemy to nieznane.
W dzisiejszym artykule:
1. @Transactional w Springu
Transakcje w springu można uruchomić używając adnotacji @Transactional na metodzie lub klasie. Kiedy metoda oznaczona jako transakcyjna zostaje wywołana, Spring przechwytuje wywołanie i na naszą metodę nakłada proxy (*lub manipuluje naszym kodem bajtowym, jeżeli zmieniliśmy domyślny proxy mode dla springa). Proxy uruchamia TransactionInterceptor, który zarządza transakcją. a następnie w klasie TransactionAspectSupport (klasa rodzic dla TransactionInterceptor), wywoływana jest docelowa metoda biznesowa. Żeby lepiej to zobrazować posłużę się obrazkiem z dokumentacji springa.
Warto wiedzieć, że samo nałożenie adnotacji @Transactional na metodę nie jest wystarczające. Oprócz tego należy spełnić jeszcze 2 warunki:
- klasa, w której znajduje się metoda musi być bean’em springowym,
- metoda, na której znajduje się adnotacja musi być publiczna (przy założeniu, że używasz domyślnego proxy mode),
- metoda oznaczona jako @Transactional musi być wołana z innego beana springowego.
2. Wyjątki w transakcjach
Wyobraź sobie taki scenariusz. Masz jakiś serwis, w którym zapisujesz zadanie do bazy danych. Twój serwis potrafi jednak wyrzucić jakiś checked exception.
1 2 3 4 5 6 7 | @Override @Transactional public void create(Task task) throws Exception { em.persist(task); // logika biznesowa która rzuca wyjątek throw new Exception(); } |
Z jednej strony widać że poleciał błąd i naturalnym by się wydawało że powinien był wykonać się rollback. Jednak, jak możesz domyślać się z kontekstu – rollback nie został wykonany. Zamiast tego akcja została zacommitowana do bazy i pozwól, że wyjaśnię co się stało i czy da radę temu jakoś zaradzić. Na początku zacytuję dokumentację:
Any RuntimeException
triggers rollback, and any checked Exception
does not.
Dlaczego architekci Springa podjęli właśnie taką decyzję? Oczywiście jest to decyzja projektowa – cytuję dalej dokumentację z innego miejsca:
While the Spring default behavior for declarative transaction management follows EJB convention
(roll back is automatic only on unchecked exceptions), it is often useful to customize this behavior.
Jeżeli zajrzymy w kod aspektu transakcji znajdujący się w TransactionAspectSupport, który odpowiedzialny jest za przechwytywanie wyjątków, dojdziemy do metody completeTransactionAfterThrowing. Metoda ta ma w sobie sprawdzenie txInfo.transactionAttribute.rollbackOn(ex).
1 2 3 4 5 6 | if (txInfo.transactionAttribute != null && txInfo.transactionAttribute.rollbackOn(ex)) { try { txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus()); } ... } |
Doprowadza nas to do klasy RuleBasedTransactionAttribute i jej metody rollbackOn. Nie będę przeklejał tutaj całej metody, ale skupię się na fragmencie, który pomoże nam zrozumieć w jaki sposób możemy rozszerzyć @Transactional o rollbackowanie wyjątków sprawdzanych. Zauważ, że jest tu robiona iteracja na this.rollbackRules. I to jest właśnie klucz.
1 2 3 4 5 6 7 8 9 10 11 | ``` if (this.rollbackRules != null) { for (RollbackRuleAttribute rule : this.rollbackRules) { int depth = rule.getDepth(ex); if (depth >= 0 && depth < deepest) { deepest = depth; winner = rule; } } } ``` |
Używając adnotacji @Transactional możemy do niej przekazać różne parametry, między innymi parametr rollbackFor, w którym definijemy klasy dziedziczące po Throwable, jakie mają być brane pod uwagę przy rollbackowaniu. Oczywiście dotyczy się to wyjątków przechwytywanych (checked exceptions). Więcej info w dokumentacji – @Transactional Settings.
3. Propagacja transakcji
Jest ważne aby wiedzieć, jak będą zachowywały się transakcje w naszym systemie, gdy mamy kilka wywołań metod a na każdej z nich adnotację @Transactional. Dodatkowo, dosyć często, pytanie o to, co się dzieje z transakcją jest zadawane na rozmowach rekrutacyjnych.
No to spójrzmy jakie typy propagacji są dostępne na adnotacji @Transactional
1 2 3 4 5 6 7 8 9 10 | public enum Propagation { REQUIRED(TransactionDefinition.PROPAGATION_REQUIRED), SUPPORTS(TransactionDefinition.PROPAGATION_SUPPORTS), MANDATORY(TransactionDefinition.PROPAGATION_MANDATORY), REQUIRES_NEW(TransactionDefinition.PROPAGATION_REQUIRES_NEW), NOT_SUPPORTED(TransactionDefinition.PROPAGATION_NOT_SUPPORTED), NEVER(TransactionDefinition.PROPAGATION_NEVER), NESTED(TransactionDefinition.PROPAGATION_NESTED); ... } |
3.1. REQUIRED
Jest do domyślny poziom propagacji. Spring wchodząc do metody oznaczonej adnotacją @Transactional sprawdza czy istnieje aktywna transakcja i jeżeli nie, to tworzy nową. W przypadku, gdy transakcja już istniała, logika metody jest aplikowana do istniejącej transakcji.
1 2 3 4 5 6 7 8 9 10 11 | // nie stosuj w taki sposób @Transactional(propagation = Propagation.REQUIRED) public void requiredExample(String user) { // ... } // preferowane użycie - Propagation.REQUIRED jest ustawiany domyślnie @Transactional public void requiredExample(String user) { // ... } |
Pseudokod logiki aplikującej REQUIRED
1 2 3 4 5 6 7 | if (isExistingTransaction()) { if (isValidateExistingTransaction()) { validateExisitingAndThrowExceptionIfNotValid(); } return existing; } return createNewTransaction(); |
3.2. SUPPORTS
Ten typ propagacji sprawdza czy istnieje transakcja i jeżeli tak, to jej używa. W przeciwnym razie metoda jest wykonywana bez użycia transakcji.
1 2 3 4 | @Transactional(propagation = Propagation.SUPPORTS) public void supportsExample(String user) { // ... } |
Pseudokod:
1 2 3 4 5 6 7 | if (isExistingTransaction()) { if (isValidateExistingTransaction()) { validateExisitingAndThrowExceptionIfNotValid(); } return existing; } return emptyTransaction; |
3.3. MANDATORY
Szuka istniejącej transakcji i jej używa. W przypadku, gdy nie może znaleźć istniejącej transakcji wyrzuca wyjątek.
1 2 3 4 | @Transactional(propagation = Propagation.MANDATORY) public void mandatoryExample(String user) { // ... } |
Pseudokod:
1 2 3 4 5 6 7 | if (isExistingTransaction()) { if (isValidateExistingTransaction()) { validateExisitingAndThrowExceptionIfNotValid(); } return existing; } throw IllegalTransactionStateException; |
3.4. REQUIRES_NEW
Zawsze powstaje nowa transakcja. W przypadku, gdy wcześniej jakaś transakcja była już otwarta, zostaje ona wstrzymana.
1 2 3 4 | @Transactional(propagation = Propagation.REQUIRES_NEW) public void requiresNewExample(String user) { // ... } |
Pseudokod:
1 2 3 4 5 6 7 8 9 10 | if (isExistingTransaction()) { suspend(existing); try { return createNewTransaction(); } catch (exception) { resumeAfterBeginException(); throw exception; } } return createNewTransaction(); |
W taki sposób możesz to sobie zilustrować.
3.5. NOT_SUPPORTED
Sprawdza, czy istnieje transakcja i jeżeli znajduje istniejącą, to ją wstrzymuje. Logika biznesowa dalej wykonywana jest już bez użycia transakcji.
1 2 3 4 | @Transactional(propagation = Propagation.NOT_SUPPORTED) public void notSupportedExample(String user) { // ... } |
Pseudokod:
1 2 3 4 | if (isExistingTransaction()) { suspend(existing); } return continueWithBusinessLogic(); |
3.6. NEVER
Wykonuje metodę bez użycia transakcji. W przypadku gdy transakcja istniała, wyrzcany jest wyjątek.
1 2 3 4 | @Transactional(propagation = Propagation.NEVER) public void neverExample(String user) { // ... } |
Pseudokod:
1 2 3 4 | if (isExistingTransaction()) { throw IllegalTransactionStateException; } return emptyTransaction; |
3.7. NESTED
Sprawdzane jest czy istnieje transakcja – jeżeli tak, to oznacza ją jako savepoint. Tzn. jeżeli logika biznesowa wyrzuci wyjątek to rollback przywróci nas do tego savepointa. Jeżeli transakcja nie istniała, to propagacja zachowuje się tak samo jak w przypadku REQUIRED.
1 2 3 4 | @Transactional(propagation = Propagation.NESTED) public void nestedExample(String user) { // ... } |
Stety, niestety – opcja ta wymaga wsparcia savepointów i działa tylko dla połączeń JDBC. Zatem, jeżeli używasz hibernate’a to przy próbie wykorzystania tego poziomu propagacji dostaniesz wyjątek:
1 | org.springframework.transaction.NestedTransactionNotSupportedException: JpaDialect does not support savepoints - check your JPA provider's capabilities |
Podsumowanie
Moim zdaniem każdy świadomy programista powinien przyswoić wiedzę o tym, jak działają transakcje. Developerzy nie znający podstaw będą kombinowali jak koń pod górkę robiąc dziwne obejścia, a wystarczyłoby odpowiednio użyć poziomów transakcji. Mam nadzieję, że ten artykuł pomógł Ci w zrozumieniu jak działa adnotacja @Transactional w Springu oraz jak działają transakcje same w sobie.
Zostawiam Ci 2 źródła z których sam korzystałem pisząc ten artkuł, jeżeli chcesz wejść jeszcze głębiej w temat transakcji:
Wydaję mi się, że checked exception zepsuję transakcje i zmiany nie zostaną zapisanet
nie
[…] chcesz dowiedzieć się więcej na temat różnych typów propagacji transakcji to zapraszam w to miejsce. Bartek przechodzi po każdym dostępnym rodzaju i wyjaśnia go w skondensowanej formie. Mam […]
Musi być spełniony jeszcze jeden warunek, o którym nie napisałeś – metoda oznaczona jako @Transactional musi być wołana z innego beana springowego, bez tego nie zadziała.
Wojtek, dzięki za komentarz. To jest bardzo ważna rzecz, aktualnie refactoruję fragment kodu z transakcją i dziwie się, że wywołanie z tego samego beana sprawia, że nie mam sesji Hibernate.
Formalnie faktycznie warto o tym wspomnieć, tak jak zrobiłeś ale fakt w jaki jest to zrobione nie jako to implikuje, ponieważ tylko dla wołania jako z beana działają w ogóle apsekty.