Java i relacyjna baza danych Część II

W pierwszym wpisie krótko powiedzieliśmy sobie o podstawach działania Spring AOP oraz Hibernate Cache, tym razem zajrzymy w podstawy współbieżnego dostępu do danych. Współbieżność jest jednym z większych problemów i to nie tylko w kontekście dostępu do danych, z drugiej strony nie można sobie wyobrazić projektów web’owych bez wielowątkowości, a już na pewno nie takich dużych jak Comarch Corporate Banking. Na szczęście większość ciężkiej pracy związanej z wielowątkowością wykonuje za nas baza danych oraz kontener web wraz ze Spring’iem lub serwer aplikacyjny (na przykład IBM WebSphere) którego jednak w naszym projekcie wykorzystywać nie chcieliśmy. Dzięki temu możemy skupić się na pisaniu logiki biznesowej a nie synchronizacji zasobów. Oczywiście czasem pojawiają się komplikację i warto wiedzieć jak sobie z nimi radzić.

Współbieżność w dostępie do bazy danych

W relacyjnych bazach danych istnieją dwa główne mechanizmy pomagające radzić sobie z problemami wynikającymi z jednoczesnego dostępu do tych samych obiektów: izolacja i blokowanie (optymistyczne i pesymistyczne).

Poziom izolacji

Pierwszy to poziom izolacji, który zazwyczaj jest zdefiniowany w ramach całej aplikacji i nie zmienia się w ramach jej cyklu życia.  W razie potrzeby można domyślne zachowanie jednak przedefiniować np. za pomocą adnotacji org.springframework.transaction.annotation.Transactional ze Spring.

Różne systemy baz danych mogą podchodzić do izolacji w trochę odmienny sposób. Jednak najczęściej używanym poziomem jest READ COMMITTED, chroniący przed dirty reads, czyli przed odczytaniem danych niezatwierdzonych jeszcze przez inne transakcje. Jest to swoisty kompromis pomiędzy dostępnością danych i szybkością przetwarzania a ich spójnością pomiędzy transakcjami.

Blokowanie

Blokowanie rekordów pozwala na eliminację zjawiska lost update. Trzeba jednak uściślić, że zjawisko lost update może mieć dwa znaczenia:
  1. Gdy dwie transakcję próbują wykonać update jedna po drugiej (w momencie gdy obie nie są zatwierdzone) – przed tym zapobiegają same systemy baz danych.
  2. Gdy pierwsza transakcja pobiera dane, druga transakcja modyfikuje te same dane i zatwierdza transakcję, pierwsza transakcja modyfikuje wcześniej pobrane dane i je zapisuje – tego problemu dotyczy dalsza część rozdziału.

Optymistyczne

W większości przypadków, w szczególności gdy mamy do czynienia ze znaczną przewagą odczytów, lepiej sprawdza się blokowanie optymistyczne opierające się na wersjonowaniu encji. W tym trybie dane w bazie są cały czas dostępne dla innych użytkowników (transakcji) - nie powoduje to więc blokowania przetwarzanego aktualnie procesu. Weryfikacja spójności odbywa się dopiero przy zapisie. Hibernate potrafi sam zażądać wersją encji (za pomocą adnotacji @Version), a samo podbicie wersji odbywa się tuż przed zatwierdzeniem transakcji. Gdy transakcje próbują zatwierdzać dane nieaktualne (z wersją starszą niż wersja encji w bazie), zostanie zwrócony wyjątek OptimisticLockException, a sama transakcja zostanie wycofana. Chroni to dane już zapisane w bazie ale może powodować utratę efektów pracy bieżącej transakcji. Aby sobie z tym poradzić konieczne byłoby ponowne pobranie encji z bazy danych i poprawnym jej zaktualizowaniu, można również zmienić sposób blokowania na pesymistyczne.

Pesymistyczne

Jakie podejście zastosować gdy modyfikacja danych jest krytyczna lub po prostu spodziewamy się, że dane będą modyfikowane przez kilka procesów naraz (np. obsługa zdarzeń dotyczących tej samej encji)? W takim wypadku blokowanie optymistyczne może doprowadzić do masowego wycofywania transakcji, co jest niekorzystne z punktu widzenia wydajności, lub nawet do utraty danych, jeśli procesu nie da się powtórzyć. W takich wypadkach lepiej sprawdzi się blokowanie pesymistyczne. W tym wariancie zakładamy najgorszy scenariusz, czyli że dane będą przetwarzane przez wiele procesów naraz.

Ustawienia dotyczące blokowania można przekazać za pomocą EntityManager’a:

