Tranzakciókezelés EJB 3 és Spring környezetben

Az előző posztban említett Spring In Action könyv olyan szépen tárgyalta a tranzakciókezelést, hogy muszáj írnom egy kicsit. Ebben a posztban csupán megpróbálom összegyűjteni a és megnevezni a fogalmakat, összehasonlítani a Spring és EJB technológiát, nem célom a részletes bemutatás.

Először is definiálni kell a tranzakció fogalmát. Nagyon fontos, hogy tranzakció bár először adatbáziskezelő rendszerekben terjedt el, ma már egyéb rendszerek is ismerik a tranzakció fogalmát, mint pl. message oriented middleware (Java környezetben JMS provider). Ezeket tehát a továbbiakban erőforráskezelőknek fogom nevezni. A tranzakció logikailag összetartozó, és egy egységként kezelt műveletsor. A műveletsor több lépésből is állhat, de ezek összetartoznak, azaz vagy mindnek le kell futnia (commit), vagy egyiknek sem (rollback). Ezzel egyrészt biztosítható az adatbázis konzisztencia (ellentmondás mentesség), és eszköze a párhuzamos kiszolgálásnak is. A tranzakciónak rendelkeznie kell az ACID tulajdonságokkal, mely betűszó feloldása: Atomic (atomicitás), consistent (konzisztencia), izolated (izoláció), durable (tartósság). Az atomicitás azt jelenti, hogy a tranzakcióba tartozó minden műveletet el kell végezni, vagy egyiket sem. A konzisztencia jelentése, hogy az adatoknak a tranzakció után ellentmondás menteseknek kell maradniuk. Az izoláció szerint minden tranzakciónak úgy kell lefutnia, mintha egyedül lenne. A tartósság biztosítja, hogyha a tranzakció befejeződött, annak kifejtett hatása már nem "veszhet el".

Az unalomig emlegetett példa az átutalás, mikor az egyik bankszámláról pénzt emelünk le, és a másikra jóváírjuk, ugye nem jó, ha bármelyik művelet is "elveszik". (Egyszer hallottam, hogy a banknál a programozóknak nem szabad hinniük a tranzakciókezelésben, és úgy kell a programot megírniuk, a műveleteket sorba állítaniuk, hogyha nem működik a tranzakciókezelés, és az egyik művelet végrehajtódik, és a másik nem, akkor ez csak úgy történhessen, hogy a banknak legyen jó.)

Az izolációnál kell megemlíteni, hogy a probléma abból adódhat, hogyha párhuzamosan történnek a módosítások. Ekkor a következő problémák merülhetnek fel. Piszkos olvasásnak (dirty read) nevezzük, ha egy tranzakció által módosított, de még jóvá nem hagyott adatot olvas ki egy másik tranzakció. A megismételhetetlen olvasás (non-repetable read) az jelenti, hogy kétszer olvasunk ki egy adatot, de másodjára más eredményt kapunk, ugyanis egy másik jóváhagyott tranzakció módosította azt. A fantom olvasás (phantom read) az előbbi egy speciális esete, mikor kétszer olvasunk ki adatokat, de másodjára már több eredményt kapunk, ugyanis egy másik jóváhagyott tranzakció új adatot vitt fel. Ezen problémák megoldására vezették be az izolációs szinteket, melyeket az adatbáziskezelők biztosítják (nem mindegyik adatbáziskezelő ismeri mindegyik izolációs szintet, és a default izolációs szintek is eltérhetnek), és programból lehet állítani, akár tranzakciónként. A read uncommitted izolációs szint esetén a tranzakció hatása már a tranzakció közben látszik másik tranzakciónál. A többi izolációs szint mindegyike megoldja a soron következő izolációs problémát. A read commited izolációs szint megoldja a dirty read-et, csak a jóváhagyott módosításokat látja a másik tranzakció. A repeatable read garantálja, hogy egy sort a tranzakció közben újraolvasva ne változzon. A serializable a tranzakciókat sorosítja, így megszünteti az összes párhuzamosságból adódó problémát. De miért is nem állítjuk a legmagasabb izolációs szintet be, hogy ne legyen problémánk? Azért, mert a ahogy növeljük az izolációs szintet, úgy lassulhat be a kiszolgálás sok párhuzamos kérés esetén.

