JPA több one-to-many kapcsolat

Felhasznált technológiák: Spring 4.1.6, Hibernate 4.3.9

Már írtam egy posztot a JPA teljesítményhangolásával, valamint a lazy loadinggal kapcsolatban. Ott egy entitáshoz egy másik kapcsolódott, one-to-many kapcsolattal. Ott folytatom, ahol abbahagytam, de most egy entitáshoz két másik entitás kapcsolódik ugyanazon, one-to-many kapcsolattal. Egyrészt megvizsgálom a Hibernate egy jellegzetes hibaüzenetét, valamint elemzek több megoldást is performancia szempontból.

Az adatmodell a következő osztálydiagramon látható. Egy Employee példányhoz több Phone és több Address példány kapcsolódik.

Osztálydiagram

A posthoz tartozó példaprogram letölthető a GitHub-ról. A projekt letöltése után az mvn test paranccsal futtatható a teszt eset. Ez egy JUnit teszt eset, mely felépíti a Spring contextet, elindít egy beépített HSQLDB adatbázis-kezelőt, létrehozza a táblákat, feltölti adatokkal, majd meghívja a service-t, mely JPA lekérdezéseket használ, és a visszatérési értéket Hamcresttel ellenőrzi. A projekt ebben a posztban bemutatott legutolsó megoldást tartalmazza, de megjegyzésben ott van a többi megoldás is.

Amennyiben elkészítjük a három entitást, és csak a kötelező annotációkkal látjuk el, és azok kötelező paramétereivel, a következő kivételt kapjuk: org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: jtechlog.descartes.Employee.phones, could not initialize proxy - no Session. Az előző posztból tudhatjuk, hogy ez azért van, mert a @OneToMany annotáció használatakor a kapcsolódó entitásokat csak akkor tölti be, mikor szükség van rá (default a lazy loading). De mivel a teszt eset kéri le először a kapcsolódó entitásokat, a persistence context már zárva, a session zárva, így a Hibernate ezeket már nem tudja lekérdezni.

Első megoldás, mely eszünkbe juthat, hogy egészítsük ki a @OneToMany annotációkat a fetch = FetchType.EAGER paraméterrel. Ekkor a következő kivételt kapjuk, már akkor, mikor elindul a Hibernate: Deploy time: Caused by: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags.

Ennek az az oka, hogy a Hibernate ebben az esetben egy joint tartalmazó select utasítást ad ki, és nem tudja kiválasztani, hogy melyik rekord melyik entitáshoz tartozik. A select a következő.

SELECT ...
FROM Employee employee0_
LEFT OUTER JOIN Address addresses1_
  ON employee0_.id = addresses1_.employee_id
LEFT OUTER JOIN Phone phones2_
  ON employee0_.id = phones2_.employee_id
WHERE employee0_.id = ?

Ezen kivétel mögött igen nagy irodalom áll, és több megoldási javaslatot is találhatunk. Egyrészt használjuk a Hibernate @IndexColumn annotációját, vagy ha nem akarunk provider függőek lenni, akkor a JPA 2.0-ban megjelent szabványos @OrderColumn annotációt. Ezt a @OneToMany annotációk mellé kell tenni, valamint a phone és az address táblába kell egy-egy új mező, mely az adott entitás listában elfoglalt pozícióját jelzi, és a JPA provider automatikusan karbantartja (, ahogy a példaprogramban is látható).

A másik megoldás, hogy mind a két esetben a List típust átírjuk Set-re. Az előbbi és ezen megoldás esetében is megmarad a join a lekérdezésben.

Van még egy megoldás. A @OneToMany annotációk mellé Hibernate specifikus @Fetch annotációt helyezünk el. Ez azt mondja meg, hogy a kapcsolódó rekordokat hogyan töltse be. Paraméterként több módot is meg lehet adni, az alapértelmezett mód a fentebb leírt JOIN, de használhatunk SELECT vagy SUBSELECT értékeket is.

Mindkettő használata esetén a persistence provider három select utasítást ad ki.

select ... from Employee employee0_ where employee0_.id=?
select ... from Phone phones0_ where phones0_.employee_id=?
select ... from Address addresses0_ where addresses0_.employee_id=?

