Problem n + 1 w Hibernate

sages.pl 3 lat temu
Hibernate jest bardzo popularnym frameworkiem typu ORM (Object-relational mapping) dedykowanym dla programów pisanych w Javie czy też innych językach uruchamianych w maszynie wirtualnej Javy, np. w Kotlinie. Tego typu narzędzia umożliwiają dwukierunkowe odwzorowywanie świata relacji baz danych na świat obiektów. Popularność relacyjnych baz danych jako składowisk znajduje swoje odbicie w popularności rozwiązań ORM. I - jak to często bywa - w tym miejscu pojawiają się często popełniane niedopatrzenia, skutkujące czy to niepoprawnym zachowaniem, czy też obniżeniem wydajności. Jednym z takich znanych problemów jest tytułowy problem n + 1, o którym traktuje ten wpis.

### Problem n + 1

Problem n + 1 może pojawić się w przypadku, w którym jedna z encji (tabel) odwołuje się do innej encji (tabeli). W takiej sytuacji zdarza się, iż w celu pobrania wartości encji zależnej wykonywanych jest *n* nadmiarowych zapytań podczas gdy wystarczyłoby tylko jedno. Nie trzeba nikogo przekonywać, iż ma to negatywny wpływ na wydajność systemu i generuje niepotrzebne obciążenie bazy danych. Zwłaszcza iż liczba zapytań rośnie wraz z *n*.
Sam problem jest często przedstawiany jako występujący tylko w relacji jeden do wielu (```javax.persistence.OneToMany```) bądź jedynie w przypadku leniwego ładowania danych (```javax.persistence.FetchType.LAZY```). Jest to nieprawda i należy pamiętać, iż problem ten może wystąpić również w relacji jeden do jeden oraz przy “zachłannym” ładowaniu encji zależnych.

Wyobraźmy sobie, iż modelujemy relację pudełka i zabawek. Na początku stwórzmy prostą klasę, która umożliwia pobranie określonej liczby pudełek z bazy danych:

```
class Storage {
fun getBoxes(limit: Int): List {
return getEntityManager()
.createQuery("select b from Box b order by id",
Box::class.java)
.setMaxResults(limit)
.resultList
}

private fun getEntityManager(): EntityManager {
return Persistence.createEntityManagerFactory("persistence")
.createEntityManager()
}
}

fun main(args: Array) {
val storage = Storage()
println(storage.getBoxes(4))
}
```

A teraz zamodelujmy relację pudełko-zabawki. Załóżmy, iż wiele zabawek może należeć do jednego pudełka. Schemat bazy danych mógłby wyglądać tak:

![obraz1blogsages.webp](/uploads/obraz1blogsages_9e5ad927c6.webp)


Widzimy tutaj relację jeden do wielu pomiędzy tabelami. Korzystając z JPA (Java Persistence API) w implementacji Hibernate możemy zapisać to w języku Kotlin w sposób następujący:
```
@Table(name = "box")
@Entity
data class Box(
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
val id: Int,

@Column(name = "name", length = 50, nullable = false)
val name: String,

@OneToMany(fetch = FetchType.EAGER, cascade = [CascadeType.ALL])
@JoinColumn(name = "box_id")
val toys: List
)

@Table(name = "toy")
@Entity
data class Toy(
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
val id: Int,

@Column(name = "name", length = 50, nullable = false)
val name: String
)
```

Wykonajmy zapytanie z funkcji ```main```, które ma za zadanie pobrać cztery pudełka z bazy danych i popatrzmy na wykonane zapytania przez Hibernate:

```
select box.id, box.name from box order by box.id limit 4
select toy.box_id, toy.id, toy.name from toy where toy.box_id=4
select toy.box_id, toy.id, toy.name from toy where toy.box_id=2
select toy.box_id, toy.id, toy.name from toy where toy.box_id=1
select toy.box_id, toy.id, toy.name from toy where toy.box_id=3
```

Wyraźnie widać, iż zostało wykonane *4 + 1* zapytań w celu pobrania czterech pudełek. Najpierw Hibernate pobrał cztery dowolne pudełka, po czym dla wszystkich z nich wykonał po jednym zapytaniu, żeby pobrać zabawki doń należące. Zadanie to mogłoby zostać z powodzeniem wykonane, używając tylko jednego zapytania, zmieniając samo zapytanie *JPQL*:

```

fun getBoxes(limit: Int): List {
return getEntityManager()
.createQuery("select b from Box b join fetch b.toys order by
b.id", Box::class.java)
.setMaxResults(limit)
.resultList
}
```

Teraz, liczba wysłanych zapytań do bazy danych została zredukowana do jednego:

```
select box.id, toy.id, box.name, toy.name, toy.box_id, from box inner join toy on box.id=toy.box_id order by box.id
```

Nie zawsze jednak najlepszym sposobem na wykonanie tego typu zadania jest pisanie własnych zapytań. Co w sytuacji, gdy korzystamy np. z repozytoriów dostarczanych przez Spring Data? Istnieje jeszcze inne podejście do rozwiązania tego problemu, a mianowicie posłużenie się adnotacją ```org.hibernate.annotations.BatchSize```, którą możemy odnaleźć w bibliotece *Hibernate ORM Hibernate Core*. Zastosujemy tę adnotację umieszczając ją nad polem toys:

```
@Table(name = "box")
@Entity
data class Box(
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
val id: Int,

@Column(name = "name", length = 50, nullable = false)
val name: String,

@OneToMany(fetch = FetchType.EAGER, cascade = [CascadeType.ALL])
@JoinColumn(name = "box_id")
@BatchSize(size = 256)
val toys: List
)
```

Dodanie adnotacji **@BatchSize** nad polem ```toys``` sprawia, iż *Hibernate* będzie pobierał dane o zabawkach przypisanych danym pudełkom “paczkami” (*batchowo*), tj. dla do 256 instancji Box, Hibernate pobierze ich zabawki w ramach jednego zapytania. Spójrzmy na zapytania, które zostały wygenerowane dla pierwszej wersji funkcji ```getBoxes```:

```
select box.id, box.name from box order by box.id limit 4
select toy.box_id, toy.id, toy.name from toy where toy.box_id in
(4, 1, 2, 3)
```

Bezsprzecznie widać, iż drugie zapytanie pobiera całą “paczkę” pudełek. o ile natomiast rozmiar paczki zostałby ograniczony do dwóch (**@BatchSize(size = 2)**) to ujrzelibyśmy dwa zapytania, każde pobierające po dwa elementy na paczkę:

```

select box.id, box.name from box order by box.id limit 4
select toy.box_id, toy.id, toy.name from toy where toy.box_id in
(4, 1)
select toy.box_id, toy.id, toy.name from toy where toy.box_id in
(2, 3)
```

### Podsumowanie

Relacje typu jeden do jeden czy też typu jeden do wielu są zupełnie naturalne w relacyjnych systemach baz danych. Z tego też powodu nierzadko można spotkać się z tym problemem w aplikacjach, które korzystają z *Hibernate*. Przedstawione tutaj wyniki zostały uzyskane, korzystając z bibliotek:
* Hibernate Core Relocation 5.4.24.Final,
* Hibernate JPA 2.0 API 1.0.0.Final,
* MySQL Connector/J 8.0.22,
oraz z bazy danych MySQL 5.7.25. Zapytania dla czytelności zostały nieco uproszczone, ale ich znaczenie i sens zostały całkowicie zachowane.
Idź do oryginalnego materiału