Java i relacyjna baza danych
- Opublikowano
- 16 min czytania
Doświadczenie pokazuje, że im większy projekt tym ciekawsze problemy napotykamy na swojej drodze. Jednym z największych projektów w jakich miałem okazje uczestniczyć podczas mojej wieloletniej programistycznej przygodzie jest Comarch Corporate Banking (CCB). Jest to rozbudowana platforma usprawniająca wszelkie operacje bankowe co czyni ją doskonałym przykładem w kontekście omawiania spójności danych. Wszak pierwszym skojarzeniem i przykładem dla transakcji technicznych są transakcje płatnicze w systemach bankowych i konieczność zachowania ponad wszystko prawidłowego stanu systemu w trakcie (odpowiedni poziom izolacji) jak i po ich wykonaniu.
Wydawać by się mogło, że tematy tutaj poruszane powinny być już dobrze znane i nie powinny sprawiać problemów, w końcu zagadnienia w obrębie których będziemy się poruszać (takie jak JPA, JTA, Hibernate, Spring transaction management) nie są nowe. Jednak podczas realizacji projektu CCB jak i również innych spostrzegłem, że problemy związane z dostępem do danych cały czas stanowią dużą część zmagań programistów. Ot czasem coś się komuś nie zapisze, a czasem zapiszę się za dużo, innym razem coś zakleszczymy lub nie będziemy widzieć danych, które przecież powinny być już w bazie. Ciekawie robi się również gdy chcemy zrezygnować z serwera aplikacyjnego, który zazwyczaj w dużym stopniu ułatwia zarządzanie transakcjami.
Niniejszy tekst jest zaledwie wstępem do licznych zagadnień związanych z relacyjną bazą danych w aplikacjach klasy enterprise, kolejne publikacje (czas pokaże ile ich będzie) będą prezentować nowe lub uszczegóławiać już przedstawione tematy.
Kod prezentowany w przykładach powstał na ich potrzeby i jeśli to koniczne został maksymalnie uproszczony w celu poprawy czytelności.
Spring Proxy i @Transactional
Dobrodziejstwa jakie daje Spring czasem stają się naszym przekleństwem. Coś co z pozoru powinno działać w określonych warunkach przestaje. Zaczyna się wtedy żmudny proces analizy przyczyny, zazwyczaj z debugiem ustawionym gdzieś w pakietach org.springframework. Czy więc warto brnąć w Spring’a? Może lepiej wrócić do samodzielnego oprogramowywania kluczowych mechanizmów w naszej aplikacji? Bez zbędnego owijania: w zdecydowanej większości przypadków warto, ważne jest jednak zrozumienie przynajmniej podstaw na których oparty jest Spring.
Jedną z nich jest Spring AOP i powiązany z nim mechanizm dynamic proxy. W trybie proxy (czyli w trybie domyślnym), wywołania metod bean’ów Spring są przechwytywane umożliwiając uruchomienie dodatkowego kodu przed rzeczywistym wywołaniem docelowej metody. Dzięki temu możliwe jest potraktowanie aspektowo części technicznych elementów, takich jak obsługa transakcji. Jednak proxy tworzone jest podczas inicjowania kontekstu Spring i jest ściśle powiązane z tworzonym bean’em, wszędzie tam gdzie go wstrzykujemy dysponujemy obiektem opakowanym w proxy. Wywołując metodę na takim obiekcie, umożliwiamy uruchomienie mechanizmów Spring powiązanych z danym bean’em, czyli na przykład obsługę transakcji zdefiniowanej za pomocą adnotacji @Transactional. Problem pojawia się w przypadku wywołania odbywającego się poza kontekstem Spring’a, a takim na przykład jest lokalne wywołanie metody, jak w przypadku:
@Service
@AllArgsConstructor
public class UserService {
private UserRepository userRepository;
public void updateUserPhone(String phone) {
User user = userRepository.loadUser();
user.setPhone(phone);
updateUser(user);
}
@Transactional
public void updateUser(User user) {
userRepository.updateUser(user);
}
}
Wywołanie metody updateUserPhone nie jest objęte transakcją, a z powodu tego że wywołanie metody updateUser jest lokalne, adnotacja @Transactional zostanie zignorowana. Aby powyższy kod miał szansę zadziałać można by zastosować poniższe podejście (podobne konstrukcje można było znaleźć w tzw. ośmiotysięcznikach w EJB 2.x):
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Lazy
@Autowired
private UserService userService;
public void updateUserPhone(String phone) {
User user = userRepository.loadUser();
user.setPhone(phone);
userService.updateUser(user);
}
@Transactional
public void updateUser(User user) {
userRepository.updateUser(user);
}
}
Powyższego podejścia oczywiście nie polecam, większy sens ma dodanie adnotacji @Transactional również na metodę updateUser.
Trzeba również pamiętać, że aby mechanizmy aspektowe działały, proxy musi być w pełni zainicjowane. Należy więc unikać transakcji w blokach inicjalizujących takich jak PostContruct. Wspomina o tym dokumentacja:
“Also, the proxy must be fully initialized to provide the expected behaviour so you should not rely on this feature in your initialization code, i.e. @PostConstruct.”
PlatformTransactionManager
PlatformTransactionManager jest centralnym interfejsem wykorzystywanym przez Spring do zarządzania transakcjami. Posiada dwie główne implementacje: JpaTransactionManager i JtaTransactionManager.
JpaTransactionManager
JpaTransactionManager zdaje egzamin w prostych aplikacjach z jednym DataSource oraz jednym PersistenceContext. Warto o tym pamiętać, ponieważ próba zdefiniowania drugiego PersistenceContext (za pomocą EntityManagerFactory) może prowadzić do nieoczekiwanych problemów. JpaTransactionManager skojarzony jest zawsze tylko z jednym EntityManagerFactory. Przykładowa konfiguracja może wyglądać tak:
@Bean(name = "transactionManagerFactory")
LocalContainerEntityManagerFactoryBean transactionManagerFactory(EntityManagerFactoryBuilder builder,
DataSource dataSource) {
return builder
.dataSource(dataSource)
.packages("com.comarch")
.persistenceUnit("DEFAULT_PERSISTENCE_UNIT")
.build();
}
@Bean(name = "transactionManager")
PlatformTransactionManager transactionManager(
@Qualifier("transactionManagerFactory") EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
Jednak nic nie stoi na przeszkodzie by dodać drugi EntityManagerFactory:
@Bean(name = "userTransactionManagerFactory")
LocalContainerEntityManagerFactoryBean userTransactionManagerFactory(EntityManagerFactoryBuilder builder,
DataSource dataSource) {
return builder
.dataSource(dataSource)
.packages("com.comarch")
.persistenceUnit("USERS_PERSISTENCE_UNIT")
.build();
}
a następnie użyć go w implementacji swojego repozytorium:
@PersistenceContext(unitName ="USERS_PERSISTENCE_UNIT")
private EntityManager userEntityManager;
@Transactional
public void updateUser(User user) {
userEntityManager.merge(user);
}
Taki kod nie zwróci żadnego błędu jednak również nie zadziała - encja nie zostanie zapisana w bazie. Gdy w tej samej transakcji skorzystamy z dwóch PersistenceContext (w tym przypadku z DEFAULT_PERSISTENCE_UNIT i USERS_PERSISTENCE_UNIT), tylko encje zapisane za pomocą DEFAULT_PERSISTENCE_UNIT zostaną utrwalone w bazie danych.
Podstawą do odkrycia dlaczego tak się dzieje, jest zrozumienie jak działa Hibernate (i inne implementacje JPA). Metoda merge() tylko łączy encję z bieżącym kontekstem, dopiero wywołanie metody flush() jest sygnałem do synchronizacji bieżącego kontekstu z bazą danych. Jednak w tym przykładzie nie wywołaliśmy metody flush() ręcznie. Operacja ta zostanie wykonana dopiero przez JpaTransactionManager tuż przed zatwierdzeniem transakcji, a z racji tego że JpaTransactionManager związany jest z konkretnym PersistenceContext (w tym przypadku z DEFAULT_PERSISTENCE_UNIT), tylko na nim synchronizacja zostanie wykonana. Drugi kontekst zostanie zignorowany. Może to być o tyle niezrozumiałe, że Spring w żaden sposób nie poinformuje nas, że coś jest nie tak. Dopiero gdy ręcznie uruchomimy flush() dla USERS_PERSISTENCE_UNIT dowiemy się o problemach (a dokładnie, że brakuje transakcji dla tego kontekstu).
JtaTransactionManager
Drugą główną implementacją PlatformTransactionManager jest JtaTransactionManager. W jego przypadku nie ma powyższych problemów (są inne), ponieważ nie jest on związany bezpośrednio z PersistenceContext. Może korzystać z transakcji zarządzanej przez serwer (z wykorzystaniem takich implementacji jak np. WebLogicJtaTransactionManager) lub przez niezależne wersje menadżerów transakcji (np. Narayana, Atomikos).
Hibernate Session Cache
Podstawowy cache zapewniony przez Hibernate mimo swojej prostoty pozwala znacznie zaoszczędzić czas serwera. W ramach procesu encja jest dostępna w pamięci i można się do niej łatwo, a co najważniejsze, szybko odwołać. Dzięki temu nie musimy budować własnego mechanizmu zapewniającego cache lub co gorsze zmieniać logikę biznesową w taki sposób by uniknąć problemów z wydajnością będących następstwem nadmiernych odwołań do bazy danych. Jednak jak intuicja podpowiada i tutaj istnieją pewne ograniczenia, które mogą w pewnych przypadkach stwarzać problemy.
Pierwszym jest fakt, że cache dotyczy encji pobieranych na podstawie klucza głównego metodą EntityManager.find() i nie działa w przypadku query, nawet tak prostego jak poniższe:
entityManager.createQuery("Select u from User u where u.id='" + id + "'").getSingleResult()
Trzeba też pamiętać o poprawnym zdefiniowaniu metod equals() i hashCode(). Ma to szczególne znaczenie gdy stan encji może się zmienić. W momencie wywołania metody flush(), Hibernate sprawdza czy dane encje zmieniły się w stosunku do tego co jest w cache. Dzięki temu może pominąć niepotrzebne update’y i uaktualnić tylko faktycznie zmodyfikowane encje. Jednak w przypadku źle zaimplementowanych metod equals() i hashCode() Hibernate może oznaczać niepoprawnie encje jako zmodyfikowane i wykonać niepotrzebne update’y.
Następną kwestią jest zasięg cache’a. Jak nazwa wskazuje jest on związany z sesją Hibernate’a, a tę rolę z kolei pełni PersistenceContext. Dawniej sesje robiło się ręcznie:
Session session = factory.openSession();
Obecnie załatwia to Spring, tylko kiedy on to robi? I tutaj odpowiedź oczywiście może być tylko jedna: to zależy.
W przypadku aplikacji webowych odpowiada za to OpenEntityManagerInViewInterceptor, który jest wpięty w cykl życia żądania HTTP. Tworzy on PersistenceContext na samym początku przetwarzania żądania. Jest to sytuacja najbardziej komfortowa, ponieważ mamy tą samą sesję (więc również cache) w ramach całego procesu, niezależnie czy jest on transakcyjny czy nie.
Jeśli akcja została zainicjowana w inny sposób, PersistenceContext zostanie utworzony dopiero wtedy gdy będzie potrzebny. I tak, jeśli do czynienia mamy z procesem transakcyjnym, potrzeba istnienia PersistenceContext’u pojawi się już w chwili rozpoczęcia transakcji. W takim wypadku ten sam PersistenceContext obowiązuje dla całej transakcji, i w ramach niej mamy do dyspozycji spójny cache. Sytuacja komplikuje się w przypadku gdy proces nie jest transakcyjny, wtedy kontekst zostanie utworzony dopiero przy faktycznym odwołaniu się do EntityManager’a. Co gorsze nie zostanie on skojarzony z żadnym nadrzędnym bytem, więc będzie tworzony za każdym razem gdy nastąpi odwołanie do EntityManager’a. W takiej sytuacji nie ma praktycznie żadnej korzyści z istnienia cache’a.
W celu zobrazowania, poniżej dwa przypadki nietransakcyjnych endpointów:
Implementacja pierwszego:
@GetMapping(value = "/testLoad")
public void testLoad(@RequestParam String id) {
LOGGER.info("First load: " + userRepository.loadUser(id).getName());
LOGGER.info("Second load: " + userRepository.loadUser(id).getName());
}
Wynik:
Hibernate: select user0_.ID as ID1_15_0_, user0_.NAME as NAME2_15_0_, user0_.PHONE as PHONE3_15_0_, products1_.USER_ID as USER_ID4_14_1_, products1_.ID as ID1_14_1_, products1_.ID as ID1_14_2_, products1_.AMOUNT as AMOUNT2_14_2_, products1_.NAME as NAME3_14_2_, products1_.USER_ID as USER_ID4_14_2_ from activities.USERS user0_ left outer join activities.PRODUCTS products1_ on user0_.ID=products1_.USER_ID where user0_.ID=?
2019-12-30 18:52:24,042 INFO - First load: Jan
2019-12-30 18:52:24,043 INFO - Second load: Jan
Implementacja drugiego:
@GetMapping(value = "/testLoad")
public void testLoad(@RequestParam String id) {
new Thread(() -> {
LOGGER.info("First load: " + userRepository.loadUser(id).getName());
LOGGER.info("Second load: " + userRepository.loadUser(id).getName());
}).start();
}
Wynik:
Hibernate: select user0_.ID as ID1_15_0_, user0_.NAME as NAME2_15_0_, user0_.PHONE as PHONE3_15_0_, products1_.USER_ID as USER_ID4_14_1_, products1_.ID as ID1_14_1_, products1_.ID as ID1_14_2_, products1_.AMOUNT as AMOUNT2_14_2_, products1_.NAME as NAME3_14_2_, products1_.USER_ID as USER_ID4_14_2_ from activities.USERS user0_ left outer join activities.PRODUCTS products1_ on user0_.ID=products1_.USER_ID where user0_.ID=?
2019-12-30 18:54:56,712 INFO - First load: Jan
Hibernate: select user0_.ID as ID1_15_0_, user0_.NAME as NAME2_15_0_, user0_.PHONE as PHONE3_15_0_, products1_.USER_ID as USER_ID4_14_1_, products1_.ID as ID1_14_1_, products1_.ID as ID1_14_2_, products1_.AMOUNT as AMOUNT2_14_2_, products1_.NAME as NAME3_14_2_, products1_.USER_ID as USER_ID4_14_2_ from activities.USERS user0_ left outer join activities.PRODUCTS products1_ on user0_.ID=products1_.USER_ID where user0_.ID=?
2019-12-30 18:54:56,714 INFO - Second load: Jan
W pierwszym przypadku oba odwołania do bazy są w ramach tego samego PersistenceContext (utworzonego przez OpenEntityManagerInViewInterceptor), więc drugie pobranie encji nie wymaga odwołania do bazy, a jedynie do cache’a. W drugim przypadku, mimo że również jest to endpoint HTTP odwołanie do bazy zostało oddelegowane do nowego wątku, w którym nie obowiązuje utworzony przez interceptor PersistenceContext. Z racji tego, że wywołania nie są również objęte transakcją nie ma tu jednego PersistenceContext obejmującego oba odwołania do bazy. Efektem jest dwukrotne wywołanie tego samego zapytania.
Oczywiście ten syntetyczny przykład nie jest zbyt życiowy, ale rzuca światło na jeden z wielu powodów dla których własne powoływanie wątków w aplikacjach zarządzanych przez Spring powinno być poprzedzone głęboką refleksją. Warto jest też zawsze przeprowadzać analizę logów z wypisywanymi zapytaniami SQL w celu weryfikacji czy zachowanie Hinernate jest zgodne z naszymi oczekiwaniami. Można również rozważyć wykorzystanie transakcji readOnly dla procesów realizujących wyłącznie odczyty (więcej o tym w przyszłości).
Rollback
Obok zatwierdzenia transakcji i utrwalenia wszystkich danych równie ważne jest poprawne wycofanie zmian, gdy jednak nie powinna ona finalnie trafić do bazy danych. Można to zrobić manualnie za pomocą TransactionAspectSupport:
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
Ale nie jest to oczywiście najlepsze podejście. Dlatego standardowo Spring automatycznie wycofuje transakcję gdy metoda transakcyjna zakończy się wyjątkiem. Jednak robi to tylko dla wyjątków RuntimeException i Error, wyjątki checked są pomijane. Jest to trochę nieintuicyjne zachowanie i może prowadzić do dziwnych sytuacji i niepożądanej niespójności. Jak większość oryginalnych podejść do rozwiązań typu enterprise i to swoje źródło wzięło ze specyfikacji EJB. W dokumentacji Spring można znaleźć poniższe wyjaśnienie:
“While the EJB default behavior is for the EJB container to automatically roll back the transaction on a system exception (usually a runtime exception), EJB CMT does not roll back the transaction automatically on an application exception (that is, a checked exception other than java.rmi.RemoteException). 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.”
Domyślne zachowanie można jednak zmienić na jeden z dwóch sposobów:
- punktowo, konfigurując odpowiednio adnotację Transactional (parametr rollbackFor adnotacji ze Spring lub parametr rollbackOn adnotacji z javax),
- globalnie nadpisując konfigurację Spring:
@Configuration
public class CustomProxyTransactionManagementConfiguration extends ProxyTransactionManagementConfiguration {
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
@Override
public TransactionAttributeSource transactionAttributeSource() {
return new AnnotationTransactionAttributeSource() {
@Override
protected TransactionAttribute determineTransactionAttribute(AnnotatedElement element) {
TransactionAttribute ta = super.determineTransactionAttribute(element);
if (ta == null) {
return null;
}
return new DelegatingTransactionAttribute(ta) {
@Override
public boolean rollbackOn(Throwable ex) {
return super.rollbackOn(ex) || ex instanceof Exception;
}
};
}
};
}
}
W Spring Boot 2.1 może być konieczne ustawienie zmiennej spring.main.allow-bean-definition-overriding=true
Podsumowanie
W tym wpisie to tyle, gorąco liczę na to, że w niedalekim czasie pojawi się następna część, a w niej problemy jakie mogą pojawić się w związku ze współbieżnym dostępem do danych
Szymon Kubicz, Senior Designer – Developer, Comarch