JPA performancekiller: N+1 select-problemet

Kenn Sano har skrevet første artikel i vores føljeton om Java Persistence API (JPA). I artiklen ser vi nærmere på et performanceproblem som vi ofte støder ind i, nemlig N+1 select-problemet. Kort fortalt handler problemet om, at den abstraktion som JPA giver os tit betyder, at man uvidende kommer til at belaste databasen på en uhensigtsmæssig “chatty” vis. Artiklen giver konkrete eksempler på og løsninger til N+1 select problemet i JPA, men metoderne er også brugbare i forbindelse med eksempelvis Hibernate eller andre ORM-frameworks.
JPA (Java Persistence API) er en abstraktion, der forsøger at slå bro mellem Java og databaser. JPA er taknemmeligt at anvende. Man kan “vandre rundt” i en objektgraf og på magisk vis hentes data, som stilles til rådighed i takt med, at vi traverserer – dvs. objekters tilstand indlæses fra databasen alt imens vi bevæger os rundt i objektgrafen. Hvis man ikke er opmærksom på, hvordan JPA fungerer, kan det resultere i mange SQL-kald mod databasen, hvilket kan have stor negativ indvirkning på performance. Kendskab til JPAs virkemåde er derfor en vigtig forudsætning for at udvikle applikationer, der kan performe.

I denne første L&B-artikel om JPA og performance skal vi se på N+1 select-problemet og JPAs fetch strategier. N+1 select-problemet udgør en almindelig faldgrube og det er nemt at komme til at introducere problemet i sin kode, hvis man ikke lige er opmærksom på det. Som udvikler er det derfor vigtigt at have kendskab til, og vide hvordan, det opstår.

Her er et eksempel:

EntityManager em = entityManagerFactory.createEntityManager();
EntityManager em = entityManagerFactory.createEntityManager();
List resultList = em.createQuery("select h from Hobbit h").getResultList();

for (Hobbit hobbit : resultList) {
    System.out.println(hobbit.getName() + " " + hobbit.getBelongings());
}

Dvs. vi har en Hobbit-entity, der ser således ud:

@Entity
public class Hobbit {

    @Id
    @GeneratedValue
    private Integer id;
    private String name;

    @OneToMany
    private Collection<Item> belongings;

    public Hobbit() {}

    // getters and setters
}

Hvorfor opstår N+1 select-problemet?

Det skyldes JPAs default fetch-strategi, der virker således at relationer annoteret med @OneToOne og @ManyToOne hentes eagerly mens @OneToMany og @ManyToMany indlæses lazy. JPA anvender sin default fetch-strategi når fetch-typen på nævnte annotationer ikke angives eksplicit. Betydningen af en eager fetch-strategi er, at associerede objekter hentes med det samme, mens lazy betyder, at associerede objekter først hentes når referencen til objektet tilgås.

I eksemplet ovenfor kan en hobbit have flere ejendele (@OneToMany Collection<Item> belongings). Når vi laver en query, der henter en liste af hobbitter, er deres ejendele derfor ikke hentet, fordi default fetch-strategien tilsiger, at dette gøres lazy. Og når vi efterfølgende itererer over listen af hobbitter og tilgår deres ejendele, opstår N+1 select-problemet, idet vi først henter alle hobbitterne ind (1 select) og dernæst laver én ny select for hver af de N hobbitter, som læser hobbittens ejendele ind ved kaldet hobbit.getBelongings(). Altså N+1 selects i alt – heraf navnet.

Hvordan løser vi det?

Umiddelbart kunne man tro, at man blot på Hobbit klassen eksplicit kan sætte fetch-strategien til EAGER således:

@OneToMany (fetch=FetchType.EAGER)
private Collection<Item> belongings;

Det er en mulighed og det vil også virke, men det er ikke sikkert, at det er en god idé. Når man, som i eksemplet her, på annotationen anfører, at fetch-strategien skal være EAGER, er det den globale fetch-strategi for associationen til Item som ændres. Dvs. hver gang en hobbit hentes fra databasen, vil dens collection af items også blive hentet. Faren ved at ændre den globale fetch-strategi er, at man kommer til at foretage for store læsninger, hvor data som ikke anvendes hentes unødigt. Eller det modsatte tilfælde: At man foretager for små læsninger, der afstedkommer mange efterfølgende kald til databasen.

Ofte er det bedre at tilpasse fetch-strategien på usecase-niveau, således at man kun henter de data, der er brug for i en given usecase. Herved kan man reducere netværkstrafik og memory footprint på såvel DB- som EE-server, idet kun nødvendige data læses. Endvidere bør man som tommelfingerregel bestræbe sig på at minimere antallet af round trips til databasen, idet der til hvert round trip er tilknyttet et overhead i form af netværkstrafik og evt. beregning af sql-plan i databasen.

Hvordan tilpasses fetch-strategien per usecase?

Dette kan gøres på flere måder. En løsning er, at man i JPQL anvender JOIN FETCH. Det gør man således:

Query q = em.createQuery("select h from " + "Hobbit h JOIN FETCH h.belongings");
List<Hobbit> resultList = q.getResultList();

Det vil resultere i et JOIN mellem Hobbit- og Item-tabellen. Bemærk, at JOIN FETCH resulterer i et INNER JOIN, således at hobbitter uden ejendele ekskluderes fra resultatlisten. Ønsker man, at også hobbitter uden ejendele skal indgå i resultatet, kan man i stedet lave et (OUTER) LEFT JOIN, således:

Query q = em.createQuery("select distinct h from " + "Hobbit h LEFT JOIN h.belongings")

Det skal i denne forbindelse nævnes, at hvis man har en entity, der har flere @OneToMany eller @ManyToMany relationer kan det være et hårdt slag for performance at hente data ved JOIN FETCH og navnlig (OUTER) LEFT JOIN. Det skyldes dels, at mængden af data som databasen skal arbejde på stiger markant, dels at antal returnerede rækker med redundant data ligeledes stiger væsentligt. Har man eksempelvis en entity med blot to @OneToMany (eller @ManyToMany) relationer, hvor hver collection har en gennemsnitsstørrelse på henholdsvis n og m, vil hver entitet returnere n * m resultater. Man kan naturligvis anvende DISTINCT, som vist ovenfor, men det ændrer ikke ved, at en forespørgsel med mange (OUTER) LEFT JOINs og/eller INNER JOINs er tidskrævende for databasen at gennemføre. (Bemærk i øvrigt, at nogle persistence providers, herunder Hibernate, ikke undersøtter flere JOIN FETCH eller at flere @OneToMany eller @ManyToMany annoteres med EAGER fetch type.).

Konklusion

JPA arbejder på et meget højt abstraktionsniveau og skjuler mange detaljer. Det er derfor nemt at komme til at anvende JPA uhensigtsmæssigt og introducere N+1 select problemet. Som applikationsudvikler er kendskab til JPAs virkemåde derfor en vigtig forudsætning for at opnå god performance.

I denne artikel er N+1 select-problemet blevet introduceret, ligesom der er givet anvisninger på, hvordan det forholdsvist nemt kan undgås. Betydningen af at sondre mellem JPAs globale fetch-strategi og fetch-strategien for en usecase-specifik query er ligeledes blevet fremhævet.

Med JPAs fetch-strategi udtrykker man, hvornår data skal hentes. Men hvis vil man tune performance yderligere, er det ikke tilstrækkeligt blot at bestemme hvornår; man må endvidere tage stilling til hvordan data skal hentes. Dette spændende emne skal vi se på i den næste artikel om JPA og performance, som bringes i næste nummer af L&Bs nyhedsbrev.