Tranzakciókezelés

Tranzakciónak nevezünk minden olyan utasítássorozatot, melyben minden egyes utasításnak sikeresen végbe kell mennie. Amennyiben a tranzakció egy vagy több utasítása sikertelen, úgy az egész tranzakció érvényét veszíti és vissza kell állítanunk a tranzakció által módosított adatokat a tranzakció előtti állapotra. Ez utóbbi az alkalmazás integritásának megőrzését segíti, azaz az adatok aktuális értéke (az alkalmazás állapota) nem sérül, azaz nem kerülünk invalid állapotba. A tranzakciók egy vagy több erőforrást használhatnak (legyen ez adatbázis vagy egy üzenet sor)

A tranzakcióknak alapvetően két fajtáját különböztethetjük meg:

  • Lokális: A tranzakciók kizárólag egy adott erőforrásra vonatkoznak (pl.: egy adatbázis).
  • Globális: A konténer kezeli, mely több tranzakciós erőforrást is magában foglalhat (egy tranzakció során több erőforrást használunk).

Lokális tranzakciók

A lokális tranzakciók megvalósítása egyszerű feladat, viszont későbbi globális bővítés során (több tranzakciós erőforrás kezelése) a megírt kód felhasználása nem valószínű.

A tranzakciók általános érvényűek, így a lokális tranzakcióban résztvevő erőforrás sokféle lehet. Itt azonban csak az adatbázisokkal foglalkozunk.

JDBC

Magát a JDBC-t már korábban bemutattuk. Most nézzük meg, hogy miként vehet részt erőforrásként a tranzakciókezelésben. Maga a JDBC a következőképpen ékelődik be az alkalmazásunk és az adatbázis közé:

JDBC

A JDBC támogatást ad az SQL utasítások tranzakciókban való futtatásához. A Connection alapértelmezett viselkedése auto-commit, azaz minden egyes utasítást külön tranzakcióként kezel, melyeket automatikusan commit-ol a lefuttatás után.

Ugyanakkor arra is lehetőség van, hogy több utasítást összefogjunk egy tranzakcióba.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Connection connection = DriverManager.getConnection(CONNECTION_URL, USER, PASSWORD);
try {
    connection.setAutoCommit(false);
    PreparedStatement firstStatement = connection .prepareStatement("firstQuery");
    firstStatement.executeUpdate();
    PreparedStatement secondStatement = connection .prepareStatement("secondQuery");
    secondStatement.executeUpdate();
    connection.commit();
} catch (Exception e) {
    connection.rollback();
}

A fenti kódban az auto-commit-ot kikapcsoljuk, így ilyen esetben manuálisan válogathatjuk össze az egy tranzakcióban lefuttatni kívánt utasításokat, melyeket aztán nekünk kell kommitálnunk vagy hiba esetén rollback-elnünk. Ezen felül a JDBC arra is lehetőséget ad, hogy mentési pontokat adjunk meg.

JPA

A JPA-val külön foglalkoztunk az előző fejezetben, így itt azt külön nem részletezzük.

Ami a tranzakciók szempontjából lényege az az, hogy egy perzisztencia kontextushoz (Persistence Context) több EntityManager is tartozhat. A perzisztencia kontext kétféle lehet:

  • Transaction-scoped: Egy darab tranzakcióhoz kötött, ez az alapértelmezett
  • Extended-scoped: Több tranzakcióhoz is kötődhet

Példa:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("jpa-example");
EntityManager entityManager = entityManagerFactory.createEntityManager();
try {
    entityManager.getTransaction().begin();
    entityManager.persist(firstEntity);
    entityManager.persist(secondEntity);
    entityManager.getTransaction().commit();
} catch (Exceotion e) {
    entityManager.getTransaction().rollback();
}

Globális tranzakciók

Globális tranzakciók megvalósítása során a JTA (Java Transactional API) áll rendelkezésre. Ebben az esetben minden tranzakciós erőforrást egy tranzakciókezelő (Resource Manager) vezérel, melyek páronként az XA(nyílt standard az elosztott tranzakciókezeléshez) protokollon keresztül képesek kommunikálni. A tranzakciókezelőket általában az adott tranzakciós erőforrás gyártója adja, például MySQL esetében MysqlXADataSource. Magukat a tranzakciókezelőket fogja össze a JTA Transaction Manager, mely koordinálja és szinkronizálja az összes tranzakciókezelő munkáját.