A tranzakciókezelésnél először meg kell különböztetni a lokális és a globális tranzakció (vagy más néven elosztott tranzakció) fogalmát. A lokális tranzakció, mikor egy erőforráskezelőn belül akarunk tranzakciót végezni. A globális/elosztott tranzakciók esetén egy tranzakció több erőforráskezelőn is átívelhet, pl. két adatbázison, vagy egy tranzakcióban akarunk pl. egy adatbázis sort módosítani, és egy üzenetet elküldeni. Ehhez azonban egy tranzakció koordinátort kell kinevezni, aki irányítja a tranzakciót. Itt jön képbe a 2PC, a two-phase commit protocol, ahol első körben az erőforráskezelők felkészülnek a tranzakcióra, és második körben hagyják jóvá azt. Minden erőforráskezelőnek vétó joga van. Az erőforráskezelők a tranzakció koordinátorral az X/Open XA protokollon keresztül kommunikálnak.

Ezen fogalmakat amúgy minden EJB könyv, így a magyar nyelven elérhető Szerk: Imre Gábor - Szoftverfejlesztés Java EE platformon című könyv is részletesen leírja.

Természetesen mind az EJB, mint a Spring támogatja a tranzakciókezelés, méghozzá mindkettő kétféleképpen. Adott a programozott tranzakciókezelés, mikor a fejlesztőnek kell meghívni a commit vagy rollback metódusokat, de lehetőség van deklarativ tranzakciókezelésre is, mikor a fejlesztő csak deklarálja a tranzakciókat, megszabja, hogy hol induljon a tranzakció és hol fejeződjön be (transaction demarcation, transaction boundaries), a tranzakciókezelést maga a környezet végzi.

EJB és Spring esetén is használhatunk lokális és elosztott tranzakciókat is. Elosztott tranzakciók használata esetén azonban mindenképp szükség van egy alkalmazásszerverre, ugyanis ezeknek a szabvány miatt kötelezően tartalmazniuk kell egy tranzakció koordinátort. Ekkor a Java Transaction API (JTA) működik a háttérben, de ezt mind az EJB, mind a Spring képes elrejteni előlünk.

EJB környezetben semmit nem kell konfigurálnunk, azonban tranzakciókat csak session bean-ben és message driven bean-ben (MDB) kezelhetünk. Az alapértelmezett a Container-Managed Transaction (CMT), azaz deklaratív tranzakciókezelés, míg a programozott tranzakciókezelést (Bean-Managed Transaction (BMT)) a @TransactionManagement(TransactionManagementType.BEAN) annotációval, vagy a vele ekvivalens deployment descriptor beállítással (<transaction-type>BEAN<transaction-type>) adhatjuk meg. A tranzakciókat az alkalmazásszerverben implementált Java EE transaction manager végzi. A programozott tranzakciókezelés esetén az EJBContext getUserTransaction metódusa által visszaadott UserTransaction példány begin(), commit() és rollback() metódusait hívhatjuk. Deklaratív esetben annotációt használhatunk. Ez esetben visszagörgetést az alkalmazásszerverrel az EJBContext.setRollbackOnly() metódussal kérhetünk, és ezt lekérdezni a getRollbackOnly() metódussal tudjuk. Az EJBContext példányhoz dependency injection-nel jutunk (@Resource).

Spring környezetben bármilyen POJO-ban definiálhatunk tranzakciót. Ehhez egy transaction manager-t kell választanunk, melyből több mint tíz áll rendelkezésre, köztük a DataSourceTransactionManager, JpaTransactionManager, vagy a JtaTransactionManager. Ezt az applicationContext.xml-ben kell konfigurálnunk. A programozott tranzakciókezelés a TransactionTemplate-tel történhet, míg a deklaratív tranzakciókezelés a TransactionProxyFactoryBean használatával vagy AOP-pal. Ez utóbbi esetben is van választási lehetőségünk, használhatjuk az applicationContext.xml-ben a tx névtérrel a Spring 2.0-ás konfigurációs elemeket, mint tx:advice, tx:attributes, tx:method. De használhatunk az EJB 3.0-hoz hasonlóan annotációkat is.

A Spring In Action könyvben van egy nagyon szemléletes ábra, hogy mire kell figyelni a deklaratív tranzakcióknál.

Az izolációt már említettem az izolációs szinteknél. A JSR 220: Enterprise JavaBeans, Version 3.0 - EJB Core Contracts and Requirements specifikáció 13.3.2-es fejezete (324. old.) írja, hogy az izolációs szint beállítása az adott erőforráskezelőre jellemző, annak API-ja definiálja, így az EJB szabvány nem foglalkozik vele. A java.sql.Connection osztálynak van pl. setTransactionIsolation(int level) metódusa.

