Korzystanie z baz danych w aplikacjach Java Enterprise różni się nieco od korzystania z nich w aplikacjach konsolowych, czy desktopowych. Różnice te polegają przede wszystkim na odmiennym sposobie uzyskiwania połączenia do baz danych. W standardowych aplikacjach pisanych w środowisku Java Standard Edition korzystamy bezpośrednio ze sterownika JDBC, z którego uzyskujemy fizyczne połączenie do bazy danych. W aplikacjach Java Enterprise dostęp do bazy danych jest o wiele bardziej złożony. Przyjrzyjmy mu się dokładniej.
Na rysunku poniżej zostało przedstawione środowisko jakie serwer aplikacji tworzy dla aplikacji korzystających z baz danych.
Rysunek 1 Dostęp do baz danych w środowisku tworzonym przez serwer aplikacji
Środowisko to składa się z następujących elementów:
<resource-ref>
w pliku web.xml. Aplikacje mogą się odwoływać jedynie do zasobów, które zostały zdefiniowane w deskryptorze. Z punktu widzenia aplikacji zasoby te są widoczne w drzewie JNDI pod zdefiniowanymi w deskryptorze nazwami.Taki sposób organizacji dostępu do bazy danych ma następujące konsekwencje:
Wszystko to powoduje, że w serwerze aplikacyjnym mamy bardzo elastyczną strukturę dostępu do bazy danych, niestety okupioną dużą złożonością jej konfiguracji.
Przyjrzyjmy się jak taka konfiguracja może wyglądać w praktyce na przykładzie prostej aplikacji internetowej, która chce skorzystać z bazy danych.
Tworząc naszą aplikację musimy zadeklarować w jej deskryptorze (plik web.xml), że będziemy korzystać z bazy danych i że obiekt umożliwiający dostęp do bazy danych (javax.sql.DataSource
) powinien się znajdować w drzewie JNDI pod określoną nazwą (tutaj jdbc/SampleDS):
<resource-ref> <description>Sample DS</description> <res-ref-name>jdbc/SampleDS </res-ref-name> <res-type>javax.sql.DataSource </res-type> <res-auth>Container</res-auth> </resource-ref>
W serwerze aplikacji musimy zdefiniować zasób fizyczny wraz z konfiguracją parametrów połączenia do bazy danych oraz konfiguracją parametrów puli połączeń (np. wielkość puli). W każdym serwerze konfigurowanie zasobu fizycznego wygląda nieco inaczej. Dla serwera JBoss w naszym przykładzie będzie to plik sample-ds.xml umieszczony w katalogu $JBOSS_HOME/server/$PROFILE_NAME/deploy:
<?xml version="1.0" encoding="UTF-8"?> <datasources> <local-tx-datasource> <jndi-name>jdbc/PhysicalSampleDS</jndi-name> <connection-url>jdbc:postgresql://localhost:5432/sampledb</connection-url> <driver-class>org.postgresql.Driver</driver-class> <user-name>testuser</user-name> <password>123456</password> <new-connection-sql>select 1</new-connection-sql> <use-java-context>false</use-java-context> <metadata> <type-mapping>PostgreSQL 8.3</type-mapping> </metadata> </local-tx-datasource> </datasources>
Ostatnią rzeczy, którą musimy przygotować jest deskryptor specyficzny dla serwera, który zawiera odwzorowanie logicznej nazwy zasobu zdefiniowanej w aplikacji na jego fizyczny odpowiednik zdefiniowany w serwerze. W przypadku JBoss'a takie mapowanie odbywa się za pomocą pliku jboss-web.xml i może wyglądać następująco:
<?xml version="1.0" encoding="UTF-8"?> <jboss-web> <resource-ref> <res-ref-name>jdbc/SampleDS</res-ref-name> <jndi-name>jdbc/PhysicalSampleDS</jndi-name> </resource-ref> </jboss-web>
Warto również pamiętać, że sterowniki JDBC powinny być umieszczone w odpowiednich katalogach serwera aplikacyjnego, a nie w archiwach aplikacji (war, ear). Przykładowo w JBoss'ie powinniśmy je umieszczać w katalogu $JBOSS_HOME/server/$PROFILE_NAME/lib gdzie JBOSS_HOME to ścieżka, w której zainstalowaliśmy serwer a PROFILE_NAME to nazwa profilu, w ramach którego ma pracować nasza aplikacja. Umieszczenie sterownika JDBC w archiwum aplikacji to proszenie się o trudne do wychwycenia problemy związane z hierarchią ładowania klas w serwerze aplikacji.
Tworząc aplikacje Java Enterprise trzeba pamiętać, żeby nigdy nie korzystać bezpośrednio ze sterownika JDBC do uzyskiwania połączeń do bazy danych (DriverManager.getConnection(...)
). Zawsze należy korzystać z mechanizmów dostarczanych przez serwer aplikacji.
Rozważania związane z transakcjami w dostępie do baz danych zacznijmy od tzw. transakcji lokalnych. Są to transakcje realizowane na poziomie menedżerów poszczególnych zasobów (np. baz danych czy kolejek JMS). Zarządzając takimi transakcjami korzystamy wyłącznie z właściwości udostępnianych przez interfejsy specyficzne dla danego zasobu. Dla baz danych będzie to interfejs JDBC. Korzystamy tutaj z tego, że menedżer zasobów (np. silnik bazy danych) jest jednocześnie menedżerem transakcji. W transakcjach lokalnych nie jest wykorzystywany menedżer transakcji serwera aplikacji. Rola serwera aplikacji w przypadku transakcji lokalnych sprowadza się jedynie do umożliwienia dostępu do zasobu.
Transakcje lokalne możemy wykorzystać, jeżeli w naszym systemie mamy jedną bazę danych, co jest dość typowym przypadkiem.
Zarządzanie transakcjami bazadanowymi odbywa się na poziomie połączenia do bazy danych, reprezentowanego w interfejsie JDBC przez obiekt java.sql.Connection
. W serwerze aplikacji obiekty te uzyskujemy z obiektu javax.sql.DataSource
pobranego przez naszą aplikację z JNDI. Obiekt java.sql.Connection
udostępnia następujące metody służące do zarządzania transakcjami:
void setAutoCommit(boolean autoCommit)
– umożliwia wyłączenie trybu autocommit
, co jest równoznaczne z rozpoczęciem transakcji. Domyślnie wszystkie zwracane przez serwer połączenia są ustawiane w tryb autocommit
, co oznacza, że każde zapytanie do bazy danych wykonywane jest w oddzielnej transakcji.void commit()
– powoduje zatwierdzenie bieżącej transakcji. Jednocześnie powoduje rozpoczęcie nowej transakcji.void rollback()
– powoduje wycofanie bieżącej transakcji. Jednocześnie powoduje rozpoczęcie nowej transakcji.void setTransactionIsolation(int level)
– ustawia wskazany poziom izolacji transakcji. Więcej na ten temat w dalszej części artykułu.Savepoint setSavepoint(String name)
– umożliwia ustawienie punktu kontrolnego o danej nazwie, do którego będzie się można wycofać bez wycofywania całej transakcji.void releaseSavepoint(Savepoint savepoint)
– powoduje usunięcie danego punktu kontrolnego z bieżącej transkacji.void rollback(Savepoint savepoint)
– powoduje wycofanie zmian do podanego punktu kontrolnego.W praktyce tworząc aplikacje Java Enterprise, które zazwyczaj mają charakter aplikacji OLTP (Online Transaction Processing) bardzo rzadko korzysta się z punktów kontrolnych transakcji. Mają one głównie zastosowanie w aplikacjach, w których występują długotrwające transakcje. W takich aplikacjach występują zazwyczaj złożone i kosztowne operacje, których wycofywanie w całości byłoby nieopłacalne. W aplikacjach internetowych ten przypadek jest bardzo rzadko spotykany. W praktyce mamy więc do czynienie tylko z trzema metodami – setAutocommit(...)
, commit()
oraz rollback()
.
Przyjrzyjmy się jak za pomocą tych metod zrealizować transakcję bazodanową wpisującą dane do dwóch różnych tabel z poziomu serwletu (pominąłem w tym przykładzie właściwą obsługę wyjątków):
public class JDBCTransactionDemoServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException { ... try { Context ctx = new InitialContext(); // uzyskanie połączenia DataSource ds1 = (DataSource) ctx .lookup("java:comp/env/jdbc/TransactionDemoDS1"); Connection conn = ds1.getConnection(); // rozpoczęcie transakcji conn.setAutoCommit(false); // wykonanie operacji PreparedStatement stmt1 = conn .prepareStatement("insert into a values (1, 'dane_x'"); PreparedStatement stmt2 = conn .prepareStatement("insert into b values (2, 'dane_y'"); stmt1.executeUpdate(); stmt2.executeUpdate(); stmt1.close(); stmt2.close(); // zatwierdzenie transakcji i zamknięcie połączenia conn.commit(); conn.close(); } catch (Exception e) { throw new RuntimeException(e); } ... } }
Jak można zauważyć koncepcyjnie zarządzanie transakcjami z poziomu interfejsu JDBC nie wydaje się szczególnie trudne i składa się z następujących kroków:
Jednak jak to zwykle bywa, diabeł tkwi w szczegółach. W naszym przypadku jest to właściwa obsługa sytuacji wyjątkowych. W trakcie działania przedstawionego wyżej kodu może dojść do wielu sytuacji, które wymagają specjalnego obsłużenia, w szczególności:
Zobaczmy jak można sobie z tymi sytuacjami poradzić:
Context ctx = new InitialContext(); DataSource ds1 = (DataSource) ctx .lookup("java:comp/env/jdbc/TransactionDemoDS1"); Connection conn = null; boolean ok = false; try { conn = ds1.getConnection(); } catch (SQLException e) { throw new RuntimeException(e); } RuntimeException exception = null; try { conn.setAutoCommit(false); // ... // tutaj wykonujemy operacje na bazie // w rozpoczętej transakcji // ... ok = true; } catch (SQLException e) { exception = new RuntimeException(e); } catch (RuntimeException e) { exception = e; } finally { if (ok) { try { conn.commit(); } catch (SQLException e) { if (exception != null) { exception = new RuntimeDoubleException(exception, e); } else { exception = new RuntimeException(e); } } } else { try { conn.rollback(); } catch (SQLException e) { if (exception != null) { exception = new RuntimeDoubleException(exception, e); } else { exception = new RuntimeException(e); } } } try { conn.close(); } catch (SQLException e) { if (exception != null) { exception = new RuntimeDoubleException(exception, e); } else { exception = new RuntimeException(e); } } } if (exception != null) { throw exception; }
Jak widać pełna obsługa tego typu transakcji wymaga sporej ilości kodu, którego działanie nie jest już takie łatwe do prześledzenia.
Teraz nasuwa się pytanie gdzie taką obsługę wyjątków umieścić w kodzie naszej aplikacji.
W pierwszym podejściu wydawałoby się, że w każdym miejscu gdzie realizujemy nasze transakcje. Jeżeli jednak zadanie prawidłowego zarządzania transakcjami i połączeniami oddamy w ręce programistów poszczególnych modułów czy ekranów aplikacji to nie możemy oczekiwać, że nasz system będzie odpowiednio wysokiej jakości. Nawet dobremu programiście zdarzy się jakiś element tej obsługi przeoczyć lub zrealizować nieprawidłowo. W najlepszym przypadku będzie to prowadziło do wycieków z puli połączeń, a w najgorszym do ulotnych, ciężkich do zlokalizowania błędów w logice naszej aplikacji. Poza tym takie podejście powoduje gigantyczną duplikację kodu. Nawet w niewielkich systemach miejsc, w których należałoby umieścić przedstawiony wyżej kod byłoby pewnie z kilkadziesiąt. Prowadzi to w oczywisty sposób do pogorszenia czytelności kodu (obsługa wyjątków zajmuje więcej niż logika operacji na bazie) i kłopotów w jego utrzymaniu.
Podejście drugie polega na zamknięciu powyższego kodu w jedną klasę usługową i wywoływaniu go z tych miejsc naszej aplikacji, gdzie występuje zarządzanie transakcjami.
Sytuacja komplikuje się wtedy, gdy budujemy złożony system, w którym liczba możliwych interakcji pomiędzy fragmentami kodu realizującym logikę transakcyjną jest trudna do oszacowania, a co za tym idzie do oprogramowania. Wtedy zostaje nam podejście trzecie, czyli scentralizowane zarządzania transakcjami.
Podejście to polega na umieszczeniu przetwarzania całego żądania do naszej aplikacji w jednej transakcji JDBC. Można to zrealizować na poziomie metod doGet(...)
czy doPost(...)
serwleta lub kodu kontrolera z modelu MVC a połączenie do bazy danych umieścić w parametrach żądania HTTP, przez co zawsze mamy do niego dostęp z dowolnego miejsca w naszej aplikacji. Takie podejście bardzo upraszcza zarządzanie transakcjami i połączeniami do bazy danych w naszej aplikacji. Oczywiście jak wszystkie "złote środki" takie podejście ma swoje ograniczenia. Jeśli zdecydujemy się na takie rozwiązanie należy przede wszystkim pamiętać, że w przypadku wykonywania przez nas długotrwałych operacji (np. skomplikowany rendering treści) cały czas blokujemy jedno połączenie na poziomie serwera aplikacji oraz zasoby (wiersze/tabele) na poziomie bazy danych.
W praktyce najlepiej sprawdza się podejście trzecie uzupełniane podejściem drugim. To znaczy standardowo zawsze wszystko obejmujemy transakcją, a tylko w pewnych krańcowych sytuacjach (np. ze względów wydajnościowych) odstępujemy od tej reguły i obsługujemy zachowanie transakcyjne naszego systemu na poziomie poszczególnych komponentów.
Jeśli w systemie operujemy na więcej niż jednym zasobie transakcje lokalne przestają wystarczać. Musimy wtedy skorzystać z dobrodziejstwa menedżera transakcji rozproszonej, który jest składową serwera aplikacji. Przyjrzyjmy się jak w takim przypadku wygląda korzystanie z baz danych. Poniższy przykład pokazuje fragment aplikacji, która operuje na dwóch bazach danych w ramach pojedynczej transakcji.
... Context ctx = new InitialContext(); UserTransaction ut = (UserTransaction) ctx .lookup("java:comp/UserTransaction"); ut.begin(); DataSource ds1 = (DataSource) ctx .lookup("java:comp/env/jdbc/TransactionDemoDS1"); DataSource ds2 = (DataSource) ctx .lookup("java:comp/env/jdbc/TransactionDemoDS2"); Connection conn1 = ds1.getConnection(); Connection conn2 = ds2.getConnection(); doSomethingInFirstDatabase(conn1); doSomethingInSecondDatabase(conn2); conn1.close(); conn2.close(); ut.commit(); ...
Jak widać z poziomu kodu uczestnictwo naszych operacji na bazach danych w transakcji rozproszonej nie wymaga żadnych specjalnych zabiegów. Widzimy również, że nie korzystamy tutaj z żadnych metod interfejsu JDBC, które służyły nam do zarządzania transakcjami lokalnymi. Wszystkie te szczegóły ukrywa przed nami menedżer transakcji i odpowiednie implementacje sterowników do baz danych.
Dla czytelności pominąłem obsługę wyjątków. Oczywiście operacje pobierania i zamykania połączeń powinny być nią objęte. Również kod całej transakcji powinien być obsłużony w taki sposób, w jaki przedstawiłem to w pierwszej części artykułu (rozdział "Obsługa wyjątków i sytuacji brzegowych przy samodzielnym zarządzaniu transakcjami").
Aby móc operować na bazach danych w ramach transakcji rozproszonej musi zostać spełnionych kilka warunków, których nie widać z przedstawionego wyżej przykładu:
Korzystając z baz danych w ramach transakcji rozproszonej (JTA) nie wolno nam używać następujących metod zdefiniowanych w obiekcie java.sql.Connection
:
setAutoCommit(...)
commit()
rollback()
setSavepoint()
Zarządzanie transakcjami przez menedżer transakcji serwera aplikacji równie dobrze sprawdza się w przypadku, gdy korzystamy z jednej bazy danych. Tak więc możemy zarządzać transakcjami w całym systemie w jednolity sposób, korzystając z interfejsu UserTranscation
, bez potrzeby wnikania w aspekt zarządzania transakcjami z poziomu JDBC. Zachęcam do takiego podejścia nawet w systemach z jednym źródłem danych.
Omówienie zastosowania baz danych w systemach transakcyjnych nie może się obyć bez poruszenia tematyki poziomów izolacji transakcji w bazach danych. Być może jest to nawet najważniejszy aspekt budowy systemów transakcyjnych korzystających z baz danych. W pierwszej części artykułu opisując właściwości izolacji transakcji wspominałem, że w odniesieniu do baz danych własność ta nastręcza wielu kłopotów. Przyjrzyjmy się dlaczego tak jest.
W idealnym świecie transakcje nie widzą żadnych efektów działań wykonywanych przez inne transakcje dopóki tamte nie zostaną zatwierdzone. Obserwując taki świat z zewnątrz mielibyśmy wrażenie, że wszystkie transakcje wykonują się po kolei (są uszeregowane). Ponieważ taki idealny świat działał by zbyt wolno, to zaczęto poszukiwać sposobów na złagodzenie tego wymagania i zwiększenie wydajności. W ten sposób narodziły się w bazach danych różne poziomy izolacji transakcji. W zależności od poziomu dopuszczają one istnienie określonych anomalii przy współbieżnym wykonywaniu transakcji. Aby dobrze zrozumieć poziomy izolacji transakcji musimy najpierw przyjrzeć się tym anomaliom.
Brudny odczyt oznacza, że transakcje mogą widzieć zmiany wykonywane przez inne transakcje zanim zmiany te zostaną zatwierdzone. W takim przypadku istnieje możliwość, że w przypadku wycofania zmian inne transakcje będą dalej pracowały na niewłaściwych danych. Sytuację brudnego odczytu ilustruje poniższy diagram:
Transakcja A Czas Transakcja B ---------------------------------------------------- begin t0 begin - - update p t1 - - - - t2 retrieve p - - rollback t3 - - - - t4 ?
Transakcje A i B rozpoczynają się w tej samej chwili t0. Następnie transakcja A aktualizuje wiersz p, który transakcja B odczytuje w chwili t2. Niestety w chwili t3 transakcja A wycofuje wykonane wcześniej zmiany, co oznacza, że transakcja B używa wartości p, która tak naprawdę nigdy nie znalazła się w bazie. Najłatwiej sobie wyobrazić jakie zniszczenia może powodować takie zachowanie systemu jeśli p oznacza wysokość oprocentowania naszego rachunku bankowego.
Niepowtarzalny odczyt oznacza, że transakcja, która wielokrotnie odczytuje ten sam wiersz, może otrzymać różne wyniki chociaż z punktu widzenia spójności danych oba poprawne, czyli zatwierdzone przez inne transakcje. Sytuację niepowtarzalnego odczytu ilustruje diagram poniżej:
Transakcja A Czas Transakcja B ---------------------------------------------------- begin t0 begin - - retrieve p t1 - - - - t2 update p - - - t3 commit - - retrieve p? t4 -
Transakcja A pobiera dwa razy wiersz p (t1 i t4), za każdym razem otrzymując inne wyniki. Jeśli inne dane są modyfikowane w oparciu o wartość p, może to prowadzić do niespójności, które jest trudno wykryć.
Anomalia ta polega na tym, że jeżeli transakcja dwa razy odczytuje zbiór danych według tych samych warunków to może ona otrzymać dwa różne wyniki. Sytuację taką ilustruje diagram poniżej:
Transakcja A Czas Transakcja B ---------------------------------------------------- begin t0 begin - - retrieve q -> a,b t1 - - - - t2 insert c into q - - - t3 commit - - retrieve q -> a,b,c! t4 - - -
Jak można zauważyć transakcja A dwa razy odczytuje tabelę q, za każdym razem otrzymując inne dane, co jest związane z zatwierdzeniem w międzyczasie transakcji B.
W oparciu o eliminację powyższych anomalii stworzono model poziomów izolacji transakcji. Model zdefiniowano w standardzie języka SQL. Specyfikacja JDBC również opiera się na tym modelu wprowadzając jeden dodatkowy poziom. Przyjrzyjmy się teraz poziomom izolacji transakcji zdefiniowanych w specyfikacji JDBC (stałe w java.sql.Connection
), w kolejności od najmniej do najbardziej restrykcyjnych:
TRANSACTION_NONE
– wskazuje, że sterownik nie wspiera transakcji. Ten poziom nie ma nic wspólnego z omawianymi wyżej anomaliami dostępu do danych. Może on być używany w sytuacji, kiedy za pomocą interfejsu JDBC udostępniane są dane lub systemy, w których nie da się zdefiniować żadnego sensownego zachowania transakcyjnego. Takim przykładem jest biblioteka csvjdbc (http://csvjdbc.sourceforge.net/), która za pomocą interfejsu JDBC umożliwia dostęp do plików CSV.TRANSACTION_READ_UNCOMMITED
- poziom, na którym mogą występować wszystkie wymienione anomalie.TRANSACTION_READ_COMMITED
- poziom oznaczający, że zmiany wykonywane przez transakcje nie są widoczne dla innych transakcji do momentu jej zatwierdzenia, czyli mamy tutaj ochronę przed brudnymi odczytami. Na tym poziomie dalej mogą występować niepowtarzalne odczyty i fantomy.TRANSACTION_REPEATABLE_READ
- ten poziom chroni zarówno przed brudnymi odczytami jak i przed niepowtarzalnymi odczytami. Fantomy dalej mogą na nim występować.TRANSACTION_SERIALIZABLE
- na tym poziomie nie mogą występować żadne z opisywanych anomalii.W kontekście tak przyjętej definicji poziomów izolacji chciałbym zwrócić uwagę na jeden znaczący fakt. Ustawienie poziomu izolacji SERIALIZABLE
wcale nie oznacza, że mamy zagwarantowane wykonywanie transakcji w sposób uszeregowany (jedna po drugiej). Niestety przez nieszczęśliwy, moim zdaniem, dobór nazwy najwyższego poziomu izolacji wiele osób jest przekonanych, że tak się właśnie dzieje.
Piękny świat poziomów izolacji zdefiniowanych w JDBC API i standardzie SQL psują dostępne implementacje baz danych, które czasami niektórych poziomów nie wspierają albo stosują inną nomenklaturę. Przykładowo w bazie PostgreSQL są tylko dwa poziomy: READ_COMMITED
i SERIALIZABLE
. W DB2 są cztery, ale inaczej się nazywają, a dodatkowo te same nazwy używane są do określenia różnych poziomów. Tą złożoną sytuację ilustruje tabela poniżej.
DB2 Transaction Isolation Level | JDBC Transaction Isolation Level |
---|---|
Uncommitted Read | READ_UNCOMMITED |
Cursor stability | READ_COMMITED |
Read stability | REPEATABLE_READ(*) |
Repeatable read (*) | SERIALIZABLE |
Z tego względu zawsze przy nowej bazie danych czeka nas lektura dokumentacji sterownika JDBC jak i samej bazy danych.
Niestety na tym nie kończą się komplikacje związane z poziomami izolacji transakcji. Dostawcy baz danych implementują poziomy izolacji korzystając z różnych mechanizmów i algorytmów. Zasadniczo podział przebiega między algorytmami wykorzystującymi pesymistyczne i optymistyczne blokowanie zasobów. Przyjrzymy się jaki może mieć to wpływ na transakcje wykonywane na tym samym poziomie izolacji w bazach danych, które stosują odmienne podejścia implementacyjne (PostgreSQL i IBM DB2).
Dla obu baz danych będziemy chcieli wykonać jednocześnie dwie transakcje (symulowane za pomocą dwóch oddzielnych konsol) na tym samym poziomie izolacji (READ COMMITTED
):
foo
(transakcja A),foo
(transakcja B).W obu przypadkach sprawdzamy jakie jest zachowanie odczytu w transakcji A w przypadku, w którym doszło już do aktualizacji danych przez transakcję B.
Przed wykonaniem tych transakcji zawartość tabeli foo
była następująca:
id data ---------------- 1 dane 1 2 dane 2 3 dane 3
PostgreSQL (baza danych z optymistycznym blokowaniem z wykorzystaniem mechanizmu Multi-Version Concurrency Control)
Transakcja A | Transakcja B |
---|---|
test=> BEGIN; | test=> BEGIN; |
test=> SET TRANSACTION ISOLATION LEVEL READ COMMITTED; | test=> SET TRANSACTION ISOLATION LEVEL READ COMMITTED; |
test=> UPDATE foo SET data='dane 2 zmienione' WHERE id=2; | |
test=> SELECT * FROM foo;
id | data ----+-------- 1 | dane 1 2 | dane 2 3 | dane 3 (3 rows)Odczyt daje się wykonać chociaż transakcja B zmieniała dane w tabeli. |
|
test=> COMMIT; | test=> COMMIT; |
test=> | test=> |
IBM DB2 (baza danych z pesymistycznym blokowaniem)
Transakcja A | Transakcja B |
---|---|
db2 => SET ISOLATION cs | db2 => SET ISOLATION cs |
db2 => UPDATE foo SET data='dane 2 - zmienione' WHERE id = 2 | |
db2 => SELECT * FROM foo W tym momencie konsola zawisa czekając na zwolnienie blokady nałożonej przez transakcję B. Wynik pojawi się dopiero po wykonaniu operacji COMMIT na transakcji B. |
|
ID DATA ----------- ---------------------------- 1 dane 1 2 dane 2 - zmienione 3 dane 3 3 record(s) selected. |
db2 => COMMIT |
db2 => COMMIT | db2 => |
db2 => |
Jak widać mimo, że wykonujemy identyczne transakcje na takim samym poziomie izolacji, to bazy zachowują się odmiennie - chociaż w obu przypadkach założenie co do braku odpowiednich anomalii jest spełnione. Niestety ma to bezpośredni wpływ na działanie naszego systemu, szczególnie w zakresie jego wydajności. Musimy taki aspekt uwzględnić przy projektowaniu i implementacji naszego systemu.
Kolejnym dość złożonym zagadnieniem jest ustawienie żądanego poziomu izolacji z poziomu aplikacji Java Enterprise. Niestety tutaj każdy serwer aplikacji zachowuje się inaczej:
<resourceRefExtensions xmi:id="ResourceRef_ext_1" isolationLevel="TRANSACTION_READ_COMMITTED"> <resourceRef href="WEB-INF/web.xml#ResRef_1" /> </resourceRefExtensions>gdzie do zasobów wymienionych w deskryptorze web.xml dodajemy dodatkowe własności, które będą uwzględniane w momencie, gdy zażądamy dostępu do danego zasobu.
setTransactionIsolation
wywołana bezpośrednio na uzyskanym z serwera aplikacji połączeniu (java.sql.Connection
). Niestety nie mamy gwarancji, że metoda ta zadziała w przypadku gdy transakcja jest już rozpoczęta, a z taką sytuacją mamy do czynienia gdy korzystamy z transakcji JTA. Niektóre bazy danych i sterowniki do nich pozwalają zmienić poziom izolacji w trakcie trwania transakcji (np. baza IBM DB2 pozwala zmienić poziom izolacji transakcji na poziomie pojedynczego zapytania SQL).Jak wynika z powyższych rozważań JDBC API w zakresie poziomów izolacji w praktyce nie jest w stanie ukryć przed programistą rzeczywistych implementacji silników baz danych i sterowników do nich. Zawsze musimy wniknąć w szczegóły działania bazy danych, konfiguracji sterownika do niej, konfiguracji serwera aplikacji i procesu instalacji aplikacji.
Można się jeszcze zastanawiać w jakich przypadkach stosować konkretne poziomy izolacji. Ciężko na to pytanie jednoznacznie odpowiedzieć nie znając wymagań dla konkretnego systemu. Można się jedynie kierować pewnymi heurystykami wynikającymi z dotychczasowego doświadczania. I tak z mojej praktyki wynika, że:
READ_COMMITTED
. Zapewnia on rozsądny kompromis między wydajnością systemu, a zapewnieniem spójności danych.READ_UNCOMMITTED
możemy użyć przy wyciąganiu mniej istotnych danych na potrzeby prezentacji. Ma to szczególne znaczenie w przypadku korzystania z bazy danych z pesymistycznym blokowaniem.SERIALIZABLE
można użyć do realizacji semafora na bazie danych oraz oczywiście do implementacji operacji transferu środków pomiędzy kontami bankowymi.Na zakończenie chciałbym przedstawić kilka problemów/aspektów, na które można się natknąć w codziennej praktyce zawodowej, a co do których dość ciężko znaleźć wyjaśnienie czy rozwiązanie w dokumentacji produktów czy w Internecie.
Pomiędzy timeout'em transakcji JTA a działaniami wykonywanymi przez nas na bazie danych nie ma żadnego związku, poza tym, że po przekroczeniu timeout'u JTA wszystkie wykonane przez nas operacje na bazie danych powinny zostać wycofone. Wynika to z przyczyn, o których pisałem w pierwszej części artykułu.
Co więcej, znane mi bazy danych nie oferują funkcjonalności, która by umożliwiała ustawienie maksymalnego czasu trwania transakcji w bazie danych.
Wiem, że wiele osób poszukuje takiego rozwiązania. Pewne przybliżone rozwiązanie można uzyskać korzystając ze specyficznych własności konkretnych silników baz danych. Możemy tu mówić w zasadzie o dwóch typach rozwiązań:
statement_timeout
w PostgreSQL) lub za pomocą JDBC API - java.sql.Statement.setQueryTimeout
(niestety w większości sterowników niezaimplementowane, gdyż silnik bazy tego nie wspiera).Czasami chcemy zrealizować jakieś operacje na bazie danych poza aktualnie trwającą transakcją. W takim przypadku możemy skorzystać bezpośrednio z menedżera transakcji. Przykład takiego rozwiązania przedstawiłem w pierwszej części artykułu w sekcji "Bezpośrednie korzystanie z menedżera transakcji".
Spotykam się czasami z pytaniem, jak zachowuje się połączenie do bazy wzięte przed rozpoczęciem transakcji JTA i co się dzieje jeśli po rozpoczęciu transakcji JTA będę chciał go dalej używać? Co się stanie jeśli po rozpoczęciu transakcji JTA wezmę połączenie z tego samego źródła danych? Mamy więc do czynienia z przykładem kodu jak poniżej:
Context ctx = new InitialContext(); DataSource ds1 = (DataSource) ctx .lookup("java:comp/env/jdbc/TransactionDemoDS1"); Connection conn1 = ds1.getConnection(); // zrób coś na połączeniu conn1 UserTransaction ut = (UserTransaction) ctx .lookup("java:comp/UserTransaction"); ut.begin(); ds1 = (DataSource) ctx.lookup("java:comp/env/jdbc/TransactionDemoDS1"); Connection conn2 = ds1.getConnection(); // zrób coś na połączeniu conn2 // zrób coś na połączeniu conn1 ut.commit();
Zachowanie systemu w takim przypadku jest następujące:
Każdy serwer aplikacji stosuje własne domyślne poziomy izolacji transakcji, co więcej mogą się one różnić w zależności od bazy danych lub użytego sterownika JDBC. Na przykład:
REPEATABLE_READ
, ale dla bazy Oracle poziome READ_COMMITED
.Tak więc zawsze trzeba sprawdzić jaki jest domyślny poziom izolacji w naszym specyficznym przypadku, a najlepiej nadpisać konfigurację własnymi ustawieniami, co chroni nas przed sytuacją, w której dostawca serwera w drobnej poprawce aktualizującej zmienia domyślny poziom, a nasza produkcyjna aplikacja dotychczas świetnie działająca załamuje się nawet pod niewielkim obciążeniem.
Ze względu na obszerność zagadnienia związanego z wykorzystaniem baz danych przy budowie systemów transakcyjnych obiecany w poprzednim artykule temat wykorzystania systemów kolejkowania postanowiłem przenieść do kolejnego artykułu.
Source: http://www.javaexpress.pl/article/show/Transakcje_w_systemach_JavaEE_korzystanie_z_baz_danych