A fentieket foglalja össze a következő ábra:

Global tranzactions

Az ilyen felépítésű globális tranzakciókezelést elosztott tranzakciókezelésnek is hívják.

Springben a PlatformTransactionManager interface a TransactionDefinition és a TransactionStatus interface-eket használja a tranzakciókezelés megvalósításában. A tranzakciókezelők tekintetében számos választásunk van:

  • DataSourceTransactionManager: JDBC-hez
  • JpaTransactionManager: JPA-val kompatibilis
  • HibernateTransactionManager: Hibernate tranzakciókezelője
  • JmsTransactionManager: JMS kompatibilis

Az eddig bemutatottak általános érvényűek. Jelen fejezetben a relációs adatbázisokra vonatkozó tranzakciókezelésre fókuszálunk.

Tranzakciók tulajdonságai

A tranzakciókezelés során 4 tulajdonságot kell szem előtt tartanunk, melyeket összefoglalva ACID néven is szoktak emlegetni

  • Atomicity: Az atomicitás megköveteli, hogy több műveletet atomi (oszthatatlan) műveletként lehessen végrehajtani, azaz vagy az összes művelet sikeresen végrehajtódik, vagy egyik sem.
  • Consistency: A konzisztencia biztosítja, hogy az adatok a tranzakció előtti érvényes állapotból ismét egy érvényes állapotba kerüljenek. Minden erre vonatkozó szabálynak (hivatkozási integritás, adatbázis triggerek stb.) érvényesülnie kell.
  • Isolation: A tranzakciók izolációja azt biztosítja, hogy az egy időben zajló tranzakciók olyan állapothoz vezetnek, mint amilyet sorban végrehajtott tranzakciók érnének el. Egy végrehajtás alatt álló tranzakció hatásai nem láthatóak a többi tranzakcióból.
  • Durability: A végrehajtott tranzakciók változtatásait egy tartós adattárolón kell tárolni, hogy a szoftver vagy a hardver meghibásodása, áramszünet, vagy egyéb hiba esetén is megmaradjon.

Ezeket a tulajdonságokat maguknak a tranzakciós erőforrásoknak kell biztosítaniuk, noha a CAP-tétel gátat szab ezek egyszerre történő biztosításakor. Néhány dolgot azonban befolyásolhatunk, mint például: tranzakció csak olvasást végezhet, izolációs szint meghatározása.

Spring-ben a PlatformTransactionManager.getTransaction() metódusa egy TransactionDefinition-t vár paraméterül és egy TransactionStatus-t ad vissza. A statust a tranzakciók futásának szabályozásához lehet felhasználni (megadja, hogy egy új tranzakcióról van szó vagy az adott tranzakció véget ért, stb.). A TransactionDefinition-ben megadhatjuk egy tranzakció tulajdonságait:

1
2
3
4
5
6
7
8
public interface TransactionDefinition {
    ...
    int getPropagationBehavior(); // megadhatjuk, hogy mi történjék, akkor ha már van egy aktív tranzakció folyamatban
    int getIsolationLevel(); // kontrollálja, hogy más tranzakciók milyen módosításokhoz férnek hozzá a tranzakció futása közben
    int getTimeout(); // Az idő ami alatt a tranzakciónak meg kell valósulnia, különben sikertelen
    boolean isReadOnly(); // megadja, hogy a tranzakció csak olvasást végez-e
    String getName(); // minden tranzakció rendelkezik egy névvel
}

A tranzakció izolációs szintje megadja, hogy a konkurens tranzakciók mit láthatnak egymás adataiból. Idevágó fogalmak, melyek az adatok konkurens hozzáférésekor léphetnek fel:

  • Dirty read: a még nem kommitált adat kiolvasása egy konkurens tranzakcióból (mely később még változhat)
  • Nonrepeatable read: Más értékek eredményül kapása egy sor újra olvasása esetén, mert egy konkurens tranzakció frissítette ugyanazt a rekordot (és kommitálta is a változásokat)
  • Phantom read: több rekord újbóli lekérdezése esetén más sorokat kapok vissza, mert egy konkurens tranzakció hozzáadott vagy törölt rekordokat (ezeket kommitálta is közben)