Itt tehát a két mód között nem látszik különbség. Azonban ha azt a metódust nézzük, mely az összes Employee példányt visszaadja (findEmployees()), azonnal láthatjuk a különbséget. A SELECT mód esetén a phone és az address táblára annyi select utasítást ad ki, amennyi rekordot az employee tábla tartalmazott. A SUBSELECT mód esetén mindig három select utasítást futtat, méghozzá a következőket.

select ... from Employee employee0_ order by employee0_.id
select ... from Phone phones0_
  where phones0_.employee_id
    in (select employee0_.id from Employee employee0_ )
select ... from Address addresses0_
  where addresses0_.employee_id
    in (select employee0_.id from Employee employee0_ )

Láthattuk, hogy hogy működik az eager fetch esetén de én ezt nem szeretem használni, mert ilyenkor mindig eager jönnek le a kapcsolódó entitások, nem tudok választani. Viszont finomabban szabályozható, ha a lekérdezésben adom meg, hogy mit akarok betölteni. Erre a join fetch való. Írjuk is át a lekérdezést, hogy a következő lekérdezést használja:

select distinct e from Employee e
  join fetch e.phones
  join fetch e.addresses where e.id = :id

A helyzet ugyanaz, mint az eager fetch esetén, MultipleBagFetchException-t kapunk. Persze megint átállhatunk Set-re, és ekkor ugyanoda lyukadunk, hogy joint tartalmazó select utasítást kapunk. Mi ezzel a probléma?

select distinct ... from Employee employee0_
  inner join Phone phones1_
    on employee0_.id=phones1_.employee_id
  inner join Address addresses2_
    on employee0_.id=addresses2_.employee_id
  where employee0_.id=?

Igen, jól látható, hogy a fenti select utasítás eredménye egy Descartes-szorzat. Azaz ha a phone táblában van tíz rekord, és a address táblában is van tíz rekord egy adott employee rekordhoz, a lekérdezés száz rekordot fog visszaadni. Ez a probléma az eager fetch-nél is fennáll JOIN mód esetén.

Mi lehet erre a megoldás? Tudjuk azt, hogy amíg él a persistence context, addig a JPA provider a memóriában tárolja, hogy mik lettek betöltve, és azokat nem kéri be újra. Tehát egyrészt lekérdezzük az Employee entitást joinnal összekötve a Phone entitásokkal, majd egy külön lekérdezésben az Employee entitást joinnal összekötve az Address entitásokkal. Ez a következőkben látszik.

@Transactional(readOnly = true)
public Employee findEmployeeById(long id) {
    em.createQuery("select e from Employee e " +
      "join fetch e.phones where e.id = :id", Employee.class)
        .setParameter("id", id)
        .getSingleResult();
    return em.createQuery("select e from Employee e " +
      "join fetch e.addresses where e.id = :id", Employee.class)
        .setParameter("id", id)
        .getSingleResult();
    }

Megfigyelhetjük, hogy az első lekérdezés eredményével nem csinálunk semmit. Csupán csak arra való, hogy az Employee és a Phone entitásokat a persistence contextbe töltse. A második query igaz, hogy csak a Address entitásokat kéri le, de mivel a Phone entitások már a persistence contextben vannak, hozzáköti őket. Ehhez persze kell a @Transactional annotáció (readOnly = true paraméterrel a sebesség érdekében), különben mindkét lekérdezéshez külön persistence contextet nyitna, így ugyanúgy LazyInitializationException lenne a jutalmunk. A lefuttatott két select utasítás a következő.

select ... from Employee employee0_
  inner join Phone phones1_
    on employee0_.id=phones1_.employee_id where employee0_.id=?
select ... from Employee employee0_
  inner join Address addresses1_
    on employee0_.id=addresses1_.employee_id where employee0_.id=?

Látható, hogy két select fut le, mindegyik eredménye tíz-tíz rekord, szemben a join változattal, ahogy egy select adott vissza száz rekordot.

A performancia hangolás érdekében még jó tisztában lenni a kiadható hintekkel is, mely persistence providerenként más és más, Hibernate esetén hasznos lehet a org.hibernate.fetchSize hint, amivel azt állíthatjuk be, hogy hány rekordonként forduljon az adatbázishoz, azaz egyszerre mennyi rekord menjen át Java oldalra. Ennek használatával sikerült akár kétszeres sebességjavulást is elérnem.