Transakcje są potężnym narzędziem, pozwalają nam na zaaplikowanie zmian w rozproszonej logice w jednym momencie. Zapewniają to, że wszystkie zmiany w danych zostaną zaaplikowane razem albo wcale. Modyfikując dane w wielu krokach, narazimy się na ryzyko posiadania niespójnych danych, jeżeli nie użyjemy transakcji. Jednakże transakcji należy używać świadomie i z rozsądkiem.
Transakcje – mechanizm zapewniający spójność danych
Logika aplikacji, która modyfikuje dane, zazwyczaj zmienia dane wielu rodzajów obiektów. Chcemy, aby:
- Operacje modyfikacji wykonały się w ściśle określonej kolejności, gdyż bieżące operacje mogą bazować na wynikach poprzedników.
- Zmiany zostały zaaplikowane albo wszystkie, albo wcale.
W tym artykule przedstawię mechanizm działania transakcji w Spring Framework.
Z artykułu dowiesz się:
- Czym jest transakcja?
- Kiedy stosować transakcje?
- Jak skonfigurować transakcje, aby zadziałały?
- Czego nie powinieneś robić?
- Wyjątki a transakcje.
- Timeout a transakcje.
Wydajność Hibernate
Twórz szybko działające aplikacje z wydajną i zoptymalizowaną obsługą bazy danych.
Logika aplikacji realizowana w wielu miejscach
Wyobraź sobie logikę w aplikacji napisanej z wykorzystaniem Spring Framework, która realizuje funkcję zapłaty za nieopłacone zamówienie z wykorzystaniem karty podarunkowej z punktami. Logika działa w ten sposób, iż w przypadku gdy klient posiada na karcie dostępne środki dla zapłaty, pomniejsza liczbę dostępnych punktów z karty, oznacza zamówienie jako zapłacone i gotowe do wysyłki oraz wysyła maila do użytkownika o tym, iż zamówienie uzyskało status Do wysyłki. Po uzyskaniu statusu Do wysyłki magazyn wysyła fizycznie paczkę do klienta.
Proces zapłaty za zamówienie składa się z następujących części:
- Podproces pobrania danych wejściowych:
- Wyszukanie nieopłaconego zamówienia do zapłaty.
- Odczytanie danych nieopłaconego zamówienia (dane klienta, liczba punktów).
- Odczytanie danych karty klienta i weryfikacja czy klient jest w stanie zapłacić.
- Podproces zapłaty (wejściem jest numer klienta, numer karty, numer zamówienia, liczba punktów za zamówienie, adres email klienta):
- Pobranie punktów z karty klienta.
- Wysłanie maila o tym ze zamówienie zostało opłacone i zostanie wysłane.
- Ustawienie statusu zamówienia Do wysłania.
- Podproces wysyłki paczki:
- Wysyła wszystkie niewysłane jeszcze paczki dla zamówień ze statusem Do Wysłania.
- Ustawia status paczki na Wysłana.
Wszystkie podprocesy są niezależne, ale są wywoływane po kolei. Wszystko działa prawidłowo, ale zawsze może pojawić się błąd w procesie.
Błąd na etapie przygotowania danych wejściowych
W podprocesie pobierania danych wejściowych wystąpił błąd. Przyczyną może być na przykład:
- Błąd połączenia do bazy danych.
- Błąd walidacji danych wejściowych.
Jakie konsekwencje mogą wystąpić w przypadku takiego błędu?
- Proces zapłaty zostanie przerwany.
- Jeżeli wystąpi błąd połączenia do bazy danych, to zawsze można będzie powtórzyć operację (po przywróceniu połączenia).
- Jeżeli wystąpi błąd walidacji danych, to proces można będzie powtórzyć po usunięciu nieprawidłowości (np. doładowanie punktów karty przez klienta).
Żadne dane nie zostały zmienione, wiec proces będzie można powtórzyć (chyba że zamówienie zostało już opłacone lub wysłane) – dane są spójne. Jeżeli zamówienie zostało już opłacone, to dane także są spójne, bo niemożliwe będzie ponowne przejście procesu – jest ono zapłacone, a proces dotyczy nieopłaconych zamówień.
Błąd na etapie zapłaty za zamówienie
Podproces pobierania danych wejściowych zakończył się pomyślnie. Kolejnym krokiem jest wywołanie logiki podprocesu zapłaty. Przykładowe nieprawidłowości, jakie mogą się pojawić to:
- Problem połączenia do bazy danych (podobnie jak w poprzednim podprocesie).
- Równoległa edycja danych (kwota została już pobrana z karty lub zamówienie zostało zapłacone, lub wysłane).
- Błąd wysyłki maila.
Jakie mogą być konsekwencje?
- Punkty z karty zniknęły.
- Użytkownik dostał maila, że zamówienie ma status Do wysłania.
- W systemie zamówienie ma nadal status Do zapłaty.
- Wysyłka do klienta nie zostanie zrealizowana, gdyż zamówienie nie ma statusu Do wysyłki.
Część danych została zmieniona a część nie – dane są niespójne !!!
Błąd projektowy – wysyłka maila w logice zapłaty
W przedstawionym powyżej procesie wysyłka maila nieprawidłowo została umieszczona pomiędzy pobieraniem punktów z karty a ustawieniem statusu Do wysyłki po zapłacie. Powinna być osobnym podprocesem, podobnie jak wysyłka paczki.
- Logika zaburza wzorzec projektowy SRP – spodziewamy się zapłaty za zamówienie kartą, a dodatkowo wysyłany jest mail do klienta.
- Nie mamy w naszej aplikacji wpływu na działanie systemu pocztowego i odczytanie przez odbiorcę (serwer nadawcy, serwer odbiorcy, dostawca łącza internetowego, folder SPAM).
- Istotne jest to, aby opłata została pobrana a zamówienie zostało wysłane – mail nie jest najważniejszy
Naprawiamy błąd i przenosimy logikę wysyłki mail do osobnego podprocesu.
Kiedy błąd wystąpi w tym samym miejscu, możemy spodziewać się następujących konsekwencji:
- Punkty z karty zniknęły.
- Zamówienie ma nadal status Do zapłaty.
- Wysyłka do klienta nie zostanie zrealizowana, gdyż zamówienie nie ma statusu Do wysyłki.
Jedyną różnicą w stosunku do poprzedniej wersji jest to, że mail nie został wysłany. Dane nadal są niespójne !!!
Koncepcja transakcji
Patrząc na sytuację powyżej, chcielibyśmy, aby wszystkie modyfikacje danych na bazie danych zostały zaaplikowane jednocześnie. Taki mechanizm to właśnie transakcje w bazie danych. Relacyjne bazy danych, które są transakcyjne (nie wszystkie są transakcyjne) wspierają takie operacje. Po stronie bazy danych proces wygląda w następujący sposób:
- Rozpoczęcie transakcji (alokacja zasobów po stronie serwera bazy danych – pamięć, dysk, procesor, dodatkowe narzuty i procesy).
- Modyfikacje danych na bazie danych (wiele operacji INSERT, UPDATE, DELETE, na różnych tabelach) w osobnej przestrzeni (są to lokalne zmiany dla bieżącej sesji bazy danych, nie są one widoczne w innych sesjach).
- Zakończenie transakcji:
- Zatwierdzenie (commit) – wszystkie zmiany zostają zatwierdzone na bazie, inne sesje od tego momentu będą widziały zmiany, jakich dokonaliśmy. Dodatkowo zostają zwolnione zasoby bazy danych.
- Wycofanie zmian (rollback), Następuje wycofanie lokalnych zmian oraz zwolnienie zasobów
Natomiast potrzebujemy także mechanizmu wspierającego transakcje w Java, a najlepiej w Spring.
@Transactional w Spring
W Spring dostępny jest mechanizm wspierający obsługę transakcji. Mechanizm ten jest ściśle powiązany z anotacją @Transactional.
Jak działa adnotacja @Transactional?
- Włącza obsługę transakcji.
- Dostarcza danych oraz API dla frameworka ORM, który odpowiada za wymianę danych z bazą danych (Spring nie wysyła zapytań do bazy danych – realizuje to ORM).
- Owija metodę beana Spring (która jest transakcyjna) dodając logikę, która jest wywoływana przed i po właściwej metodzie klasy (beana Spring):
- Rozpoczyna transakcję lub używa wcześniej rozpoczętej transakcji przed wejściem do metody.
- Umożliwia dwukierunkową integracje Spring z ORM (np. przekazuje połączenie do bazy danych do ORM, informuje ORM o konieczności zakończenia transakcji) .
- Uruchamia mechanizmy monitorujące czas wykonania operacji czy też rzucane wyjątki przez wywołania pomiędzy beanami Spring.
- Zatwierdza lub wycofuje zmiany na bazie po wyjściu z metody.
Kiedy transakcje zadziałają w Spring?
- Kiedy obsługa transakcji w Spring jest włączona (poprzez anotację @EnableTransactionManagement umieszczoną w klasie adnotowanej @Configuration lub z poziomu konfiguracji Spring w XML).
@Configuration
@EnableTransactionManagement
public class OrderPaymentsConfig { ... }
- Anotacja @Transactional musi być umieszczona w klasie, a nie w interfejsie.
@Service
@Transactional
public class ShippingProcessor {
@Transactional(propagation = Propagation.REQUIRES_NEW)
void markOrderShipped(String orderNumber) {
......
}
}
Dla interfejsu nie zadziała.
@Transactional
public interface CardApi {
@Transactional(propagation = Propagation.REQUIRES_NEW)
void addMoneyToCard(String cardUUid, BigDecimal amountToAdd);
}
- Tylko z poziomu wywołania z wykorzystaniem proxy – jeden bean Spring używa metody transakcyjnej innego beana Spring (wołana metoda lub klasa zawierająca tę metodę ma adnotacje @Transactional)
@Service
public class CustomerOrderManager {
.............................
CustomerRepository customerRepository;
void processPaymentForOrder(String orderNumber, String chargedCustomerId) {
var customer = customerRepository.findById(chargedCustomerId);
orderPayments.chargeCardForOrder(customer.getCardId(), orderNumber);
mailNotifier.sendOrderPaidMessage(customer.getEmailAddress(),orderNumber);
}
}
@Service
@RequiredArgsConstructor
public class OrderPayments {
@Transactional
void chargeCardForOrder(String cardUuid, String orderNumber) {
.......................
}
}
Wywołanie metody lockAllUserCards nie odniesie skutku, ponieważ transakcyjna metoda lockCard jest wołana bezpośrednio, a nie przez innego beana. Transakcja nie zostanie rozpoczęta.
@Service
public class CardManager {
@Transactional
void lockCard(String cardUuid) {
.................
}
void lockAllUserCards(String userId) {
findUsersCards(userId)
.forEach(this::lockCard);
}
private List<String> findUserCards(String userId) {
.............
}
}
Logika aplikacji
Wykonujemy kolejne kroki procesu:
- Wyszukanie danych klienta w bazie danych (aby pobrać adres mailowy do powiadomień).
- Obciążenie karty klienta kosztami zamówienia o podanym numerze.
- Wysłanie powiadomienia do klienta o tym, iż zamówienie zostało opłacone.
@Service
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
@RequiredArgsConstructor
public class CustomerOrderManager {
CustomerRepository customerRepository;
OrderPayments orderPayments;
MailNotifier mailNotifier;
void processPaymentForOrder(String orderNumber, String chargedCustomerId) {
var customer = customerRepository.findById(chargedCustomerId);
.......
orderPayments.chargeCardForOrder(customer.getCardId(), orderNumber);
mailNotifier.sendOrderPaidMessage(customer.getEmailAddress(),orderNumber);
}
}
Logika podprocesu zapłaty musi być transakcyjna, gdyż musimy zmodyfikować dane w dwóch miejscach jednocześnie:
- Pomniejszyć limit karty o kwotę/liczbę punktów, jaką trzeba zapłacić za zamówienie.
- Zmienić status zamówienia na zapłacone.
@Service
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
@RequiredArgsConstructor
public class OrderPayments {
CardRepository cardRepository;
OrderRepository orderRepository;
@Transactional
void chargeCardForOrder(String cardUuid, String orderNumber) {
var order = orderRepository.findById(orderNumber);
var card = cardRepository.findById(cardUuid);
card.charge(order.getAmountToPay());
cardRepository.save(card);
order.markAsPaid();
orderRepository.save(order);
}
}
Widać, że nie wywołujemy w żadnym miejscu zatwierdzenia – to dlatego, że mechanizm obsługi transakcji Spring zintegrowany z frameworkiem do obsługi bazy danych sam wywoła commit lub rollback, w zależności od tego, czy wystąpił wyjątek.
Co więcej, jeżeli framework do obsługi baz danych jest zgodny z Java Persistence Api (np. Hibernate) to nie potrzebujemy także wywoływać metody save na repository. Załatwi to Entity Manager, który oznaczy encje Card oraz Order jako zmodyfikowane.
@Transactional
void chargeCardForOrder(String cardUuid, String orderNumber) {
var order = orderRepository.findById(orderNumber);
var card = cardRepository.findById(cardUuid);
card.charge(order.getAmountToPay());
order.markAsPaid();
}
Logika wysyłki maila nie wspiera transakcji, jeżeli wystąpi błąd podczas wysyłki, to zostanie on jedynie zalogowany. W przypadku takiego rodzaju logiki rekomendowane jest, aby klasa MailNotifier była asynchroniczna (adnotacją @Async + włączona jej obsługa auto konfiguracją @EnableAsync).
@Component
@Slf4j
@Async(ASYNC_NOTIFICATION_EXECUTOR)
public class MailNotifier {
EmailApi emailApi;
void sendOrderPaidMessage(String emailAddress, String orderNumber) {
try {
emailApi.sendEmail(emailAddress, SHOP_SENDER_ADDRESS,
ORDER_PAID_TEMPLATE, orderNumber);
} catch (Exception e) {
log.error("Błąd wysyłki maila", e);
}
}
}
Koszt transakcji w Spring
Mechanizm transakcji jest dość kosztowny. Wymaga on dodatkowych zasobów zarówno po stronie aplikacji Java (Spring, framework ORM), jak i serwera bazy danych. Dodatkowo potrzebny jest czas na wykonanie takich operacji jak:
- Uruchomienie dodatkowych zasobów (pamięć, procesy, połączenia sieciowe).
- Zwolnienie dodatkowych zasobów.
- Zatwierdzenie lub wycofanie zmian.
- Monitoring (zmiany, czas trwania, wyjątki).
Konfiguracja w @Transactional
Adnotacja @Transactional posiada kilka atrybutów, służących do konfiguracji transakcji:
- Manager transakcji – w Spring możliwe jest zdefiniowanie więcej niż jednego managera transakcji.
- Rodzaj propagacji transakcji – steruje sposobem wykorzystania transakcji. Najważniejsze to:
- REQUIRED – domyślna wartość, jeżeli jest otwarta transakcja, to ją używa, jeśli nie – tworzy nową.
- REQUIRES_NEW – zawiesza bieżąca transakcję i tworzy nową odrębną. Przykładem wykorzystania może być np. wstępna rezerwacja towaru, tak aby nie był już dostępny, mimo że jeszcze nie skończyliśmy. Uwaga: Zmiany będą propagowane do bazy danych (to osobna transakcja).
- NOT_SUPPORTED – zawiesza bieżącą transakcję i wykonuje operację bez transakcji, co za tym idzie, nie obowiązują ograniczenia (np. timeout). Uwaga: Jeśli metoda wywołująca będzie transakcyjna będzie dla niej obowiązywał timeout.
- Poziom izolacji transakcji – pamater używany do bardziej zaawansowanego użycia (musi być wspierany także po stronie bazy danych). Zmieniając wartość, wskazujemy sposób dostępu do danych z innych transakcji (w tym także niezatwierdzonych). Przykładowo READ_UNCOMMITTED pozwala odczytać niezatwierdzone zmiany z innych transakcji.
- Dopuszczalny maksymalny czas życia transakcji, po którego przekroczeniu wystąpi wyjątek i wycofanie zmian.
- Flaga tylko do odczytu, włączenie skutkuje tym iż w przypadku próby zmiany danych na bazie zostanie rzucony wyjątek,
- Sterowanie wyjątkami dla wycofywania transkacji – zbiór atrybutów wskazujący, które klasy wyjątków będą powodowały wycofanie wyjątków, a które nie.
Maksymalny czas trwania operacji
Jeżeli chodzi o maksymalny dopuszczalny czas trwania operacji, to możemy wyróżnić następujące ograniczenia:
- Na poziomie adnotacji @Transactional – własność timeout.
- Dla JDBC (dla pojedynczej operacji SQL) – Statement.setQueryLimit.
- W konfiguracji samej bazy danych.
W każdej z powyższych sytuacji wystąpi wyjątek (dla najkrótszego z czasów) i cała transakcja zostanie wycofana.
Wyjątki a @Transactional
Zakończenie metody transakcyjnej wyjątkiem może spowodować wycofanie zmian. Słowo może jest tutaj bardzo ważne, gdyż zależy to od konfiguracji wycofywania transakcji. Domyślnie Spring wycofuje transakcję, jeśli zostanie rzucony wyjątek RuntimeException. Jeśli natomiast rzucilibyśmy wyjątek FileNotFoundException, to zmiany dokonane przed wystąpieniem wyjątku zostaną zatwierdzone.
Podsumowanie
Kiedy powinniśmy korzystać z transakcji?
- Tylko wtedy kiedy tego potrzebujemy (modyfikacja danych).
- Na najniższym możliwym poziomie – nad metodą, która faktycznie potrzebuje transakcji – nie na wyrost.
Czego nie powinniśmy robić?
- Używać transakcji zbyt szeroko (duży narzut, alokacja zasobów, ryzyko przekroczenia dopuszczalnego czasu operacji)
- Uzależniać transakcję od zewnętrznych systemów (serwer pocztowy, inny system)
Wpis który czytasz to zaledwie fragment wiedzy zawartej w Programie szkoleniowym Java Developera od SoftwareSkill. Mamy do przekazania sporo usystematyzowanej wiedzy z zakresu kluczowych kompetencji i umiejętności Java Developera. Program składa się z kilku modułów w cotygodniowych dawkach wiedzy w formie video.
Wydajność Hibernate
Twórz szybko działające aplikacje z wydajną i zoptymalizowaną obsługą bazy danych.
Zakończenie
Jest to zbiór operacji wykonywanych na bazie danych, posiadający trzy fazy: rozpoczęcie, wykonanie operacji oraz zakończenie. Po zakończeniu wszystkie zmiany zostaną zatwierdzone lub wszystkie zostaną wycofane (nie ma częściowych zmian).
Nie, tylko wtedy, kiedy logika wymaga, aby modyfikacje danych skutkujące wykonaniem wielu zapytań SQL były zatwierdzone jednocześnie. Jeżeli chcesz modyfikować dane bez transakcji (pojedynczy update), to upewnij się, że zostanie wykonany commit dla zmian (autocommit lub wywołanie w kodzie).
Nie, musi być włączona obsługa transakcji, musi być umieszczona w klasie (nie interfejsie) oraz metoda musi być wywołana z poziomu innego beana Spring (musi zadziałać mechanizm proxy).
Gdybyś potrzebował jeszcze więcej:
Jesteś Java Developerem?
Przejdź na wyższy poziom wiedzy
„Droga do Seniora” 🔥💪
Jesteś Team Leaderem? Masz zespół?
Podnieś efektywność i wiedzę swojego zespołu 👌