Az izolációs szintek a következőek lehetnek:

  • ISOLATION_DEFAULT: Alapértelmezett (a mögötte lévő datasource-tól kéri le)
  • ISOLATION_READ_UNCOMMITTED: A legalacsonyabb szintű izoláció, mivel ez a tranzakció láthatja a többi tranzakció még nem commitált adatait (olvasásra). Dirty-rad, Nonrepeatable read és Phantom read is előfordulhat.
  • ISOLATION_READ_COMMITTED: A legtöbb adatbázisban ez az alapértelmezett. Biztosítja, hogy a még nem kommitált adatokat ne lehessen olvasni (más tranzakcióknak) a tranzakció közben. Ugyanakkor, a kommitált adatokat kiolvashatják más tranzakciók és módosíthatják is azt. A dirty read-től megvéd, de a többi még előfordulhat.
  • ISOLATION_REPEATABLE_READ: Az előzőnél szigorúbb. Újra kiválasztható az adathalmaz azután is, hogy egy másik tranzakció beszúrt új adatot (még nem kommitált adatot), viszont ugyanazt az eredményt kapjuk. A phantom read továbbra is felléphet, viszont ezzel az izolációs szinttel megakadályozzuk, hogy két konkurens tranzakció ugyanazt a rekordot szerkessze egyszerre.
  • ISOLATION_SERIALIZABLE: Legmegbízhatóbb, de a legdrágább. Teljes atomicitás biztosítása: olyan mintha a tranzakciók egymás után futnának le.

Az izolációs szint kiválasztása kulcsfontosságú az adatok konzisztenciájának megőrzése szempontjából, ugyanakkor a konzisztencia szempontjából leghatékonyabb megoldás nyilván a legtöbbet veszi el a teljesítményből.

A propagálás szintjei a következőek lehetnek:

  • REQUIRED: Az alapértelmezett propagációs szint. Amennyiben nincs futó tranzakció a megadott néven, akkor a Spring csinál egy újat. Ellenkező esetben a lefuttatandó utasítások belekerülnek a tranzakció végére.
  • SUPPORTS: Amennyiben van futó tranzakció, akkor annak utasításai után belekerülnek az új utasítások is, de ha nincs futó tranzakció, akkor az utasítások végrehajtása tranzakció nélküli lesz.
  • MANDATORY: Ha van aktív tranzakció, akkor azt használja, ha nincs akkor kivételt dob.
  • NEVER: Ha van futó tranzakció, akkor kivételt dob
  • NOT_SUPPORTED: A Spring felfüggeszti a futó tranzakciót, ha van ilyen, a kiadott utasításokat nem tranzakcionálisan lefuttatja, majd folytatja a tranzakció futását.
  • REQUIRES_NEW: A futó tranzakciót felfüggeszti és egy új tranzakcióban végrehajtja az utasításokat.
  • NESTED: Ha van futó tranzakció, akkor azt felfüggeszti, de egy SavePoint-ot is rak erre az állapotra, majd az új utasításokat futtatja egy tranzakcióban. Ha az új tranzakció elhasal akkor visszakerülünk a savepoint-ra. Ha nincs aktív tranzakció, akkor egyenértékű a REQUIRED-el.

A TransactionStatus a következőket adja meg:

1
2
3
4
5
6
7
8
public interface TransactionStatus extends SavepointManager {
    boolean isNewTransaction(); // új tranzakcióról van-e szó
    boolean hasSavepoint(); // van-e beállítva mentési pont a tranzakcióra
    void setRollbackOnly(); // rollback kiváltása és tranzakció félbeszakítása
    boolean isRollbackOnly(); // rollback-elni kell-e a tranzakció alapján
    void flush();
    boolean isCompleted(); // befejeződött-e a tranzakció (akár rollback vagy commit lett a vége)
}

Tranzakciók használata Spring-ben

Példa tranzakciók használatára:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@Service("customerService")
@Transactional
public class CustomerServiceImpl implements CustomerService {

    private CustomerRepository customerRepository;

    @Autowired
    public void setCustomerRepository(CustomerRepository customerRepository) {
        this.customerRepository = customerRepository;
    }