A Spring ezzel szemben a TransactionDefinition interfészben definiálja az izolációs szinteket az ISOLATION_ prefixszel rendelkező konstansokban. Ezt meg lehet adni paraméterül a TransactionProxyFactoryBean transactionAttributes attribútumának, vagy a tx:method konfigurációs elem isolation tulajdonságának, vagy a @Transactional annotáció isolation attribútumának.

A következő tulajdonság a read-only tulajdonság. Ezt csak a Spring definiálja, és ezt állítsuk true-ra csak olvasást végző műveleteknél, ha azt akarjuk, hogy bizonyos optimalizációkat elvégezzen az erőforráskezelő. Pl. Hibernate esetén a flush mode-ot FLUSH_NEVER-re állítja, ami azt jelenti, hogy a session állapotát, azaz a perzisztens példányokat nem írja ki az adatbázisba, tehát nem hívja meg a flush() függvényt. Ez csak olvasást végző tranzakcióknál sebességet növelhet. Ugyan a javax.sql.Connection osztálynak is van setReadOnly(boolean readOnly) metódusa, azonban máshogy valósíthatják meg a különböző adatbázis driver-ek. Az Oracle JDBC driver pl. abszolút nem valósítja meg ezt a metódust.

A következő az időtúllépés (timeout). Az EJB 3.0 szabvány programozott tranzakciókezelés esetén biztosítja a UserTransaction.setTransactionTimeout(int seconds) metódust. Deklaratív esetben nem definiál erre megoldást, viszont az alkalmazásszerver gyártóknak van saját megoldásuk. Általában több helyen lehet megadni a timout értékét. Meg lehet adni globálisan valamilyen konfigurációs állományban, vagy meg lehet adni bean-re is. Ekkor vagy gyártófüggő deployment descriptor-ba kell írnunk valamit, vagy saját annotáció is létezhet rá. Pl. JBoss esetén a jboss-service.xml-ben kell keresni a vagy létezik a @TransactionTimeout.

A Spring biztosít lehetőséget a TransactionProxyFactoryBean transactionAttributes attribútumának, vagy a tx:method konfigurációs elem timeout tulajdonságának, vagy a @Transactional annotáció timeout attribútumának használatával.

A következő tulajdonság a propagáció, melynek a deklaratív tranzakciókezelésnél van értelme, hiszen a propagációs tulajdonságokkal adhatjuk meg egy metódusra, hogy hogyan vegyen részt egy tranzakcióban.

Először nézzünk egy egyszerű esetet, mikor is egy kliens meghívja a Bean1 bean-ünk üzleti metódusát, ami meghívja a Bean2 bean-ünk üzleti metódusát. A tranzakciókezelés deklaratív, és a tranzakciókezelést az inversion of control miatt nem a bean-ek végzik, hanem pl. proxy objektumok, melyek a bean-ek előtt helyezkednek el. Amikor a Bean1 metódusát meghívjuk (proxy-n keresztül), a proxy észleli, hogy nincs tranzakció, ezért indít egyet. Majd meghívja a Bean1 bean-ünk metódusát, ami szintén proxy-n keresztül meghívja a Bean2 bean-ünk metódusát. Itt a proxy észleli, hogy már van nyitott tranzakció, így csatlakozik ehhez, majd meghívja a Bean2 bean-ünk metódusát (delegáció). Visszatéréskor a Bean2 proxy továbbengedi a visszatérési értéket, de a Bean1 proxy észleli, hogy ő nyitotta a tranzakciót, és ezért neki is kell valamit kezdenie vele, alapesetben így ő fogja a commit műveletet elvégezni. Ez az alapértelmezett működési mód, mely a legtöbb esetben elegendő nekünk, és ezt hívják REQUIRED propagációs attribútumnak.

Azonban vannak más tranzakciós attribútumok is, összegezve:

  • REQUIRED (default): ha nincs tranzakció, indít egyet, ha van csatlakozik hozzá
  • REQUIRES_NEW: mindenképp új tranzakciót indít
  • SUPPORTS: ha van tranzakció, abban fut, ha nincs, nem indít újat
  • MANDATORY: ha van tranzakció, abban fut, ha nincs, kivételt dob
  • NOT_SUPPORTED: ha van tranzakció, a tranzakciót felfüggeszti, ha nincs, nem indít újat
  • NEVER: ha van tranzakció, kivételt dob, ha nincs, nem indít újat

A tranzakciós attribútumot az EJB-ben deployment descriptor-ban vagy a @TransactionAttribute annotáció attribútumaként is meg lehet adni. Spring-ben a TransactionProxyFactoryBean transactionAttributes attribútumának, vagy a tx:method konfigurációs elem propagation tulajdonságának, vagy a @Transactional annotáció propagation attribútumának lehet megadni. Az EJB-hez képesti különbség annyi, hogy a nevek elé elé kell tenni a PROPAGATION_ prefixet is.

