Hibernate a optimalizace

Častým argumentem proti používání Hibernate je, že ruční psaní dotazů umožní mít efektivnější a rychlejší program. Chtěl bych vám proto popsat naše zkušenosti s Hibernate a jeho optimalizací pro výkon.

Následující článek vám přiblíží některé vlastnosti Hibernate ovlivňující výkon.

Budeme předpokládat, že nás zajímá efektivita z pohledu SQL databáze. Jinými slovy – zda by byl ručně napsaný dotaz efektivnější než Hibernate.
Procesorový čas nutný pro samotný Hibernate však budeme ignorovat.

V následujícím textu budeme předpokládat takovýto datový model:

Class Diagram

Základem je Strašidlo, které patří k nějakému Hradu (hrad může mít více strašidel). Každé Strašidlo je určitého typu. Každý hrad má ještě kromě strašidel i další obyvatele.

Kritéria porovnávání

Budou nás zajímat vždy dvě informace:

  • počet dotazů – počet provedených dotazů. Každý dotaz musí být odeslán na server, kde je následně zpracován, a zpět je odeslána odpověď. Pokud SQL server běží přímo ve vaší aplikaci (např. Derby), je tato režie celkem zanedbatelná, pokud však běží na jiném počítači, je nutné s ní počítat. Při přesunu databáze na vlastní stroj tak může paradoxně dojít ke zpomalení aplikace.
  • složitost dotazů – pokud dotaz trvá několik vteřin, aplikace bude pomalá. Dotazy se snažíme buď zjednodušit (odstraníme např. složitou podmínku nebo složitý join, agregace, …) nebo zoptimalizovat databázi (např. vytvořením správného indexu).

Aby jste chování Hibernate porozuměli snáze, doporučuji zapnout v konfiguraci toto:

  • hibernate.show_sql=true – zobrazuj v logu dotazy
  • hibernate.use_sql_comments=true – do dotazů vkládej původní Hibernate dotaz jako koměntář.

V logu se pak začnou objevovat dotazy:

Hibernate: /* from Hrad this where this.name = :p_name */ select hrad0_.I_D as I1_28_, hrad0_.version as version28_, hrad0_.NAME as NAME28_ from HRAD hrad0_ where hrad0_.NAME=? limit ?
Hibernate: /* criteria query */ ...
Hibernate: /* load collection Hrad.strasidla */ ...
Hibernate: /* load cz.softeu.evidence.UserAccountImpl */ ...

Všimněte si tučně označených částí. Ty znamenají toto:

  • uživatel provedl dotaz pomocí session.createQuery()
  • uživatel použil Criteria API
  • uživatel načetl objekty v relaci Hrad.strasidla
  • uživatel načetl objekt pomocí metody session.load()

To umožňuje snáze pochopit, proč jsou některé dotazy provedeny.

Bohužel dotazy neobsahují hodnoty použité pro dotazy. Pro jejich získání doporučuji např. program P6Spy.

Cache

Hibernate podporuje dva druhy cache:

  • first-level cache – Tato cache je přímo svázána se Session a jsou v ní uloženy instance všech načtených objektů. Každá Session má tuto cache vlastní a nejsou tak sdílené mezi transakcemi různých session. Tuto cache nelze vypnout, ale lze ji vyprázdnit voláním session.clear();.
  • second-level cache – Tato cache je sdílená mezi všemi transakcemi a obsahuje pouze položky z databáze. Nejsou v ní uložené instance objektů, ale jen hodnoty. Tato cache může být distribuovaná a lze ji vypnout, omezit jen na některé třídy nebo ji případně vyměnit za jinou implementaci.

Second-level cache lze dále rozdělit na tři části:

  • cache atributů – v této části jsou uloženy atributy tak, jak jsou načteny z databáze [ 1 => { nazev = 'Radyně' } ]. Tato cache je implicitně zapnutá.
  • cache relací – obsahuje k objektu primární klíče objektů v relaci [ 1 => [ strasidla => { 1, 2, 3 }, obyvatele => { 4, 5, 6 } ] ]. Tato cache je implicitně vypnutá.
  • cache výsledků dotazů – když provedete dotaz a nastavíte u něj query.setCacheable(true); je výsledek uložen do této cache. Do cache je výsledek uložen i s parametry, proto pro různé parametry umožňuje vracet různé výsledky. Tato cache je implicitně vypnutá.

Pokud tedy načtete objekt z relace nebo dotazu, je nejdříve využita cache pro danou relaci/dotaz. Když je nalezen objekt, je nejdříve hledán ve first-level cache a poté v second-level cache atributů.

Cache je skvělý sluha, ale zlý pán. Její nevýhodou je vyšší paměťová spotřeba (a tak i pomalejší GC), vyšší fragmentace paměti a více dlouho žijících objektů. Pokud je cache často používána (např. lidé obvykle čtou nejnovější článek na blogu) je to výborný pomocník. Pokud ovšem nemůže zafungovat (každý objekt je načten cca jednou denně) je lepší její používání omezit.