entityManager.find(User.class, id, LockModeType.PESSIMISTIC_WRITE)

Z poziomu JPA jesteśmy w stanie zdefiniować dwa typu blokowania pesymistycznego.

PESSIMISTIC_READ

Jest to odpowiednik shared lock - w tym trybie możliwe jest odczytywanie zablokowanego rekordu, jednak nie jest możliwa jego modyfikacja.
Jednak nie wszystkie bazy danych obsługują ten tryb blokowania, należy więc używać go rozważnie.

PESSIMISTIC_WRITE (and PESSIMISTIC_FORCE_INCREMENT)

Rekordy zostaną zablokowane do zapisu i do odczytu, więc inny proces może uzyskać dostęp do danych dopiero po zwolnieniu blokady przez aktualny. Jednak trzeba pamiętać, że blokada dotyczy tylko zapytań z klauzulą for update, zwykłe zapytania wybierające nadal będą wykonywane bez problemów co w połączeniu z JPA może doprowadzić do nieoczekiwanych problemów – o czym za chwile. Ten typ blokowania sprawdzi się, gdy chcemy wymusić sekwencyjne przetwarzanie przez niezależne wątki.

JPA i blokowanie pesymistyczne

Jeżeli korzystamy z JPA, blokowanie pesymistyczne może być zdradliwe. Wyobraźmy sobie encje UserProduct z relacją miedzy nimi jak poniżej:

 @Entity
@Table(name = "USERS")
public class User {
@Id
@Column(name = "ID")
private String id;
@Column(name = "NAME")
private String name;
@Column(name = "PHONE")
private String phone;
@OneToMany(fetch = FetchType.LAZY, cascade = {CascadeType.ALL},
mappedBy = "user", orphanRemoval = true)
private Set<Product> products;
}

@Entity
@Table(name = "PRODUCTS")
public class Product {
@Id
@Column(name = "ID")
private String id;
@Column(name = "NAME")
private String name;
@Column(name = "AMOUNT")
private long amount;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "USER_ID", nullable = false)
private User user;
}


Obje encje używamy niezależnie w oddzielnych procesach i blokujemy je przy odczycie:

entityManager.find(User.class, id, LockModeType.PESSIMISTIC_WRITE)
entityManager.find(Product.class, id, LockModeType.PESSIMISTIC_WRITE);

Hibernate, w celu pobrania całego obiektu wraz z relacją, w tym przypadku wykona dwa zapytania do bazy, z czego tylko jedno będzie miało klauzulę for update. Poniżej przykładowe zapytania dla pobrania encji User wraz z powiązanymi produktami (dla encji Product sytuacja będzie analogiczna):

     Hibernate: select product0_.ID as ID1_11_0_, product0_.AMOUNT as
AMOUNT2_11_0_, product0_.NAME as NAME3_11_0_, product0_.USER_ID as
USER_ID4_11_0_ from PRODUCTS product0_ where product0_.ID=? for update
Hibernate: select user0_.ID as ID1_12_0_, user0_.NAME as NAME2_12_0_,
user0_.PHONE as PHONE3_12_0_ from USERS user0_ where user0_.ID=?

W praktyce oznacza to, że przy pobieraniu encji User zablokowany będzie tylko rekord w tabeli USERS, a w przypadku encji Product rekord w tabeli PRODUCTS. Wyobraźmy sobie teraz dwa procesy:

@Transactional
public void updateProduct(String id, String newUserName, long amount) {
Product product = userRepository.loadProduct(id);

product.setAmount(amount);
product.getUser().setName(newUserName);

userRepository.updateProduct(product);
}

@Transactional
public void updateUser(String id, String newUserName, long amount) {
User user = userRepository.loadUser(id);

user.setName(newUserName);
user.getProducts().forEach(product -> product.setAmount(amount));

userRepository.updateUser(user);
}

Zasadniczo oba robią prawie to samo lecz wychodzą od innej strony relacji. Rozważmy teraz dwa wątki (T1 i  T2) wykonujące jednocześnie powyższe metody na tych samych danych:

PRODUCTS
IDAMOUNTNAMEUSER_ID
121T-Shirt1
231Shoes1
311Skirt1

USERS
IDPHONENAME
1123123123SMITH

Poniżej kroki wykonywane przez poszczególne wątki:

T1T2
Pobranie encji Product (ID=2) i blokada rekordu w tabeli PRODUCTS.Pobranie encji User (ID=1) i blokada rekordu w tabeli USERS.

Pobranie encji User (ID=1)  przez Hiberante’a.

Pomimo, że rekord jest zablokowany, pobranie bez klauzuli for update nadal jest możliwe.