    @Override
    @Transactional(readOnly = true)
    public List<Customer> findAll() {
        return Lists.newArrayList(customerRepository.findAll());
    }

}

A fenti példában a @Transactional annotációval látjuk el magát az osztály, mely által a Spring biztosítja, hogy maga a tranzakció rendelkezésre álljon még azelőtt, hogy bármelyik metódust is lefuttatnánk). Ugyanezt az annotációt a metódusokra is alkalmazhatjuk, melyek által egy-egy tranzakciós utasítássorozat (a metódusban lévő utasítások halmaza) tulajdonságait adhatjuk meg (isolation, propgation, readOnly, timeout, stb). Üres @Transactional annotáció a metóduson így a következőt jelenti:

  • Izoláció: DEFAULT
  • Propagáció: REQUIRED
  • Timeout: DEFAULT
  • Mód: Olvasás-írás

TransactionTemplate

A fent bemutatott annotáció alapú tranzakciószabályozás csak egy lehetőség arra, hogy a finomhangoljuk a tranzakcióinkat (daklaratív módon).

Nézzünk egy példát, hogy mikor sülhet el rosszul egy tranzakció.

1
2
3
4
5
6
7
@Transactional
public void request(SomeRequest request) {
    saveRequest(request); // Database
    callExternalApi(request); // External API call
    updateState(request); // Database
    saveHistory(request); // Database
}

A fenti példában többféle műveletet végzünk. Először adatbázishoz fordulunk aztán REST API hívást végzünk, majd ismét két adatbázis műveletet hajtunk végre. A baj akkor történik, amikor a REST API hívás sok idő múlva ad csak választ. Mivel Transactional-el annotált a metódust, így a REST API hívás idején is nyitva marad a kapcsolat, melyet a Connection Pool-tól kapunk. Ha a rendszerünket sokan használják, akkor gyorsan kifuthatunk a megengedett kapcsolatokból, ha egyszerre sok kérést kell kiszolgálnunk.

Fontos

Az adatbázis műveleteket más típusú input/output műveletekkel vegyíteni igencsak bad smell. Kerüljük annak használatát!!!

A TransactionTemplate call-back alapú API-t biztosít a tranzakciók manuális menedzseléséhez.

1
2
3
4
5
6
7
8
@Bean
public TransactionTemplate transactionTemplate() {
    TransactionTemplate tt = new TransactionTemplate();
    tt.setPropagationBehavior(TransactionDefinition.PROPAGATION_NEVER);
    tt.setTimeout(30);
    tt.setTransactionManager(transactionManager()); // PlatformTransactionManager bean-t ad
    return tt;
}

Látható, hogy a TransactionTemplate-nek megadhatjuk a tranzakció beállításait is (ha többféle konfigurációra van szükségünk, akkor csináljunk több TransactionTemplate-t). A TransactionTemplate a TransactionManager segítségével tud létrehozni, kommitolni és rollback-elni tranzakciókat. A TransactionTemplate kínál egy execute nevű metódust, melynek segítségével bármilyen kódot futtathatunk egy tranzakcióban, mely utána valamilyen eredménnyel visszatér. Példa:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@Service("customerService")
@Repository
public class CustomerServiceImpl implements CustomerService {

    @Autowired
    private CustomerRepository customerRepository;

    @Autowired
    private TransactionTemplate transactionTemplate;

    @PersistenceContext
    private EntityManager em;

    @Override
    public long countAll() {
        return transactionTemplate.execute(transactionStatus -> em.createNamedQuery(Customer.COUNT_ALL, Long.class).getSingleResult());
    }
}

Az execute egy TransactionCallback<T> (interfész) típusú objektumot vár, melynek aztán meghívja a doInTransaction() metódusát (a fenti példában lambdát használunk, mely alapján ezt nem láthatjuk). Amennyiben hiba áll be akkor meghívhatjuk a transactionStatus.setRollbackOnly(); metódust, amely rollback-et idéz elő.

Amennyiben a tranzakció nem állít elő eredményt, akkor használhatjuk a TransactionCallbackWithoutResult callback osztályt.

Kapcsolódó linkek

Java tranzakciók - Baeldung


Utolsó frissítés: 2020-10-04 18:59:50