Ze zkušenosti vím, že cache je příjemný přínos, ale neměla by se na ní stavět architektura aplikace. Doporučuji např. cache dotazů používat jen pro konkrétní dotazy, které mají dlouhou platnost a jsou často používány.

Články:

Lazy loading

Hibernate podporuje tzv. opožděné načítání. Tj. načte záznamy až ve chvíli kdy jsou potřeba. Pokud tedy přistoupíte k objektu Hrad až použijete relaci strašidla, jsou načteny odpovídající záznamy. Tím je zabráněno zbytečnému načítání dat, která pak nebudou použita (a někdy by to znamenalo načíst všechna data z databáze do paměti). Nevýhodou je, že se pak provádí více dotazů.

Opakem lazy-loadingu je tzv. včasné načítání (eager loading). Pro ní existuje několik režimů:

  • select – pro načtení objektu je proveden samostatný dotaz
  • left join – pro načtení objektu je použit fetch join, který načte data společně s původním objektem. Toto ovšem není možné vždy použít (ovlivňovalo by to výsledky dotazu nebo máme dvě takové vazby současně) a proto Hibernate automaticky přepne na režim select.
  • subselect – pro načtení objektu je proveden subselect při načítání původního objektu. Omezením je, že objekt smí být v relaci 1:1 nebo N:1 (tj. na protější straně je maximálně jeden záznam).

Režimy je možné měnit i pro konkrétní dotaz. Opožděné načítání relací je realizováno pomocí vlastní implementace Collection, která načítá data na požádání.

Případy, kdy je na protější straně jen jeden objekt je nutno rozdělit podle toho, kde je uložen vazební klíč. Problémem totiž je hodnota null – pokud relace neobsahuje objekt, má reference hodnotu null. Pokud existuje, je nahrazen tzv. dynamickou proxy, která načte záznam až když je na něj přistoupeno. Problém tedy je, pokud máme vazbu 1:1 – na jedné straně není vazební záznam. V takovém případě musí Hibernate provést dotaz, aby mohl rozhodnout zda má umístit proxy nebo hodnotu null. To ovšem znamená nefunkčnost lazy-loadingu pro tento typ relací.

Jedním řešením je použití tzv. instrumentace v době překladu – hibernate do kódu doplní načtení dat při zavolání metody. Bohužel nám tento přístup nikdy nefungoval. Proto jsme v některých případech nahradili vazbu 1:1 za 1:N a vkládáme do ní vždy jen jeden záznam (databázové schéma je pro oba případy stejné).

Pokud vracíte objekty vzdáleně např. přes RMI a nemůžete tak využít lazy-loading, vyzkoušejte např. remote lazy-loading.

Články:

Batch loading

Základní způsob načtení objektu přes Hibernate odpovídá jednoduchému SQL dotazu. Pokud zavoláte příkaz

Hrad h = (Hrad) session.load(Hrad.class, 1);

odpovídá tomu jednoduchý dotaz (mírně upraveno)

select * from Hrad where id = 1

Pokud použijete lazy-loading (implicitní chování), načte se seznam strašidel až když k ním přistoupíte:

h.getStrasidla()
select * from Strasidlo where hrad = 1

Do této chvíle vše odpovídá ručně psaným dotazům. Zajímavější situace nastane pokud načteme současně více hradů.

Při ručním psaní dotazů můžeme dotaz napsat takto:

select * from Hrad h left join Strasidlo s where s.hrad = h.id
Collection<Hrad> hrady = (Collection<Hrad>) session.createQuery("from Hrad").list();
for(Hrad h : hrady) {
    h.getStrasidla();
}

Nyní však již zafungovala logika Hibernate a dostaneme překvapivě jen tyto dotazy:

select * from hrad;
select * from strasidla where hrad in (1, 2, 3, 4, 5, 6);

Zde zafungoval tzv. batch loading. Při přístupu k relaci u prvního prvku typu Hrad, Hibernate zjistí, že bylo jedním dotazem načteno více hradů a proto rovnou načte strašidla i pro tyto další hrady. Pro načítání strašidel hradům lze nastavit, kolika hradům současně se načtou strašidla v jednom dotazu (konfigurační položka hibernate.default_batch_fetch_size).

Je samozřejmě možné vše načíst i na jeden dotaz a to takto:

Collection<Hrad> hrady = (Collection<Hrad>) session.createQuery("from Hrad hrad left join fetch hrad.strasidla").list();

Pokud však současně přistoupíme ke dvěma relacím, nelze již pomocí join fetch data načítat ani při ručním načítání (je zde 2x vazba 1:N).
Hibernate však stále umožňuje batch loading:

Collection<Hrad> hrady = (Collection<Hrad>) session.createQuery("from Hrad").list();
for(Hrad h : hrady) {
    h.getStrasidla();
    h.getObyvatele();
}

a dostaneme tyto dotazy:

select * from hrad;
select * from strasidla where hrad in (1, 2, 3, 4, 5, 6);
select * from obyvatel where hrad in (1, 2, 3, 4, 5, 6);

Stejného efektu lze samozřejmě dosáhnout i při ručním zpracování, musíme ovšem po načtení dat rozdělit načtené prvky k odpovídajícím objektům v relaci.

Hibernate se snaží být chytrý a proto batch loading použije jen při načítání více záznamů. Pokud tedy použijeme následující postup, batch loading bohužel nezafunguje.

Hrad hrad1 = (Hrad) session.load(Hrad.class, 1);
Collection<Hrad> hrady = (Collection<Hrad>) session.createQuery("from Hrad").list();
for(Hrad h : hrady) {
    h.getStrasidla();
    h.getObyvatele();
}

Hibernate načetl objekt a označil jej, že není v kolekci. Když však načte kolekci, přiřadí do něj již jednou načtený objekt (sdílí instance). Zde už ovšem nemůže zapnout batch loading – při práci s více kolekcemi obsahující stejné objekty by to znamenalo problémy.

Flushing

Když změníte objekt, nejsou změny uloženy do databáze okamžitě, ale až při operaci zvané flush. Tento přístup umožňuje eliminovat zbytečné dotazy v databázi. Pokud fakticky nedojde k žádné změně, není ani proveden dotaz.

Pokud ovšem chcete provést dotaz (session.createQuery), Hibernate musí automaticky uložit provedené změny – musí projít všechny načtené objekty a hledat změny. Při velkém množství objektů může tato operace chvíli trvat a pokud provedete spoustu dotazů za sebou, může to znamenat znatelné zpomalení a především zbytečné operace. Jednou z možností je session pravidelně vyprazdňovat (není vždy ovšem možné – zneplatní to všechny načtené objekty).

Proto je možné změnit dobu, kdy má být flush proveden. Implicitně je proveden před každým dotazem, je možné jej provádět před commitem a nebo jej vypnout úplně a změny ukládat ručně (výhodné pokud session odpojíte od databáze a změny chcete uložit až později).

V každém případě můžete vždy zavolat session.flush();.

Vícenásobné změny

Pokud provádíte změny nad více objekty, není dobré je všechny načíst do paměti, provést změny a pak zase uložit. Zde je lepší použít dávkové úpravy, které jsou v Hibernate od verze 3.0.

Závěr

Hibernate velmi usnadňuje vývoj databázově orientovaných aplikací. Někdy ovšem může přinést i problémy s výkonem. Při dobrém porozumění chování Hibernate, lze těmto problémům předcházet.

Většina výkonnostních problémů se dá vyřešit buď přímo pomocí Hibernate a nebo můžete napsat vlastní SQL dotaz a tím mít vše naprosto pod kontrolou. To umožňuje soustředit se na psaní aplikace a řešit pouze ty případy, kde je problém s výkonem.

Hibernate oproti ručnímu psaní dotazů má jednu velkou výhodu: nemusíte mít zvláštní metody pro efektivní načtení záznamů a potřebných kombinací jejich relací (načtení všech hradů, načtení všech hradů a jejich strašidel, načtení všech hradů a jejich obyvatel apod.). Díky batch-loadingu stačí jen jedna metoda, která načte všechny hrady a přitom zachovává vysokou efektivitu.

Navíc některé přístupy by se při tradičním ručním psaní, dosahovaly jen velmi pracně.

Další články:

2 komentáře u „Hibernate a optimalizace“

  1. Dobry den,

    chci se zeptat ohledne vyprazdnovani CASH.
    Kdyz si rozjedu aplikaci a zacnu provadet akce pri nichz dochazi k dotazum na databazi pak mi zacne stoupat ‚pamet serveru‘ pri pouziti metody clear() za kazdym dotazem se bohuzel nic nemeni, muzete mi prosim poradit jak na to ?

    Omlouvam se zrejme za hloupy dotaz, ale nevim si rady.
    Pokud jsem to pochopil spravne mela by metoda clear() pamet vyprazdnit ?

    Dekuji mnohokrat.

  2. Zdravím,

    záleží na tom, nad čím zavoláte metodu clear(). Pokud nad session, dojde k vyprázdnění first-level-cache. Second-level-cache stále existuje. Na druhou stranu second-level má automatické zahazování dat, když dochází paměť. Případně se dá zcela v konfiguraci vypnout. First-level-cache vypnout nejde (a ani to nedává smysl).

Napsat komentář

Vaše emailová adresa nebude zveřejněna. Vyžadované informace jsou označeny *