Pobranie listy encji Product (ID=1, 2, 3)  przez Hiberante’a.

Pomimo, że rekord Product o ID=2 jest zablokowany, pobranie bez klauzuli for update nadal jest możliwe.

Zmiana parametrów User.nameProduct.amount.Zmiana parametrów User.nameProduct.amount dla wszystkich elementów listy.
Update encji Product oraz User.Update encji Product oraz User.
Oczekiwanie na zdjęcie blokady z rekordu dla encji User (ID=1).
Oczekiwanie na zdjęcie blokady z rekordu dla encji Product (ID=2).
DEADLOCK
Wątek T1 nigdy nie doczeka się zwolnienia blokady rekordu w tabeli USERS, a wątek T2 zwolnienia blokady rekordu w tabeli PRODUCTS. Większość systemów baz danych wykryję taką sytuacje i zgłosi wyjątek, w przeciwnym razie wątki nigdy nie wróciłby do puli.

Opisany przykład jest oczywiście z jednej strony bardzo uproszczony a z drugiej przerysowany. Pobierając encję Product, w ogóle nie powinniśmy ruszać encji User, co więcej w poprawnie zamodelowanych komponentach, jeśli traktujemy Product jako agregat (w rozumieniu Domain Driven Design), prawdopodobnie nie powinniśmy mieć możliwości jego modyfikacji z poziomu relacji encji User. Jednak techniczna możliwość takiego „pójścia na skróty” istnieje, więc może w określonych przypadkach zostać wykorzystana. Należy wtedy pamiętać by modyfikować najlepiej tylko to co zablokujemy.

TransactionSynchronization

TransactionSynchronization powstał wraz z całym mechanizmem Spring Transaction i jest jednym z jego podstawowych elementów. Pomimo, że interfejs TransactionSynchronization nie jest bezpośrednio powiązany ze współbieżnością, może być wykorzystywany do wymiany informacji pomiędzy wątkami lub procesami. Jednym z powszechniejszych zastosowań jest emisja nietransakcyjnych zdarzeń dotyczących zmian stanu obiektów. Zazwyczaj chcemy by takie zdarzenie zostało wysłane tylko w przypadku pomyślnie zakończonego procesu (zdarzenie ma reprezentować czynność dokonaną). TransactionSynchronization umożliwia wpięcie akcji w konkretny punkt cyklu życia transakcji, czyli możemy wyemitować zdarzenie tuż przed (beforeCommit) lub tuż po (afterCommit) zatwierdzeniu transakcji. To które miejsce wybrać do wykonania naszej logiki zależy od tego czego od niej oczekujemy. Jeśli chcemy mieć pewność, że ta logika zostanie wykonana poprawnie i jest krytyczna z punktu widzenia całej transakcji, powinna się znaleźć w beforeCommit. Jeśli natomiast chcemy mieć pewność że transakcja biznesowa zostanie wykonana i zatwierdzona zanim podejmiemy dalsze działania, powinniśmy skorzystać z afterCommit (lub afterCompletion).

Wybór pomiędzy beforeCommitafterCommit jest więc zawsze kompromisem i jeśli logika uruchamiana z tych metod prowadzi do zmiany stanów zewnętrznych obiektów, może to prowadzić do niespójności danych. Trzeba również pamiętać, że w momencie wykonywania metody afterCommit (oraz afterCompletion) zasoby transakcji mogę nie być jeszcze wyczyszczone. Oznacza to, że kod w tych metodach nadal uczestniczy w transakcji do której został przypięty, jednak nigdy nie zostanie zatwierdzony. Dlatego, jeśli chcemy w tym miejscu wykonać transakcyjny kod, powinien on być oznaczony propagacją REQUIRES_NEW.


Do rejestracji TransactionSynchronization można wykorzystać TransactionSynchronizationManager:

TransactionSynchronizationManager.registerSynchronization(new 
TransactionSynchronizationAdapter() {
public void afterCommit(){
// do sth
}
});

Lub użyć wprowadzonego w Spring 4.2 API opartego na adnotacjach @EventListener oraz @TransactionalEventListener.

Warto też wspomnieć o możliwości zdefiniowania kolejności wykonywania TransactionSynchronization za pomocą interfejsu Ordered.

Podsumowanie

Na dzisiaj to tyle, w następnym wpisie powiemy sobie trochę o JTA.

Szymon Kubicz, Senior Designer – Developer, Comarch

Skontaktuj się z ekspertem Comarch

Powiedz nam o potrzebach Twojej firmy. Znajdziemy idealne rozwiązanie.