A REQUIRED-en kívül a többit ritkán használjuk. A REQUIRES_NEW akkor jöhet jól, mikor egy olyan műveletet akarunk futtatni, aminek mindenképpen le kell futnia, a hívó tranzakció rollback-je esetén is. Gondoljunk el pl. egy audit naplózást. Az nem lehet, hogyha a művelet rollback-re fut, akkor a naplózás sem történik meg. A többi attribútumra már csak nagyon mondvacsinált példákat tudok hozni. A SUPPORTS read-only műveleteknél jó, mert ha nem jön tranzakció, akkor nem indít újat feleslegesen. A MANDATORY-t akkor használjuk, ha biztosak akarunk lenni abban, hogy egy rollback visszahat a hívó félre. A NOT_SUPPORTED akkor használható, ha pl. EJB környezetben az MDB-nk nem tranzakcionálisan kapcsolódik a JMS provider-hez. A NEVER-t használhatjuk akkor, ha nem tranzakcionális erőforrást piszkálunk, és tudatosítani akarjuk a hívó félben, hogy itt ne is számítson tranzakcionális működésre. Nektek van jobb példáitok?

A leggyakoribb hiba a tranzakciós attribútumokkal kapcsolatban, hogy van egy osztályon belül két metódus, ahol az egyik hívja a másikat, és más a tranzakciós attribútumuk. Normál esetben ugyanis azt tapasztaljuk, hogy a második metódus tranzakciós attribútuma hatástalan. Ez azért van, mert az egy példányon belüli hívás nem megy át a proxy-n, így nem tudja kezelni a tranzakciós attribútumot, tehát mintha ott sem lenne. A megoldás, hogy a hívást mindenképp átvezetjük valahogy a proxy-n. Vagy átszervezzük a kódot, és a két metódust külön bean-be tesszük. EJB esetén pl. a SessionContext.getEJBObject() metódus adja vissza a proxy objektumot. Spring esetén három megoldás közül is választhatunk. Vagy az ApplicationContext-től a getBean-nel név alapján lekérjük a proxy példányt, vagy az AopContext.currentProxy() metódusát hívjuk, de mindkettővel kötjük magunkat a Spring-hez. A harmadik megoldás az AspectJ weaving használata, mikor nem proxy végzi a tranzakciókezelést, hanem maga az objektum, ugyanis az AOP ekkor nem proxy-val valósul meg, hanem bytecode buherálással.

Az utolsó tulajdonság a visszagörgetési szabályok alkalmazása kivétel esetén, deklaratív környezetben. Az EJB-ben a következőképpen működik. Megkülönböztetünk rendszerszintű kivételeket (RuntimeException és RemoteException vagy leszármazottai), és alkalmazásszintű kivételeket (többi). Rendszerszintű kivételek esetén mindig rollback van, alkalmazás szintű kivételeknél commit, kivéve, ha meghívtuk a cache ágban a EJBContext.setRollbackOnly() metódust, vagy saját kivétel esetén rátettük a @ApplicationException(rollback = true) annotációt, mert ilyenkor itt is rollback lesz.

Spring esetében az alapértelmezett működés, hogy RuntimeException és leszármazottja esetén rollback, amúgy commit (az EJB-vel megegyező módon). Viszont itt finomabban szabályozható, ugyanis rollback-for (rollbackFor) attribútummal felsorolhatjuk a metódusnál azokat a kivételeket, melyekre rollback-et akarunk, és a no-rollback-for (noRollBackFor) attribútummal azon kivételeket, ahol ne történjen rollback. Azaz így meg tudunk adni metódusonként olyan RuntimeException leszármazottakat, amire ne legyen rollback, és olyan alkalmazás szintű kivételeket, melyekre rollback legyen.

És végezetül egy saját probléma megoldása. Szükségünk volt a Spring-ben több tranzakció menedzserre, adatforrásonként egyre. A Spring 2.5 esetén nem lehetett megadni, hogy az annotációval jelölt bean-eknél melyik tranzakciómenedzser legyen a nyerő, hanem globálisan lehetett csak megadni. Ezért a bean-ek egyik felét módon, a másik felét @Transactional annotációval konfiguráltuk (persze lehetett volna mindet -szal is. A Spring 3 egyik újdonsága, hogy a @Transactional annotációnak a value attribútumában meg lehet adni, hogy melyik tranzakciómenedzsert használja.

További források:

Spring AOP top problem #1 - aspects are not applied Transaction strategies: Understanding transaction pitfalls